Skip to content

Commit 919537b

Browse files
committed
feat(jira): add Jira Server/DC compatibility with API v2
The implementation now supports both Jira Cloud and Server/Data Center: - Cloud (API v3): GET /rest/api/3/search/jql with cursor-based pagination - Server/DC (API v2): POST /rest/api/2/search with offset-based pagination The API version is auto-detected from the URL (atlassian.net = Cloud). Added tests for Server/DC path including pagination.
1 parent 8ae83e7 commit 919537b

2 files changed

Lines changed: 136 additions & 4 deletions

File tree

src/github_analyzer/api/jira_client.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,24 +366,42 @@ def search_issues(
366366
date_str = since_date.strftime("%Y-%m-%d")
367367
jql = f"project in ({projects_jql}) AND updated >= '{date_str}' ORDER BY updated DESC"
368368

369-
# Use new /search/jql endpoint with cursor-based pagination
370-
# See: https://developer.atlassian.com/changelog/#CHANGE-2046
369+
# Use different endpoint/pagination based on API version
370+
# - Cloud (v3): GET /search/jql with cursor-based pagination (nextPageToken)
371+
# - Server/DC (v2): POST /search with offset-based pagination (startAt/total)
372+
if self.api_version == "3":
373+
yield from self._search_issues_cloud(jql)
374+
else:
375+
yield from self._search_issues_server(jql)
376+
377+
def _search_issues_cloud(self, jql: str) -> Iterator[JiraIssue]:
378+
"""Search issues using Jira Cloud API (v3).
379+
380+
Uses GET /rest/api/3/search/jql with cursor-based pagination.
381+
See: https://developer.atlassian.com/changelog/#CHANGE-2046
382+
383+
Args:
384+
jql: JQL query string.
385+
386+
Yields:
387+
JiraIssue objects matching the criteria.
388+
"""
371389
max_results = 100
372390
next_page_token: str | None = None
373391

374392
while True:
375393
params: dict[str, Any] = {
376394
"jql": jql,
377395
"maxResults": max_results,
378-
"fields": "*all,-comment", # All fields except comments (fetched separately)
396+
"fields": "*all,-comment",
379397
}
380398

381399
if next_page_token:
382400
params["nextPageToken"] = next_page_token
383401

384402
response = self._make_request(
385403
"GET",
386-
f"/rest/api/{self.api_version}/search/jql",
404+
"/rest/api/3/search/jql",
387405
params=params,
388406
)
389407

@@ -398,6 +416,47 @@ def search_issues(
398416

399417
next_page_token = response.get("nextPageToken")
400418

419+
def _search_issues_server(self, jql: str) -> Iterator[JiraIssue]:
420+
"""Search issues using Jira Server/Data Center API (v2).
421+
422+
Uses POST /rest/api/2/search with offset-based pagination.
423+
424+
Args:
425+
jql: JQL query string.
426+
427+
Yields:
428+
JiraIssue objects matching the criteria.
429+
"""
430+
max_results = 100
431+
start_at = 0
432+
433+
while True:
434+
# Server API uses POST with JSON body
435+
body = {
436+
"jql": jql,
437+
"startAt": start_at,
438+
"maxResults": max_results,
439+
"fields": ["*all", "-comment"],
440+
}
441+
442+
response = self._make_request(
443+
"POST",
444+
"/rest/api/2/search",
445+
data=body,
446+
)
447+
448+
issues = response.get("issues", [])
449+
450+
for issue_data in issues:
451+
yield self._parse_issue(issue_data)
452+
453+
# Check if more pages (offset-based pagination)
454+
total = response.get("total", 0)
455+
start_at += len(issues)
456+
457+
if start_at >= total or not issues:
458+
break
459+
401460
def _parse_issue(self, data: dict[str, Any]) -> JiraIssue:
402461
"""Parse API response into JiraIssue.
403462

tests/unit/api/test_jira_client.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,79 @@ def test_search_issues_empty_project_keys(self, jira_config: JiraConfig) -> None
288288
assert issues == []
289289
mock_request.assert_not_called()
290290

291+
def test_search_issues_server_uses_post_search(self) -> None:
292+
"""search_issues uses POST /rest/api/2/search for Server/DC."""
293+
from src.github_analyzer.api.jira_client import JiraClient, JiraIssue
294+
295+
# Server/DC config (non-atlassian.net URL)
296+
server_config = JiraConfig(
297+
jira_url="https://jira.company.com",
298+
jira_email="test@company.com",
299+
jira_api_token="test-token",
300+
)
301+
client = JiraClient(server_config)
302+
assert client.api_version == "2" # Verify it's detected as Server
303+
304+
since_date = datetime(2025, 11, 1, tzinfo=timezone.utc)
305+
306+
# Server API response format (offset-based pagination)
307+
server_response = {
308+
"startAt": 0,
309+
"maxResults": 100,
310+
"total": 2,
311+
"issues": ISSUE_SEARCH_RESPONSE_PAGE_1["issues"],
312+
}
313+
314+
with mock.patch.object(client, "_make_request") as mock_request:
315+
mock_request.return_value = server_response
316+
issues = list(client.search_issues(["PROJ"], since_date))
317+
318+
assert len(issues) == 2
319+
assert all(isinstance(i, JiraIssue) for i in issues)
320+
321+
# Verify POST was used with correct endpoint
322+
mock_request.assert_called_once()
323+
call_args = mock_request.call_args
324+
assert call_args[0][0] == "POST" # HTTP method
325+
assert call_args[0][1] == "/rest/api/2/search" # Endpoint
326+
327+
def test_search_issues_server_pagination(self) -> None:
328+
"""search_issues handles offset-based pagination for Server/DC."""
329+
from src.github_analyzer.api.jira_client import JiraClient
330+
331+
server_config = JiraConfig(
332+
jira_url="https://jira.company.com",
333+
jira_email="test@company.com",
334+
jira_api_token="test-token",
335+
)
336+
client = JiraClient(server_config)
337+
338+
since_date = datetime(2025, 11, 1, tzinfo=timezone.utc)
339+
340+
# Page 1: more pages available (startAt + len(issues) < total)
341+
page_1 = {
342+
"startAt": 0,
343+
"maxResults": 2,
344+
"total": 3,
345+
"issues": ISSUE_SEARCH_RESPONSE_PAGE_1["issues"], # 2 issues
346+
}
347+
348+
# Page 2: last page
349+
page_2 = {
350+
"startAt": 2,
351+
"maxResults": 2,
352+
"total": 3,
353+
"issues": ISSUE_SEARCH_RESPONSE_PAGE_2["issues"], # 1 issue
354+
}
355+
356+
with mock.patch.object(client, "_make_request") as mock_request:
357+
mock_request.side_effect = [page_1, page_2]
358+
issues = list(client.search_issues(["PROJ"], since_date))
359+
360+
# 2 from page 1 + 1 from page 2
361+
assert len(issues) == 3
362+
assert mock_request.call_count == 2
363+
291364

292365
class TestJiraClientGetComments:
293366
"""Tests for get_comments() method."""

0 commit comments

Comments
 (0)