[Integration][Jira] Add support for sprint kind#3164
Conversation
Review Summary by QodoAdd Jira Sprint kind with Agile API support
WalkthroughsDescription• Adds sprint as new kind to Jira integration with Agile API support • Implements JiraSprintSelector with configurable state filtering (active/closed/future) • Adds concurrent sprint fetching across boards with resilience and error handling • Implements SprintWebhookProcessor for sprint lifecycle event handling • Comprehensive test coverage including large-scale fan-out scenarios (200-5000 boards) Diagramflowchart LR
A["Jira Agile API"] -->|get_paginated_sprints_for_board| B["Sprint Fetcher"]
B -->|per-board resilience| C["Error Handler"]
C -->|HTTPStatusError/RequestError| D["Skip Board & Log"]
B -->|stream_async_iterators_tasks| E["Concurrent Fan-out"]
E -->|bounded by RateLimiter| F["Sprint Batches"]
G["Sprint Webhook Events"] -->|sprint_created/updated/closed| H["SprintWebhookProcessor"]
H -->|upsert on closed| I["Port Entities"]
F -->|on_resync_sprints| I
File Changes1. integrations/jira/jira/client.py
|
Code Review by Qodo
1. Partial resync deletes sprints
|
f31b60a to
99e594b
Compare
| try: | ||
| async for sprint_batch in self._get_agile_paginated_data( | ||
| url=url, | ||
| initial_params=query_params, | ||
| ): | ||
| yield sprint_batch | ||
| except httpx.HTTPStatusError as e: | ||
| logger.warning( | ||
| f"Failed to fetch sprints for board {board_id}: " | ||
| f"HTTP {e.response.status_code} — skipping board and continuing resync" | ||
| ) | ||
| except httpx.RequestError as e: | ||
| logger.warning( | ||
| f"Network error fetching sprints for board {board_id}: " | ||
| f"{e} — skipping board and continuing resync" | ||
| ) |
There was a problem hiding this comment.
1. Partial resync deletes sprints 🐞 Bug ≡ Correctness
get_paginated_sprints_for_board() swallows per-board HTTP/network errors and yields nothing for that board, allowing the sprint resync to finish with an incomplete "after" set. Port Ocean’s sync then computes deletions from entities_at_port vs. the returned after-set, so previously-synced sprints from failed boards can be incorrectly deleted in that resync.
Agent Prompt
### Issue description
`get_paginated_sprints_for_board()` catches `HTTPStatusError`/`RequestError` and silently skips a board. During resync, this can produce a partial desired-state set; Port Ocean then performs deletions based on the returned `after` set, which can delete previously-synced sprints from boards that temporarily failed.
### Issue Context
Port Ocean sync computes deletions via `delete_diff({before: entities_at_port, after: modified_entities}, entity_deletion_threshold)`.
### Fix Focus Areas
- integrations/jira/jira/client.py[688-727]
- port_ocean/core/integrations/mixins/sync.py[98-106]
- port_ocean/core/handlers/port_app_config/models.py[155-162]
### Suggested fix approach
- Do **not** swallow transient errors during resync sprint fan-out. Prefer one of:
- Re-raise on `httpx.RequestError` and on `HTTPStatusError` for retryable status codes (e.g. 429/5xx), so the resync fails and deletions are not applied on partial data.
- Alternatively, have `on_resync_sprints` track any per-board failures and raise at the end if any occurred (again preventing deletions on partial data).
- If you still want to tolerate permanent authorization failures (e.g. 403), explicitly document the deletion implications and/or implement a strategy to prevent deletions when any board was skipped.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Pull request overview
Adds Sprint as a new resource kind to the Jira integration, including resync support (via Jira Agile REST API) and webhook-driven updates, with configuration validation and test coverage.
Changes:
- Introduces
sprintkind support end-to-end: config models, resync handler, and webhook processor. - Extends
JiraClientwith sprint list (per-board) and single-sprint fetch APIs, plus webhook event registration. - Adds tests for sprint selector validation, sprint webhook processing, and sprint client pagination/error handling.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| integrations/jira/webhook_processors/sprint_webhook_processor.py | New webhook processor for sprint_* events with delete vs upsert behavior. |
| integrations/jira/tests/webhook_processors/test_sprint_webhook_processor.py | Unit tests for sprint webhook processor behavior and payload validation. |
| integrations/jira/tests/test_overrides.py | Adds sprint selector/resource config parsing + validation tests. |
| integrations/jira/tests/test_client.py | Adds extensive client tests for sprint pagination, state param behavior, and fan-out scenarios. |
| integrations/jira/main.py | Adds on_resync_sprints and registers SprintWebhookProcessor. |
| integrations/jira/kinds.py | Adds SPRINT to Kinds. |
| integrations/jira/jira/overrides.py | Adds JiraSprintSelector + JiraSprintResourceConfig and wires into app config union. |
| integrations/jira/jira/client.py | Adds sprint webhook events and implements sprint fetch methods (paginated per board + single sprint). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| contract: https://developer.atlassian.com/cloud/jira/software/rest/api-group-board/ | ||
| #api-rest-agile-1-0-board-boardid-sprint-get |
| cls, value: list[str] | None | ||
| ) -> list[str] | None: | ||
| if value is None: | ||
| return value # NULL fetches all state |
| ] | ||
|
|
||
| async for sprint_batch in stream_async_iterators_tasks(*sprint_streams): | ||
| logger.info(f"Received sprint batch with {len(sprint_batch)} sprints") |
| async def test_get_paginated_sprints_for_board_skips_board_and_logs_warning_on_http_error( | ||
| mock_jira_client: JiraClient, | ||
| ) -> None: | ||
| """A board returning HTTPStatusError must yield nothing and log a warning — | ||
| one inaccessible board must not abort the entire resync fan-out.""" | ||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.side_effect = httpx.HTTPStatusError( | ||
| "Forbidden", | ||
| request=Request( | ||
| "GET", | ||
| "https://example.atlassian.net/rest/agile/latest/board/99/sprint", | ||
| ), | ||
| response=Response( | ||
| 403, | ||
| request=Request("GET", "https://example.atlassian.net"), | ||
| ), | ||
| ) | ||
|
|
||
| batches: list[list[dict[str, Any]]] = [] | ||
| async for batch in mock_jira_client.get_paginated_sprints_for_board( | ||
| board_id=99, | ||
| sprint_state=["active"], | ||
| ): | ||
| batches.append(batch) | ||
|
|
||
| assert len(batches) == 0 | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_single_sprint_returns_sprint_by_id( | ||
| mock_jira_client: JiraClient, | ||
| ) -> None: | ||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.return_value = MOCK_SPRINT | ||
|
|
||
| result = await mock_jira_client.get_single_sprint(sprint_id=1) | ||
|
|
||
| assert result == MOCK_SPRINT | ||
| call_url = mock_request.call_args[0][1] | ||
| assert call_url.endswith("/sprint/1") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_single_sprint_propagates_http_status_error_on_not_found( | ||
| mock_jira_client: JiraClient, | ||
| ) -> None: | ||
| """get_single_sprint must propagate HTTPStatusError — webhook processors | ||
| must handle sprint fetch failures explicitly, not silently swallow them.""" | ||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.side_effect = httpx.HTTPStatusError( | ||
| "Not Found", | ||
| request=Request( | ||
| "GET", | ||
| "https://example.atlassian.net/rest/agile/latest/sprint/999", | ||
| ), | ||
| response=Response( | ||
| 404, | ||
| request=Request("GET", "https://example.atlassian.net"), | ||
| ), | ||
| ) | ||
|
|
||
| with pytest.raises(httpx.HTTPStatusError): | ||
| await mock_jira_client.get_single_sprint(sprint_id=999) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_paginated_sprints_for_board_skips_board_and_logs_warning_on_http_status_error( | ||
| mock_jira_client: JiraClient, | ||
| ) -> None: | ||
| """HTTPStatusError on a board must yield nothing and log warning — | ||
| one inaccessible board must not abort the entire resync fan-out.""" | ||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.side_effect = httpx.HTTPStatusError( | ||
| "Forbidden", | ||
| request=Request( | ||
| "GET", | ||
| "https://example.atlassian.net/rest/agile/latest/board/99/sprint", | ||
| ), | ||
| response=Response( | ||
| 403, | ||
| request=Request("GET", "https://example.atlassian.net"), | ||
| ), | ||
| ) | ||
|
|
||
| batches: list[list[dict[str, Any]]] = [] | ||
| async for batch in mock_jira_client.get_paginated_sprints_for_board( | ||
| board_id=99, | ||
| sprint_state=["active"], | ||
| ): | ||
| batches.append(batch) | ||
|
|
||
| assert len(batches) == 0 |
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "board_count, sprints_per_board", | ||
| [ | ||
| (200, 5), | ||
| (500, 3), | ||
| (1000, 1), | ||
| (2000, 2), | ||
| (5000, 1), | ||
| ], | ||
| ids=[ | ||
| "200_boards_5_sprints_each", | ||
| "500_boards_3_sprints_each", | ||
| "1000_boards_1_sprint_each", | ||
| "2000_boards_2_sprints_each", | ||
| "5000_boards_1_sprint_each", | ||
| ], | ||
| ) | ||
| async def test_get_paginated_sprints_fan_out_fetches_all_sprints_across_large_board_counts( | ||
| mock_jira_client: JiraClient, | ||
| board_count: int, | ||
| sprints_per_board: int, | ||
| ) -> None: | ||
| board_ids: list[int] = list(range(1, board_count + 1)) | ||
| boards: list[dict[str, Any]] = [{"id": i, "name": f"Board {i}"} for i in board_ids] | ||
|
|
||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.side_effect = [ | ||
| _build_mock_sprint_page(board_id, sprints_per_board) | ||
| for board_id in board_ids | ||
| ] | ||
|
|
||
| all_sprints: list[dict[str, Any]] = [] | ||
|
|
||
| async def collect_sprints_for_board(board: dict[str, Any]) -> None: | ||
| async for batch in mock_jira_client.get_paginated_sprints_for_board( | ||
| board_id=cast(int, board["id"]), | ||
| sprint_state=["active"], | ||
| ): | ||
| all_sprints.extend(batch) | ||
|
|
||
| await asyncio.gather(*[collect_sprints_for_board(board) for board in boards]) | ||
|
|
||
| assert mock_request.call_count == board_count | ||
| assert len(all_sprints) == board_count * sprints_per_board |
| @pytest.mark.asyncio | ||
| async def test_get_paginated_sprints_for_board_returns_active_sprints_by_default( | ||
| mock_jira_client: JiraClient, | ||
| ) -> None: | ||
| with patch.object( | ||
| mock_jira_client, "_send_api_request", new_callable=AsyncMock | ||
| ) as mock_request: | ||
| mock_request.return_value = { | ||
| "isLast": True, | ||
| "values": [MOCK_SPRINT], | ||
| } | ||
|
|
||
| batches: list[list[dict[str, Any]]] = [] | ||
| async for batch in mock_jira_client.get_paginated_sprints_for_board( | ||
| board_id=1, | ||
| sprint_state=["active"], | ||
| ): | ||
| batches.append(batch) | ||
|
|
||
| call_params = ( | ||
| mock_request.call_args[1].get("params") or mock_request.call_args[0][2] | ||
| ) | ||
| assert call_params.get("state") == "active" | ||
| assert len(batches) == 1 | ||
| assert batches[0][0]["id"] == 1 | ||
|
|
Description
What - Adds
sprintas a new kind to the Jira integration, sourced from the Jira Software Cloud Agile REST API (/rest/agile/latest/board/{boardId}/sprint)Why - Part of the Jira Agile kinds feature request to support Boards, Sprints, Epics, and Backlog in Port's Jira integration.
How -
JiraSprintSelector:sprintStateis a validatedlist[Literal["active", "closed", "future"]]defaulting to["active"]. Large Jira instances accumulate years of closed sprints across hundreds of boards; fetching all states by default would compound into thousands of API calls per resync. Therefore, this decision was made out of performance consideration. Morealso, duplicate states and empty lists fail at parse time, not at runtime.get_paginated_sprints_for_board: sprint retrieval is per-board, so resilience matters here.HTTPStatusErrorandRequestErrorare caught per board, a 403 on one private board or a timeout mid-resync does not abort the fan-out across remaining boards.on_resync_sprints: fans out concurrently across all boards viastream_async_iterators_tasks, bounded byJiraRateLimiter. Boards with no valididare skipped before entering the fan-out.SprintWebhookProcessor:sprint_closedis treated as an upsert (state transition to closed), not a delete. Sprint webhook events silently ignore JQL filters at delivery time per Jira's webhook docs, no JQL is registered.Type of change
All tests should be run against the port production environment(using a testing org).
Core testing checklist
Integration testing checklist
examplesfolder in the integration directory.Preflight checklist
Screenshots
Include screenshots from your environment showing how the resources of the integration will look.
API Documentation
Provide links to the API documentation used for this integration.