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.
- gitflow_analytics/_version.py +1 -1
- gitflow_analytics/classification/__init__.py +31 -0
- gitflow_analytics/classification/batch_classifier.py +752 -0
- gitflow_analytics/classification/classifier.py +464 -0
- gitflow_analytics/classification/feature_extractor.py +725 -0
- gitflow_analytics/classification/linguist_analyzer.py +574 -0
- gitflow_analytics/classification/model.py +455 -0
- gitflow_analytics/cli.py +4158 -350
- gitflow_analytics/cli_rich.py +198 -48
- gitflow_analytics/config/__init__.py +43 -0
- gitflow_analytics/config/errors.py +261 -0
- gitflow_analytics/config/loader.py +905 -0
- gitflow_analytics/config/profiles.py +264 -0
- gitflow_analytics/config/repository.py +124 -0
- gitflow_analytics/config/schema.py +444 -0
- gitflow_analytics/config/validator.py +154 -0
- gitflow_analytics/config.py +44 -508
- gitflow_analytics/core/analyzer.py +1209 -98
- gitflow_analytics/core/cache.py +1337 -29
- gitflow_analytics/core/data_fetcher.py +1285 -0
- gitflow_analytics/core/identity.py +363 -14
- gitflow_analytics/core/metrics_storage.py +526 -0
- gitflow_analytics/core/progress.py +372 -0
- gitflow_analytics/core/schema_version.py +269 -0
- gitflow_analytics/extractors/ml_tickets.py +1100 -0
- gitflow_analytics/extractors/story_points.py +8 -1
- gitflow_analytics/extractors/tickets.py +749 -11
- gitflow_analytics/identity_llm/__init__.py +6 -0
- gitflow_analytics/identity_llm/analysis_pass.py +231 -0
- gitflow_analytics/identity_llm/analyzer.py +464 -0
- gitflow_analytics/identity_llm/models.py +76 -0
- gitflow_analytics/integrations/github_integration.py +175 -11
- gitflow_analytics/integrations/jira_integration.py +461 -24
- gitflow_analytics/integrations/orchestrator.py +124 -1
- gitflow_analytics/metrics/activity_scoring.py +322 -0
- gitflow_analytics/metrics/branch_health.py +470 -0
- gitflow_analytics/metrics/dora.py +379 -20
- gitflow_analytics/models/database.py +843 -53
- gitflow_analytics/pm_framework/__init__.py +115 -0
- gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
- gitflow_analytics/pm_framework/base.py +406 -0
- gitflow_analytics/pm_framework/models.py +211 -0
- gitflow_analytics/pm_framework/orchestrator.py +652 -0
- gitflow_analytics/pm_framework/registry.py +333 -0
- gitflow_analytics/qualitative/__init__.py +9 -10
- gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +3 -3
- gitflow_analytics/qualitative/classifiers/change_type.py +518 -244
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +272 -165
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +321 -222
- gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
- gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
- gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
- gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
- gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
- gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +215 -189
- gitflow_analytics/qualitative/core/__init__.py +4 -4
- gitflow_analytics/qualitative/core/llm_fallback.py +239 -235
- gitflow_analytics/qualitative/core/nlp_engine.py +157 -148
- gitflow_analytics/qualitative/core/pattern_cache.py +214 -192
- gitflow_analytics/qualitative/core/processor.py +381 -248
- gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
- gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
- gitflow_analytics/qualitative/models/__init__.py +7 -7
- gitflow_analytics/qualitative/models/schemas.py +155 -121
- gitflow_analytics/qualitative/utils/__init__.py +4 -4
- gitflow_analytics/qualitative/utils/batch_processor.py +136 -123
- gitflow_analytics/qualitative/utils/cost_tracker.py +142 -140
- gitflow_analytics/qualitative/utils/metrics.py +172 -158
- gitflow_analytics/qualitative/utils/text_processing.py +146 -104
- gitflow_analytics/reports/__init__.py +100 -0
- gitflow_analytics/reports/analytics_writer.py +539 -14
- gitflow_analytics/reports/base.py +648 -0
- gitflow_analytics/reports/branch_health_writer.py +322 -0
- gitflow_analytics/reports/classification_writer.py +924 -0
- gitflow_analytics/reports/cli_integration.py +427 -0
- gitflow_analytics/reports/csv_writer.py +1676 -212
- gitflow_analytics/reports/data_models.py +504 -0
- gitflow_analytics/reports/database_report_generator.py +427 -0
- gitflow_analytics/reports/example_usage.py +344 -0
- gitflow_analytics/reports/factory.py +499 -0
- gitflow_analytics/reports/formatters.py +698 -0
- gitflow_analytics/reports/html_generator.py +1116 -0
- gitflow_analytics/reports/interfaces.py +489 -0
- gitflow_analytics/reports/json_exporter.py +2770 -0
- gitflow_analytics/reports/narrative_writer.py +2287 -158
- gitflow_analytics/reports/story_point_correlation.py +1144 -0
- gitflow_analytics/reports/weekly_trends_writer.py +389 -0
- gitflow_analytics/training/__init__.py +5 -0
- gitflow_analytics/training/model_loader.py +377 -0
- gitflow_analytics/training/pipeline.py +550 -0
- gitflow_analytics/tui/__init__.py +1 -1
- gitflow_analytics/tui/app.py +129 -126
- gitflow_analytics/tui/screens/__init__.py +3 -3
- gitflow_analytics/tui/screens/analysis_progress_screen.py +188 -179
- gitflow_analytics/tui/screens/configuration_screen.py +154 -178
- gitflow_analytics/tui/screens/loading_screen.py +100 -110
- gitflow_analytics/tui/screens/main_screen.py +89 -72
- gitflow_analytics/tui/screens/results_screen.py +305 -281
- gitflow_analytics/tui/widgets/__init__.py +2 -2
- gitflow_analytics/tui/widgets/data_table.py +67 -69
- gitflow_analytics/tui/widgets/export_modal.py +76 -76
- gitflow_analytics/tui/widgets/progress_widget.py +41 -46
- gitflow_analytics-1.3.11.dist-info/METADATA +1015 -0
- gitflow_analytics-1.3.11.dist-info/RECORD +122 -0
- gitflow_analytics-1.0.3.dist-info/METADATA +0 -490
- gitflow_analytics-1.0.3.dist-info/RECORD +0 -62
- {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/licenses/LICENSE +0 -0
- {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
"""JIRA API integration for story point and ticket enrichment."""
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
4
7
|
from typing import Any, Optional
|
|
5
8
|
|
|
6
9
|
import requests
|
|
7
|
-
from requests.
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from requests.exceptions import ConnectionError, RequestException, Timeout
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
8
13
|
|
|
9
14
|
from ..core.cache import GitAnalysisCache
|
|
10
15
|
|
|
@@ -19,6 +24,12 @@ class JIRAIntegration:
|
|
|
19
24
|
api_token: str,
|
|
20
25
|
cache: GitAnalysisCache,
|
|
21
26
|
story_point_fields: Optional[list[str]] = None,
|
|
27
|
+
dns_timeout: int = 10,
|
|
28
|
+
connection_timeout: int = 30,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
backoff_factor: float = 1.0,
|
|
31
|
+
enable_proxy: bool = False,
|
|
32
|
+
proxy_url: Optional[str] = None,
|
|
22
33
|
):
|
|
23
34
|
"""Initialize JIRA integration.
|
|
24
35
|
|
|
@@ -28,9 +39,26 @@ class JIRAIntegration:
|
|
|
28
39
|
api_token: JIRA API token
|
|
29
40
|
cache: Git analysis cache for storing JIRA data
|
|
30
41
|
story_point_fields: List of custom field IDs for story points
|
|
42
|
+
dns_timeout: DNS resolution timeout in seconds (default: 10)
|
|
43
|
+
connection_timeout: HTTP connection timeout in seconds (default: 30)
|
|
44
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
45
|
+
backoff_factor: Exponential backoff factor for retries (default: 1.0)
|
|
46
|
+
enable_proxy: Whether to use proxy settings (default: False)
|
|
47
|
+
proxy_url: Proxy URL if proxy is enabled (default: None)
|
|
31
48
|
"""
|
|
32
49
|
self.base_url = base_url.rstrip("/")
|
|
33
50
|
self.cache = cache
|
|
51
|
+
self.dns_timeout = dns_timeout
|
|
52
|
+
self.connection_timeout = connection_timeout
|
|
53
|
+
self.max_retries = max_retries
|
|
54
|
+
self.backoff_factor = backoff_factor
|
|
55
|
+
self.enable_proxy = enable_proxy
|
|
56
|
+
self.proxy_url = proxy_url
|
|
57
|
+
|
|
58
|
+
# Network connectivity status
|
|
59
|
+
self._connection_validated = False
|
|
60
|
+
self._last_dns_check = 0
|
|
61
|
+
self._dns_check_interval = 300 # 5 minutes
|
|
34
62
|
|
|
35
63
|
# Set up authentication
|
|
36
64
|
credentials = base64.b64encode(f"{username}:{api_token}".encode()).decode()
|
|
@@ -38,6 +66,7 @@ class JIRAIntegration:
|
|
|
38
66
|
"Authorization": f"Basic {credentials}",
|
|
39
67
|
"Accept": "application/json",
|
|
40
68
|
"Content-Type": "application/json",
|
|
69
|
+
"User-Agent": "GitFlow-Analytics-JIRA/1.0",
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
# Default story point field names/IDs
|
|
@@ -52,12 +81,20 @@ class JIRAIntegration:
|
|
|
52
81
|
# Cache for field mapping
|
|
53
82
|
self._field_mapping = None
|
|
54
83
|
|
|
84
|
+
# Initialize HTTP session with enhanced error handling
|
|
85
|
+
self._session = self._create_resilient_session()
|
|
86
|
+
|
|
55
87
|
def enrich_commits_with_jira_data(self, commits: list[dict[str, Any]]) -> None:
|
|
56
88
|
"""Enrich commits with JIRA story points by looking up ticket references.
|
|
57
89
|
|
|
58
90
|
Args:
|
|
59
91
|
commits: List of commit dictionaries to enrich
|
|
60
92
|
"""
|
|
93
|
+
# Validate network connectivity before attempting JIRA operations
|
|
94
|
+
if not self._validate_network_connectivity():
|
|
95
|
+
print(" ⚠️ JIRA network connectivity issues detected, skipping commit enrichment")
|
|
96
|
+
return
|
|
97
|
+
|
|
61
98
|
# Collect all unique JIRA tickets from commits
|
|
62
99
|
jira_tickets = set()
|
|
63
100
|
for commit in commits:
|
|
@@ -71,7 +108,7 @@ class JIRAIntegration:
|
|
|
71
108
|
if not jira_tickets:
|
|
72
109
|
return
|
|
73
110
|
|
|
74
|
-
# Fetch ticket data from JIRA
|
|
111
|
+
# Fetch ticket data from JIRA with enhanced error handling
|
|
75
112
|
ticket_data = self._fetch_tickets_batch(list(jira_tickets))
|
|
76
113
|
|
|
77
114
|
# Enrich commits with story points
|
|
@@ -100,6 +137,11 @@ class JIRAIntegration:
|
|
|
100
137
|
Args:
|
|
101
138
|
prs: List of PR dictionaries to enrich
|
|
102
139
|
"""
|
|
140
|
+
# Validate network connectivity before attempting JIRA operations
|
|
141
|
+
if not self._validate_network_connectivity():
|
|
142
|
+
print(" ⚠️ JIRA network connectivity issues detected, skipping PR enrichment")
|
|
143
|
+
return
|
|
144
|
+
|
|
103
145
|
# Similar to commits, extract JIRA tickets from PR titles/descriptions
|
|
104
146
|
for pr in prs:
|
|
105
147
|
pr_text = f"{pr.get('title', '')} {pr.get('description', '')}"
|
|
@@ -119,7 +161,11 @@ class JIRAIntegration:
|
|
|
119
161
|
pr["story_points"] = max_points
|
|
120
162
|
|
|
121
163
|
def _fetch_tickets_batch(self, ticket_ids: list[str]) -> dict[str, dict[str, Any]]:
|
|
122
|
-
"""Fetch multiple tickets from JIRA API.
|
|
164
|
+
"""Fetch multiple tickets from JIRA API with optimized caching.
|
|
165
|
+
|
|
166
|
+
WHY: This method implements comprehensive caching to minimize JIRA API calls,
|
|
167
|
+
which are often the slowest part of the analysis. It uses bulk cache lookups
|
|
168
|
+
and provides detailed cache hit/miss metrics.
|
|
123
169
|
|
|
124
170
|
Args:
|
|
125
171
|
ticket_ids: List of JIRA ticket IDs
|
|
@@ -130,34 +176,39 @@ class JIRAIntegration:
|
|
|
130
176
|
if not ticket_ids:
|
|
131
177
|
return {}
|
|
132
178
|
|
|
133
|
-
#
|
|
134
|
-
cached_tickets =
|
|
135
|
-
tickets_to_fetch = []
|
|
179
|
+
# Bulk cache lookup for better performance
|
|
180
|
+
cached_tickets = self._get_cached_tickets_bulk(ticket_ids)
|
|
181
|
+
tickets_to_fetch = [tid for tid in ticket_ids if tid not in cached_tickets]
|
|
136
182
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
183
|
+
# Track cache performance
|
|
184
|
+
cache_hits = len(cached_tickets)
|
|
185
|
+
cache_misses = len(tickets_to_fetch)
|
|
186
|
+
|
|
187
|
+
if cache_hits > 0 or cache_misses > 0:
|
|
188
|
+
print(
|
|
189
|
+
f" 📊 JIRA cache: {cache_hits} hits, {cache_misses} misses ({cache_hits/(cache_hits+cache_misses)*100:.1f}% hit rate)"
|
|
190
|
+
)
|
|
143
191
|
|
|
144
192
|
# Fetch missing tickets from JIRA
|
|
145
193
|
if tickets_to_fetch:
|
|
146
194
|
# JIRA JQL has a limit, so batch the requests
|
|
147
195
|
batch_size = 50
|
|
196
|
+
new_tickets = [] # Collect new tickets for bulk caching
|
|
197
|
+
|
|
148
198
|
for i in range(0, len(tickets_to_fetch), batch_size):
|
|
149
199
|
batch = tickets_to_fetch[i : i + batch_size]
|
|
150
200
|
jql = f"key in ({','.join(batch)})"
|
|
151
201
|
|
|
152
202
|
try:
|
|
153
|
-
|
|
203
|
+
print(f" 🔍 Fetching {len(batch)} JIRA tickets from API...")
|
|
204
|
+
response = self._session.get(
|
|
154
205
|
f"{self.base_url}/rest/api/3/search",
|
|
155
|
-
headers=self.headers,
|
|
156
206
|
params={
|
|
157
207
|
"jql": jql,
|
|
158
208
|
"fields": "*all", # Get all fields to find story points
|
|
159
209
|
"maxResults": batch_size,
|
|
160
210
|
},
|
|
211
|
+
timeout=self.connection_timeout,
|
|
161
212
|
)
|
|
162
213
|
response.raise_for_status()
|
|
163
214
|
|
|
@@ -165,11 +216,25 @@ class JIRAIntegration:
|
|
|
165
216
|
for issue in data.get("issues", []):
|
|
166
217
|
ticket_data = self._extract_ticket_data(issue)
|
|
167
218
|
cached_tickets[ticket_data["id"]] = ticket_data
|
|
168
|
-
|
|
219
|
+
new_tickets.append(ticket_data)
|
|
169
220
|
|
|
221
|
+
except ConnectionError as e:
|
|
222
|
+
print(f" ❌ JIRA DNS/connection error: {self._format_network_error(e)}")
|
|
223
|
+
print(
|
|
224
|
+
f" Troubleshooting: Check network connectivity and DNS resolution for {self.base_url}"
|
|
225
|
+
)
|
|
226
|
+
break # Stop processing batches on network errors
|
|
227
|
+
except Timeout as e:
|
|
228
|
+
print(f" ⏱️ JIRA request timeout: {e}")
|
|
229
|
+
print(" Consider increasing timeout settings or checking network latency")
|
|
170
230
|
except RequestException as e:
|
|
171
231
|
print(f" ⚠️ Failed to fetch JIRA tickets: {e}")
|
|
172
232
|
|
|
233
|
+
# Bulk cache all new tickets
|
|
234
|
+
if new_tickets:
|
|
235
|
+
self._cache_tickets_bulk(new_tickets)
|
|
236
|
+
print(f" 💾 Cached {len(new_tickets)} new JIRA tickets")
|
|
237
|
+
|
|
173
238
|
return cached_tickets
|
|
174
239
|
|
|
175
240
|
def _extract_ticket_data(self, issue: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -220,15 +285,189 @@ class JIRAIntegration:
|
|
|
220
285
|
return set(matches)
|
|
221
286
|
|
|
222
287
|
def _get_cached_ticket(self, ticket_id: str) -> Optional[dict[str, Any]]:
|
|
223
|
-
"""Get ticket data from cache.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
288
|
+
"""Get ticket data from cache.
|
|
289
|
+
|
|
290
|
+
WHY: JIRA API calls are expensive and slow. Caching ticket data
|
|
291
|
+
significantly improves performance on repeated runs over the same
|
|
292
|
+
time period, especially when analyzing multiple repositories.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
ticket_id: JIRA ticket ID (e.g., "PROJ-123")
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Cached ticket data or None if not found/stale
|
|
299
|
+
"""
|
|
300
|
+
with self.cache.get_session() as session:
|
|
301
|
+
from ..models.database import IssueCache
|
|
302
|
+
|
|
303
|
+
cached_ticket = (
|
|
304
|
+
session.query(IssueCache)
|
|
305
|
+
.filter(IssueCache.platform == "jira", IssueCache.issue_id == ticket_id)
|
|
306
|
+
.first()
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if cached_ticket and not self._is_ticket_stale(cached_ticket.cached_at):
|
|
310
|
+
self.cache.cache_hits += 1
|
|
311
|
+
if self.cache.debug_mode:
|
|
312
|
+
print(f"DEBUG: JIRA cache HIT for ticket {ticket_id}")
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
"id": cached_ticket.issue_id,
|
|
316
|
+
"summary": cached_ticket.title or "",
|
|
317
|
+
"status": cached_ticket.status or "",
|
|
318
|
+
"story_points": cached_ticket.story_points or 0,
|
|
319
|
+
"assignee": cached_ticket.assignee or "",
|
|
320
|
+
"created": (
|
|
321
|
+
cached_ticket.created_at.isoformat() if cached_ticket.created_at else ""
|
|
322
|
+
),
|
|
323
|
+
"updated": (
|
|
324
|
+
cached_ticket.updated_at.isoformat() if cached_ticket.updated_at else ""
|
|
325
|
+
),
|
|
326
|
+
"platform_data": cached_ticket.platform_data or {},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
self.cache.cache_misses += 1
|
|
330
|
+
if self.cache.debug_mode:
|
|
331
|
+
print(f"DEBUG: JIRA cache MISS for ticket {ticket_id}")
|
|
332
|
+
return None
|
|
227
333
|
|
|
228
334
|
def _cache_ticket(self, ticket_id: str, ticket_data: dict[str, Any]) -> None:
|
|
229
|
-
"""Cache ticket data.
|
|
230
|
-
|
|
231
|
-
|
|
335
|
+
"""Cache ticket data.
|
|
336
|
+
|
|
337
|
+
WHY: Caching JIRA ticket data prevents redundant API calls and
|
|
338
|
+
significantly improves performance on subsequent runs. The cache
|
|
339
|
+
respects TTL settings to ensure data freshness.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
ticket_id: JIRA ticket ID
|
|
343
|
+
ticket_data: Ticket data from JIRA API
|
|
344
|
+
"""
|
|
345
|
+
# Use the existing cache_issue method which handles JIRA tickets
|
|
346
|
+
cache_data = {
|
|
347
|
+
"id": ticket_id,
|
|
348
|
+
"project_key": self._extract_project_key(ticket_id),
|
|
349
|
+
"title": ticket_data.get("summary", ""),
|
|
350
|
+
"description": "", # Not typically needed for analytics
|
|
351
|
+
"status": ticket_data.get("status", ""),
|
|
352
|
+
"assignee": ticket_data.get("assignee", ""),
|
|
353
|
+
"created_at": self._parse_jira_date(ticket_data.get("created")),
|
|
354
|
+
"updated_at": self._parse_jira_date(ticket_data.get("updated")),
|
|
355
|
+
"story_points": ticket_data.get("story_points", 0),
|
|
356
|
+
"labels": [], # Could extract from JIRA data if needed
|
|
357
|
+
"platform_data": ticket_data, # Store full JIRA response for future use
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
self.cache.cache_issue("jira", cache_data)
|
|
361
|
+
|
|
362
|
+
def _is_ticket_stale(self, cached_at: datetime) -> bool:
|
|
363
|
+
"""Check if cached ticket data is stale based on cache TTL.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
cached_at: When the ticket was cached
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if stale and should be refreshed, False if still fresh
|
|
370
|
+
"""
|
|
371
|
+
from datetime import timedelta
|
|
372
|
+
|
|
373
|
+
if self.cache.ttl_hours == 0: # No expiration
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
stale_threshold = datetime.utcnow() - timedelta(hours=self.cache.ttl_hours)
|
|
377
|
+
return cached_at < stale_threshold
|
|
378
|
+
|
|
379
|
+
def _extract_project_key(self, ticket_id: str) -> str:
|
|
380
|
+
"""Extract project key from JIRA ticket ID.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
ticket_id: JIRA ticket ID (e.g., "PROJ-123")
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Project key (e.g., "PROJ")
|
|
387
|
+
"""
|
|
388
|
+
return ticket_id.split("-")[0] if "-" in ticket_id else ticket_id
|
|
389
|
+
|
|
390
|
+
def _parse_jira_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
|
391
|
+
"""Parse JIRA date string to datetime object.
|
|
392
|
+
|
|
393
|
+
Args:
|
|
394
|
+
date_str: JIRA date string or None
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
Parsed datetime object or None
|
|
398
|
+
"""
|
|
399
|
+
if not date_str:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
# JIRA typically returns ISO format dates
|
|
404
|
+
from dateutil import parser
|
|
405
|
+
|
|
406
|
+
return parser.parse(date_str).replace(tzinfo=None) # Store as naive UTC
|
|
407
|
+
except (ValueError, ImportError):
|
|
408
|
+
# Fallback for basic ISO format
|
|
409
|
+
try:
|
|
410
|
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00")).replace(tzinfo=None)
|
|
411
|
+
except ValueError:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
def _get_cached_tickets_bulk(self, ticket_ids: list[str]) -> dict[str, dict[str, Any]]:
|
|
415
|
+
"""Get multiple tickets from cache in a single query.
|
|
416
|
+
|
|
417
|
+
WHY: Bulk cache lookups are much more efficient than individual lookups
|
|
418
|
+
when checking many tickets, reducing database overhead significantly.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
ticket_ids: List of JIRA ticket IDs to look up
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Dictionary mapping ticket ID to cached data (only non-stale entries)
|
|
425
|
+
"""
|
|
426
|
+
if not ticket_ids:
|
|
427
|
+
return {}
|
|
428
|
+
|
|
429
|
+
cached_tickets = {}
|
|
430
|
+
with self.cache.get_session() as session:
|
|
431
|
+
from ..models.database import IssueCache
|
|
432
|
+
|
|
433
|
+
cached_results = (
|
|
434
|
+
session.query(IssueCache)
|
|
435
|
+
.filter(IssueCache.platform == "jira", IssueCache.issue_id.in_(ticket_ids))
|
|
436
|
+
.all()
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
for cached in cached_results:
|
|
440
|
+
if not self._is_ticket_stale(cached.cached_at):
|
|
441
|
+
ticket_data = {
|
|
442
|
+
"id": cached.issue_id,
|
|
443
|
+
"summary": cached.title or "",
|
|
444
|
+
"status": cached.status or "",
|
|
445
|
+
"story_points": cached.story_points or 0,
|
|
446
|
+
"assignee": cached.assignee or "",
|
|
447
|
+
"created": cached.created_at.isoformat() if cached.created_at else "",
|
|
448
|
+
"updated": cached.updated_at.isoformat() if cached.updated_at else "",
|
|
449
|
+
"platform_data": cached.platform_data or {},
|
|
450
|
+
}
|
|
451
|
+
cached_tickets[cached.issue_id] = ticket_data
|
|
452
|
+
|
|
453
|
+
return cached_tickets
|
|
454
|
+
|
|
455
|
+
def _cache_tickets_bulk(self, tickets: list[dict[str, Any]]) -> None:
|
|
456
|
+
"""Cache multiple tickets in a single transaction.
|
|
457
|
+
|
|
458
|
+
WHY: Bulk caching is more efficient than individual cache operations,
|
|
459
|
+
reducing database overhead and improving performance when caching
|
|
460
|
+
many tickets from JIRA API responses.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
tickets: List of ticket data dictionaries to cache
|
|
464
|
+
"""
|
|
465
|
+
if not tickets:
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
for ticket_data in tickets:
|
|
469
|
+
# Use individual cache method which handles upserts properly
|
|
470
|
+
self._cache_ticket(ticket_data["id"], ticket_data)
|
|
232
471
|
|
|
233
472
|
def validate_connection(self) -> bool:
|
|
234
473
|
"""Validate JIRA connection and credentials.
|
|
@@ -237,9 +476,26 @@ class JIRAIntegration:
|
|
|
237
476
|
True if connection is valid
|
|
238
477
|
"""
|
|
239
478
|
try:
|
|
240
|
-
|
|
479
|
+
# First validate network connectivity
|
|
480
|
+
if not self._validate_network_connectivity():
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
response = self._session.get(
|
|
484
|
+
f"{self.base_url}/rest/api/3/myself", timeout=self.connection_timeout
|
|
485
|
+
)
|
|
241
486
|
response.raise_for_status()
|
|
487
|
+
self._connection_validated = True
|
|
242
488
|
return True
|
|
489
|
+
except ConnectionError as e:
|
|
490
|
+
print(f" ❌ JIRA DNS/connection error: {self._format_network_error(e)}")
|
|
491
|
+
print(
|
|
492
|
+
f" Troubleshooting: Check network connectivity and DNS resolution for {self.base_url}"
|
|
493
|
+
)
|
|
494
|
+
return False
|
|
495
|
+
except Timeout as e:
|
|
496
|
+
print(f" ⏱️ JIRA connection timeout: {e}")
|
|
497
|
+
print(" Consider increasing timeout settings or checking network latency")
|
|
498
|
+
return False
|
|
243
499
|
except RequestException as e:
|
|
244
500
|
print(f" ❌ JIRA connection failed: {e}")
|
|
245
501
|
return False
|
|
@@ -251,7 +507,13 @@ class JIRAIntegration:
|
|
|
251
507
|
Dictionary mapping field IDs to their names and types
|
|
252
508
|
"""
|
|
253
509
|
try:
|
|
254
|
-
|
|
510
|
+
# Validate network connectivity first
|
|
511
|
+
if not self._validate_network_connectivity():
|
|
512
|
+
return {}
|
|
513
|
+
|
|
514
|
+
response = self._session.get(
|
|
515
|
+
f"{self.base_url}/rest/api/3/field", timeout=self.connection_timeout
|
|
516
|
+
)
|
|
255
517
|
response.raise_for_status()
|
|
256
518
|
|
|
257
519
|
fields = {}
|
|
@@ -279,6 +541,181 @@ class JIRAIntegration:
|
|
|
279
541
|
|
|
280
542
|
return fields
|
|
281
543
|
|
|
544
|
+
except ConnectionError as e:
|
|
545
|
+
print(
|
|
546
|
+
f" ❌ JIRA DNS/connection error during field discovery: {self._format_network_error(e)}"
|
|
547
|
+
)
|
|
548
|
+
print(
|
|
549
|
+
f" Troubleshooting: Check network connectivity and DNS resolution for {self.base_url}"
|
|
550
|
+
)
|
|
551
|
+
return {}
|
|
552
|
+
except Timeout as e:
|
|
553
|
+
print(f" ⏱️ JIRA field discovery timeout: {e}")
|
|
554
|
+
print(" Consider increasing timeout settings or checking network latency")
|
|
555
|
+
return {}
|
|
282
556
|
except RequestException as e:
|
|
283
557
|
print(f" ⚠️ Failed to discover JIRA fields: {e}")
|
|
284
558
|
return {}
|
|
559
|
+
|
|
560
|
+
def _create_resilient_session(self) -> requests.Session:
|
|
561
|
+
"""Create HTTP session with enhanced retry logic and DNS error handling.
|
|
562
|
+
|
|
563
|
+
WHY: DNS resolution failures and network issues are common when connecting
|
|
564
|
+
to external JIRA instances. This session provides resilient connections
|
|
565
|
+
with exponential backoff and comprehensive error handling.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
Configured requests session with retry strategy and network resilience.
|
|
569
|
+
"""
|
|
570
|
+
session = requests.Session()
|
|
571
|
+
|
|
572
|
+
# Configure retry strategy for network resilience
|
|
573
|
+
retry_strategy = Retry(
|
|
574
|
+
total=self.max_retries,
|
|
575
|
+
backoff_factor=self.backoff_factor,
|
|
576
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
577
|
+
allowed_methods=["HEAD", "GET", "OPTIONS", "POST"],
|
|
578
|
+
raise_on_status=False, # Let us handle status codes
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
582
|
+
session.mount("http://", adapter)
|
|
583
|
+
session.mount("https://", adapter)
|
|
584
|
+
|
|
585
|
+
# Set default headers
|
|
586
|
+
session.headers.update(self.headers)
|
|
587
|
+
|
|
588
|
+
# Configure proxy if enabled
|
|
589
|
+
if self.enable_proxy and self.proxy_url:
|
|
590
|
+
session.proxies = {
|
|
591
|
+
"http": self.proxy_url,
|
|
592
|
+
"https": self.proxy_url,
|
|
593
|
+
}
|
|
594
|
+
print(f" 🌐 Using proxy: {self.proxy_url}")
|
|
595
|
+
|
|
596
|
+
# Set default timeout
|
|
597
|
+
session.timeout = self.connection_timeout
|
|
598
|
+
|
|
599
|
+
return session
|
|
600
|
+
|
|
601
|
+
def _validate_network_connectivity(self) -> bool:
|
|
602
|
+
"""Validate network connectivity to JIRA instance.
|
|
603
|
+
|
|
604
|
+
WHY: DNS resolution errors are a common cause of JIRA integration failures.
|
|
605
|
+
This method performs proactive network validation to detect issues early
|
|
606
|
+
and provide better error messages for troubleshooting.
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
True if network connectivity is available, False otherwise.
|
|
610
|
+
"""
|
|
611
|
+
current_time = time.time()
|
|
612
|
+
|
|
613
|
+
# Skip check if recently validated (within interval)
|
|
614
|
+
if (
|
|
615
|
+
self._connection_validated
|
|
616
|
+
and current_time - self._last_dns_check < self._dns_check_interval
|
|
617
|
+
):
|
|
618
|
+
return True
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
# Extract hostname from base URL
|
|
622
|
+
from urllib.parse import urlparse
|
|
623
|
+
|
|
624
|
+
parsed_url = urlparse(self.base_url)
|
|
625
|
+
hostname = parsed_url.hostname
|
|
626
|
+
port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80)
|
|
627
|
+
|
|
628
|
+
if not hostname:
|
|
629
|
+
print(f" ❌ Invalid JIRA URL format: {self.base_url}")
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
# Test DNS resolution
|
|
633
|
+
print(f" 🔍 Validating DNS resolution for {hostname}...")
|
|
634
|
+
socket.setdefaulttimeout(self.dns_timeout)
|
|
635
|
+
|
|
636
|
+
# Attempt to resolve hostname
|
|
637
|
+
addr_info = socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
638
|
+
if not addr_info:
|
|
639
|
+
print(f" ❌ DNS resolution failed: No addresses found for {hostname}")
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
# Test basic connectivity
|
|
643
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
644
|
+
sock.settimeout(self.dns_timeout)
|
|
645
|
+
try:
|
|
646
|
+
result = sock.connect_ex((addr_info[0][4][0], port))
|
|
647
|
+
if result == 0:
|
|
648
|
+
print(f" ✅ Network connectivity confirmed to {hostname}:{port}")
|
|
649
|
+
self._connection_validated = True
|
|
650
|
+
self._last_dns_check = current_time
|
|
651
|
+
return True
|
|
652
|
+
else:
|
|
653
|
+
print(f" ❌ Connection failed to {hostname}:{port} (error code: {result})")
|
|
654
|
+
return False
|
|
655
|
+
finally:
|
|
656
|
+
sock.close()
|
|
657
|
+
|
|
658
|
+
except socket.gaierror as e:
|
|
659
|
+
print(f" ❌ DNS resolution error: {self._format_dns_error(e)}")
|
|
660
|
+
print(f" Hostname: {hostname}")
|
|
661
|
+
print(" Troubleshooting:")
|
|
662
|
+
print(f" 1. Verify the hostname is correct: {hostname}")
|
|
663
|
+
print(" 2. Check your internet connection")
|
|
664
|
+
print(f" 3. Verify DNS settings (try: nslookup {hostname})")
|
|
665
|
+
print(" 4. Check if behind corporate firewall/proxy")
|
|
666
|
+
print(" 5. Verify JIRA instance is accessible externally")
|
|
667
|
+
return False
|
|
668
|
+
except socket.timeout:
|
|
669
|
+
print(f" ⏱️ DNS resolution timeout for {hostname} (>{self.dns_timeout}s)")
|
|
670
|
+
print(" Consider increasing dns_timeout or checking network latency")
|
|
671
|
+
return False
|
|
672
|
+
except Exception as e:
|
|
673
|
+
print(f" ❌ Network validation error: {e}")
|
|
674
|
+
return False
|
|
675
|
+
finally:
|
|
676
|
+
socket.setdefaulttimeout(None) # Reset to default
|
|
677
|
+
|
|
678
|
+
def _format_network_error(self, error: Exception) -> str:
|
|
679
|
+
"""Format network errors with helpful context.
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
error: The network exception that occurred.
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
Formatted error message with troubleshooting context.
|
|
686
|
+
"""
|
|
687
|
+
error_str = str(error)
|
|
688
|
+
|
|
689
|
+
if "nodename nor servname provided" in error_str or "[Errno 8]" in error_str:
|
|
690
|
+
return f"DNS resolution failed - hostname not found ({error_str})"
|
|
691
|
+
elif "Name or service not known" in error_str or "[Errno -2]" in error_str:
|
|
692
|
+
return f"DNS resolution failed - service not known ({error_str})"
|
|
693
|
+
elif "Connection refused" in error_str or "[Errno 111]" in error_str:
|
|
694
|
+
return f"Connection refused - service not running ({error_str})"
|
|
695
|
+
elif "Network is unreachable" in error_str or "[Errno 101]" in error_str:
|
|
696
|
+
return f"Network unreachable - check internet connection ({error_str})"
|
|
697
|
+
elif "timeout" in error_str.lower():
|
|
698
|
+
return f"Network timeout - slow connection or high latency ({error_str})"
|
|
699
|
+
else:
|
|
700
|
+
return f"Network error ({error_str})"
|
|
701
|
+
|
|
702
|
+
def _format_dns_error(self, error: socket.gaierror) -> str:
|
|
703
|
+
"""Format DNS resolution errors with specific guidance.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
error: The DNS resolution error that occurred.
|
|
707
|
+
|
|
708
|
+
Returns:
|
|
709
|
+
Formatted DNS error message with troubleshooting guidance.
|
|
710
|
+
"""
|
|
711
|
+
error_code = error.errno if hasattr(error, "errno") else "unknown"
|
|
712
|
+
error_msg = str(error)
|
|
713
|
+
|
|
714
|
+
if error_code == 8 or "nodename nor servname provided" in error_msg:
|
|
715
|
+
return f"Hostname not found in DNS (error code: {error_code})"
|
|
716
|
+
elif error_code == -2 or "Name or service not known" in error_msg:
|
|
717
|
+
return f"DNS name resolution failed (error code: {error_code})"
|
|
718
|
+
elif error_code == -3 or "Temporary failure in name resolution" in error_msg:
|
|
719
|
+
return f"Temporary DNS failure - try again later (error code: {error_code})"
|
|
720
|
+
else:
|
|
721
|
+
return f"DNS error (code: {error_code}, message: {error_msg})"
|