Skip to content

[Integration][Jira] Add support for sprint kind#3164

Open
eriport wants to merge 5 commits intomainfrom
task_tdl7hb/add_support_for_jira_sprint_kind
Open

[Integration][Jira] Add support for sprint kind#3164
eriport wants to merge 5 commits intomainfrom
task_tdl7hb/add_support_for_jira_sprint_kind

Conversation

@eriport
Copy link
Copy Markdown
Contributor

@eriport eriport commented Apr 29, 2026

Description

What - Adds sprint as 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: sprintState is a validated list[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. HTTPStatusError and RequestError are 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 via stream_async_iterators_tasks, bounded by JiraRateLimiter. Boards with no valid id are skipped before entering the fan-out.
  • SprintWebhookProcessor: sprint_closed is 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.
  • Tests stress-tested fan-out correctness at 200, 500, 1000, 2000, and 5000 boards with controlled failure injection at specific board IDs.

Type of change

  • New feature (non-breaking change which adds functionality)

All tests should be run against the port production environment(using a testing org).

Core testing checklist

  • Integration able to create all default resources from scratch
  • Resync finishes successfully
  • Resync able to create entities
  • Resync able to update entities
  • Resync able to detect and delete entities
  • Scheduled resync able to abort existing resync and start a new one
  • Tested with at least 2 integrations from scratch
  • Tested with Kafka and Polling event listeners
  • Tested deletion of entities that don't pass the selector

Integration testing checklist

  • Integration able to create all default resources from scratch
  • Completed a full resync from a freshly installed integration and it completed successfully
  • Resync able to create entities
  • Resync able to update entities
  • Resync able to detect and delete entities
  • Resync finishes successfully
  • If new resource kind is added or updated in the integration, add example raw data, mapping and expected result to the examples folder in the integration directory.
  • If resource kind is updated, run the integration with the example data and check if the expected result is achieved
  • If new resource kind is added or updated, validate that live-events for that resource are working as expected
  • Docs PR link here

Preflight checklist

  • Handled rate limiting
  • Handled pagination
  • Implemented the code in async
  • Support Multi account

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.

@eriport eriport marked this pull request as ready for review April 29, 2026 15:24
@eriport eriport requested a review from a team as a code owner April 29, 2026 15:24
Copilot AI review requested due to automatic review settings April 29, 2026 15:24
@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Add Jira Sprint kind with Agile API support

✨ Enhancement

Grey Divider

Walkthroughs

Description
• 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)
Diagram
flowchart 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
Loading

Grey Divider

File Changes

1. integrations/jira/jira/client.py ✨ Enhancement +58/-1

Add sprint API methods and webhook events

• Added sprint webhook events (sprint_created, sprint_updated, sprint_deleted, sprint_closed) to
 both BASIC_WEBHOOK_EVENTS and OAUTH2_WEBHOOK_EVENTS lists
• Implemented get_paginated_sprints_for_board() async generator with per-board error resilience for
 HTTPStatusError and RequestError
• Implemented get_single_sprint() method to fetch individual sprints by ID from Agile API
• Added Literal type import for sprint state validation

integrations/jira/jira/client.py


2. integrations/jira/jira/overrides.py ✨ Enhancement +49/-0

Add sprint selector and resource configuration

• Created JiraSprintSelector with sprint_state field supporting active/closed/future states
• Added validator to reject empty lists and duplicate state values at parse time
• Created JiraSprintResourceConfig with sprint kind and selector configuration
• Added JiraSprintResourceConfig to JiraPortAppConfig resources union type

integrations/jira/jira/overrides.py


3. integrations/jira/kinds.py ✨ Enhancement +1/-0

Add sprint kind enum

• Added SPRINT = "sprint" to Kinds enum

integrations/jira/kinds.py


View more (5)
4. integrations/jira/main.py ✨ Enhancement +29/-0

Add sprint resync handler and webhook processor

• Imported JiraSprintResourceConfig and SprintWebhookProcessor
• Implemented on_resync_sprints() handler that fans out concurrent sprint fetching across all boards
• Registered SprintWebhookProcessor webhook handler at /webhook endpoint
• Uses stream_async_iterators_tasks for bounded concurrent board processing

integrations/jira/main.py


5. integrations/jira/tests/test_client.py 🧪 Tests +486/-1

Add comprehensive sprint client tests

• Added MOCK_SPRINT, MOCK_SPRINT_CLOSED, MOCK_SPRINT_FUTURE test fixtures
• Added _build_mock_sprint_page() helper for generating paginated sprint responses
• Added 11 tests for get_paginated_sprints_for_board() covering state filtering, pagination, error
 handling, and invalid board IDs
• Added 2 tests for get_single_sprint() covering success and error propagation
• Added 2 stress tests for fan-out correctness with 200-5000 boards and controlled failure injection

integrations/jira/tests/test_client.py


6. integrations/jira/tests/test_overrides.py 🧪 Tests +244/-0

Add sprint selector validation tests

• Added SPRINT_MAPPING test fixture with sprint entity mapping configuration
• Added 7 parametrized tests for valid sprint state combinations
• Added 7 parametrized tests rejecting invalid state values
• Added 5 parametrized tests rejecting duplicate state values
• Added TestJiraSprintSelector class with 9 tests covering defaults, single/multiple states, null
 handling, and validation

integrations/jira/tests/test_overrides.py


7. integrations/jira/tests/webhook_processors/test_sprint_webhook_processor.py 🧪 Tests +341/-0

Add sprint webhook processor tests

• Created new test file with comprehensive SprintWebhookProcessor test coverage
• Added TestSprintWebhookProcessorShouldProcessEvent with 7 tests for event filtering
• Added TestSprintWebhookProcessorGetMatchingKinds test
• Added TestSprintWebhookProcessorAuthenticate test
• Added TestSprintWebhookProcessorValidatePayload with 6 tests for payload validation
• Added TestSprintWebhookProcessorHandleEvent with 5 tests covering sprint_deleted, sprint_created,
 sprint_updated, sprint_closed upsert behavior, and missing sprint handling

integrations/jira/tests/webhook_processors/test_sprint_webhook_processor.py


8. integrations/jira/webhook_processors/sprint_webhook_processor.py ✨ Enhancement +64/-0

Add sprint webhook processor implementation

• Created new SprintWebhookProcessor class extending AbstractWebhookProcessor
• Implemented should_process_event() to filter sprint_* webhook events
• Implemented get_matching_kinds() returning sprint kind
• Implemented validate_payload() checking for sprint dict with valid id
• Implemented handle_event() treating sprint_deleted as delete, sprint_created/updated/closed as
 upserts via get_single_sprint()

integrations/jira/webhook_processors/sprint_webhook_processor.py


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented Apr 29, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0)

Grey Divider


Action required

1. Partial resync deletes sprints 🐞 Bug ≡ Correctness
Description
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.
Code

integrations/jira/jira/client.py[R712-727]

+        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"
+            )
Evidence
The new sprint fetch path explicitly logs and continues on HTTPStatusError/RequestError, meaning the
resync output may omit sprints for boards that temporarily fail. Port Ocean’s sync() then calls
delete_diff() using the returned after-set (modified_entities) with a default deletion threshold of
0.9, so even small omissions (<< 90%) can still trigger deletions of previously existing entities.

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]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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



Remediation recommended

2. Fan-out test spawns 5000 tasks 🐞 Bug ➹ Performance
Description
The new sprint fan-out test creates up to 5000 concurrent coroutines via asyncio.gather(), which can
slow down or destabilize CI runners. This is test-only but can cause flaky pipelines/timeouts under
constrained resources.
Code

integrations/jira/tests/test_client.py[R1750-1793]

+@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])
Evidence
The parametrized test includes a 5000-board case and gathers one coroutine per board, creating 5000
tasks at once.

integrations/jira/tests/test_client.py[1750-1796]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
A unit test spawns up to 5000 concurrent tasks, which can make CI slow or flaky.

### Issue Context
This is a stress test for fan-out behavior, but it uses `asyncio.gather()` over every board without a concurrency limit.

### Fix Focus Areas
- integrations/jira/tests/test_client.py[1750-1796]

### Suggested fix approach
- Reduce the upper bound (e.g., 5000 -> 1000/2000), **or**
- Keep the 5000 case but bound concurrency using a semaphore in the test helper (e.g. run N=100 at a time), **or**
- Mark the 5000 case as `@pytest.mark.slow` and exclude from default CI test runs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@eriport eriport changed the title [Integration][Jira] Add sprint kind [Integration][Jira] Add support for sprint kind Apr 29, 2026
@eriport eriport force-pushed the task_tdl7hb/add_support_for_jira_sprint_kind branch from f31b60a to 99e594b Compare April 29, 2026 15:27
Comment on lines +712 to +727
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"
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sprint kind support end-to-end: config models, resync handler, and webhook processor.
  • Extends JiraClient with 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.

Comment on lines +696 to +697
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
Comment thread integrations/jira/main.py
]

async for sprint_batch in stream_async_iterators_tasks(*sprint_streams):
logger.info(f"Received sprint batch with {len(sprint_batch)} sprints")
Comment on lines +1580 to +1679
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
Comment on lines +1750 to +1796
@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
Comment on lines +1438 to +1463
@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants