gitflow-analytics 1.0.1__py3-none-any.whl → 1.3.6__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 (119) hide show
  1. gitflow_analytics/__init__.py +11 -11
  2. gitflow_analytics/_version.py +2 -2
  3. gitflow_analytics/classification/__init__.py +31 -0
  4. gitflow_analytics/classification/batch_classifier.py +752 -0
  5. gitflow_analytics/classification/classifier.py +464 -0
  6. gitflow_analytics/classification/feature_extractor.py +725 -0
  7. gitflow_analytics/classification/linguist_analyzer.py +574 -0
  8. gitflow_analytics/classification/model.py +455 -0
  9. gitflow_analytics/cli.py +4490 -378
  10. gitflow_analytics/cli_rich.py +503 -0
  11. gitflow_analytics/config/__init__.py +43 -0
  12. gitflow_analytics/config/errors.py +261 -0
  13. gitflow_analytics/config/loader.py +904 -0
  14. gitflow_analytics/config/profiles.py +264 -0
  15. gitflow_analytics/config/repository.py +124 -0
  16. gitflow_analytics/config/schema.py +441 -0
  17. gitflow_analytics/config/validator.py +154 -0
  18. gitflow_analytics/config.py +44 -398
  19. gitflow_analytics/core/analyzer.py +1320 -172
  20. gitflow_analytics/core/branch_mapper.py +132 -132
  21. gitflow_analytics/core/cache.py +1554 -175
  22. gitflow_analytics/core/data_fetcher.py +1193 -0
  23. gitflow_analytics/core/identity.py +571 -185
  24. gitflow_analytics/core/metrics_storage.py +526 -0
  25. gitflow_analytics/core/progress.py +372 -0
  26. gitflow_analytics/core/schema_version.py +269 -0
  27. gitflow_analytics/extractors/base.py +13 -11
  28. gitflow_analytics/extractors/ml_tickets.py +1100 -0
  29. gitflow_analytics/extractors/story_points.py +77 -59
  30. gitflow_analytics/extractors/tickets.py +841 -89
  31. gitflow_analytics/identity_llm/__init__.py +6 -0
  32. gitflow_analytics/identity_llm/analysis_pass.py +231 -0
  33. gitflow_analytics/identity_llm/analyzer.py +464 -0
  34. gitflow_analytics/identity_llm/models.py +76 -0
  35. gitflow_analytics/integrations/github_integration.py +258 -87
  36. gitflow_analytics/integrations/jira_integration.py +572 -123
  37. gitflow_analytics/integrations/orchestrator.py +206 -82
  38. gitflow_analytics/metrics/activity_scoring.py +322 -0
  39. gitflow_analytics/metrics/branch_health.py +470 -0
  40. gitflow_analytics/metrics/dora.py +542 -179
  41. gitflow_analytics/models/database.py +986 -59
  42. gitflow_analytics/pm_framework/__init__.py +115 -0
  43. gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
  44. gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
  45. gitflow_analytics/pm_framework/base.py +406 -0
  46. gitflow_analytics/pm_framework/models.py +211 -0
  47. gitflow_analytics/pm_framework/orchestrator.py +652 -0
  48. gitflow_analytics/pm_framework/registry.py +333 -0
  49. gitflow_analytics/qualitative/__init__.py +29 -0
  50. gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
  51. gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
  52. gitflow_analytics/qualitative/classifiers/change_type.py +742 -0
  53. gitflow_analytics/qualitative/classifiers/domain_classifier.py +506 -0
  54. gitflow_analytics/qualitative/classifiers/intent_analyzer.py +535 -0
  55. gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
  56. gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
  57. gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
  58. gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
  59. gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
  60. gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
  61. gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
  62. gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
  63. gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
  64. gitflow_analytics/qualitative/classifiers/risk_analyzer.py +438 -0
  65. gitflow_analytics/qualitative/core/__init__.py +13 -0
  66. gitflow_analytics/qualitative/core/llm_fallback.py +657 -0
  67. gitflow_analytics/qualitative/core/nlp_engine.py +382 -0
  68. gitflow_analytics/qualitative/core/pattern_cache.py +479 -0
  69. gitflow_analytics/qualitative/core/processor.py +673 -0
  70. gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
  71. gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
  72. gitflow_analytics/qualitative/models/__init__.py +25 -0
  73. gitflow_analytics/qualitative/models/schemas.py +306 -0
  74. gitflow_analytics/qualitative/utils/__init__.py +13 -0
  75. gitflow_analytics/qualitative/utils/batch_processor.py +339 -0
  76. gitflow_analytics/qualitative/utils/cost_tracker.py +345 -0
  77. gitflow_analytics/qualitative/utils/metrics.py +361 -0
  78. gitflow_analytics/qualitative/utils/text_processing.py +285 -0
  79. gitflow_analytics/reports/__init__.py +100 -0
  80. gitflow_analytics/reports/analytics_writer.py +550 -18
  81. gitflow_analytics/reports/base.py +648 -0
  82. gitflow_analytics/reports/branch_health_writer.py +322 -0
  83. gitflow_analytics/reports/classification_writer.py +924 -0
  84. gitflow_analytics/reports/cli_integration.py +427 -0
  85. gitflow_analytics/reports/csv_writer.py +1700 -216
  86. gitflow_analytics/reports/data_models.py +504 -0
  87. gitflow_analytics/reports/database_report_generator.py +427 -0
  88. gitflow_analytics/reports/example_usage.py +344 -0
  89. gitflow_analytics/reports/factory.py +499 -0
  90. gitflow_analytics/reports/formatters.py +698 -0
  91. gitflow_analytics/reports/html_generator.py +1116 -0
  92. gitflow_analytics/reports/interfaces.py +489 -0
  93. gitflow_analytics/reports/json_exporter.py +2770 -0
  94. gitflow_analytics/reports/narrative_writer.py +2289 -158
  95. gitflow_analytics/reports/story_point_correlation.py +1144 -0
  96. gitflow_analytics/reports/weekly_trends_writer.py +389 -0
  97. gitflow_analytics/training/__init__.py +5 -0
  98. gitflow_analytics/training/model_loader.py +377 -0
  99. gitflow_analytics/training/pipeline.py +550 -0
  100. gitflow_analytics/tui/__init__.py +5 -0
  101. gitflow_analytics/tui/app.py +724 -0
  102. gitflow_analytics/tui/screens/__init__.py +8 -0
  103. gitflow_analytics/tui/screens/analysis_progress_screen.py +496 -0
  104. gitflow_analytics/tui/screens/configuration_screen.py +523 -0
  105. gitflow_analytics/tui/screens/loading_screen.py +348 -0
  106. gitflow_analytics/tui/screens/main_screen.py +321 -0
  107. gitflow_analytics/tui/screens/results_screen.py +722 -0
  108. gitflow_analytics/tui/widgets/__init__.py +7 -0
  109. gitflow_analytics/tui/widgets/data_table.py +255 -0
  110. gitflow_analytics/tui/widgets/export_modal.py +301 -0
  111. gitflow_analytics/tui/widgets/progress_widget.py +187 -0
  112. gitflow_analytics-1.3.6.dist-info/METADATA +1015 -0
  113. gitflow_analytics-1.3.6.dist-info/RECORD +122 -0
  114. gitflow_analytics-1.0.1.dist-info/METADATA +0 -463
  115. gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
  116. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/WHEEL +0 -0
  117. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/entry_points.txt +0 -0
  118. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/licenses/LICENSE +0 -0
  119. {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1845 @@
1
+ """JIRA platform adapter for PM framework integration.
2
+
3
+ This module provides comprehensive JIRA integration for the GitFlow Analytics PM framework,
4
+ supporting JIRA Cloud and Server instances with advanced features like custom fields,
5
+ sprint tracking, and optimized batch operations.
6
+ """
7
+
8
+ import base64
9
+ import logging
10
+ import sqlite3
11
+ import time
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import requests
17
+ from requests.adapters import HTTPAdapter
18
+ from requests.exceptions import ConnectionError, RequestException, Timeout
19
+ from urllib3.util.retry import Retry
20
+
21
+ from ..base import BasePlatformAdapter, PlatformCapabilities
22
+ from ..models import (
23
+ IssueStatus,
24
+ IssueType,
25
+ UnifiedIssue,
26
+ UnifiedProject,
27
+ UnifiedSprint,
28
+ UnifiedUser,
29
+ )
30
+
31
+ # Configure logger for JIRA adapter
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class JiraTicketCache:
36
+ """SQLite-based cache for JIRA ticket responses.
37
+
38
+ WHY: JIRA API calls are expensive and can be slow, especially for large
39
+ organizations. This cache stores ticket responses with configurable TTL
40
+ to dramatically speed up repeated runs while maintaining data freshness.
41
+
42
+ DESIGN DECISION: Store cache in config directory (not .gitflow-cache)
43
+ as requested, use SQLite for efficient querying and storage, include
44
+ comprehensive metadata for cache management and performance tracking.
45
+
46
+ Cache Strategy:
47
+ - Individual ticket responses cached with full JSON data
48
+ - Configurable TTL with default 7 days (168 hours)
49
+ - Cache hit/miss metrics for performance monitoring
50
+ - Automatic cleanup of expired entries
51
+ - Size management with configurable limits
52
+ """
53
+
54
+ def __init__(self, config_dir: Path, ttl_hours: int = 168) -> None:
55
+ """Initialize JIRA ticket cache.
56
+
57
+ Args:
58
+ config_dir: Directory to store cache database (config file directory)
59
+ ttl_hours: Time to live for cached tickets in hours (default: 7 days)
60
+ """
61
+ self.config_dir = Path(config_dir)
62
+ self.ttl_hours = ttl_hours
63
+ self.cache_path = self.config_dir / "jira_tickets.db"
64
+
65
+ # Performance metrics
66
+ self.cache_hits = 0
67
+ self.cache_misses = 0
68
+ self.cache_stores = 0
69
+ self.session_start = datetime.now()
70
+
71
+ # Ensure config directory exists
72
+ self.config_dir.mkdir(parents=True, exist_ok=True)
73
+
74
+ # Initialize database
75
+ self._init_database()
76
+
77
+ logger.info(f"Initialized JIRA ticket cache: {self.cache_path}")
78
+
79
+ def _init_database(self) -> None:
80
+ """Initialize SQLite database with ticket cache tables.
81
+
82
+ WHY: Comprehensive schema design captures all ticket metadata
83
+ needed for analytics while enabling efficient querying and
84
+ cache management operations.
85
+ """
86
+ with sqlite3.connect(self.cache_path) as conn:
87
+ conn.execute(
88
+ """
89
+ CREATE TABLE IF NOT EXISTS jira_tickets (
90
+ ticket_key TEXT PRIMARY KEY,
91
+ project_key TEXT NOT NULL,
92
+ ticket_data JSON NOT NULL,
93
+ story_points INTEGER,
94
+ status TEXT,
95
+ issue_type TEXT,
96
+ assignee TEXT,
97
+ reporter TEXT,
98
+ created_at TIMESTAMP,
99
+ updated_at TIMESTAMP,
100
+ resolved_at TIMESTAMP,
101
+ cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
102
+ expires_at TIMESTAMP NOT NULL,
103
+ access_count INTEGER DEFAULT 1,
104
+ last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP
105
+ )
106
+ """
107
+ )
108
+
109
+ # Indexes for efficient querying
110
+ conn.execute(
111
+ """
112
+ CREATE INDEX IF NOT EXISTS idx_project_key
113
+ ON jira_tickets(project_key)
114
+ """
115
+ )
116
+ conn.execute(
117
+ """
118
+ CREATE INDEX IF NOT EXISTS idx_expires_at
119
+ ON jira_tickets(expires_at)
120
+ """
121
+ )
122
+ conn.execute(
123
+ """
124
+ CREATE INDEX IF NOT EXISTS idx_status
125
+ ON jira_tickets(status)
126
+ """
127
+ )
128
+ conn.execute(
129
+ """
130
+ CREATE INDEX IF NOT EXISTS idx_updated_at
131
+ ON jira_tickets(updated_at)
132
+ """
133
+ )
134
+
135
+ # Cache metadata table
136
+ conn.execute(
137
+ """
138
+ CREATE TABLE IF NOT EXISTS cache_metadata (
139
+ key TEXT PRIMARY KEY,
140
+ value TEXT,
141
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
142
+ )
143
+ """
144
+ )
145
+
146
+ conn.commit()
147
+
148
+ def get_ticket(self, ticket_key: str) -> Optional[dict[str, Any]]:
149
+ """Retrieve cached ticket data if not expired.
150
+
151
+ Args:
152
+ ticket_key: JIRA ticket key (e.g., 'PROJ-123')
153
+
154
+ Returns:
155
+ Cached ticket data as dictionary, or None if not found/expired
156
+ """
157
+ with sqlite3.connect(self.cache_path) as conn:
158
+ conn.row_factory = sqlite3.Row
159
+ cursor = conn.cursor()
160
+
161
+ cursor.execute(
162
+ """
163
+ SELECT ticket_data, expires_at, access_count
164
+ FROM jira_tickets
165
+ WHERE ticket_key = ? AND expires_at > CURRENT_TIMESTAMP
166
+ """,
167
+ (ticket_key,),
168
+ )
169
+
170
+ row = cursor.fetchone()
171
+ if row:
172
+ # Update access statistics
173
+ cursor.execute(
174
+ """
175
+ UPDATE jira_tickets
176
+ SET access_count = ?, last_accessed = CURRENT_TIMESTAMP
177
+ WHERE ticket_key = ?
178
+ """,
179
+ (row["access_count"] + 1, ticket_key),
180
+ )
181
+ conn.commit()
182
+
183
+ self.cache_hits += 1
184
+ logger.debug(f"Cache HIT for ticket {ticket_key}")
185
+
186
+ import json
187
+
188
+ return json.loads(row["ticket_data"])
189
+
190
+ self.cache_misses += 1
191
+ logger.debug(f"Cache MISS for ticket {ticket_key}")
192
+ return None
193
+
194
+ def store_ticket(self, ticket_key: str, ticket_data: dict[str, Any]) -> None:
195
+ """Store ticket data in cache with TTL.
196
+
197
+ Args:
198
+ ticket_key: JIRA ticket key (e.g., 'PROJ-123')
199
+ ticket_data: Complete ticket data from JIRA API
200
+ """
201
+ import json
202
+
203
+ # Calculate expiry time
204
+ expires_at = datetime.now() + timedelta(hours=self.ttl_hours)
205
+
206
+ # Extract key fields for efficient querying
207
+ project_key = ticket_data.get("project_id", ticket_key.split("-")[0])
208
+ story_points = ticket_data.get("story_points")
209
+ status = ticket_data.get("status")
210
+ issue_type = ticket_data.get("issue_type")
211
+ assignee = (
212
+ ticket_data.get("assignee", {}).get("display_name")
213
+ if ticket_data.get("assignee")
214
+ else None
215
+ )
216
+ reporter = (
217
+ ticket_data.get("reporter", {}).get("display_name")
218
+ if ticket_data.get("reporter")
219
+ else None
220
+ )
221
+ created_at = ticket_data.get("created_date")
222
+ updated_at = ticket_data.get("updated_date")
223
+ resolved_at = ticket_data.get("resolved_date")
224
+
225
+ with sqlite3.connect(self.cache_path) as conn:
226
+ conn.execute(
227
+ """
228
+ INSERT OR REPLACE INTO jira_tickets (
229
+ ticket_key, project_key, ticket_data, story_points, status,
230
+ issue_type, assignee, reporter, created_at, updated_at,
231
+ resolved_at, expires_at, access_count, last_accessed
232
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
233
+ """,
234
+ (
235
+ ticket_key,
236
+ project_key,
237
+ json.dumps(ticket_data),
238
+ story_points,
239
+ status,
240
+ issue_type,
241
+ assignee,
242
+ reporter,
243
+ created_at,
244
+ updated_at,
245
+ resolved_at,
246
+ expires_at,
247
+ ),
248
+ )
249
+ conn.commit()
250
+
251
+ self.cache_stores += 1
252
+ logger.debug(f"Cached ticket {ticket_key} (expires: {expires_at})")
253
+
254
+ def get_project_tickets(
255
+ self, project_key: str, include_expired: bool = False
256
+ ) -> list[dict[str, Any]]:
257
+ """Get all cached tickets for a project.
258
+
259
+ Args:
260
+ project_key: JIRA project key
261
+ include_expired: Whether to include expired entries
262
+
263
+ Returns:
264
+ List of cached ticket data dictionaries
265
+ """
266
+ import json
267
+
268
+ where_clause = "WHERE project_key = ?"
269
+ params = [project_key]
270
+
271
+ if not include_expired:
272
+ where_clause += " AND expires_at > CURRENT_TIMESTAMP"
273
+
274
+ with sqlite3.connect(self.cache_path) as conn:
275
+ conn.row_factory = sqlite3.Row
276
+ cursor = conn.cursor()
277
+
278
+ cursor.execute(
279
+ f"""
280
+ SELECT ticket_data FROM jira_tickets {where_clause}
281
+ ORDER BY updated_at DESC
282
+ """,
283
+ params,
284
+ )
285
+
286
+ tickets = []
287
+ for row in cursor.fetchall():
288
+ tickets.append(json.loads(row["ticket_data"]))
289
+
290
+ return tickets
291
+
292
+ def invalidate_ticket(self, ticket_key: str) -> bool:
293
+ """Mark a specific ticket as expired/invalid.
294
+
295
+ Args:
296
+ ticket_key: JIRA ticket key to invalidate
297
+
298
+ Returns:
299
+ True if ticket was found and invalidated, False otherwise
300
+ """
301
+ with sqlite3.connect(self.cache_path) as conn:
302
+ cursor = conn.cursor()
303
+ cursor.execute(
304
+ """
305
+ UPDATE jira_tickets
306
+ SET expires_at = DATETIME('now', '-1 hour')
307
+ WHERE ticket_key = ?
308
+ """,
309
+ (ticket_key,),
310
+ )
311
+ conn.commit()
312
+
313
+ return cursor.rowcount > 0
314
+
315
+ def cleanup_expired(self) -> int:
316
+ """Remove expired cache entries.
317
+
318
+ Returns:
319
+ Number of expired entries removed
320
+ """
321
+ with sqlite3.connect(self.cache_path) as conn:
322
+ cursor = conn.cursor()
323
+ cursor.execute(
324
+ """
325
+ DELETE FROM jira_tickets WHERE expires_at <= CURRENT_TIMESTAMP
326
+ """
327
+ )
328
+ removed = cursor.rowcount
329
+ conn.commit()
330
+
331
+ if removed > 0:
332
+ logger.info(f"Cleaned up {removed} expired cache entries")
333
+
334
+ return removed
335
+
336
+ def clear_cache(self) -> int:
337
+ """Clear all cached tickets.
338
+
339
+ Returns:
340
+ Number of entries removed
341
+ """
342
+ with sqlite3.connect(self.cache_path) as conn:
343
+ cursor = conn.cursor()
344
+ cursor.execute("SELECT COUNT(*) FROM jira_tickets")
345
+ count = cursor.fetchone()[0]
346
+
347
+ cursor.execute("DELETE FROM jira_tickets")
348
+ conn.commit()
349
+
350
+ logger.info(f"Cleared all {count} cached tickets")
351
+ return count
352
+
353
+ def get_cache_stats(self) -> dict[str, Any]:
354
+ """Get comprehensive cache statistics.
355
+
356
+ Returns:
357
+ Dictionary with cache performance and storage metrics
358
+ """
359
+ with sqlite3.connect(self.cache_path) as conn:
360
+ conn.row_factory = sqlite3.Row
361
+ cursor = conn.cursor()
362
+
363
+ # Basic counts
364
+ cursor.execute("SELECT COUNT(*) as total FROM jira_tickets")
365
+ total_tickets = cursor.fetchone()["total"]
366
+
367
+ cursor.execute(
368
+ """
369
+ SELECT COUNT(*) as fresh FROM jira_tickets
370
+ WHERE expires_at > CURRENT_TIMESTAMP
371
+ """
372
+ )
373
+ fresh_tickets = cursor.fetchone()["fresh"]
374
+
375
+ cursor.execute(
376
+ """
377
+ SELECT COUNT(*) as expired FROM jira_tickets
378
+ WHERE expires_at <= CURRENT_TIMESTAMP
379
+ """
380
+ )
381
+ expired_tickets = cursor.fetchone()["expired"]
382
+
383
+ # Project distribution
384
+ cursor.execute(
385
+ """
386
+ SELECT project_key, COUNT(*) as count
387
+ FROM jira_tickets
388
+ WHERE expires_at > CURRENT_TIMESTAMP
389
+ GROUP BY project_key
390
+ ORDER BY count DESC
391
+ LIMIT 10
392
+ """
393
+ )
394
+ project_distribution = {row["project_key"]: row["count"] for row in cursor.fetchall()}
395
+
396
+ # Access patterns
397
+ cursor.execute(
398
+ """
399
+ SELECT AVG(access_count) as avg_access,
400
+ MAX(access_count) as max_access,
401
+ COUNT(*) as accessed_tickets
402
+ FROM jira_tickets
403
+ WHERE access_count > 1 AND expires_at > CURRENT_TIMESTAMP
404
+ """
405
+ )
406
+ access_stats = cursor.fetchone()
407
+
408
+ # Recent activity
409
+ cursor.execute(
410
+ """
411
+ SELECT COUNT(*) as recent FROM jira_tickets
412
+ WHERE cached_at > DATETIME('now', '-24 hours')
413
+ """
414
+ )
415
+ recent_cached = cursor.fetchone()["recent"]
416
+
417
+ # Database size
418
+ try:
419
+ db_size_mb = self.cache_path.stat().st_size / (1024 * 1024)
420
+ except FileNotFoundError:
421
+ db_size_mb = 0
422
+
423
+ # Session performance
424
+ session_duration = (datetime.now() - self.session_start).total_seconds()
425
+ total_requests = self.cache_hits + self.cache_misses
426
+ hit_rate = (self.cache_hits / total_requests * 100) if total_requests > 0 else 0
427
+
428
+ # Time savings estimation
429
+ api_calls_avoided = self.cache_hits
430
+ estimated_time_saved = api_calls_avoided * 0.5 # 0.5 seconds per API call
431
+
432
+ return {
433
+ # Storage metrics
434
+ "total_tickets": total_tickets,
435
+ "fresh_tickets": fresh_tickets,
436
+ "expired_tickets": expired_tickets,
437
+ "database_size_mb": db_size_mb,
438
+ "recent_cached_24h": recent_cached,
439
+ # Performance metrics
440
+ "cache_hits": self.cache_hits,
441
+ "cache_misses": self.cache_misses,
442
+ "cache_stores": self.cache_stores,
443
+ "hit_rate_percent": hit_rate,
444
+ "total_requests": total_requests,
445
+ # Time savings
446
+ "api_calls_avoided": api_calls_avoided,
447
+ "estimated_time_saved_seconds": estimated_time_saved,
448
+ "session_duration_seconds": session_duration,
449
+ # Access patterns
450
+ "project_distribution": project_distribution,
451
+ "avg_access_count": float(access_stats["avg_access"] or 0),
452
+ "max_access_count": access_stats["max_access"] or 0,
453
+ "frequently_accessed_tickets": access_stats["accessed_tickets"] or 0,
454
+ # Configuration
455
+ "ttl_hours": self.ttl_hours,
456
+ "cache_path": str(self.cache_path),
457
+ }
458
+
459
+ def print_cache_summary(self) -> None:
460
+ """Print user-friendly cache performance summary."""
461
+ stats = self.get_cache_stats()
462
+
463
+ print("🎫 JIRA Ticket Cache Summary")
464
+ print("─" * 40)
465
+
466
+ # Cache contents
467
+ print("📦 Cache Contents:")
468
+ print(
469
+ f" • Total Tickets: {stats['total_tickets']:,} ({stats['fresh_tickets']:,} fresh, {stats['expired_tickets']:,} expired)"
470
+ )
471
+ print(f" • Database Size: {stats['database_size_mb']:.1f} MB")
472
+ print(f" • Recent Activity: {stats['recent_cached_24h']:,} tickets cached in last 24h")
473
+
474
+ # Project distribution
475
+ if stats["project_distribution"]:
476
+ print("\n📊 Top Projects:")
477
+ for project, count in list(stats["project_distribution"].items())[:5]:
478
+ print(f" • {project}: {count:,} tickets")
479
+
480
+ # Performance metrics
481
+ if stats["total_requests"] > 0:
482
+ print("\n⚡ Session Performance:")
483
+ print(
484
+ f" • Hit Rate: {stats['hit_rate_percent']:.1f}% ({stats['cache_hits']:,}/{stats['total_requests']:,})"
485
+ )
486
+ print(f" • API Calls Avoided: {stats['api_calls_avoided']:,}")
487
+
488
+ if stats["estimated_time_saved_seconds"] > 60:
489
+ print(f" • Time Saved: {stats['estimated_time_saved_seconds'] / 60:.1f} minutes")
490
+ else:
491
+ print(f" • Time Saved: {stats['estimated_time_saved_seconds']:.1f} seconds")
492
+
493
+ # Access patterns
494
+ if stats["frequently_accessed_tickets"] > 0:
495
+ print("\n🔄 Access Patterns:")
496
+ print(f" • Frequently Accessed: {stats['frequently_accessed_tickets']:,} tickets")
497
+ print(f" • Average Access Count: {stats['avg_access_count']:.1f}")
498
+ print(f" • Most Accessed: {stats['max_access_count']} times")
499
+
500
+ # Performance insights
501
+ if stats["hit_rate_percent"] > 80:
502
+ print(" ✅ Excellent cache performance!")
503
+ elif stats["hit_rate_percent"] > 50:
504
+ print(" 👍 Good cache performance")
505
+ elif stats["total_requests"] > 0:
506
+ print(" ⚠️ Consider adjusting TTL or clearing stale entries")
507
+
508
+ print()
509
+
510
+
511
+ class JIRAAdapter(BasePlatformAdapter):
512
+ """JIRA platform adapter implementation.
513
+
514
+ WHY: JIRA is one of the most widely used project management platforms,
515
+ requiring comprehensive support for story points, sprints, custom fields,
516
+ and advanced workflow management.
517
+
518
+ DESIGN DECISION: Implement full JIRA API v3 support with optimized batch
519
+ operations, rate limiting, and comprehensive error handling. Use session
520
+ reuse and intelligent pagination for performance.
521
+
522
+ Key Features:
523
+ - JIRA Cloud and Server API v3 support
524
+ - Advanced authentication with API tokens
525
+ - Custom field discovery and mapping
526
+ - Sprint and agile board integration
527
+ - Optimized batch fetching with JQL
528
+ - Comprehensive error handling and retry logic
529
+ - Rate limiting with exponential backoff
530
+ """
531
+
532
+ def __init__(self, config: dict[str, Any]) -> None:
533
+ """Initialize JIRA adapter with configuration.
534
+
535
+ Args:
536
+ config: JIRA configuration including:
537
+ - base_url: JIRA instance URL (required)
538
+ - username: JIRA username/email (required)
539
+ - api_token: JIRA API token (required)
540
+ - story_point_fields: Custom field IDs for story points (optional)
541
+ - sprint_fields: Custom field IDs for sprint data (optional)
542
+ - batch_size: Number of issues to fetch per request (default: 50)
543
+ - rate_limit_delay: Delay between requests in seconds (default: 0.1)
544
+ - verify_ssl: Whether to verify SSL certificates (default: True)
545
+ - cache_dir: Directory for ticket cache (optional, defaults to current directory)
546
+ - cache_ttl_hours: Cache TTL in hours (optional, default: 168 = 7 days)
547
+ """
548
+ print(f" 🔍 JIRA adapter __init__ called with config keys: {list(config.keys())}")
549
+ super().__init__(config)
550
+
551
+ # Required configuration (use defaults for capability checking)
552
+ self.base_url = config.get("base_url", "https://example.atlassian.net").rstrip("/")
553
+ self.username = config.get("username", "user@example.com")
554
+ self.api_token = config.get("api_token", "dummy-token")
555
+
556
+ # Debug output
557
+ logger.info(
558
+ f"JIRA adapter init: base_url={self.base_url}, username={self.username}, has_token={bool(self.api_token and self.api_token != 'dummy-token')}"
559
+ )
560
+ print(
561
+ f" 🔍 JIRA adapter received: username={self.username}, has_token={bool(self.api_token and self.api_token != 'dummy-token')}, base_url={self.base_url}"
562
+ )
563
+
564
+ # Optional configuration with defaults
565
+ self.story_point_fields = config.get(
566
+ "story_point_fields",
567
+ [
568
+ "customfield_10016", # Common JIRA Cloud story points field
569
+ "customfield_10021", # Alternative field
570
+ "customfield_10002", # Another common ID
571
+ "Story Points", # Field name fallback
572
+ "storypoints", # Alternative name
573
+ ],
574
+ )
575
+ self.sprint_fields = config.get(
576
+ "sprint_fields",
577
+ [
578
+ "customfield_10020", # Common JIRA Cloud sprint field
579
+ "customfield_10010", # Alternative field
580
+ "Sprint", # Field name fallback
581
+ ],
582
+ )
583
+ self.batch_size = min(config.get("batch_size", 50), 100) # JIRA API limit
584
+ self.rate_limit_delay = config.get("rate_limit_delay", 0.1)
585
+ self.verify_ssl = config.get("verify_ssl", True)
586
+
587
+ # Initialize ticket cache
588
+ cache_dir = Path(config.get("cache_dir", Path.cwd()))
589
+ cache_ttl_hours = config.get("cache_ttl_hours", 168) # 7 days default
590
+ self.ticket_cache = JiraTicketCache(cache_dir, cache_ttl_hours)
591
+ logger.info(f"Initialized JIRA ticket cache: {self.ticket_cache.cache_path}")
592
+
593
+ # Initialize HTTP session with retry strategy (only if we have real config)
594
+ self._session: Optional[requests.Session] = None
595
+ if config.get("base_url") and config.get("username") and config.get("api_token"):
596
+ self._session = self._create_session()
597
+
598
+ # Cache for field mappings and metadata
599
+ self._field_mapping: Optional[dict[str, Any]] = None
600
+ self._project_cache: Optional[list[UnifiedProject]] = None
601
+ self._authenticated = False
602
+
603
+ logger.info(f"Initialized JIRA adapter for {self.base_url}")
604
+
605
+ def _ensure_session(self) -> requests.Session:
606
+ """Ensure session is available for API calls.
607
+
608
+ WHY: Some methods may be called before authentication, but still need
609
+ a session. This helper ensures the session is properly initialized.
610
+
611
+ Returns:
612
+ Active requests session.
613
+
614
+ Raises:
615
+ ConnectionError: If session cannot be created.
616
+ """
617
+ if self._session is None:
618
+ self._session = self._create_session()
619
+ return self._session
620
+
621
+ def _get_platform_name(self) -> str:
622
+ """Return the platform name."""
623
+ return "jira"
624
+
625
+ def _get_capabilities(self) -> PlatformCapabilities:
626
+ """Return JIRA platform capabilities."""
627
+ capabilities = PlatformCapabilities()
628
+
629
+ # JIRA supports most advanced features
630
+ capabilities.supports_projects = True
631
+ capabilities.supports_issues = True
632
+ capabilities.supports_sprints = True
633
+ capabilities.supports_time_tracking = True
634
+ capabilities.supports_story_points = True
635
+ capabilities.supports_custom_fields = True
636
+ capabilities.supports_issue_linking = True
637
+ capabilities.supports_comments = True
638
+ capabilities.supports_attachments = True
639
+ capabilities.supports_workflows = True
640
+ capabilities.supports_bulk_operations = True
641
+
642
+ # JIRA API rate limits (conservative estimates)
643
+ capabilities.rate_limit_requests_per_hour = 3000 # JIRA Cloud typical limit
644
+ capabilities.rate_limit_burst_size = 100
645
+ capabilities.max_results_per_page = 100 # JIRA API maximum
646
+ capabilities.supports_cursor_pagination = False # JIRA uses offset pagination
647
+
648
+ return capabilities
649
+
650
+ def _create_session(self) -> requests.Session:
651
+ """Create HTTP session with retry strategy and authentication.
652
+
653
+ WHY: JIRA APIs can be unstable under load. This session configuration
654
+ provides resilient connections with exponential backoff retry logic
655
+ and persistent authentication headers.
656
+
657
+ Returns:
658
+ Configured requests session with retry strategy.
659
+ """
660
+ session = requests.Session()
661
+
662
+ # Configure retry strategy for resilient connections
663
+ retry_strategy = Retry(
664
+ total=3,
665
+ backoff_factor=1,
666
+ status_forcelist=[429, 500, 502, 503, 504],
667
+ allowed_methods=["HEAD", "GET", "OPTIONS"],
668
+ )
669
+
670
+ adapter = HTTPAdapter(max_retries=retry_strategy)
671
+ session.mount("http://", adapter)
672
+ session.mount("https://", adapter)
673
+
674
+ # Set authentication headers
675
+ credentials = base64.b64encode(f"{self.username}:{self.api_token}".encode()).decode()
676
+ session.headers.update(
677
+ {
678
+ "Authorization": f"Basic {credentials}",
679
+ "Accept": "application/json",
680
+ "Content-Type": "application/json",
681
+ "User-Agent": "GitFlow-Analytics/1.0",
682
+ }
683
+ )
684
+
685
+ # SSL verification setting
686
+ session.verify = self.verify_ssl
687
+
688
+ return session
689
+
690
+ def authenticate(self) -> bool:
691
+ """Authenticate with JIRA API.
692
+
693
+ WHY: JIRA authentication validation ensures credentials are correct
694
+ and the instance is accessible before attempting data collection.
695
+ This prevents later failures during analysis.
696
+
697
+ Returns:
698
+ True if authentication successful, False otherwise.
699
+ """
700
+ try:
701
+ self.logger.info("Authenticating with JIRA API...")
702
+
703
+ # Test authentication by getting current user info
704
+ session = self._ensure_session()
705
+ response = session.get(f"{self.base_url}/rest/api/3/myself")
706
+ response.raise_for_status()
707
+
708
+ user_info = response.json()
709
+ self._authenticated = True
710
+
711
+ self.logger.info(
712
+ f"Successfully authenticated as: {user_info.get('displayName', 'Unknown')}"
713
+ )
714
+ return True
715
+
716
+ except ConnectionError as e:
717
+ self.logger.error(f"JIRA DNS/connection error: {self._format_network_error(e)}")
718
+ self.logger.error("Troubleshooting: Check network connectivity and DNS resolution")
719
+ self._authenticated = False
720
+ return False
721
+ except Timeout as e:
722
+ self.logger.error(f"JIRA authentication timeout: {e}")
723
+ self.logger.error("Consider increasing timeout settings or checking network latency")
724
+ self._authenticated = False
725
+ return False
726
+ except RequestException as e:
727
+ self.logger.error(f"JIRA authentication failed: {e}")
728
+ if hasattr(e, "response") and e.response is not None:
729
+ if e.response.status_code == 401:
730
+ self.logger.error("Invalid JIRA credentials - check username/API token")
731
+ elif e.response.status_code == 403:
732
+ self.logger.error("JIRA access forbidden - check permissions")
733
+ else:
734
+ self.logger.error(
735
+ f"JIRA API error: {e.response.status_code} - {e.response.text}"
736
+ )
737
+ self._authenticated = False
738
+ return False
739
+ except Exception as e:
740
+ self.logger.error(f"Unexpected authentication error: {e}")
741
+ self._authenticated = False
742
+ return False
743
+
744
+ def test_connection(self) -> dict[str, Any]:
745
+ """Test JIRA connection and return diagnostic information.
746
+
747
+ WHY: Provides comprehensive diagnostic information for troubleshooting
748
+ JIRA configuration issues, including server info, permissions, and
749
+ available features.
750
+
751
+ Returns:
752
+ Dictionary with connection status and diagnostic details.
753
+ """
754
+ result = {
755
+ "status": "disconnected",
756
+ "platform": "jira",
757
+ "base_url": self.base_url,
758
+ "authenticated_user": None,
759
+ "server_info": {},
760
+ "permissions": {},
761
+ "available_projects": 0,
762
+ "custom_fields_discovered": 0,
763
+ "error": None,
764
+ }
765
+
766
+ try:
767
+ # Test basic connectivity
768
+ if not self._authenticated and not self.authenticate():
769
+ result["error"] = "Authentication failed"
770
+ return result
771
+
772
+ # Get server information
773
+ session = self._ensure_session()
774
+ server_response = session.get(f"{self.base_url}/rest/api/3/serverInfo")
775
+ if server_response.status_code == 200:
776
+ result["server_info"] = server_response.json()
777
+
778
+ # Get current user info
779
+ user_response = session.get(f"{self.base_url}/rest/api/3/myself")
780
+ user_response.raise_for_status()
781
+ user_info = user_response.json()
782
+ result["authenticated_user"] = user_info.get("displayName", "Unknown")
783
+
784
+ # Test project access
785
+ projects_response = session.get(
786
+ f"{self.base_url}/rest/api/3/project", params={"maxResults": 1}
787
+ )
788
+ if projects_response.status_code == 200:
789
+ result["available_projects"] = len(projects_response.json())
790
+
791
+ # Discover custom fields
792
+ fields_response = session.get(f"{self.base_url}/rest/api/3/field")
793
+ if fields_response.status_code == 200:
794
+ result["custom_fields_discovered"] = len(
795
+ [f for f in fields_response.json() if f.get("custom", False)]
796
+ )
797
+
798
+ result["status"] = "connected"
799
+ self.logger.info("JIRA connection test successful")
800
+
801
+ except ConnectionError as e:
802
+ error_msg = f"DNS/connection error: {self._format_network_error(e)}"
803
+ result["error"] = error_msg
804
+ self.logger.error(error_msg)
805
+ self.logger.error("Troubleshooting: Check network connectivity and DNS resolution")
806
+ except Timeout as e:
807
+ error_msg = f"Connection timeout: {e}"
808
+ result["error"] = error_msg
809
+ self.logger.error(error_msg)
810
+ self.logger.error("Consider increasing timeout settings or checking network latency")
811
+ except RequestException as e:
812
+ error_msg = f"Connection test failed: {e}"
813
+ if hasattr(e, "response") and e.response is not None:
814
+ error_msg += f" (HTTP {e.response.status_code})"
815
+ result["error"] = error_msg
816
+ self.logger.error(error_msg)
817
+ except Exception as e:
818
+ result["error"] = f"Unexpected error: {e}"
819
+ self.logger.error(f"Unexpected connection test error: {e}")
820
+
821
+ return result
822
+
823
+ def get_projects(self) -> list[UnifiedProject]:
824
+ """Retrieve all accessible projects from JIRA.
825
+
826
+ WHY: JIRA projects are the primary organizational unit for issues.
827
+ This method discovers all accessible projects for subsequent issue
828
+ retrieval, with caching for performance optimization.
829
+
830
+ Returns:
831
+ List of UnifiedProject objects representing JIRA projects.
832
+ """
833
+ if self._project_cache is not None:
834
+ self.logger.debug("Returning cached JIRA projects")
835
+ return self._project_cache
836
+
837
+ if not self._authenticated and not self.authenticate():
838
+ raise ConnectionError("Not authenticated with JIRA")
839
+
840
+ try:
841
+ self.logger.info("Fetching JIRA projects...")
842
+
843
+ # Fetch all projects with details
844
+ session = self._ensure_session()
845
+ response = session.get(
846
+ f"{self.base_url}/rest/api/3/project",
847
+ params={
848
+ "expand": "description,lead,url,projectKeys",
849
+ "properties": "key,name,description,projectTypeKey",
850
+ },
851
+ )
852
+ response.raise_for_status()
853
+
854
+ projects_data = response.json()
855
+ projects = []
856
+
857
+ for project_data in projects_data:
858
+ # Map JIRA project to unified model
859
+ project = UnifiedProject(
860
+ id=project_data["id"],
861
+ key=project_data["key"],
862
+ name=project_data["name"],
863
+ description=project_data.get("description", ""),
864
+ platform=self.platform_name,
865
+ is_active=True, # JIRA doesn't provide explicit active status
866
+ created_date=None, # Not available in basic project info
867
+ platform_data={
868
+ "project_type": project_data.get("projectTypeKey", "unknown"),
869
+ "lead": project_data.get("lead", {}).get("displayName", ""),
870
+ "url": project_data.get("self", ""),
871
+ "avatar_urls": project_data.get("avatarUrls", {}),
872
+ "category": project_data.get("projectCategory", {}).get("name", ""),
873
+ },
874
+ )
875
+ projects.append(project)
876
+
877
+ self.logger.debug(f"Found project: {project.key} - {project.name}")
878
+
879
+ self._project_cache = projects
880
+ self.logger.info(f"Successfully retrieved {len(projects)} JIRA projects")
881
+
882
+ return projects
883
+
884
+ except RequestException as e:
885
+ self._handle_api_error(e, "get_projects")
886
+ raise
887
+
888
+ def get_issues(
889
+ self,
890
+ project_id: str,
891
+ since: Optional[datetime] = None,
892
+ issue_types: Optional[list[IssueType]] = None,
893
+ ) -> list[UnifiedIssue]:
894
+ """Retrieve issues for a JIRA project with advanced filtering.
895
+
896
+ WHY: JIRA issues contain rich metadata including story points, sprints,
897
+ and custom fields. This method uses optimized JQL queries with pagination
898
+ to efficiently retrieve large datasets while respecting API limits.
899
+
900
+ Args:
901
+ project_id: JIRA project key or ID to retrieve issues from.
902
+ since: Optional datetime to filter issues updated after this date.
903
+ issue_types: Optional list of issue types to filter by.
904
+
905
+ Returns:
906
+ List of UnifiedIssue objects for the specified project.
907
+ """
908
+ if not self._authenticated and not self.authenticate():
909
+ raise ConnectionError("Not authenticated with JIRA")
910
+
911
+ try:
912
+ # Ensure field mapping is available
913
+ if self._field_mapping is None:
914
+ self._discover_fields()
915
+
916
+ # Build JQL query
917
+ jql_conditions = [f"project = {project_id}"]
918
+
919
+ if since:
920
+ # Format datetime for JIRA JQL (JIRA expects specific format)
921
+ since_str = since.strftime("%Y-%m-%d %H:%M")
922
+ jql_conditions.append(f"updated >= '{since_str}'")
923
+
924
+ if issue_types:
925
+ # Map unified issue types to JIRA issue types
926
+ jira_types = []
927
+ for issue_type in issue_types:
928
+ jira_types.extend(self._map_issue_type_to_jira(issue_type))
929
+
930
+ if jira_types:
931
+ types_str = "', '".join(jira_types)
932
+ jql_conditions.append(f"issuetype in ('{types_str}')")
933
+
934
+ jql = " AND ".join(jql_conditions)
935
+
936
+ self.logger.info(f"Fetching JIRA issues with JQL: {jql}")
937
+
938
+ # Fetch issues with pagination
939
+ issues = []
940
+ start_at = 0
941
+
942
+ while True:
943
+ # Add rate limiting delay
944
+ time.sleep(self.rate_limit_delay)
945
+
946
+ session = self._ensure_session()
947
+ response = session.get(
948
+ f"{self.base_url}/rest/api/3/search",
949
+ params={
950
+ "jql": jql,
951
+ "startAt": start_at,
952
+ "maxResults": self.batch_size,
953
+ "fields": "*all", # Get all fields including custom fields
954
+ "expand": "changelog,renderedFields",
955
+ },
956
+ )
957
+ response.raise_for_status()
958
+
959
+ data = response.json()
960
+ batch_issues = data.get("issues", [])
961
+
962
+ if not batch_issues:
963
+ break
964
+
965
+ # Convert JIRA issues to unified format and cache them
966
+ for issue_data in batch_issues:
967
+ unified_issue = self._convert_jira_issue(issue_data)
968
+ issues.append(unified_issue)
969
+
970
+ # Cache each issue individually for future lookups
971
+ if unified_issue and unified_issue.key:
972
+ try:
973
+ cache_data = self._unified_issue_to_dict(unified_issue)
974
+ self.ticket_cache.store_ticket(unified_issue.key, cache_data)
975
+ except Exception as e:
976
+ logger.warning(f"Failed to cache issue {unified_issue.key}: {e}")
977
+
978
+ logger.debug(f"Processed {len(batch_issues)} issues (total: {len(issues)})")
979
+
980
+ # Check if we've retrieved all issues
981
+ if len(batch_issues) < self.batch_size:
982
+ break
983
+
984
+ start_at += self.batch_size
985
+
986
+ # Safety check to prevent infinite loops
987
+ if start_at > data.get("total", 0):
988
+ break
989
+
990
+ self.logger.info(
991
+ f"Successfully retrieved {len(issues)} JIRA issues for project {project_id}"
992
+ )
993
+ return issues
994
+
995
+ except RequestException as e:
996
+ self._handle_api_error(e, f"get_issues for project {project_id}")
997
+ raise
998
+
999
+ def get_issue_by_key(self, issue_key: str) -> Optional[UnifiedIssue]:
1000
+ """Retrieve a single issue by its key with caching.
1001
+
1002
+ WHY: Training pipeline needs to fetch specific issues to determine
1003
+ their types for classification labeling. Caching dramatically speeds
1004
+ up repeated access to the same tickets.
1005
+
1006
+ Args:
1007
+ issue_key: JIRA issue key (e.g., 'PROJ-123')
1008
+
1009
+ Returns:
1010
+ UnifiedIssue object if found, None otherwise.
1011
+ """
1012
+ if not self._authenticated and not self.authenticate():
1013
+ raise ConnectionError("Not authenticated with JIRA")
1014
+
1015
+ try:
1016
+ # Check cache first
1017
+ cached_data = self.ticket_cache.get_ticket(issue_key)
1018
+ if cached_data:
1019
+ logger.debug(f"Using cached data for issue {issue_key}")
1020
+ # Convert cached data back to UnifiedIssue
1021
+ # The cached data is already in unified format
1022
+ return self._dict_to_unified_issue(cached_data)
1023
+
1024
+ # Cache miss - fetch from API
1025
+ logger.debug(f"Fetching JIRA issue {issue_key} from API")
1026
+
1027
+ session = self._ensure_session()
1028
+ response = session.get(
1029
+ f"{self.base_url}/rest/api/3/issue/{issue_key}",
1030
+ params={"expand": "names,renderedFields", "fields": "*all"},
1031
+ )
1032
+
1033
+ if response.status_code == 404:
1034
+ logger.warning(f"Issue {issue_key} not found")
1035
+ return None
1036
+
1037
+ response.raise_for_status()
1038
+ issue_data = response.json()
1039
+
1040
+ # Convert to unified format
1041
+ unified_issue = self._convert_jira_issue(issue_data)
1042
+
1043
+ # Cache the unified issue data
1044
+ if unified_issue:
1045
+ cache_data = self._unified_issue_to_dict(unified_issue)
1046
+ self.ticket_cache.store_ticket(issue_key, cache_data)
1047
+
1048
+ return unified_issue
1049
+
1050
+ except RequestException as e:
1051
+ self._handle_api_error(e, f"get_issue_by_key for {issue_key}")
1052
+ return None
1053
+
1054
+ def get_sprints(self, project_id: str) -> list[UnifiedSprint]:
1055
+ """Retrieve sprints for a JIRA project.
1056
+
1057
+ WHY: Sprint data is essential for agile metrics and velocity tracking.
1058
+ JIRA provides comprehensive sprint information through board APIs.
1059
+
1060
+ Args:
1061
+ project_id: JIRA project key or ID to retrieve sprints from.
1062
+
1063
+ Returns:
1064
+ List of UnifiedSprint objects for the project's agile boards.
1065
+ """
1066
+ if not self._authenticated and not self.authenticate():
1067
+ raise ConnectionError("Not authenticated with JIRA")
1068
+
1069
+ try:
1070
+ self.logger.info(f"Fetching JIRA sprints for project {project_id}")
1071
+
1072
+ # First, find agile boards for the project
1073
+ session = self._ensure_session()
1074
+ boards_response = session.get(
1075
+ f"{self.base_url}/rest/agile/1.0/board",
1076
+ params={
1077
+ "projectKeyOrId": project_id,
1078
+ "type": "scrum", # Focus on scrum boards which have sprints
1079
+ },
1080
+ )
1081
+ boards_response.raise_for_status()
1082
+
1083
+ boards = boards_response.json().get("values", [])
1084
+ all_sprints = []
1085
+
1086
+ # Get sprints from each board
1087
+ for board in boards:
1088
+ board_id = board["id"]
1089
+ start_at = 0
1090
+
1091
+ while True:
1092
+ time.sleep(self.rate_limit_delay)
1093
+
1094
+ sprints_response = session.get(
1095
+ f"{self.base_url}/rest/agile/1.0/board/{board_id}/sprint",
1096
+ params={"startAt": start_at, "maxResults": 50}, # JIRA Agile API limit
1097
+ )
1098
+ sprints_response.raise_for_status()
1099
+
1100
+ sprint_data = sprints_response.json()
1101
+ batch_sprints = sprint_data.get("values", [])
1102
+
1103
+ if not batch_sprints:
1104
+ break
1105
+
1106
+ # Convert JIRA sprints to unified format
1107
+ for sprint_info in batch_sprints:
1108
+ unified_sprint = self._convert_jira_sprint(sprint_info, project_id)
1109
+ all_sprints.append(unified_sprint)
1110
+
1111
+ # Check pagination
1112
+ if len(batch_sprints) < 50:
1113
+ break
1114
+
1115
+ start_at += 50
1116
+
1117
+ self.logger.info(f"Retrieved {len(all_sprints)} sprints for project {project_id}")
1118
+ return all_sprints
1119
+
1120
+ except RequestException as e:
1121
+ # Sprints might not be available for all project types
1122
+ if hasattr(e, "response") and e.response is not None and e.response.status_code == 404:
1123
+ self.logger.warning(f"No agile boards found for project {project_id}")
1124
+ return []
1125
+ self._handle_api_error(e, f"get_sprints for project {project_id}")
1126
+ raise
1127
+
1128
+ def _discover_fields(self) -> None:
1129
+ """Discover and cache JIRA field mappings.
1130
+
1131
+ WHY: JIRA custom fields use cryptic IDs (e.g., customfield_10016).
1132
+ This method discovers field mappings to enable story point extraction
1133
+ and other custom field processing.
1134
+ """
1135
+ try:
1136
+ self.logger.info("Discovering JIRA field mappings...")
1137
+
1138
+ session = self._ensure_session()
1139
+ response = session.get(f"{self.base_url}/rest/api/3/field")
1140
+ response.raise_for_status()
1141
+
1142
+ fields = response.json()
1143
+ self._field_mapping = {}
1144
+
1145
+ story_point_candidates = []
1146
+ sprint_field_candidates = []
1147
+
1148
+ for field in fields:
1149
+ field_id = field.get("id", "")
1150
+ field_name = field.get("name", "").lower()
1151
+ field_type = field.get("schema", {}).get("type", "")
1152
+
1153
+ self._field_mapping[field_id] = {
1154
+ "name": field.get("name", ""),
1155
+ "type": field_type,
1156
+ "custom": field.get("custom", False),
1157
+ }
1158
+
1159
+ # Identify potential story point fields
1160
+ if any(term in field_name for term in ["story", "point", "estimate", "size"]):
1161
+ story_point_candidates.append((field_id, field.get("name", "")))
1162
+
1163
+ # Identify potential sprint fields
1164
+ if any(term in field_name for term in ["sprint", "iteration"]):
1165
+ sprint_field_candidates.append((field_id, field.get("name", "")))
1166
+
1167
+ self.logger.info(f"Discovered {len(fields)} JIRA fields")
1168
+
1169
+ if story_point_candidates:
1170
+ self.logger.info("Potential story point fields found:")
1171
+ for field_id, field_name in story_point_candidates[:5]: # Show top 5
1172
+ self.logger.info(f" {field_id}: {field_name}")
1173
+
1174
+ if sprint_field_candidates:
1175
+ self.logger.info("Potential sprint fields found:")
1176
+ for field_id, field_name in sprint_field_candidates[:3]: # Show top 3
1177
+ self.logger.info(f" {field_id}: {field_name}")
1178
+
1179
+ except RequestException as e:
1180
+ self.logger.warning(f"Failed to discover JIRA fields: {e}")
1181
+ self._field_mapping = {}
1182
+
1183
+ def _convert_jira_issue(self, issue_data: dict[str, Any]) -> UnifiedIssue:
1184
+ """Convert JIRA issue data to unified issue format.
1185
+
1186
+ WHY: JIRA issues have complex nested structures with custom fields.
1187
+ This method normalizes JIRA data to the unified format while preserving
1188
+ important metadata in platform_data.
1189
+
1190
+ Args:
1191
+ issue_data: Raw JIRA issue data from API.
1192
+
1193
+ Returns:
1194
+ UnifiedIssue object with normalized data.
1195
+ """
1196
+ fields = issue_data.get("fields", {})
1197
+
1198
+ # Extract basic issue information
1199
+ issue_key = issue_data.get("key", "")
1200
+ summary = fields.get("summary", "")
1201
+ description = fields.get("description", "")
1202
+ if isinstance(description, dict):
1203
+ # Handle JIRA's Atlassian Document Format
1204
+ description = self._extract_text_from_adf(description)
1205
+
1206
+ # Parse dates
1207
+ created_date = self._normalize_date(fields.get("created"))
1208
+ updated_date = self._normalize_date(fields.get("updated"))
1209
+ resolved_date = self._normalize_date(fields.get("resolutiondate"))
1210
+ due_date = self._normalize_date(fields.get("duedate"))
1211
+
1212
+ # Map issue type
1213
+ issue_type_data = fields.get("issuetype", {})
1214
+ issue_type = self._map_jira_issue_type(issue_type_data.get("name", ""))
1215
+
1216
+ # Map status
1217
+ status_data = fields.get("status", {})
1218
+ status = self._map_jira_status(status_data.get("name", ""))
1219
+
1220
+ # Map priority
1221
+ priority_data = fields.get("priority", {})
1222
+ priority = self._map_priority(priority_data.get("name", "") if priority_data else "")
1223
+
1224
+ # Extract users
1225
+ assignee = self._extract_jira_user(fields.get("assignee"))
1226
+ reporter = self._extract_jira_user(fields.get("reporter"))
1227
+
1228
+ # Extract story points from custom fields
1229
+ story_points = self._extract_story_points(fields)
1230
+
1231
+ # Extract sprint information
1232
+ sprint_id, sprint_name = self._extract_sprint_info(fields)
1233
+
1234
+ # Extract time tracking
1235
+ time_tracking = fields.get("timetracking", {})
1236
+ original_estimate_hours = self._seconds_to_hours(
1237
+ time_tracking.get("originalEstimateSeconds")
1238
+ )
1239
+ remaining_estimate_hours = self._seconds_to_hours(
1240
+ time_tracking.get("remainingEstimateSeconds")
1241
+ )
1242
+ time_spent_hours = self._seconds_to_hours(time_tracking.get("timeSpentSeconds"))
1243
+
1244
+ # Extract relationships
1245
+ parent_key = None
1246
+ if fields.get("parent"):
1247
+ parent_key = fields["parent"].get("key")
1248
+
1249
+ subtasks = [subtask.get("key", "") for subtask in fields.get("subtasks", [])]
1250
+
1251
+ # Extract issue links
1252
+ linked_issues = []
1253
+ for link in fields.get("issuelinks", []):
1254
+ if "outwardIssue" in link:
1255
+ linked_issues.append(
1256
+ {
1257
+ "key": link["outwardIssue"].get("key", ""),
1258
+ "type": link.get("type", {}).get("outward", "links"),
1259
+ }
1260
+ )
1261
+ if "inwardIssue" in link:
1262
+ linked_issues.append(
1263
+ {
1264
+ "key": link["inwardIssue"].get("key", ""),
1265
+ "type": link.get("type", {}).get("inward", "links"),
1266
+ }
1267
+ )
1268
+
1269
+ # Extract labels and components
1270
+ labels = [label for label in fields.get("labels", [])]
1271
+ components = [comp.get("name", "") for comp in fields.get("components", [])]
1272
+
1273
+ # Create unified issue
1274
+ unified_issue = UnifiedIssue(
1275
+ id=issue_data.get("id", ""),
1276
+ key=issue_key,
1277
+ platform=self.platform_name,
1278
+ project_id=fields.get("project", {}).get("key", ""),
1279
+ title=summary,
1280
+ description=description,
1281
+ created_date=created_date or datetime.now(timezone.utc),
1282
+ updated_date=updated_date or datetime.now(timezone.utc),
1283
+ issue_type=issue_type,
1284
+ status=status,
1285
+ priority=priority,
1286
+ assignee=assignee,
1287
+ reporter=reporter,
1288
+ resolved_date=resolved_date,
1289
+ due_date=due_date,
1290
+ story_points=story_points,
1291
+ original_estimate_hours=original_estimate_hours,
1292
+ remaining_estimate_hours=remaining_estimate_hours,
1293
+ time_spent_hours=time_spent_hours,
1294
+ parent_issue_key=parent_key,
1295
+ subtasks=subtasks,
1296
+ linked_issues=linked_issues,
1297
+ sprint_id=sprint_id,
1298
+ sprint_name=sprint_name,
1299
+ labels=labels,
1300
+ components=components,
1301
+ platform_data={
1302
+ "issue_type_id": issue_type_data.get("id", ""),
1303
+ "status_id": status_data.get("id", ""),
1304
+ "status_category": status_data.get("statusCategory", {}).get("name", ""),
1305
+ "priority_id": priority_data.get("id", "") if priority_data else "",
1306
+ "resolution": (
1307
+ fields.get("resolution", {}).get("name", "") if fields.get("resolution") else ""
1308
+ ),
1309
+ "environment": fields.get("environment", ""),
1310
+ "security_level": (
1311
+ fields.get("security", {}).get("name", "") if fields.get("security") else ""
1312
+ ),
1313
+ "votes": fields.get("votes", {}).get("votes", 0),
1314
+ "watches": fields.get("watches", {}).get("watchCount", 0),
1315
+ "custom_fields": self._extract_custom_fields(fields),
1316
+ "jira_url": f"{self.base_url}/browse/{issue_key}",
1317
+ },
1318
+ )
1319
+
1320
+ return unified_issue
1321
+
1322
+ def _convert_jira_sprint(self, sprint_data: dict[str, Any], project_id: str) -> UnifiedSprint:
1323
+ """Convert JIRA sprint data to unified sprint format.
1324
+
1325
+ Args:
1326
+ sprint_data: Raw JIRA sprint data from Agile API.
1327
+ project_id: Project ID the sprint belongs to.
1328
+
1329
+ Returns:
1330
+ UnifiedSprint object with normalized data.
1331
+ """
1332
+ start_date = self._normalize_date(sprint_data.get("startDate"))
1333
+ end_date = self._normalize_date(sprint_data.get("endDate"))
1334
+ complete_date = self._normalize_date(sprint_data.get("completeDate"))
1335
+
1336
+ # Determine sprint state
1337
+ state = sprint_data.get("state", "").lower()
1338
+ is_active = state == "active"
1339
+ is_completed = state == "closed" or complete_date is not None
1340
+
1341
+ return UnifiedSprint(
1342
+ id=str(sprint_data.get("id", "")),
1343
+ name=sprint_data.get("name", ""),
1344
+ project_id=project_id,
1345
+ platform=self.platform_name,
1346
+ start_date=start_date,
1347
+ end_date=end_date,
1348
+ is_active=is_active,
1349
+ is_completed=is_completed,
1350
+ planned_story_points=None, # Not directly available from JIRA API
1351
+ completed_story_points=None, # Would need to calculate from issues
1352
+ issue_keys=[], # Would need separate API call to get sprint issues
1353
+ platform_data={
1354
+ "state": sprint_data.get("state", ""),
1355
+ "goal": sprint_data.get("goal", ""),
1356
+ "complete_date": complete_date,
1357
+ "board_id": sprint_data.get("originBoardId"),
1358
+ "jira_url": sprint_data.get("self", ""),
1359
+ },
1360
+ )
1361
+
1362
+ def _extract_jira_user(self, user_data: Optional[dict[str, Any]]) -> Optional[UnifiedUser]:
1363
+ """Extract user information from JIRA user data.
1364
+
1365
+ Args:
1366
+ user_data: JIRA user object from API.
1367
+
1368
+ Returns:
1369
+ UnifiedUser object or None if user_data is empty.
1370
+ """
1371
+ if not user_data:
1372
+ return None
1373
+
1374
+ return UnifiedUser(
1375
+ id=user_data.get("accountId", user_data.get("name", "")),
1376
+ email=user_data.get("emailAddress"),
1377
+ display_name=user_data.get("displayName", ""),
1378
+ username=user_data.get("name"), # Deprecated in JIRA Cloud but may exist
1379
+ platform=self.platform_name,
1380
+ is_active=user_data.get("active", True),
1381
+ platform_data={
1382
+ "avatar_urls": user_data.get("avatarUrls", {}),
1383
+ "timezone": user_data.get("timeZone", ""),
1384
+ "locale": user_data.get("locale", ""),
1385
+ },
1386
+ )
1387
+
1388
+ def _extract_story_points(self, fields: dict[str, Any]) -> Optional[int]:
1389
+ """Extract story points from JIRA custom fields.
1390
+
1391
+ WHY: Story points can be stored in various custom fields depending
1392
+ on JIRA configuration. This method tries multiple common field IDs
1393
+ and field names to find story point values.
1394
+
1395
+ Args:
1396
+ fields: JIRA issue fields dictionary.
1397
+
1398
+ Returns:
1399
+ Story points as integer, or None if not found.
1400
+ """
1401
+ # Try configured story point fields first
1402
+ for field_id in self.story_point_fields:
1403
+ if field_id in fields and fields[field_id] is not None:
1404
+ value = fields[field_id]
1405
+ try:
1406
+ if isinstance(value, (int, float)):
1407
+ return int(value)
1408
+ elif isinstance(value, str) and value.strip():
1409
+ return int(float(value.strip()))
1410
+ except (ValueError, TypeError):
1411
+ continue
1412
+
1413
+ # Use base class method as fallback
1414
+ return super()._extract_story_points(fields)
1415
+
1416
+ def _extract_sprint_info(self, fields: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
1417
+ """Extract sprint information from JIRA fields.
1418
+
1419
+ Args:
1420
+ fields: JIRA issue fields dictionary.
1421
+
1422
+ Returns:
1423
+ Tuple of (sprint_id, sprint_name) or (None, None) if not found.
1424
+ """
1425
+ # Try configured sprint fields
1426
+ for field_id in self.sprint_fields:
1427
+ sprint_data = fields.get(field_id)
1428
+ if not sprint_data:
1429
+ continue
1430
+
1431
+ # Sprint field can be an array of sprints (issue in multiple sprints)
1432
+ if isinstance(sprint_data, list) and sprint_data:
1433
+ sprint_data = sprint_data[-1] # Use the latest sprint
1434
+
1435
+ if isinstance(sprint_data, dict):
1436
+ return str(sprint_data.get("id", "")), sprint_data.get("name", "")
1437
+ elif isinstance(sprint_data, str) and "id=" in sprint_data:
1438
+ # Handle legacy sprint string format: "com.atlassian.greenhopper.service.sprint.Sprint@abc[id=123,name=Sprint 1,...]"
1439
+ try:
1440
+ import re
1441
+
1442
+ id_match = re.search(r"id=(\d+)", sprint_data)
1443
+ name_match = re.search(r"name=([^,\]]+)", sprint_data)
1444
+ if id_match and name_match:
1445
+ return id_match.group(1), name_match.group(1)
1446
+ except Exception:
1447
+ pass
1448
+
1449
+ return None, None
1450
+
1451
+ def _extract_custom_fields(self, fields: dict[str, Any]) -> dict[str, Any]:
1452
+ """Extract custom field values from JIRA fields.
1453
+
1454
+ Args:
1455
+ fields: JIRA issue fields dictionary.
1456
+
1457
+ Returns:
1458
+ Dictionary of custom field values.
1459
+ """
1460
+ custom_fields = {}
1461
+
1462
+ for field_id, value in fields.items():
1463
+ if field_id.startswith("customfield_") and value is not None:
1464
+ # Get field metadata if available
1465
+ field_info = self._field_mapping.get(field_id, {}) if self._field_mapping else {}
1466
+ field_name = field_info.get("name", field_id)
1467
+
1468
+ # Simplify complex field values
1469
+ if isinstance(value, dict):
1470
+ if "value" in value:
1471
+ custom_fields[field_name] = value["value"]
1472
+ elif "displayName" in value:
1473
+ custom_fields[field_name] = value["displayName"]
1474
+ elif "name" in value:
1475
+ custom_fields[field_name] = value["name"]
1476
+ else:
1477
+ custom_fields[field_name] = str(value)
1478
+ elif isinstance(value, list):
1479
+ if value and isinstance(value[0], dict):
1480
+ # Extract display values from option lists
1481
+ display_values = []
1482
+ for item in value:
1483
+ if "value" in item:
1484
+ display_values.append(item["value"])
1485
+ elif "name" in item:
1486
+ display_values.append(item["name"])
1487
+ else:
1488
+ display_values.append(str(item))
1489
+ custom_fields[field_name] = display_values
1490
+ else:
1491
+ custom_fields[field_name] = value
1492
+ else:
1493
+ custom_fields[field_name] = value
1494
+
1495
+ return custom_fields
1496
+
1497
+ def _map_jira_issue_type(self, jira_type: str) -> IssueType:
1498
+ """Map JIRA issue type to unified issue type.
1499
+
1500
+ Args:
1501
+ jira_type: JIRA issue type name.
1502
+
1503
+ Returns:
1504
+ Unified IssueType enum value.
1505
+ """
1506
+ if not jira_type:
1507
+ return IssueType.UNKNOWN
1508
+
1509
+ type_lower = jira_type.lower()
1510
+
1511
+ # Common JIRA issue type mappings
1512
+ if type_lower in ["epic"]:
1513
+ return IssueType.EPIC
1514
+ elif type_lower in ["story", "user story"]:
1515
+ return IssueType.STORY
1516
+ elif type_lower in ["task"]:
1517
+ return IssueType.TASK
1518
+ elif type_lower in ["bug", "defect"]:
1519
+ return IssueType.BUG
1520
+ elif type_lower in ["new feature", "feature"]:
1521
+ return IssueType.FEATURE
1522
+ elif type_lower in ["improvement", "enhancement"]:
1523
+ return IssueType.IMPROVEMENT
1524
+ elif type_lower in ["sub-task", "subtask"]:
1525
+ return IssueType.SUBTASK
1526
+ elif type_lower in ["incident", "outage"]:
1527
+ return IssueType.INCIDENT
1528
+ else:
1529
+ return IssueType.UNKNOWN
1530
+
1531
+ def _map_jira_status(self, jira_status: str) -> IssueStatus:
1532
+ """Map JIRA status to unified issue status.
1533
+
1534
+ Args:
1535
+ jira_status: JIRA status name.
1536
+
1537
+ Returns:
1538
+ Unified IssueStatus enum value.
1539
+ """
1540
+ if not jira_status:
1541
+ return IssueStatus.UNKNOWN
1542
+
1543
+ status_lower = jira_status.lower()
1544
+
1545
+ # Common JIRA status mappings
1546
+ if status_lower in ["open", "to do", "todo", "new", "created", "backlog"]:
1547
+ return IssueStatus.TODO
1548
+ elif status_lower in ["in progress", "in-progress", "in development", "active", "assigned"]:
1549
+ return IssueStatus.IN_PROGRESS
1550
+ elif status_lower in ["in review", "in-review", "review", "code review", "peer review"]:
1551
+ return IssueStatus.IN_REVIEW
1552
+ elif status_lower in ["testing", "in testing", "in-testing", "qa", "verification"]:
1553
+ return IssueStatus.TESTING
1554
+ elif status_lower in ["done", "closed", "resolved", "completed", "fixed", "verified"]:
1555
+ return IssueStatus.DONE
1556
+ elif status_lower in ["cancelled", "canceled", "rejected", "wont do", "won't do"]:
1557
+ return IssueStatus.CANCELLED
1558
+ elif status_lower in ["blocked", "on hold", "waiting", "impediment"]:
1559
+ return IssueStatus.BLOCKED
1560
+ else:
1561
+ return IssueStatus.UNKNOWN
1562
+
1563
+ def _map_issue_type_to_jira(self, issue_type: IssueType) -> list[str]:
1564
+ """Map unified issue type to JIRA issue type names.
1565
+
1566
+ Args:
1567
+ issue_type: Unified IssueType enum value.
1568
+
1569
+ Returns:
1570
+ List of possible JIRA issue type names.
1571
+ """
1572
+ mapping = {
1573
+ IssueType.EPIC: ["Epic"],
1574
+ IssueType.STORY: ["Story", "User Story"],
1575
+ IssueType.TASK: ["Task"],
1576
+ IssueType.BUG: ["Bug", "Defect"],
1577
+ IssueType.FEATURE: ["New Feature", "Feature"],
1578
+ IssueType.IMPROVEMENT: ["Improvement", "Enhancement"],
1579
+ IssueType.SUBTASK: ["Sub-task", "Subtask"],
1580
+ IssueType.INCIDENT: ["Incident", "Outage"],
1581
+ }
1582
+
1583
+ return mapping.get(issue_type, [])
1584
+
1585
+ def _extract_text_from_adf(self, adf_doc: dict[str, Any]) -> str:
1586
+ """Extract plain text from JIRA's Atlassian Document Format.
1587
+
1588
+ WHY: JIRA Cloud uses ADF (Atlassian Document Format) for rich text.
1589
+ This method extracts plain text for consistent processing.
1590
+
1591
+ Args:
1592
+ adf_doc: ADF document structure.
1593
+
1594
+ Returns:
1595
+ Plain text extracted from ADF.
1596
+ """
1597
+
1598
+ def extract_text_recursive(node: Any) -> str:
1599
+ if isinstance(node, dict):
1600
+ if node.get("type") == "text":
1601
+ text_value = node.get("text", "")
1602
+ return str(text_value) if text_value else ""
1603
+ elif "content" in node:
1604
+ return "".join(extract_text_recursive(child) for child in node["content"])
1605
+ elif isinstance(node, list):
1606
+ return "".join(extract_text_recursive(child) for child in node)
1607
+ return ""
1608
+
1609
+ try:
1610
+ return extract_text_recursive(adf_doc)
1611
+ except Exception:
1612
+ return str(adf_doc)
1613
+
1614
+ def _seconds_to_hours(self, seconds: Optional[int]) -> Optional[float]:
1615
+ """Convert seconds to hours for time tracking fields.
1616
+
1617
+ Args:
1618
+ seconds: Time in seconds.
1619
+
1620
+ Returns:
1621
+ Time in hours, or None if seconds is None.
1622
+ """
1623
+ return seconds / 3600.0 if seconds is not None else None
1624
+
1625
+ def _format_network_error(self, error: Exception) -> str:
1626
+ """Format network errors with helpful context.
1627
+
1628
+ Args:
1629
+ error: The network exception that occurred.
1630
+
1631
+ Returns:
1632
+ Formatted error message with troubleshooting context.
1633
+ """
1634
+ error_str = str(error)
1635
+
1636
+ if "nodename nor servname provided" in error_str or "[Errno 8]" in error_str:
1637
+ return f"DNS resolution failed - hostname not found ({error_str})"
1638
+ elif "Name or service not known" in error_str or "[Errno -2]" in error_str:
1639
+ return f"DNS resolution failed - service not known ({error_str})"
1640
+ elif "Connection refused" in error_str or "[Errno 111]" in error_str:
1641
+ return f"Connection refused - service not running ({error_str})"
1642
+ elif "Network is unreachable" in error_str or "[Errno 101]" in error_str:
1643
+ return f"Network unreachable - check internet connection ({error_str})"
1644
+ elif "timeout" in error_str.lower():
1645
+ return f"Network timeout - slow connection or high latency ({error_str})"
1646
+ else:
1647
+ return f"Network error ({error_str})"
1648
+
1649
+ def _unified_issue_to_dict(self, issue: UnifiedIssue) -> dict[str, Any]:
1650
+ """Convert UnifiedIssue to dictionary for caching.
1651
+
1652
+ WHY: Cache storage requires serializable data structures.
1653
+ This method converts the UnifiedIssue object to a dictionary
1654
+ that preserves all data needed for reconstruction.
1655
+
1656
+ Args:
1657
+ issue: UnifiedIssue object to convert
1658
+
1659
+ Returns:
1660
+ Dictionary representation suitable for caching
1661
+ """
1662
+ return {
1663
+ "id": issue.id,
1664
+ "key": issue.key,
1665
+ "platform": issue.platform,
1666
+ "project_id": issue.project_id,
1667
+ "title": issue.title,
1668
+ "description": issue.description,
1669
+ "created_date": issue.created_date.isoformat() if issue.created_date else None,
1670
+ "updated_date": issue.updated_date.isoformat() if issue.updated_date else None,
1671
+ "issue_type": issue.issue_type.value if issue.issue_type else None,
1672
+ "status": issue.status.value if issue.status else None,
1673
+ "priority": issue.priority.value if issue.priority else None,
1674
+ "assignee": (
1675
+ {
1676
+ "id": issue.assignee.id,
1677
+ "email": issue.assignee.email,
1678
+ "display_name": issue.assignee.display_name,
1679
+ "username": issue.assignee.username,
1680
+ "platform": issue.assignee.platform,
1681
+ "is_active": issue.assignee.is_active,
1682
+ "platform_data": issue.assignee.platform_data,
1683
+ }
1684
+ if issue.assignee
1685
+ else None
1686
+ ),
1687
+ "reporter": (
1688
+ {
1689
+ "id": issue.reporter.id,
1690
+ "email": issue.reporter.email,
1691
+ "display_name": issue.reporter.display_name,
1692
+ "username": issue.reporter.username,
1693
+ "platform": issue.reporter.platform,
1694
+ "is_active": issue.reporter.is_active,
1695
+ "platform_data": issue.reporter.platform_data,
1696
+ }
1697
+ if issue.reporter
1698
+ else None
1699
+ ),
1700
+ "resolved_date": issue.resolved_date.isoformat() if issue.resolved_date else None,
1701
+ "due_date": issue.due_date.isoformat() if issue.due_date else None,
1702
+ "story_points": issue.story_points,
1703
+ "original_estimate_hours": issue.original_estimate_hours,
1704
+ "remaining_estimate_hours": issue.remaining_estimate_hours,
1705
+ "time_spent_hours": issue.time_spent_hours,
1706
+ "parent_issue_key": issue.parent_issue_key,
1707
+ "subtasks": issue.subtasks or [],
1708
+ "linked_issues": issue.linked_issues or [],
1709
+ "sprint_id": issue.sprint_id,
1710
+ "sprint_name": issue.sprint_name,
1711
+ "labels": issue.labels or [],
1712
+ "components": issue.components or [],
1713
+ "platform_data": issue.platform_data or {},
1714
+ }
1715
+
1716
+ def _dict_to_unified_issue(self, data: dict[str, Any]) -> UnifiedIssue:
1717
+ """Convert dictionary back to UnifiedIssue object.
1718
+
1719
+ WHY: Cache retrieval needs to reconstruct UnifiedIssue objects
1720
+ from stored dictionary data. This method handles the conversion
1721
+ including proper enum and datetime parsing.
1722
+
1723
+ Args:
1724
+ data: Dictionary representation from cache
1725
+
1726
+ Returns:
1727
+ UnifiedIssue object reconstructed from cached data
1728
+ """
1729
+ from datetime import datetime, timezone
1730
+
1731
+ # Helper function to parse ISO datetime strings
1732
+ def parse_datetime(date_str: Optional[str]) -> Optional[datetime]:
1733
+ if not date_str:
1734
+ return None
1735
+ try:
1736
+ dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
1737
+ # Ensure timezone awareness
1738
+ if dt.tzinfo is None:
1739
+ dt = dt.replace(tzinfo=timezone.utc)
1740
+ return dt
1741
+ except (ValueError, TypeError):
1742
+ return None
1743
+
1744
+ # Convert string enums back to enum values
1745
+ def safe_enum_conversion(enum_class, value):
1746
+ if not value:
1747
+ return None
1748
+ try:
1749
+ return enum_class(value)
1750
+ except (ValueError, TypeError):
1751
+ return None
1752
+
1753
+ # Reconstruct user objects
1754
+ def dict_to_user(user_data: Optional[dict[str, Any]]) -> Optional[UnifiedUser]:
1755
+ if not user_data:
1756
+ return None
1757
+ return UnifiedUser(
1758
+ id=user_data.get("id", ""),
1759
+ email=user_data.get("email"),
1760
+ display_name=user_data.get("display_name", ""),
1761
+ username=user_data.get("username"),
1762
+ platform=user_data.get("platform", self.platform_name),
1763
+ is_active=user_data.get("is_active", True),
1764
+ platform_data=user_data.get("platform_data", {}),
1765
+ )
1766
+
1767
+ return UnifiedIssue(
1768
+ id=data.get("id", ""),
1769
+ key=data.get("key", ""),
1770
+ platform=data.get("platform", self.platform_name),
1771
+ project_id=data.get("project_id", ""),
1772
+ title=data.get("title", ""),
1773
+ description=data.get("description", ""),
1774
+ created_date=parse_datetime(data.get("created_date")) or datetime.now(timezone.utc),
1775
+ updated_date=parse_datetime(data.get("updated_date")) or datetime.now(timezone.utc),
1776
+ issue_type=safe_enum_conversion(IssueType, data.get("issue_type")),
1777
+ status=safe_enum_conversion(IssueStatus, data.get("status")),
1778
+ priority=safe_enum_conversion(self._get_priority_enum(), data.get("priority")),
1779
+ assignee=dict_to_user(data.get("assignee")),
1780
+ reporter=dict_to_user(data.get("reporter")),
1781
+ resolved_date=parse_datetime(data.get("resolved_date")),
1782
+ due_date=parse_datetime(data.get("due_date")),
1783
+ story_points=data.get("story_points"),
1784
+ original_estimate_hours=data.get("original_estimate_hours"),
1785
+ remaining_estimate_hours=data.get("remaining_estimate_hours"),
1786
+ time_spent_hours=data.get("time_spent_hours"),
1787
+ parent_issue_key=data.get("parent_issue_key"),
1788
+ subtasks=data.get("subtasks", []),
1789
+ linked_issues=data.get("linked_issues", []),
1790
+ sprint_id=data.get("sprint_id"),
1791
+ sprint_name=data.get("sprint_name"),
1792
+ labels=data.get("labels", []),
1793
+ components=data.get("components", []),
1794
+ platform_data=data.get("platform_data", {}),
1795
+ )
1796
+
1797
+ def _get_priority_enum(self):
1798
+ """Get priority enum class for safe conversion."""
1799
+ # Import here to avoid circular imports
1800
+ from ..models import Priority
1801
+
1802
+ return Priority
1803
+
1804
+ def get_cache_statistics(self) -> dict[str, Any]:
1805
+ """Get comprehensive cache statistics for monitoring and debugging.
1806
+
1807
+ WHY: Cache performance monitoring is essential for optimization
1808
+ and troubleshooting. This method provides detailed metrics about
1809
+ cache usage, effectiveness, and storage patterns.
1810
+
1811
+ Returns:
1812
+ Dictionary with detailed cache statistics
1813
+ """
1814
+ return self.ticket_cache.get_cache_stats()
1815
+
1816
+ def print_cache_summary(self) -> None:
1817
+ """Print user-friendly cache performance summary."""
1818
+ self.ticket_cache.print_cache_summary()
1819
+
1820
+ def clear_ticket_cache(self) -> int:
1821
+ """Clear all cached tickets.
1822
+
1823
+ Returns:
1824
+ Number of entries removed
1825
+ """
1826
+ return self.ticket_cache.clear_cache()
1827
+
1828
+ def cleanup_expired_cache(self) -> int:
1829
+ """Remove expired cache entries.
1830
+
1831
+ Returns:
1832
+ Number of expired entries removed
1833
+ """
1834
+ return self.ticket_cache.cleanup_expired()
1835
+
1836
+ def invalidate_ticket_cache(self, ticket_key: str) -> bool:
1837
+ """Invalidate cache for specific ticket.
1838
+
1839
+ Args:
1840
+ ticket_key: JIRA ticket key to invalidate
1841
+
1842
+ Returns:
1843
+ True if ticket was found and invalidated
1844
+ """
1845
+ return self.ticket_cache.invalidate_ticket(ticket_key)