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.
@@ -1,14 +1,12 @@
1
1
  """Caching layer for Git analysis with SQLite backend."""
2
- import hashlib
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 contextlib import contextmanager
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, CachedCommit, PullRequestCache, IssueCache
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
- from datetime import datetime
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.orm import Session
10
- from sqlalchemy import and_, or_
9
+ from sqlalchemy import and_
11
10
 
12
- from ..models.database import Database, DeveloperIdentity, DeveloperAlias
11
+ from ..models.database import Database, DeveloperAlias, DeveloperIdentity
13
12
 
14
13
 
15
14
  class DeveloperIdentityResolver:
@@ -1,6 +1,6 @@
1
1
  """Base classes for pluggable extractors."""
2
2
  from abc import ABC, abstractmethod
3
- from typing import Any, Optional, List, Dict
3
+ from typing import Any, Dict, List, Optional
4
4
 
5
5
 
6
6
  class ExtractorBase(ABC):
@@ -1,6 +1,6 @@
1
1
  """Story point extraction from commits and pull requests."""
2
2
  import re
3
- from typing import Optional, List, Dict, Any
3
+ from typing import Any, Dict, List, Optional
4
4
 
5
5
 
6
6
  class StoryPointExtractor:
@@ -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
- # Future: Add other platform integrations here
56
- # - ClickUp
57
- # - JIRA
58
- # - Linear
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, timedelta
3
- from typing import List, Dict, Any, Optional
4
- from collections import defaultdict
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(c['insertions'] + c['deletions'] for c in commits)
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'] += commit['insertions'] + commit['deletions']
43
- dev_project_activity[dev_id][project]['files'] += commit['files_changed']
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 = commit['insertions'] + commit['deletions']
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
- total_lines = sum(c['insertions'] + c['deletions'] for c in commits)
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
- weekly_data[key]['lines_added'] += commit.get('insertions', 0)
235
- weekly_data[key]['lines_removed'] += commit.get('deletions', 0)
236
- weekly_data[key]['files_changed'] += commit.get('files_changed', 0)
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(c['insertions'] + c['deletions'] for c in commits)
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")