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.
- gitflow_analytics/__init__.py +11 -11
- gitflow_analytics/_version.py +2 -2
- gitflow_analytics/classification/__init__.py +31 -0
- gitflow_analytics/classification/batch_classifier.py +752 -0
- gitflow_analytics/classification/classifier.py +464 -0
- gitflow_analytics/classification/feature_extractor.py +725 -0
- gitflow_analytics/classification/linguist_analyzer.py +574 -0
- gitflow_analytics/classification/model.py +455 -0
- gitflow_analytics/cli.py +4490 -378
- gitflow_analytics/cli_rich.py +503 -0
- gitflow_analytics/config/__init__.py +43 -0
- gitflow_analytics/config/errors.py +261 -0
- gitflow_analytics/config/loader.py +904 -0
- gitflow_analytics/config/profiles.py +264 -0
- gitflow_analytics/config/repository.py +124 -0
- gitflow_analytics/config/schema.py +441 -0
- gitflow_analytics/config/validator.py +154 -0
- gitflow_analytics/config.py +44 -398
- gitflow_analytics/core/analyzer.py +1320 -172
- gitflow_analytics/core/branch_mapper.py +132 -132
- gitflow_analytics/core/cache.py +1554 -175
- gitflow_analytics/core/data_fetcher.py +1193 -0
- gitflow_analytics/core/identity.py +571 -185
- gitflow_analytics/core/metrics_storage.py +526 -0
- gitflow_analytics/core/progress.py +372 -0
- gitflow_analytics/core/schema_version.py +269 -0
- gitflow_analytics/extractors/base.py +13 -11
- gitflow_analytics/extractors/ml_tickets.py +1100 -0
- gitflow_analytics/extractors/story_points.py +77 -59
- gitflow_analytics/extractors/tickets.py +841 -89
- gitflow_analytics/identity_llm/__init__.py +6 -0
- gitflow_analytics/identity_llm/analysis_pass.py +231 -0
- gitflow_analytics/identity_llm/analyzer.py +464 -0
- gitflow_analytics/identity_llm/models.py +76 -0
- gitflow_analytics/integrations/github_integration.py +258 -87
- gitflow_analytics/integrations/jira_integration.py +572 -123
- gitflow_analytics/integrations/orchestrator.py +206 -82
- gitflow_analytics/metrics/activity_scoring.py +322 -0
- gitflow_analytics/metrics/branch_health.py +470 -0
- gitflow_analytics/metrics/dora.py +542 -179
- gitflow_analytics/models/database.py +986 -59
- gitflow_analytics/pm_framework/__init__.py +115 -0
- gitflow_analytics/pm_framework/adapters/__init__.py +50 -0
- gitflow_analytics/pm_framework/adapters/jira_adapter.py +1845 -0
- gitflow_analytics/pm_framework/base.py +406 -0
- gitflow_analytics/pm_framework/models.py +211 -0
- gitflow_analytics/pm_framework/orchestrator.py +652 -0
- gitflow_analytics/pm_framework/registry.py +333 -0
- gitflow_analytics/qualitative/__init__.py +29 -0
- gitflow_analytics/qualitative/chatgpt_analyzer.py +259 -0
- gitflow_analytics/qualitative/classifiers/__init__.py +13 -0
- gitflow_analytics/qualitative/classifiers/change_type.py +742 -0
- gitflow_analytics/qualitative/classifiers/domain_classifier.py +506 -0
- gitflow_analytics/qualitative/classifiers/intent_analyzer.py +535 -0
- gitflow_analytics/qualitative/classifiers/llm/__init__.py +35 -0
- gitflow_analytics/qualitative/classifiers/llm/base.py +193 -0
- gitflow_analytics/qualitative/classifiers/llm/batch_processor.py +383 -0
- gitflow_analytics/qualitative/classifiers/llm/cache.py +479 -0
- gitflow_analytics/qualitative/classifiers/llm/cost_tracker.py +435 -0
- gitflow_analytics/qualitative/classifiers/llm/openai_client.py +403 -0
- gitflow_analytics/qualitative/classifiers/llm/prompts.py +373 -0
- gitflow_analytics/qualitative/classifiers/llm/response_parser.py +287 -0
- gitflow_analytics/qualitative/classifiers/llm_commit_classifier.py +607 -0
- gitflow_analytics/qualitative/classifiers/risk_analyzer.py +438 -0
- gitflow_analytics/qualitative/core/__init__.py +13 -0
- gitflow_analytics/qualitative/core/llm_fallback.py +657 -0
- gitflow_analytics/qualitative/core/nlp_engine.py +382 -0
- gitflow_analytics/qualitative/core/pattern_cache.py +479 -0
- gitflow_analytics/qualitative/core/processor.py +673 -0
- gitflow_analytics/qualitative/enhanced_analyzer.py +2236 -0
- gitflow_analytics/qualitative/example_enhanced_usage.py +420 -0
- gitflow_analytics/qualitative/models/__init__.py +25 -0
- gitflow_analytics/qualitative/models/schemas.py +306 -0
- gitflow_analytics/qualitative/utils/__init__.py +13 -0
- gitflow_analytics/qualitative/utils/batch_processor.py +339 -0
- gitflow_analytics/qualitative/utils/cost_tracker.py +345 -0
- gitflow_analytics/qualitative/utils/metrics.py +361 -0
- gitflow_analytics/qualitative/utils/text_processing.py +285 -0
- gitflow_analytics/reports/__init__.py +100 -0
- gitflow_analytics/reports/analytics_writer.py +550 -18
- gitflow_analytics/reports/base.py +648 -0
- gitflow_analytics/reports/branch_health_writer.py +322 -0
- gitflow_analytics/reports/classification_writer.py +924 -0
- gitflow_analytics/reports/cli_integration.py +427 -0
- gitflow_analytics/reports/csv_writer.py +1700 -216
- gitflow_analytics/reports/data_models.py +504 -0
- gitflow_analytics/reports/database_report_generator.py +427 -0
- gitflow_analytics/reports/example_usage.py +344 -0
- gitflow_analytics/reports/factory.py +499 -0
- gitflow_analytics/reports/formatters.py +698 -0
- gitflow_analytics/reports/html_generator.py +1116 -0
- gitflow_analytics/reports/interfaces.py +489 -0
- gitflow_analytics/reports/json_exporter.py +2770 -0
- gitflow_analytics/reports/narrative_writer.py +2289 -158
- gitflow_analytics/reports/story_point_correlation.py +1144 -0
- gitflow_analytics/reports/weekly_trends_writer.py +389 -0
- gitflow_analytics/training/__init__.py +5 -0
- gitflow_analytics/training/model_loader.py +377 -0
- gitflow_analytics/training/pipeline.py +550 -0
- gitflow_analytics/tui/__init__.py +5 -0
- gitflow_analytics/tui/app.py +724 -0
- gitflow_analytics/tui/screens/__init__.py +8 -0
- gitflow_analytics/tui/screens/analysis_progress_screen.py +496 -0
- gitflow_analytics/tui/screens/configuration_screen.py +523 -0
- gitflow_analytics/tui/screens/loading_screen.py +348 -0
- gitflow_analytics/tui/screens/main_screen.py +321 -0
- gitflow_analytics/tui/screens/results_screen.py +722 -0
- gitflow_analytics/tui/widgets/__init__.py +7 -0
- gitflow_analytics/tui/widgets/data_table.py +255 -0
- gitflow_analytics/tui/widgets/export_modal.py +301 -0
- gitflow_analytics/tui/widgets/progress_widget.py +187 -0
- gitflow_analytics-1.3.6.dist-info/METADATA +1015 -0
- gitflow_analytics-1.3.6.dist-info/RECORD +122 -0
- gitflow_analytics-1.0.1.dist-info/METADATA +0 -463
- gitflow_analytics-1.0.1.dist-info/RECORD +0 -31
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/WHEEL +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/entry_points.txt +0 -0
- {gitflow_analytics-1.0.1.dist-info → gitflow_analytics-1.3.6.dist-info}/licenses/LICENSE +0 -0
- {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)
|