gitflow-analytics 1.0.1__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 (57) hide show
  1. gitflow_analytics/__init__.py +11 -11
  2. gitflow_analytics/_version.py +2 -2
  3. gitflow_analytics/cli.py +612 -258
  4. gitflow_analytics/cli_rich.py +353 -0
  5. gitflow_analytics/config.py +251 -141
  6. gitflow_analytics/core/analyzer.py +140 -103
  7. gitflow_analytics/core/branch_mapper.py +132 -132
  8. gitflow_analytics/core/cache.py +240 -169
  9. gitflow_analytics/core/identity.py +210 -173
  10. gitflow_analytics/extractors/base.py +13 -11
  11. gitflow_analytics/extractors/story_points.py +70 -59
  12. gitflow_analytics/extractors/tickets.py +101 -87
  13. gitflow_analytics/integrations/github_integration.py +84 -77
  14. gitflow_analytics/integrations/jira_integration.py +116 -104
  15. gitflow_analytics/integrations/orchestrator.py +86 -85
  16. gitflow_analytics/metrics/dora.py +181 -177
  17. gitflow_analytics/models/database.py +190 -53
  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 +11 -4
  37. gitflow_analytics/reports/csv_writer.py +51 -31
  38. gitflow_analytics/reports/narrative_writer.py +16 -14
  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.1.dist-info → gitflow_analytics-1.0.3.dist-info}/METADATA +31 -4
  52. gitflow_analytics-1.0.3.dist-info/RECORD +62 -0
  53. gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
  54. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/WHEEL +0 -0
  55. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/entry_points.txt +0 -0
  56. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/licenses/LICENSE +0 -0
  57. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.0.3.dist-info}/top_level.txt +0 -0
@@ -1,30 +1,31 @@
1
1
  """Story point extraction from commits and pull requests."""
2
+
2
3
  import re
3
- from typing import Any, Dict, List, Optional
4
+ from typing import Any, Optional
4
5
 
5
6
 
6
7
  class StoryPointExtractor:
7
8
  """Extract story points from text using configurable patterns."""
8
-
9
- def __init__(self, patterns: Optional[List[str]] = None):
9
+
10
+ def __init__(self, patterns: Optional[list[str]] = None):
10
11
  """Initialize with extraction patterns."""
11
12
  if patterns is None:
12
13
  patterns = [
13
- r'(?:story\s*points?|sp|pts?)\s*[:=]\s*(\d+)', # SP: 5, story points = 3
14
- r'\[(\d+)\s*(?:sp|pts?)\]', # [3sp], [5 pts]
15
- r'#(\d+)sp', # #3sp
16
- r'estimate:\s*(\d+)', # estimate: 5
17
- r'\bSP(\d+)\b', # SP5, SP13
18
- r'points?:\s*(\d+)', # points: 8
14
+ r"(?:story\s*points?|sp|pts?)\s*[:=]\s*(\d+)", # SP: 5, story points = 3
15
+ r"\[(\d+)\s*(?:sp|pts?)\]", # [3sp], [5 pts]
16
+ r"#(\d+)sp", # #3sp
17
+ r"estimate:\s*(\d+)", # estimate: 5
18
+ r"\bSP(\d+)\b", # SP5, SP13
19
+ r"points?:\s*(\d+)", # points: 8
19
20
  ]
20
-
21
+
21
22
  self.patterns = [re.compile(pattern, re.IGNORECASE) for pattern in patterns]
22
-
23
+
23
24
  def extract_from_text(self, text: str) -> Optional[int]:
24
25
  """Extract story points from text."""
25
26
  if not text:
26
27
  return None
27
-
28
+
28
29
  for pattern in self.patterns:
29
30
  match = pattern.search(text)
30
31
  if match:
@@ -35,28 +36,29 @@ class StoryPointExtractor:
35
36
  return points
36
37
  except (ValueError, IndexError):
37
38
  continue
38
-
39
+
39
40
  return None
40
-
41
- def extract_from_pr(self, pr_data: Dict[str, Any],
42
- commit_messages: Optional[List[str]] = None) -> Optional[int]:
41
+
42
+ def extract_from_pr(
43
+ self, pr_data: dict[str, Any], commit_messages: Optional[list[str]] = None
44
+ ) -> Optional[int]:
43
45
  """Extract story points from PR with fallback to commits."""
44
46
  # Try PR description first (most authoritative)
45
- points = self.extract_from_text(pr_data.get('description', ''))
47
+ points = self.extract_from_text(pr_data.get("description", ""))
46
48
  if points:
47
49
  return points
48
-
50
+
49
51
  # Try PR title
50
- points = self.extract_from_text(pr_data.get('title', ''))
52
+ points = self.extract_from_text(pr_data.get("title", ""))
51
53
  if points:
52
54
  return points
53
-
55
+
54
56
  # Try PR body (if different from description)
55
- if 'body' in pr_data:
56
- points = self.extract_from_text(pr_data['body'])
57
+ if "body" in pr_data:
58
+ points = self.extract_from_text(pr_data["body"])
57
59
  if points:
58
60
  return points
59
-
61
+
60
62
  # Fallback to commit messages
61
63
  if commit_messages:
62
64
  commit_points = []
@@ -64,65 +66,74 @@ class StoryPointExtractor:
64
66
  points = self.extract_from_text(message)
65
67
  if points:
66
68
  commit_points.append(points)
67
-
69
+
68
70
  if commit_points:
69
71
  # Use the most common value or max if no consensus
70
72
  from collections import Counter
73
+
71
74
  point_counts = Counter(commit_points)
72
75
  most_common = point_counts.most_common(1)
73
76
  if most_common:
74
77
  return most_common[0][0]
75
-
78
+
76
79
  return None
77
-
78
- def aggregate_story_points(self, prs: List[Dict[str, Any]],
79
- commits: List[Dict[str, Any]]) -> Dict[str, Any]:
80
+
81
+ def aggregate_story_points(
82
+ self, prs: list[dict[str, Any]], commits: list[dict[str, Any]]
83
+ ) -> dict[str, Any]:
80
84
  """Aggregate story points from PRs and commits."""
81
85
  # Map commits to PRs
82
86
  pr_by_commit = {}
83
87
  for pr in prs:
84
- for commit_hash in pr.get('commit_hashes', []):
88
+ for commit_hash in pr.get("commit_hashes", []):
85
89
  pr_by_commit[commit_hash] = pr
86
-
90
+
87
91
  # Track which commits are associated with PRs
88
92
  pr_commits = set(pr_by_commit.keys())
89
-
93
+
90
94
  # Aggregate results
95
+ orphan_commits: list[dict[str, Any]] = []
96
+ unestimated_prs: list[dict[str, Any]] = []
97
+
91
98
  results = {
92
- 'total_story_points': 0,
93
- 'pr_story_points': 0,
94
- 'commit_story_points': 0,
95
- 'orphan_commits': [], # Commits without PRs
96
- 'unestimated_prs': [] # PRs without story points
99
+ "total_story_points": 0,
100
+ "pr_story_points": 0,
101
+ "commit_story_points": 0,
102
+ "orphan_commits": orphan_commits, # Commits without PRs
103
+ "unestimated_prs": unestimated_prs, # PRs without story points
97
104
  }
98
-
105
+
99
106
  # Process PRs
100
107
  for pr in prs:
101
- pr_points = pr.get('story_points', 0)
108
+ pr_points = pr.get("story_points", 0)
102
109
  if pr_points:
103
- results['pr_story_points'] += pr_points
104
- results['total_story_points'] += pr_points
110
+ results["pr_story_points"] += pr_points
111
+ results["total_story_points"] += pr_points
105
112
  else:
106
- results['unestimated_prs'].append({
107
- 'number': pr['number'],
108
- 'title': pr['title']
109
- })
110
-
113
+ unestimated_prs.append(
114
+ {"number": pr.get("number", 0), "title": pr.get("title", "")}
115
+ )
116
+
111
117
  # Process commits not in PRs
112
118
  for commit in commits:
113
- if commit['hash'] not in pr_commits:
114
- commit_points = commit.get('story_points', 0)
119
+ commit_hash = commit.get("hash", "")
120
+ if commit_hash not in pr_commits:
121
+ commit_points = commit.get("story_points", 0)
115
122
  if commit_points:
116
- results['commit_story_points'] += commit_points
117
- results['total_story_points'] += commit_points
118
-
123
+ results["commit_story_points"] += commit_points
124
+ results["total_story_points"] += commit_points
125
+
119
126
  # Track significant orphan commits
120
- if commit['files_changed'] > 5 or commit['insertions'] > 100:
121
- results['orphan_commits'].append({
122
- 'hash': commit['hash'][:7],
123
- 'message': commit['message'].split('\n')[0][:80],
124
- 'story_points': commit_points,
125
- 'files_changed': commit['files_changed']
126
- })
127
-
128
- return results
127
+ files_changed = commit.get("files_changed", 0)
128
+ insertions = commit.get("insertions", 0)
129
+ if files_changed > 5 or insertions > 100:
130
+ orphan_commits.append(
131
+ {
132
+ "hash": commit.get("hash", "")[:7],
133
+ "message": commit.get("message", "").split("\n")[0][:80],
134
+ "story_points": commit_points,
135
+ "files_changed": files_changed,
136
+ }
137
+ )
138
+
139
+ return results
@@ -1,39 +1,40 @@
1
1
  """Ticket reference extraction for multiple platforms."""
2
+
2
3
  import re
3
4
  from collections import defaultdict
4
- from typing import Any, Dict, List
5
+ from typing import Any, Optional, cast
5
6
 
6
7
 
7
8
  class TicketExtractor:
8
9
  """Extract ticket references from various issue tracking systems."""
9
-
10
- def __init__(self, allowed_platforms=None):
10
+
11
+ def __init__(self, allowed_platforms: Optional[list[str]] = None) -> None:
11
12
  """Initialize with patterns for different platforms.
12
-
13
+
13
14
  Args:
14
15
  allowed_platforms: List of platforms to extract tickets from.
15
16
  If None, all platforms are allowed.
16
17
  """
17
18
  self.allowed_platforms = allowed_platforms
18
19
  self.patterns = {
19
- 'jira': [
20
- r'([A-Z]{2,10}-\d+)', # Standard JIRA format: PROJ-123
20
+ "jira": [
21
+ r"([A-Z]{2,10}-\d+)", # Standard JIRA format: PROJ-123
21
22
  ],
22
- 'github': [
23
- r'#(\d+)', # GitHub issues: #123
24
- r'GH-(\d+)', # Alternative format: GH-123
25
- r'(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#(\d+)',
23
+ "github": [
24
+ r"#(\d+)", # GitHub issues: #123
25
+ r"GH-(\d+)", # Alternative format: GH-123
26
+ r"(?:fix|fixes|fixed|close|closes|closed|resolve|resolves|resolved)\s+#(\d+)",
26
27
  ],
27
- 'clickup': [
28
- r'CU-([a-z0-9]+)', # ClickUp: CU-abc123
29
- r'#([a-z0-9]{6,})', # ClickUp short format
28
+ "clickup": [
29
+ r"CU-([a-z0-9]+)", # ClickUp: CU-abc123
30
+ r"#([a-z0-9]{6,})", # ClickUp short format
31
+ ],
32
+ "linear": [
33
+ r"([A-Z]{2,5}-\d+)", # Linear: ENG-123, similar to JIRA
34
+ r"LIN-(\d+)", # Alternative: LIN-123
30
35
  ],
31
- 'linear': [
32
- r'([A-Z]{2,5}-\d+)', # Linear: ENG-123, similar to JIRA
33
- r'LIN-(\d+)', # Alternative: LIN-123
34
- ]
35
36
  }
36
-
37
+
37
38
  # Compile patterns only for allowed platforms
38
39
  self.compiled_patterns = {}
39
40
  for platform, patterns in self.patterns.items():
@@ -41,126 +42,139 @@ class TicketExtractor:
41
42
  if self.allowed_platforms and platform not in self.allowed_platforms:
42
43
  continue
43
44
  self.compiled_patterns[platform] = [
44
- re.compile(pattern, re.IGNORECASE if platform != 'jira' else 0)
45
+ re.compile(pattern, re.IGNORECASE if platform != "jira" else 0)
45
46
  for pattern in patterns
46
47
  ]
47
-
48
- def extract_from_text(self, text: str) -> List[Dict[str, str]]:
48
+
49
+ def extract_from_text(self, text: str) -> list[dict[str, str]]:
49
50
  """Extract all ticket references from text."""
50
51
  if not text:
51
52
  return []
52
-
53
+
53
54
  tickets = []
54
55
  seen = set() # Avoid duplicates
55
-
56
+
56
57
  for platform, patterns in self.compiled_patterns.items():
57
58
  for pattern in patterns:
58
59
  matches = pattern.findall(text)
59
60
  for match in matches:
60
61
  ticket_id = match if isinstance(match, str) else match[0]
61
-
62
+
62
63
  # Normalize ticket ID
63
- if platform == 'jira' or platform == 'linear':
64
+ if platform == "jira" or platform == "linear":
64
65
  ticket_id = ticket_id.upper()
65
-
66
+
66
67
  # Create unique key
67
68
  key = f"{platform}:{ticket_id}"
68
69
  if key not in seen:
69
70
  seen.add(key)
70
- tickets.append({
71
- 'platform': platform,
72
- 'id': ticket_id,
73
- 'full_id': self._format_ticket_id(platform, ticket_id)
74
- })
75
-
71
+ tickets.append(
72
+ {
73
+ "platform": platform,
74
+ "id": ticket_id,
75
+ "full_id": self._format_ticket_id(platform, ticket_id),
76
+ }
77
+ )
78
+
76
79
  return tickets
77
-
78
- def extract_by_platform(self, text: str) -> Dict[str, List[str]]:
80
+
81
+ def extract_by_platform(self, text: str) -> dict[str, list[str]]:
79
82
  """Extract tickets grouped by platform."""
80
83
  tickets = self.extract_from_text(text)
81
-
84
+
82
85
  by_platform = defaultdict(list)
83
86
  for ticket in tickets:
84
- by_platform[ticket['platform']].append(ticket['id'])
85
-
87
+ by_platform[ticket["platform"]].append(ticket["id"])
88
+
86
89
  return dict(by_platform)
87
-
88
- def analyze_ticket_coverage(self, commits: List[Dict[str, Any]],
89
- prs: List[Dict[str, Any]]) -> Dict[str, Any]:
90
+
91
+ def analyze_ticket_coverage(
92
+ self, commits: list[dict[str, Any]], prs: list[dict[str, Any]]
93
+ ) -> dict[str, Any]:
90
94
  """Analyze ticket reference coverage across commits and PRs."""
95
+ ticket_platforms: defaultdict[str, int] = defaultdict(int)
96
+ untracked_commits: list[dict[str, Any]] = []
97
+ ticket_summary: defaultdict[str, set[str]] = defaultdict(set)
98
+
91
99
  results = {
92
- 'total_commits': len(commits),
93
- 'total_prs': len(prs),
94
- 'commits_with_tickets': 0,
95
- 'prs_with_tickets': 0,
96
- 'ticket_platforms': defaultdict(int),
97
- 'untracked_commits': [],
98
- 'ticket_summary': defaultdict(set)
100
+ "total_commits": len(commits),
101
+ "total_prs": len(prs),
102
+ "commits_with_tickets": 0,
103
+ "prs_with_tickets": 0,
104
+ "ticket_platforms": ticket_platforms,
105
+ "untracked_commits": untracked_commits,
106
+ "ticket_summary": ticket_summary,
99
107
  }
100
-
108
+
101
109
  # Analyze commits
102
110
  for commit in commits:
103
- ticket_refs = commit.get('ticket_references', [])
111
+ ticket_refs = commit.get("ticket_references", [])
104
112
  if ticket_refs:
105
- results['commits_with_tickets'] += 1
113
+ commits_with_tickets = cast(int, results["commits_with_tickets"])
114
+ results["commits_with_tickets"] = commits_with_tickets + 1
106
115
  for ticket in ticket_refs:
107
116
  if isinstance(ticket, dict):
108
- platform = ticket.get('platform', 'unknown')
109
- ticket_id = ticket.get('id', '')
117
+ platform = ticket.get("platform", "unknown")
118
+ ticket_id = ticket.get("id", "")
110
119
  else:
111
120
  # Legacy format - assume JIRA
112
- platform = 'jira'
121
+ platform = "jira"
113
122
  ticket_id = ticket
114
-
115
- results['ticket_platforms'][platform] += 1
116
- results['ticket_summary'][platform].add(ticket_id)
123
+
124
+ platform_count = ticket_platforms[platform]
125
+ ticket_platforms[platform] = platform_count + 1
126
+ ticket_summary[platform].add(ticket_id)
117
127
  else:
118
128
  # Track significant untracked commits
119
- if (not commit.get('is_merge') and
120
- commit.get('files_changed', 0) > 3):
121
- results['untracked_commits'].append({
122
- 'hash': commit['hash'][:7],
123
- 'message': commit['message'].split('\n')[0][:60],
124
- 'files_changed': commit.get('files_changed', 0)
125
- })
126
-
129
+ if not commit.get("is_merge") and commit.get("files_changed", 0) > 3:
130
+ untracked_commits.append(
131
+ {
132
+ "hash": commit.get("hash", "")[:7],
133
+ "message": commit.get("message", "").split("\n")[0][:60],
134
+ "files_changed": commit.get("files_changed", 0),
135
+ }
136
+ )
137
+
127
138
  # Analyze PRs
128
139
  for pr in prs:
129
140
  # Extract tickets from PR title and description
130
141
  pr_text = f"{pr.get('title', '')} {pr.get('description', '')}"
131
142
  tickets = self.extract_from_text(pr_text)
132
-
143
+
133
144
  if tickets:
134
- results['prs_with_tickets'] += 1
145
+ prs_with_tickets = cast(int, results["prs_with_tickets"])
146
+ results["prs_with_tickets"] = prs_with_tickets + 1
135
147
  for ticket in tickets:
136
- platform = ticket['platform']
137
- results['ticket_platforms'][platform] += 1
138
- results['ticket_summary'][platform].add(ticket['id'])
139
-
148
+ platform = ticket["platform"]
149
+ platform_count = ticket_platforms[platform]
150
+ ticket_platforms[platform] = platform_count + 1
151
+ ticket_summary[platform].add(ticket["id"])
152
+
140
153
  # Calculate coverage percentages
141
- results['commit_coverage_pct'] = (
142
- results['commits_with_tickets'] / results['total_commits'] * 100
143
- if results['total_commits'] > 0 else 0
154
+ total_commits = cast(int, results["total_commits"])
155
+ commits_with_tickets_count = cast(int, results["commits_with_tickets"])
156
+ results["commit_coverage_pct"] = (
157
+ commits_with_tickets_count / total_commits * 100 if total_commits > 0 else 0
144
158
  )
145
-
146
- results['pr_coverage_pct'] = (
147
- results['prs_with_tickets'] / results['total_prs'] * 100
148
- if results['total_prs'] > 0 else 0
159
+
160
+ total_prs = cast(int, results["total_prs"])
161
+ prs_with_tickets_count = cast(int, results["prs_with_tickets"])
162
+ results["pr_coverage_pct"] = (
163
+ prs_with_tickets_count / total_prs * 100 if total_prs > 0 else 0
149
164
  )
150
-
165
+
151
166
  # Convert sets to counts for summary
152
- results['ticket_summary'] = {
153
- platform: len(tickets)
154
- for platform, tickets in results['ticket_summary'].items()
167
+ results["ticket_summary"] = {
168
+ platform: len(tickets) for platform, tickets in ticket_summary.items()
155
169
  }
156
-
170
+
157
171
  return results
158
-
172
+
159
173
  def _format_ticket_id(self, platform: str, ticket_id: str) -> str:
160
174
  """Format ticket ID for display."""
161
- if platform == 'github':
175
+ if platform == "github":
162
176
  return f"#{ticket_id}"
163
- elif platform == 'clickup':
164
- return f"CU-{ticket_id}" if not ticket_id.startswith('CU-') else ticket_id
177
+ elif platform == "clickup":
178
+ return f"CU-{ticket_id}" if not ticket_id.startswith("CU-") else ticket_id
165
179
  else:
166
- return ticket_id
180
+ return ticket_id