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
@@ -1,30 +1,31 @@
1
1
  """Story point extraction from commits and pull requests."""
2
+
2
3
  import re
3
- from typing import Optional, List, Dict, Any
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,157 +1,180 @@
1
1
  """Ticket reference extraction for multiple platforms."""
2
+
2
3
  import re
3
- from typing import List, Dict, Any, Set
4
4
  from collections import defaultdict
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):
11
- """Initialize with patterns for different platforms."""
10
+
11
+ def __init__(self, allowed_platforms: Optional[list[str]] = None) -> None:
12
+ """Initialize with patterns for different platforms.
13
+
14
+ Args:
15
+ allowed_platforms: List of platforms to extract tickets from.
16
+ If None, all platforms are allowed.
17
+ """
18
+ self.allowed_platforms = allowed_platforms
12
19
  self.patterns = {
13
- 'jira': [
14
- 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
15
22
  ],
16
- 'github': [
17
- r'#(\d+)', # GitHub issues: #123
18
- r'GH-(\d+)', # Alternative format: GH-123
19
- 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+)",
20
27
  ],
21
- 'clickup': [
22
- r'CU-([a-z0-9]+)', # ClickUp: CU-abc123
23
- 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
24
35
  ],
25
- 'linear': [
26
- r'([A-Z]{2,5}-\d+)', # Linear: ENG-123, similar to JIRA
27
- r'LIN-(\d+)', # Alternative: LIN-123
28
- ]
29
36
  }
30
-
31
- # Compile patterns
37
+
38
+ # Compile patterns only for allowed platforms
32
39
  self.compiled_patterns = {}
33
40
  for platform, patterns in self.patterns.items():
41
+ # Skip platforms not in allowed list
42
+ if self.allowed_platforms and platform not in self.allowed_platforms:
43
+ continue
34
44
  self.compiled_patterns[platform] = [
35
- re.compile(pattern, re.IGNORECASE if platform != 'jira' else 0)
45
+ re.compile(pattern, re.IGNORECASE if platform != "jira" else 0)
36
46
  for pattern in patterns
37
47
  ]
38
-
39
- def extract_from_text(self, text: str) -> List[Dict[str, str]]:
48
+
49
+ def extract_from_text(self, text: str) -> list[dict[str, str]]:
40
50
  """Extract all ticket references from text."""
41
51
  if not text:
42
52
  return []
43
-
53
+
44
54
  tickets = []
45
55
  seen = set() # Avoid duplicates
46
-
56
+
47
57
  for platform, patterns in self.compiled_patterns.items():
48
58
  for pattern in patterns:
49
59
  matches = pattern.findall(text)
50
60
  for match in matches:
51
61
  ticket_id = match if isinstance(match, str) else match[0]
52
-
62
+
53
63
  # Normalize ticket ID
54
- if platform == 'jira' or platform == 'linear':
64
+ if platform == "jira" or platform == "linear":
55
65
  ticket_id = ticket_id.upper()
56
-
66
+
57
67
  # Create unique key
58
68
  key = f"{platform}:{ticket_id}"
59
69
  if key not in seen:
60
70
  seen.add(key)
61
- tickets.append({
62
- 'platform': platform,
63
- 'id': ticket_id,
64
- 'full_id': self._format_ticket_id(platform, ticket_id)
65
- })
66
-
71
+ tickets.append(
72
+ {
73
+ "platform": platform,
74
+ "id": ticket_id,
75
+ "full_id": self._format_ticket_id(platform, ticket_id),
76
+ }
77
+ )
78
+
67
79
  return tickets
68
-
69
- def extract_by_platform(self, text: str) -> Dict[str, List[str]]:
80
+
81
+ def extract_by_platform(self, text: str) -> dict[str, list[str]]:
70
82
  """Extract tickets grouped by platform."""
71
83
  tickets = self.extract_from_text(text)
72
-
84
+
73
85
  by_platform = defaultdict(list)
74
86
  for ticket in tickets:
75
- by_platform[ticket['platform']].append(ticket['id'])
76
-
87
+ by_platform[ticket["platform"]].append(ticket["id"])
88
+
77
89
  return dict(by_platform)
78
-
79
- def analyze_ticket_coverage(self, commits: List[Dict[str, Any]],
80
- 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]:
81
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
+
82
99
  results = {
83
- 'total_commits': len(commits),
84
- 'total_prs': len(prs),
85
- 'commits_with_tickets': 0,
86
- 'prs_with_tickets': 0,
87
- 'ticket_platforms': defaultdict(int),
88
- 'untracked_commits': [],
89
- '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,
90
107
  }
91
-
108
+
92
109
  # Analyze commits
93
110
  for commit in commits:
94
- ticket_refs = commit.get('ticket_references', [])
111
+ ticket_refs = commit.get("ticket_references", [])
95
112
  if ticket_refs:
96
- results['commits_with_tickets'] += 1
113
+ commits_with_tickets = cast(int, results["commits_with_tickets"])
114
+ results["commits_with_tickets"] = commits_with_tickets + 1
97
115
  for ticket in ticket_refs:
98
116
  if isinstance(ticket, dict):
99
- platform = ticket.get('platform', 'unknown')
100
- ticket_id = ticket.get('id', '')
117
+ platform = ticket.get("platform", "unknown")
118
+ ticket_id = ticket.get("id", "")
101
119
  else:
102
120
  # Legacy format - assume JIRA
103
- platform = 'jira'
121
+ platform = "jira"
104
122
  ticket_id = ticket
105
-
106
- results['ticket_platforms'][platform] += 1
107
- 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)
108
127
  else:
109
128
  # Track significant untracked commits
110
- if (not commit.get('is_merge') and
111
- commit.get('files_changed', 0) > 3):
112
- results['untracked_commits'].append({
113
- 'hash': commit['hash'][:7],
114
- 'message': commit['message'].split('\n')[0][:60],
115
- 'files_changed': commit.get('files_changed', 0)
116
- })
117
-
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
+
118
138
  # Analyze PRs
119
139
  for pr in prs:
120
140
  # Extract tickets from PR title and description
121
141
  pr_text = f"{pr.get('title', '')} {pr.get('description', '')}"
122
142
  tickets = self.extract_from_text(pr_text)
123
-
143
+
124
144
  if tickets:
125
- results['prs_with_tickets'] += 1
145
+ prs_with_tickets = cast(int, results["prs_with_tickets"])
146
+ results["prs_with_tickets"] = prs_with_tickets + 1
126
147
  for ticket in tickets:
127
- platform = ticket['platform']
128
- results['ticket_platforms'][platform] += 1
129
- results['ticket_summary'][platform].add(ticket['id'])
130
-
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
+
131
153
  # Calculate coverage percentages
132
- results['commit_coverage_pct'] = (
133
- results['commits_with_tickets'] / results['total_commits'] * 100
134
- 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
135
158
  )
136
-
137
- results['pr_coverage_pct'] = (
138
- results['prs_with_tickets'] / results['total_prs'] * 100
139
- 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
140
164
  )
141
-
165
+
142
166
  # Convert sets to counts for summary
143
- results['ticket_summary'] = {
144
- platform: len(tickets)
145
- for platform, tickets in results['ticket_summary'].items()
167
+ results["ticket_summary"] = {
168
+ platform: len(tickets) for platform, tickets in ticket_summary.items()
146
169
  }
147
-
170
+
148
171
  return results
149
-
172
+
150
173
  def _format_ticket_id(self, platform: str, ticket_id: str) -> str:
151
174
  """Format ticket ID for display."""
152
- if platform == 'github':
175
+ if platform == "github":
153
176
  return f"#{ticket_id}"
154
- elif platform == 'clickup':
155
- 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
156
179
  else:
157
- return ticket_id
180
+ return ticket_id