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.
Files changed (116) hide show
  1. gitflow_analytics/_version.py +1 -1
  2. gitflow_analytics/classification/__init__.py +31 -0
  3. gitflow_analytics/classification/batch_classifier.py +752 -0
  4. gitflow_analytics/classification/classifier.py +464 -0
  5. gitflow_analytics/classification/feature_extractor.py +725 -0
  6. gitflow_analytics/classification/linguist_analyzer.py +574 -0
  7. gitflow_analytics/classification/model.py +455 -0
  8. gitflow_analytics/cli.py +4158 -350
  9. gitflow_analytics/cli_rich.py +198 -48
  10. gitflow_analytics/config/__init__.py +43 -0
  11. gitflow_analytics/config/errors.py +261 -0
  12. gitflow_analytics/config/loader.py +905 -0
  13. gitflow_analytics/config/profiles.py +264 -0
  14. gitflow_analytics/config/repository.py +124 -0
  15. gitflow_analytics/config/schema.py +444 -0
  16. gitflow_analytics/config/validator.py +154 -0
  17. gitflow_analytics/config.py +44 -508
  18. gitflow_analytics/core/analyzer.py +1209 -98
  19. gitflow_analytics/core/cache.py +1337 -29
  20. gitflow_analytics/core/data_fetcher.py +1285 -0
  21. gitflow_analytics/core/identity.py +363 -14
  22. gitflow_analytics/core/metrics_storage.py +526 -0
  23. gitflow_analytics/core/progress.py +372 -0
  24. gitflow_analytics/core/schema_version.py +269 -0
  25. gitflow_analytics/extractors/ml_tickets.py +1100 -0
  26. gitflow_analytics/extractors/story_points.py +8 -1
  27. gitflow_analytics/extractors/tickets.py +749 -11
  28. gitflow_analytics/identity_llm/__init__.py +6 -0
  29. gitflow_analytics/identity_llm/analysis_pass.py +231 -0
  30. gitflow_analytics/identity_llm/analyzer.py +464 -0
  31. gitflow_analytics/identity_llm/models.py +76 -0
  32. gitflow_analytics/integrations/github_integration.py +175 -11
  33. gitflow_analytics/integrations/jira_integration.py +461 -24
  34. gitflow_analytics/integrations/orchestrator.py +124 -1
  35. gitflow_analytics/metrics/activity_scoring.py +322 -0
  36. gitflow_analytics/metrics/branch_health.py +470 -0
  37. gitflow_analytics/metrics/dora.py +379 -20
  38. gitflow_analytics/models/database.py +843 -53
  39. gitflow_analytics/pm_framework/__init__.py +115 -0
  40. gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
  41. gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
  42. gitflow_analytics/pm_framework/base.py +406 -0
  43. gitflow_analytics/pm_framework/models.py +211 -0
  44. gitflow_analytics/pm_framework/orchestrator.py +652 -0
  45. gitflow_analytics/pm_framework/registry.py +333 -0
  46. gitflow_analytics/qualitative/__init__.py +9 -10
  47. gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
  48. gitflow_analytics/qualitative/classifiers/__init__.py +3 -3
  49. gitflow_analytics/qualitative/classifiers/change_type.py +518 -244
  50. gitflow_analytics/qualitative/classifiers/domain_classifier.py +272 -165
  51. gitflow_analytics/qualitative/classifiers/intent_analyzer.py +321 -222
  52. gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
  53. gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
  54. gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
  55. gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
  56. gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
  57. gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
  58. gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
  59. gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
  60. gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
  61. gitflow_analytics/qualitative/classifiers/risk_analyzer.py +215 -189
  62. gitflow_analytics/qualitative/core/__init__.py +4 -4
  63. gitflow_analytics/qualitative/core/llm_fallback.py +239 -235
  64. gitflow_analytics/qualitative/core/nlp_engine.py +157 -148
  65. gitflow_analytics/qualitative/core/pattern_cache.py +214 -192
  66. gitflow_analytics/qualitative/core/processor.py +381 -248
  67. gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
  68. gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
  69. gitflow_analytics/qualitative/models/__init__.py +7 -7
  70. gitflow_analytics/qualitative/models/schemas.py +155 -121
  71. gitflow_analytics/qualitative/utils/__init__.py +4 -4
  72. gitflow_analytics/qualitative/utils/batch_processor.py +136 -123
  73. gitflow_analytics/qualitative/utils/cost_tracker.py +142 -140
  74. gitflow_analytics/qualitative/utils/metrics.py +172 -158
  75. gitflow_analytics/qualitative/utils/text_processing.py +146 -104
  76. gitflow_analytics/reports/__init__.py +100 -0
  77. gitflow_analytics/reports/analytics_writer.py +539 -14
  78. gitflow_analytics/reports/base.py +648 -0
  79. gitflow_analytics/reports/branch_health_writer.py +322 -0
  80. gitflow_analytics/reports/classification_writer.py +924 -0
  81. gitflow_analytics/reports/cli_integration.py +427 -0
  82. gitflow_analytics/reports/csv_writer.py +1676 -212
  83. gitflow_analytics/reports/data_models.py +504 -0
  84. gitflow_analytics/reports/database_report_generator.py +427 -0
  85. gitflow_analytics/reports/example_usage.py +344 -0
  86. gitflow_analytics/reports/factory.py +499 -0
  87. gitflow_analytics/reports/formatters.py +698 -0
  88. gitflow_analytics/reports/html_generator.py +1116 -0
  89. gitflow_analytics/reports/interfaces.py +489 -0
  90. gitflow_analytics/reports/json_exporter.py +2770 -0
  91. gitflow_analytics/reports/narrative_writer.py +2287 -158
  92. gitflow_analytics/reports/story_point_correlation.py +1144 -0
  93. gitflow_analytics/reports/weekly_trends_writer.py +389 -0
  94. gitflow_analytics/training/__init__.py +5 -0
  95. gitflow_analytics/training/model_loader.py +377 -0
  96. gitflow_analytics/training/pipeline.py +550 -0
  97. gitflow_analytics/tui/__init__.py +1 -1
  98. gitflow_analytics/tui/app.py +129 -126
  99. gitflow_analytics/tui/screens/__init__.py +3 -3
  100. gitflow_analytics/tui/screens/analysis_progress_screen.py +188 -179
  101. gitflow_analytics/tui/screens/configuration_screen.py +154 -178
  102. gitflow_analytics/tui/screens/loading_screen.py +100 -110
  103. gitflow_analytics/tui/screens/main_screen.py +89 -72
  104. gitflow_analytics/tui/screens/results_screen.py +305 -281
  105. gitflow_analytics/tui/widgets/__init__.py +2 -2
  106. gitflow_analytics/tui/widgets/data_table.py +67 -69
  107. gitflow_analytics/tui/widgets/export_modal.py +76 -76
  108. gitflow_analytics/tui/widgets/progress_widget.py +41 -46
  109. gitflow_analytics-1.3.11.dist-info/METADATA +1015 -0
  110. gitflow_analytics-1.3.11.dist-info/RECORD +122 -0
  111. gitflow_analytics-1.0.3.dist-info/METADATA +0 -490
  112. gitflow_analytics-1.0.3.dist-info/RECORD +0 -62
  113. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/WHEEL +0 -0
  114. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/entry_points.txt +0 -0
  115. {gitflow_analytics-1.0.3.dist-info → gitflow_analytics-1.3.11.dist-info}/licenses/LICENSE +0 -0
  116. {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.exceptions import RequestException
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
- # Check cache first
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
- for ticket_id in ticket_ids:
138
- cached = self._get_cached_ticket(ticket_id)
139
- if cached:
140
- cached_tickets[ticket_id] = cached
141
- else:
142
- tickets_to_fetch.append(ticket_id)
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
- response = requests.get(
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
- self._cache_ticket(ticket_data["id"], ticket_data)
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
- # TODO: Implement cache lookup using self.cache
225
- # For now, return None to always fetch from API
226
- return None
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
- # TODO: Implement cache storage using self.cache
231
- pass
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
- response = requests.get(f"{self.base_url}/rest/api/3/myself", headers=self.headers)
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
- response = requests.get(f"{self.base_url}/rest/api/3/field", headers=self.headers)
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})"