Skip to content

Commit 361826a

Browse files
committed
fix(jira): migrate to new /search/jql API and fix integration issues
- Migrate from deprecated /rest/api/X/search to /rest/api/3/search/jql (Atlassian removed the old endpoint, returning 410 Gone) - Use cursor-based pagination (nextPageToken/isLast) instead of offset-based - Quote project keys in JQL to handle reserved words (e.g., "AS", "IN") - Auto-select all Jira projects when jira_projects.txt is missing/empty - Integrate Jira extraction into main() CLI flow with auto-detection - Update test fixtures for new API response format
1 parent 5b61d3b commit 361826a

7 files changed

Lines changed: 174 additions & 144 deletions

File tree

src/github_analyzer/api/jira_client.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -360,25 +360,30 @@ def search_issues(
360360
return
361361

362362
# Build JQL query (FR-005)
363-
projects_jql = ", ".join(project_keys)
363+
# Quote project keys to handle reserved JQL words (e.g., "AS", "IN", "OR")
364+
quoted_keys = [f'"{key}"' for key in project_keys]
365+
projects_jql = ", ".join(quoted_keys)
364366
date_str = since_date.strftime("%Y-%m-%d")
365367
jql = f"project in ({projects_jql}) AND updated >= '{date_str}' ORDER BY updated DESC"
366368

367-
# Pagination parameters (FR-008)
368-
start_at = 0
369-
max_results = 100 # Jira maximum
369+
# Use new /search/jql endpoint with cursor-based pagination
370+
# See: https://developer.atlassian.com/changelog/#CHANGE-2046
371+
max_results = 100
372+
next_page_token: str | None = None
370373

371374
while True:
372-
params = {
375+
params: dict[str, Any] = {
373376
"jql": jql,
374-
"startAt": start_at,
375377
"maxResults": max_results,
376-
"fields": "summary,description,status,issuetype,priority,assignee,reporter,created,updated,resolutiondate,project",
378+
"fields": "*all,-comment", # All fields except comments (fetched separately)
377379
}
378380

381+
if next_page_token:
382+
params["nextPageToken"] = next_page_token
383+
379384
response = self._make_request(
380385
"GET",
381-
f"/rest/api/{self.api_version}/search",
386+
f"/rest/api/{self.api_version}/search/jql",
382387
params=params,
383388
)
384389

@@ -387,13 +392,12 @@ def search_issues(
387392
for issue_data in issues:
388393
yield self._parse_issue(issue_data)
389394

390-
# Check if more pages
391-
total = response.get("total", 0)
392-
start_at += len(issues)
393-
394-
if start_at >= total or not issues:
395+
# Check if more pages (cursor-based pagination)
396+
if response.get("isLast", True) or not issues:
395397
break
396398

399+
next_page_token = response.get("nextPageToken")
400+
397401
def _parse_issue(self, data: dict[str, Any]) -> JiraIssue:
398402
"""Parse API response into JiraIssue.
399403

src/github_analyzer/cli/main.py

Lines changed: 79 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -464,24 +464,22 @@ def parse_project_selection(selection: str, max_projects: int) -> list[int]:
464464
def select_jira_projects(
465465
projects_file: str,
466466
jira_config: JiraConfig | None,
467-
save_selection: bool = False,
468467
) -> list[str]:
469-
"""Select Jira projects interactively or from file.
468+
"""Select Jira projects from file or use all available.
470469
471470
Args:
472471
projects_file: Path to jira_projects.txt file.
473-
jira_config: Jira configuration (required for interactive selection).
474-
save_selection: Whether to save selection to file.
472+
jira_config: Jira configuration (required to fetch available projects).
475473
476474
Returns:
477-
List of selected project keys.
475+
List of project keys to analyze.
478476
"""
479477
# Try loading from file first
480478
file_projects = load_jira_projects(projects_file)
481479
if file_projects:
482480
return file_projects
483481

484-
# No file or empty - need interactive selection
482+
# No file or empty - use all available projects
485483
if not jira_config:
486484
return []
487485

@@ -495,31 +493,11 @@ def select_jira_projects(
495493
print("No projects found in Jira instance.")
496494
return []
497495

498-
# Display projects
499-
print("\nAvailable Jira projects:")
500-
print(format_project_list(available_projects))
501-
print("\nEnter project numbers (e.g., '1,3,5' or '1-3' or 'all'):")
496+
# Use all available projects
497+
all_keys = [p.key for p in available_projects]
498+
print(f"\nNo {projects_file} found. Using all {len(all_keys)} available Jira projects.")
502499

503-
try:
504-
selection = input("> ").strip()
505-
indices = parse_project_selection(selection, len(available_projects))
506-
507-
if not indices:
508-
print("No valid projects selected.")
509-
return []
510-
511-
selected = [available_projects[i].key for i in indices]
512-
513-
# Optionally save to file
514-
if save_selection and prompt_yes_no("Save selection to file for future use?", default=True):
515-
Path(projects_file).write_text("\n".join(selected) + "\n")
516-
print(f"Saved to {projects_file}")
517-
518-
return selected
519-
520-
except (EOFError, KeyboardInterrupt):
521-
print("\nSelection cancelled.")
522-
return []
500+
return all_keys
523501

524502

525503
def run_extraction(
@@ -576,7 +554,7 @@ def run_extraction(
576554

577555
# Get Jira projects
578556
projects_file = jira_projects_file or jira_config.jira_projects_file
579-
project_keys = select_jira_projects(projects_file, jira_config, save_selection=True)
557+
project_keys = select_jira_projects(projects_file, jira_config)
580558

581559
if not project_keys:
582560
print("No Jira projects selected. Skipping Jira extraction.")
@@ -634,6 +612,18 @@ def main() -> int:
634612

635613
config.validate()
636614

615+
# Determine data sources
616+
if args.sources == "auto":
617+
sources = auto_detect_sources()
618+
if not sources:
619+
output.error("No data sources available. Set GITHUB_TOKEN or Jira credentials.")
620+
return 1
621+
output.log(f"Auto-detected sources: {', '.join(s.value for s in sources)}", "info")
622+
else:
623+
sources = parse_sources_list(args.sources)
624+
validate_sources(sources)
625+
output.log(f"Using sources: {', '.join(s.value for s in sources)}", "info")
626+
637627
# Interactive prompts for options not provided via CLI
638628
print()
639629

@@ -664,13 +654,29 @@ def main() -> int:
664654
output.log(f"Verbose mode: {'Yes' if config.verbose else 'No'}", "info")
665655
output.log(f"Full PR details: {'Yes' if fetch_pr_details else 'No'}", "info")
666656

667-
# Load repositories
668-
output.log(f"Loading repositories from {config.repos_file}...")
669-
repositories = load_repositories(config.repos_file)
670-
output.log(f"Found {len(repositories)} repositories to analyze", "success")
671-
672-
for repo in repositories:
673-
output.log(f" • {repo.full_name}", "info")
657+
# Load GitHub repositories if GitHub source is enabled
658+
repositories = []
659+
if DataSource.GITHUB in sources:
660+
output.log(f"Loading repositories from {config.repos_file}...")
661+
repositories = load_repositories(config.repos_file)
662+
output.log(f"Found {len(repositories)} repositories to analyze", "success")
663+
664+
for repo in repositories:
665+
output.log(f" • {repo.full_name}", "info")
666+
667+
# Load Jira projects if Jira source is enabled
668+
jira_config = None
669+
project_keys: list[str] = []
670+
if DataSource.JIRA in sources:
671+
jira_config = JiraConfig.from_env()
672+
if jira_config:
673+
projects_file = args.jira_projects or jira_config.jira_projects_file
674+
project_keys = select_jira_projects(projects_file, jira_config)
675+
output.log(f"Found {len(project_keys)} Jira projects to analyze", "success")
676+
for key in project_keys[:5]:
677+
output.log(f" • {key}", "info")
678+
if len(project_keys) > 5:
679+
output.log(f" ... and {len(project_keys) - 5} more", "info")
674680

675681
# Confirm before starting
676682
print()
@@ -681,11 +687,41 @@ def main() -> int:
681687
# Run analysis
682688
output.section("🚀 ANALYSIS")
683689

684-
analyzer = GitHubAnalyzer(config, fetch_pr_details=fetch_pr_details)
685-
try:
686-
analyzer.run(repositories)
687-
finally:
688-
analyzer.close()
690+
# Run GitHub analysis
691+
if DataSource.GITHUB in sources and repositories:
692+
output.log("Starting GitHub analysis...", "info")
693+
analyzer = GitHubAnalyzer(config, fetch_pr_details=fetch_pr_details)
694+
try:
695+
analyzer.run(repositories)
696+
finally:
697+
analyzer.close()
698+
699+
# Run Jira extraction
700+
if DataSource.JIRA in sources and jira_config and project_keys:
701+
output.log("Starting Jira extraction...", "info")
702+
from src.github_analyzer.api.jira_client import JiraClient
703+
704+
client = JiraClient(jira_config)
705+
since = datetime.now(timezone.utc) - timedelta(days=config.days)
706+
707+
# Collect issues and comments
708+
output.log(f"Fetching issues from {len(project_keys)} projects...", "info")
709+
all_issues = list(client.search_issues(project_keys, since))
710+
output.log(f"Found {len(all_issues)} issues", "success")
711+
712+
output.log("Fetching comments...", "info")
713+
all_comments = []
714+
for issue in all_issues:
715+
comments = client.get_comments(issue.key)
716+
all_comments.extend(comments)
717+
output.log(f"Found {len(all_comments)} comments", "success")
718+
719+
# Export Jira data to CSV
720+
jira_exporter = JiraExporter(config.output_dir)
721+
issues_file = jira_exporter.export_issues(all_issues)
722+
comments_file = jira_exporter.export_comments(all_comments)
723+
output.log(f"Exported Jira issues to {issues_file}", "success")
724+
output.log(f"Exported Jira comments to {comments_file}", "success")
689725

690726
return 0
691727

src/github_analyzer/cli/output.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ def banner(self) -> None:
8484
print()
8585
print(f"{c.BOLD}{c.PURPLE}╔══════════════════════════════════════════════════════════════════════╗{c.RESET}")
8686
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
87-
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}███████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
88-
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
89-
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}█████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
90-
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
91-
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}███████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}████{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
87+
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}███████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE} {c.RESET}")
88+
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE} {c.RESET}")
89+
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}█████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE} {c.RESET}")
90+
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██{c.RESET} {c.BOLD}{c.PURPLE} {c.RESET}")
91+
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}██████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}███████{c.RESET} {c.BOLD}{c.BRIGHT_CYAN}████{c.RESET} {c.BOLD}{c.PURPLE} {c.RESET}")
9292
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
9393
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.ORANGE}█████{c.RESET} {c.BOLD}{c.ORANGE}███{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}█████{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}███████{c.RESET} {c.BOLD}{c.ORANGE}███████{c.RESET} {c.BOLD}{c.ORANGE}██████{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")
9494
print(f"{c.BOLD}{c.PURPLE}{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}████{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}███{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.ORANGE}██{c.RESET} {c.BOLD}{c.PURPLE}{c.RESET}")

tests/fixtures/jira_responses.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,8 @@
6565
"description": "Main project for development",
6666
}
6767

68-
# Issue search response (first page)
68+
# Issue search response (first page) - new /search/jql format
6969
ISSUE_SEARCH_RESPONSE_PAGE_1 = {
70-
"expand": "schema,names",
71-
"startAt": 0,
72-
"maxResults": 100,
73-
"total": 150,
7470
"issues": [
7571
{
7672
"id": "10001",
@@ -120,14 +116,12 @@
120116
},
121117
},
122118
],
119+
"nextPageToken": "token123",
120+
"isLast": False,
123121
}
124122

125-
# Issue search response (second page - last page)
123+
# Issue search response (second page - last page) - new /search/jql format
126124
ISSUE_SEARCH_RESPONSE_PAGE_2 = {
127-
"expand": "schema,names",
128-
"startAt": 100,
129-
"maxResults": 100,
130-
"total": 150,
131125
"issues": [
132126
{
133127
"id": "10101",
@@ -157,15 +151,15 @@
157151
},
158152
},
159153
],
154+
"nextPageToken": None,
155+
"isLast": True,
160156
}
161157

162-
# Empty search response
158+
# Empty search response - new /search/jql format
163159
ISSUE_SEARCH_EMPTY_RESPONSE = {
164-
"expand": "schema,names",
165-
"startAt": 0,
166-
"maxResults": 100,
167-
"total": 0,
168160
"issues": [],
161+
"nextPageToken": None,
162+
"isLast": True,
169163
}
170164

171165
# Comments response for an issue

0 commit comments

Comments
 (0)