gitflow-analytics 1.0.0__py3-none-any.whl → 1.0.1__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 +2 -0
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/cli.py +113 -19
- gitflow_analytics/config.py +211 -28
- gitflow_analytics/core/analyzer.py +68 -8
- gitflow_analytics/core/branch_mapper.py +1 -1
- gitflow_analytics/core/cache.py +3 -5
- gitflow_analytics/core/identity.py +5 -6
- gitflow_analytics/extractors/base.py +1 -1
- gitflow_analytics/extractors/story_points.py +1 -1
- gitflow_analytics/extractors/tickets.py +13 -4
- gitflow_analytics/integrations/github_integration.py +11 -4
- gitflow_analytics/integrations/jira_integration.py +272 -0
- gitflow_analytics/integrations/orchestrator.py +33 -7
- gitflow_analytics/metrics/dora.py +3 -3
- gitflow_analytics/models/database.py +4 -4
- gitflow_analytics/reports/analytics_writer.py +14 -4
- gitflow_analytics/reports/csv_writer.py +12 -4
- gitflow_analytics/reports/narrative_writer.py +5 -1
- gitflow_analytics-1.0.1.dist-info/METADATA +463 -0
- gitflow_analytics-1.0.1.dist-info/RECORD +31 -0
- gitflow_analytics-1.0.0.dist-info/METADATA +0 -201
- gitflow_analytics-1.0.0.dist-info/RECORD +0 -30
- {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.1.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.1.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.0.dist-info → gitflow_analytics-1.0.1.dist-info}/top_level.txt +0 -0
gitflow_analytics/core/cache.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
"""Caching layer for Git analysis with SQLite backend."""
|
|
2
|
-
import
|
|
2
|
+
from contextlib import contextmanager
|
|
3
3
|
from datetime import datetime, timedelta
|
|
4
|
-
from typing import List, Optional, Dict, Any
|
|
5
4
|
from pathlib import Path
|
|
6
|
-
from
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
7
6
|
|
|
8
|
-
from sqlalchemy.orm import Session
|
|
9
7
|
from sqlalchemy import and_
|
|
10
8
|
|
|
11
|
-
from ..models.database import Database,
|
|
9
|
+
from ..models.database import CachedCommit, Database, IssueCache, PullRequestCache
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
class GitAnalysisCache:
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"""Developer identity resolution with persistence."""
|
|
2
|
-
import uuid
|
|
3
2
|
import difflib
|
|
4
|
-
|
|
5
|
-
from typing import Dict, List, Optional, Set, Tuple, Any
|
|
3
|
+
import uuid
|
|
6
4
|
from collections import defaultdict
|
|
7
5
|
from contextlib import contextmanager
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
8
8
|
|
|
9
|
-
from sqlalchemy
|
|
10
|
-
from sqlalchemy import and_, or_
|
|
9
|
+
from sqlalchemy import and_
|
|
11
10
|
|
|
12
|
-
from ..models.database import Database,
|
|
11
|
+
from ..models.database import Database, DeveloperAlias, DeveloperIdentity
|
|
13
12
|
|
|
14
13
|
|
|
15
14
|
class DeveloperIdentityResolver:
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"""Ticket reference extraction for multiple platforms."""
|
|
2
2
|
import re
|
|
3
|
-
from typing import List, Dict, Any, Set
|
|
4
3
|
from collections import defaultdict
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class TicketExtractor:
|
|
8
8
|
"""Extract ticket references from various issue tracking systems."""
|
|
9
9
|
|
|
10
|
-
def __init__(self):
|
|
11
|
-
"""Initialize with patterns for different platforms.
|
|
10
|
+
def __init__(self, allowed_platforms=None):
|
|
11
|
+
"""Initialize with patterns for different platforms.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
allowed_platforms: List of platforms to extract tickets from.
|
|
15
|
+
If None, all platforms are allowed.
|
|
16
|
+
"""
|
|
17
|
+
self.allowed_platforms = allowed_platforms
|
|
12
18
|
self.patterns = {
|
|
13
19
|
'jira': [
|
|
14
20
|
r'([A-Z]{2,10}-\d+)', # Standard JIRA format: PROJ-123
|
|
@@ -28,9 +34,12 @@ class TicketExtractor:
|
|
|
28
34
|
]
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
# Compile patterns
|
|
37
|
+
# Compile patterns only for allowed platforms
|
|
32
38
|
self.compiled_patterns = {}
|
|
33
39
|
for platform, patterns in self.patterns.items():
|
|
40
|
+
# Skip platforms not in allowed list
|
|
41
|
+
if self.allowed_platforms and platform not in self.allowed_platforms:
|
|
42
|
+
continue
|
|
34
43
|
self.compiled_patterns[platform] = [
|
|
35
44
|
re.compile(pattern, re.IGNORECASE if platform != 'jira' else 0)
|
|
36
45
|
for pattern in patterns
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""GitHub API integration for PR and issue enrichment."""
|
|
2
|
-
from datetime import datetime, timedelta
|
|
3
|
-
from typing import List, Dict, Any, Optional
|
|
4
2
|
import time
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
5
6
|
from github import Github
|
|
6
7
|
from github.GithubException import RateLimitExceededException, UnknownObjectException
|
|
7
8
|
|
|
@@ -12,12 +13,14 @@ class GitHubIntegration:
|
|
|
12
13
|
"""Integrate with GitHub API for PR and issue data."""
|
|
13
14
|
|
|
14
15
|
def __init__(self, token: str, cache: GitAnalysisCache,
|
|
15
|
-
rate_limit_retries: int = 3, backoff_factor: int = 2
|
|
16
|
+
rate_limit_retries: int = 3, backoff_factor: int = 2,
|
|
17
|
+
allowed_ticket_platforms: Optional[List[str]] = None):
|
|
16
18
|
"""Initialize GitHub integration."""
|
|
17
19
|
self.github = Github(token)
|
|
18
20
|
self.cache = cache
|
|
19
21
|
self.rate_limit_retries = rate_limit_retries
|
|
20
22
|
self.backoff_factor = backoff_factor
|
|
23
|
+
self.allowed_ticket_platforms = allowed_ticket_platforms
|
|
21
24
|
|
|
22
25
|
def enrich_repository_with_prs(self, repo_name: str, commits: List[Dict[str, Any]],
|
|
23
26
|
since: datetime) -> List[Dict[str, Any]]:
|
|
@@ -67,6 +70,10 @@ class GitHubIntegration:
|
|
|
67
70
|
"""Get pull requests with rate limit handling."""
|
|
68
71
|
prs = []
|
|
69
72
|
|
|
73
|
+
# Ensure since is timezone-aware for comparison with GitHub's timezone-aware datetimes
|
|
74
|
+
if since.tzinfo is None:
|
|
75
|
+
since = since.replace(tzinfo=timezone.utc)
|
|
76
|
+
|
|
70
77
|
for attempt in range(self.rate_limit_retries):
|
|
71
78
|
try:
|
|
72
79
|
# Get all PRs updated since the date
|
|
@@ -97,7 +104,7 @@ class GitHubIntegration:
|
|
|
97
104
|
from ..extractors.tickets import TicketExtractor
|
|
98
105
|
|
|
99
106
|
sp_extractor = StoryPointExtractor()
|
|
100
|
-
ticket_extractor = TicketExtractor()
|
|
107
|
+
ticket_extractor = TicketExtractor(allowed_platforms=self.allowed_ticket_platforms)
|
|
101
108
|
|
|
102
109
|
# Extract story points from PR title and body
|
|
103
110
|
pr_text = f"{pr.title} {pr.body or ''}"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""JIRA API integration for story point and ticket enrichment."""
|
|
2
|
+
import base64
|
|
3
|
+
from typing import Any, Dict, List, Optional, Set
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from requests.exceptions import RequestException
|
|
7
|
+
|
|
8
|
+
from ..core.cache import GitAnalysisCache
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JIRAIntegration:
|
|
12
|
+
"""Integrate with JIRA API for ticket and story point data."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, base_url: str, username: str, api_token: str,
|
|
15
|
+
cache: GitAnalysisCache,
|
|
16
|
+
story_point_fields: Optional[List[str]] = None):
|
|
17
|
+
"""Initialize JIRA integration.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
base_url: JIRA instance base URL (e.g., https://company.atlassian.net)
|
|
21
|
+
username: JIRA username/email
|
|
22
|
+
api_token: JIRA API token
|
|
23
|
+
cache: Git analysis cache for storing JIRA data
|
|
24
|
+
story_point_fields: List of custom field IDs for story points
|
|
25
|
+
"""
|
|
26
|
+
self.base_url = base_url.rstrip('/')
|
|
27
|
+
self.cache = cache
|
|
28
|
+
|
|
29
|
+
# Set up authentication
|
|
30
|
+
credentials = base64.b64encode(f"{username}:{api_token}".encode()).decode()
|
|
31
|
+
self.headers = {
|
|
32
|
+
"Authorization": f"Basic {credentials}",
|
|
33
|
+
"Accept": "application/json",
|
|
34
|
+
"Content-Type": "application/json"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Default story point field names/IDs
|
|
38
|
+
self.story_point_fields = story_point_fields or [
|
|
39
|
+
"customfield_10016", # Common story points field
|
|
40
|
+
"customfield_10021", # Alternative field
|
|
41
|
+
"Story Points", # Field name
|
|
42
|
+
"storypoints", # Alternative name
|
|
43
|
+
"customfield_10002", # Another common ID
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Cache for field mapping
|
|
47
|
+
self._field_mapping = None
|
|
48
|
+
|
|
49
|
+
def enrich_commits_with_jira_data(self, commits: List[Dict[str, Any]]) -> None:
|
|
50
|
+
"""Enrich commits with JIRA story points by looking up ticket references.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
commits: List of commit dictionaries to enrich
|
|
54
|
+
"""
|
|
55
|
+
# Collect all unique JIRA tickets from commits
|
|
56
|
+
jira_tickets = set()
|
|
57
|
+
for commit in commits:
|
|
58
|
+
ticket_refs = commit.get('ticket_references', [])
|
|
59
|
+
for ref in ticket_refs:
|
|
60
|
+
if isinstance(ref, dict) and ref.get('platform') == 'jira':
|
|
61
|
+
jira_tickets.add(ref['id'])
|
|
62
|
+
elif isinstance(ref, str) and self._is_jira_ticket(ref):
|
|
63
|
+
jira_tickets.add(ref)
|
|
64
|
+
|
|
65
|
+
if not jira_tickets:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Fetch ticket data from JIRA
|
|
69
|
+
ticket_data = self._fetch_tickets_batch(list(jira_tickets))
|
|
70
|
+
|
|
71
|
+
# Enrich commits with story points
|
|
72
|
+
for commit in commits:
|
|
73
|
+
commit_story_points = 0
|
|
74
|
+
ticket_refs = commit.get('ticket_references', [])
|
|
75
|
+
|
|
76
|
+
for ref in ticket_refs:
|
|
77
|
+
ticket_id = None
|
|
78
|
+
if isinstance(ref, dict) and ref.get('platform') == 'jira':
|
|
79
|
+
ticket_id = ref['id']
|
|
80
|
+
elif isinstance(ref, str) and self._is_jira_ticket(ref):
|
|
81
|
+
ticket_id = ref
|
|
82
|
+
|
|
83
|
+
if ticket_id and ticket_id in ticket_data:
|
|
84
|
+
points = ticket_data[ticket_id].get('story_points', 0)
|
|
85
|
+
if points:
|
|
86
|
+
commit_story_points = max(commit_story_points, points)
|
|
87
|
+
|
|
88
|
+
if commit_story_points > 0:
|
|
89
|
+
commit['story_points'] = commit_story_points
|
|
90
|
+
|
|
91
|
+
def enrich_prs_with_jira_data(self, prs: List[Dict[str, Any]]) -> None:
|
|
92
|
+
"""Enrich PRs with JIRA story points.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
prs: List of PR dictionaries to enrich
|
|
96
|
+
"""
|
|
97
|
+
# Similar to commits, extract JIRA tickets from PR titles/descriptions
|
|
98
|
+
for pr in prs:
|
|
99
|
+
pr_text = f"{pr.get('title', '')} {pr.get('description', '')}"
|
|
100
|
+
jira_tickets = self._extract_jira_tickets(pr_text)
|
|
101
|
+
|
|
102
|
+
if jira_tickets:
|
|
103
|
+
ticket_data = self._fetch_tickets_batch(list(jira_tickets))
|
|
104
|
+
|
|
105
|
+
# Use the highest story point value found
|
|
106
|
+
max_points = 0
|
|
107
|
+
for ticket_id in jira_tickets:
|
|
108
|
+
if ticket_id in ticket_data:
|
|
109
|
+
points = ticket_data[ticket_id].get('story_points', 0)
|
|
110
|
+
max_points = max(max_points, points)
|
|
111
|
+
|
|
112
|
+
if max_points > 0:
|
|
113
|
+
pr['story_points'] = max_points
|
|
114
|
+
|
|
115
|
+
def _fetch_tickets_batch(self, ticket_ids: List[str]) -> Dict[str, Dict[str, Any]]:
|
|
116
|
+
"""Fetch multiple tickets from JIRA API.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
ticket_ids: List of JIRA ticket IDs
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dictionary mapping ticket ID to ticket data
|
|
123
|
+
"""
|
|
124
|
+
if not ticket_ids:
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
# Check cache first
|
|
128
|
+
cached_tickets = {}
|
|
129
|
+
tickets_to_fetch = []
|
|
130
|
+
|
|
131
|
+
for ticket_id in ticket_ids:
|
|
132
|
+
cached = self._get_cached_ticket(ticket_id)
|
|
133
|
+
if cached:
|
|
134
|
+
cached_tickets[ticket_id] = cached
|
|
135
|
+
else:
|
|
136
|
+
tickets_to_fetch.append(ticket_id)
|
|
137
|
+
|
|
138
|
+
# Fetch missing tickets from JIRA
|
|
139
|
+
if tickets_to_fetch:
|
|
140
|
+
# JIRA JQL has a limit, so batch the requests
|
|
141
|
+
batch_size = 50
|
|
142
|
+
for i in range(0, len(tickets_to_fetch), batch_size):
|
|
143
|
+
batch = tickets_to_fetch[i:i + batch_size]
|
|
144
|
+
jql = f"key in ({','.join(batch)})"
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
response = requests.get(
|
|
148
|
+
f"{self.base_url}/rest/api/3/search",
|
|
149
|
+
headers=self.headers,
|
|
150
|
+
params={
|
|
151
|
+
"jql": jql,
|
|
152
|
+
"fields": "*all", # Get all fields to find story points
|
|
153
|
+
"maxResults": batch_size
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
|
|
158
|
+
data = response.json()
|
|
159
|
+
for issue in data.get('issues', []):
|
|
160
|
+
ticket_data = self._extract_ticket_data(issue)
|
|
161
|
+
cached_tickets[ticket_data['id']] = ticket_data
|
|
162
|
+
self._cache_ticket(ticket_data['id'], ticket_data)
|
|
163
|
+
|
|
164
|
+
except RequestException as e:
|
|
165
|
+
print(f" ⚠️ Failed to fetch JIRA tickets: {e}")
|
|
166
|
+
|
|
167
|
+
return cached_tickets
|
|
168
|
+
|
|
169
|
+
def _extract_ticket_data(self, issue: Dict[str, Any]) -> Dict[str, Any]:
|
|
170
|
+
"""Extract relevant data from JIRA issue.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
issue: JIRA issue data from API
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Dictionary with extracted ticket data
|
|
177
|
+
"""
|
|
178
|
+
fields = issue.get('fields', {})
|
|
179
|
+
|
|
180
|
+
# Extract story points from various possible fields
|
|
181
|
+
story_points = 0
|
|
182
|
+
for field_id in self.story_point_fields:
|
|
183
|
+
if field_id in fields and fields[field_id] is not None:
|
|
184
|
+
try:
|
|
185
|
+
story_points = float(fields[field_id])
|
|
186
|
+
break
|
|
187
|
+
except (ValueError, TypeError):
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
'id': issue['key'],
|
|
192
|
+
'summary': fields.get('summary', ''),
|
|
193
|
+
'status': fields.get('status', {}).get('name', ''),
|
|
194
|
+
'story_points': int(story_points) if story_points else 0,
|
|
195
|
+
'assignee': fields.get('assignee', {}).get('displayName', '') if fields.get('assignee') else '',
|
|
196
|
+
'created': fields.get('created', ''),
|
|
197
|
+
'updated': fields.get('updated', '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
def _is_jira_ticket(self, text: str) -> bool:
|
|
201
|
+
"""Check if text matches JIRA ticket pattern."""
|
|
202
|
+
import re
|
|
203
|
+
return bool(re.match(r'^[A-Z]{2,10}-\d+$', text))
|
|
204
|
+
|
|
205
|
+
def _extract_jira_tickets(self, text: str) -> Set[str]:
|
|
206
|
+
"""Extract JIRA ticket IDs from text."""
|
|
207
|
+
import re
|
|
208
|
+
pattern = r'([A-Z]{2,10}-\d+)'
|
|
209
|
+
matches = re.findall(pattern, text)
|
|
210
|
+
return set(matches)
|
|
211
|
+
|
|
212
|
+
def _get_cached_ticket(self, ticket_id: str) -> Optional[Dict[str, Any]]:
|
|
213
|
+
"""Get ticket data from cache."""
|
|
214
|
+
# TODO: Implement cache lookup using self.cache
|
|
215
|
+
# For now, return None to always fetch from API
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _cache_ticket(self, ticket_id: str, ticket_data: Dict[str, Any]) -> None:
|
|
219
|
+
"""Cache ticket data."""
|
|
220
|
+
# TODO: Implement cache storage using self.cache
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
def validate_connection(self) -> bool:
|
|
224
|
+
"""Validate JIRA connection and credentials.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
True if connection is valid
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
response = requests.get(
|
|
231
|
+
f"{self.base_url}/rest/api/3/myself",
|
|
232
|
+
headers=self.headers
|
|
233
|
+
)
|
|
234
|
+
response.raise_for_status()
|
|
235
|
+
return True
|
|
236
|
+
except RequestException as e:
|
|
237
|
+
print(f" ❌ JIRA connection failed: {e}")
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
def discover_fields(self) -> Dict[str, Dict[str, str]]:
|
|
241
|
+
"""Discover all available fields in JIRA instance.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Dictionary mapping field IDs to their names and types
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
response = requests.get(
|
|
248
|
+
f"{self.base_url}/rest/api/3/field",
|
|
249
|
+
headers=self.headers
|
|
250
|
+
)
|
|
251
|
+
response.raise_for_status()
|
|
252
|
+
|
|
253
|
+
fields = {}
|
|
254
|
+
for field in response.json():
|
|
255
|
+
field_id = field.get('id', '')
|
|
256
|
+
field_name = field.get('name', '')
|
|
257
|
+
field_type = field.get('schema', {}).get('type', 'unknown') if field.get('schema') else 'unknown'
|
|
258
|
+
|
|
259
|
+
# Look for potential story point fields
|
|
260
|
+
if any(term in field_name.lower() for term in ['story', 'point', 'estimate', 'size']):
|
|
261
|
+
fields[field_id] = {
|
|
262
|
+
'name': field_name,
|
|
263
|
+
'type': field_type,
|
|
264
|
+
'is_custom': field.get('custom', False)
|
|
265
|
+
}
|
|
266
|
+
print(f" 📊 Potential story point field: {field_id} = '{field_name}' (type: {field_type})")
|
|
267
|
+
|
|
268
|
+
return fields
|
|
269
|
+
|
|
270
|
+
except RequestException as e:
|
|
271
|
+
print(f" ⚠️ Failed to discover JIRA fields: {e}")
|
|
272
|
+
return {}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""Integration orchestrator for multiple platforms."""
|
|
2
|
-
from typing import Dict, Any, List, Optional
|
|
3
|
-
from datetime import datetime
|
|
4
2
|
import json
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
5
|
|
|
6
6
|
from ..core.cache import GitAnalysisCache
|
|
7
7
|
from .github_integration import GitHubIntegration
|
|
8
|
+
from .jira_integration import JIRAIntegration
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class IntegrationOrchestrator:
|
|
@@ -22,8 +23,24 @@ class IntegrationOrchestrator:
|
|
|
22
23
|
config.github.token,
|
|
23
24
|
cache,
|
|
24
25
|
config.github.max_retries,
|
|
25
|
-
config.github.backoff_factor
|
|
26
|
+
config.github.backoff_factor,
|
|
27
|
+
allowed_ticket_platforms=getattr(config.analysis, 'ticket_platforms', None)
|
|
26
28
|
)
|
|
29
|
+
|
|
30
|
+
# Initialize JIRA integration if configured
|
|
31
|
+
if config.jira and config.jira.access_user and config.jira.access_token:
|
|
32
|
+
# Get JIRA specific settings if available
|
|
33
|
+
jira_settings = getattr(config, 'jira_integration', {})
|
|
34
|
+
if hasattr(jira_settings, 'enabled') and jira_settings.enabled:
|
|
35
|
+
base_url = getattr(config.jira, 'base_url', None)
|
|
36
|
+
if base_url:
|
|
37
|
+
self.integrations['jira'] = JIRAIntegration(
|
|
38
|
+
base_url,
|
|
39
|
+
config.jira.access_user,
|
|
40
|
+
config.jira.access_token,
|
|
41
|
+
cache,
|
|
42
|
+
story_point_fields=getattr(jira_settings, 'story_point_fields', None)
|
|
43
|
+
)
|
|
27
44
|
|
|
28
45
|
def enrich_repository_data(self, repo_config: Any, commits: List[Dict[str, Any]],
|
|
29
46
|
since: datetime) -> Dict[str, Any]:
|
|
@@ -52,10 +69,19 @@ class IntegrationOrchestrator:
|
|
|
52
69
|
except Exception as e:
|
|
53
70
|
print(f" ⚠️ GitHub enrichment failed: {e}")
|
|
54
71
|
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
# JIRA enrichment for story points
|
|
73
|
+
if 'jira' in self.integrations:
|
|
74
|
+
jira = self.integrations['jira']
|
|
75
|
+
try:
|
|
76
|
+
# Enrich commits with JIRA story points
|
|
77
|
+
jira.enrich_commits_with_jira_data(commits)
|
|
78
|
+
|
|
79
|
+
# Enrich PRs with JIRA story points
|
|
80
|
+
if enrichment['prs']:
|
|
81
|
+
jira.enrich_prs_with_jira_data(enrichment['prs'])
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f" ⚠️ JIRA enrichment failed: {e}")
|
|
59
85
|
|
|
60
86
|
return enrichment
|
|
61
87
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""DORA (DevOps Research and Assessment) metrics calculation."""
|
|
2
|
-
from datetime import datetime
|
|
3
|
-
from typing import
|
|
4
|
-
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, List
|
|
4
|
+
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
7
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
"""Database models for GitFlow Analytics using SQLAlchemy."""
|
|
2
2
|
from datetime import datetime
|
|
3
|
-
from typing import Optional
|
|
4
|
-
from sqlalchemy import create_engine, Column, String, Integer, Float, DateTime, Boolean, JSON, Index
|
|
5
|
-
from sqlalchemy.ext.declarative import declarative_base
|
|
6
|
-
from sqlalchemy.orm import sessionmaker, Session
|
|
7
3
|
from pathlib import Path
|
|
8
4
|
|
|
5
|
+
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Index, Integer, String, create_engine
|
|
6
|
+
from sqlalchemy.ext.declarative import declarative_base
|
|
7
|
+
from sqlalchemy.orm import Session, sessionmaker
|
|
8
|
+
|
|
9
9
|
Base = declarative_base()
|
|
10
10
|
|
|
11
11
|
class CachedCommit(Base):
|
|
@@ -26,7 +26,11 @@ class AnalyticsReportGenerator:
|
|
|
26
26
|
|
|
27
27
|
# Calculate totals
|
|
28
28
|
total_commits = len(commits)
|
|
29
|
-
total_lines = sum(
|
|
29
|
+
total_lines = sum(
|
|
30
|
+
c.get('filtered_insertions', c.get('insertions', 0)) +
|
|
31
|
+
c.get('filtered_deletions', c.get('deletions', 0))
|
|
32
|
+
for c in commits
|
|
33
|
+
)
|
|
30
34
|
total_files = sum(c['files_changed'] for c in commits)
|
|
31
35
|
|
|
32
36
|
# Group by developer and project
|
|
@@ -39,8 +43,11 @@ class AnalyticsReportGenerator:
|
|
|
39
43
|
project = commit.get('project_key', 'UNKNOWN')
|
|
40
44
|
|
|
41
45
|
dev_project_activity[dev_id][project]['commits'] += 1
|
|
42
|
-
dev_project_activity[dev_id][project]['lines'] +=
|
|
43
|
-
|
|
46
|
+
dev_project_activity[dev_id][project]['lines'] += (
|
|
47
|
+
commit.get('filtered_insertions', commit.get('insertions', 0)) +
|
|
48
|
+
commit.get('filtered_deletions', commit.get('deletions', 0))
|
|
49
|
+
)
|
|
50
|
+
dev_project_activity[dev_id][project]['files'] += commit.get('filtered_files_changed', commit.get('files_changed', 0))
|
|
44
51
|
dev_project_activity[dev_id][project]['story_points'] += commit.get('story_points', 0) or 0
|
|
45
52
|
|
|
46
53
|
# Build report data
|
|
@@ -162,7 +169,10 @@ class AnalyticsReportGenerator:
|
|
|
162
169
|
projects[project_key] += 1
|
|
163
170
|
|
|
164
171
|
# Lines changed per project
|
|
165
|
-
lines_changed =
|
|
172
|
+
lines_changed = (
|
|
173
|
+
commit.get('filtered_insertions', commit.get('insertions', 0)) +
|
|
174
|
+
commit.get('filtered_deletions', commit.get('deletions', 0))
|
|
175
|
+
)
|
|
166
176
|
project_lines[project_key] += lines_changed
|
|
167
177
|
|
|
168
178
|
# Weekly distribution
|
|
@@ -90,7 +90,12 @@ class CSVReportGenerator:
|
|
|
90
90
|
# Overall statistics
|
|
91
91
|
total_commits = len(commits)
|
|
92
92
|
total_story_points = sum(c.get('story_points', 0) or 0 for c in commits)
|
|
93
|
-
|
|
93
|
+
# Use filtered stats if available, otherwise fall back to raw stats
|
|
94
|
+
total_lines = sum(
|
|
95
|
+
c.get('filtered_insertions', c.get('insertions', 0)) +
|
|
96
|
+
c.get('filtered_deletions', c.get('deletions', 0))
|
|
97
|
+
for c in commits
|
|
98
|
+
)
|
|
94
99
|
|
|
95
100
|
summary_data.append({
|
|
96
101
|
'metric': 'Total Commits',
|
|
@@ -231,9 +236,12 @@ class CSVReportGenerator:
|
|
|
231
236
|
# Aggregate metrics
|
|
232
237
|
weekly_data[key]['commits'] += 1
|
|
233
238
|
weekly_data[key]['story_points'] += commit.get('story_points', 0) or 0
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
weekly_data[key]['
|
|
239
|
+
|
|
240
|
+
# Use filtered stats if available, otherwise fall back to raw stats
|
|
241
|
+
weekly_data[key]['lines_added'] += commit.get('filtered_insertions', commit.get('insertions', 0))
|
|
242
|
+
weekly_data[key]['lines_removed'] += commit.get('filtered_deletions', commit.get('deletions', 0))
|
|
243
|
+
weekly_data[key]['files_changed'] += commit.get('filtered_files_changed', commit.get('files_changed', 0))
|
|
244
|
+
|
|
237
245
|
weekly_data[key]['complexity_delta'] += commit.get('complexity_delta', 0.0)
|
|
238
246
|
|
|
239
247
|
# Track tickets
|
|
@@ -78,7 +78,11 @@ class NarrativeReportGenerator:
|
|
|
78
78
|
"""Write executive summary section."""
|
|
79
79
|
total_commits = len(commits)
|
|
80
80
|
total_developers = len(developer_stats)
|
|
81
|
-
total_lines = sum(
|
|
81
|
+
total_lines = sum(
|
|
82
|
+
c.get('filtered_insertions', c.get('insertions', 0)) +
|
|
83
|
+
c.get('filtered_deletions', c.get('deletions', 0))
|
|
84
|
+
for c in commits
|
|
85
|
+
)
|
|
82
86
|
|
|
83
87
|
report.write(f"- **Total Commits**: {total_commits:,}\n")
|
|
84
88
|
report.write(f"- **Active Developers**: {total_developers}\n")
|