Skip to content

Commit 44c6484

Browse files
[Integration][Bitbucket Server] Delete Old Repo Entity on Move or Rename Webhook (#2907)
# Description What - - Fix Bitbucket Server live-events handling so `repo:modified` deletes the *old* repository entity when a repo is moved between projects or renamed (slug change), preventing orphan repository entities in Port. - Make webhook client initialization lazy to avoid requiring Ocean context during import. Why - - Repository identifiers can change when a repo is moved/renamed. Previously we upserted the “new” repo but never removed the “old” one, leaving stale/orphan entities until resync (and sometimes even after). How - - On `repo:modified`, emit `deleted_raw_results=[old]` only when `old.slug != new.slug` or `old.project.key != new.project.key`. - Keep existing behavior for `repo:refs_changed` (no deletes). - Add tests covering move/rename vs name-only change. ## Type of change Please leave one option from the following and delete the rest: - [x] Bug fix (non-breaking change which fixes an issue) <h4> All tests should be run against the port production environment(using a testing org). </h4> ### Core testing checklist - [x] Integration able to create all default resources from scratch - [x] Resync finishes successfully - [x] Resync able to create entities - [x] Resync able to update entities - [x] Resync able to detect and delete entities - [x] Scheduled resync able to abort existing resync and start a new one - [x] Tested with at least 2 integrations from scratch - [x] Tested with Kafka and Polling event listeners - [x] Tested deletion of entities that don't pass the selector ### Integration testing checklist - [x] Integration able to create all default resources from scratch - [x] Completed a full resync from a freshly installed integration and it completed successfully - [x] Resync able to create entities - [x] Resync able to update entities - [x] Resync able to detect and delete entities - [x] Resync finishes successfully - [x] 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. - [x] If resource kind is updated, run the integration with the example data and check if the expected result is achieved - [x] 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. --------- Co-authored-by: Michael Kofi Armah <mikeyarmah@gmail.com>
1 parent 9e40065 commit 44c6484

File tree

6 files changed

+189
-18
lines changed

6 files changed

+189
-18
lines changed

integrations/bitbucket-server/.env.example

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ OCEAN__INTEGRATION__IDENTIFIER=bitbucket-server
44
OCEAN__PORT__BASE_URL=https://api.getport.io
55
OCEAN__EVENT_LISTENER__TYPE=POLLING
66
OCEAN__INITIALIZE_PORT_RESOURCES=true
7-
OCEAN__BITBUCKET__USERNAME=your-bitbucket-username
8-
OCEAN__BITBUCKET__PASSWORD=your-bitbucket-password
9-
OCEAN__BITBUCKET__BASE_URL=https://your-bitbucket-server-url
10-
OCEAN__BITBUCKET__IS_VERSION_8_POINT_7_OR_OLDER=false
7+
OCEAN__INTEGRATION__CONFIG__BITBUCKET_USERNAME=your-bitbucket-username
8+
OCEAN__INTEGRATION__CONFIG__BITBUCKET_PASSWORD=your-bitbucket-password
9+
OCEAN__INTEGRATION__CONFIG__BITBUCKET_BASE_URL=https://your-bitbucket-server-url
10+
OCEAN__INTEGRATION__CONFIG__BITBUCKET_IS_VERSION8_POINT7_OR_OLDER=false

integrations/bitbucket-server/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- towncrier release notes start -->
99

10+
## 0.1.140-beta (2026-03-15)
11+
12+
13+
### Bug Fixes
14+
15+
- Delete the old repository entity on `repo:modified` when a repository is moved between projects or renamed, preventing orphan repositories in Port.
16+
17+
1018
## 0.1.139-beta (2026-03-12)
1119

1220

integrations/bitbucket-server/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[tool.poetry]
22
name = "bitbucket-server"
33

4-
version = "0.1.139-beta"
4+
version = "0.1.140-beta"
55

66
description = "Bitbucket Server integration for Port"
77
authors = ["Ayodeji Adeoti <ayodeji.adeoti@getport.io>"]

integrations/bitbucket-server/tests/conftest.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,40 @@
77
from port_ocean.exceptions.context import PortOceanContextAlreadyInitializedError
88

99

10-
@pytest.fixture(autouse=True)
11-
def mock_ocean_context() -> None | MagicMock:
12-
"""Fixture to initialize the PortOcean context."""
10+
def _build_mock_ocean_app() -> MagicMock:
1311
mock_app = MagicMock()
14-
mock_app.integration_config = {
15-
"bitbucket_server_url": "https://bitbucket.example.com",
12+
# Used by init_webhook_client() for callback URL construction
13+
mock_app.base_url = "https://app.example.com"
14+
15+
# PortOceanContext.integration_config reads from app.config.integration.config
16+
mock_app.config.integration.config = {
1617
"bitbucket_username": "test_user",
1718
"bitbucket_password": "test_password",
19+
"bitbucket_base_url": "https://bitbucket.example.com",
20+
"bitbucket_webhook_secret": None,
21+
"bitbucket_is_version8_point7_or_older": False,
22+
"bitbucket_rate_limit_quota": 1000,
23+
"bitbucket_rate_limit_window": 3600,
1824
}
25+
return mock_app
26+
27+
28+
def _initialize_ocean_context_for_collection() -> None:
29+
# Ensure Ocean context exists during test collection/import time.
30+
mock_app = _build_mock_ocean_app()
31+
try:
32+
initialize_port_ocean_context(mock_app)
33+
except PortOceanContextAlreadyInitializedError:
34+
pass
35+
36+
37+
_initialize_ocean_context_for_collection()
38+
39+
40+
@pytest.fixture(autouse=True)
41+
def mock_ocean_context() -> None | MagicMock:
42+
"""Fixture to initialize the PortOcean context."""
43+
mock_app = _build_mock_ocean_app()
1944
try:
2045
initialize_port_ocean_context(mock_app)
2146
except PortOceanContextAlreadyInitializedError:
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from unittest.mock import AsyncMock, MagicMock
2+
3+
import pytest
4+
5+
from webhook_processors.processors.repository_webhook_processor import (
6+
RepositoryWebhookProcessor,
7+
)
8+
9+
10+
@pytest.fixture
11+
def repository_webhook_processor() -> RepositoryWebhookProcessor:
12+
processor = RepositoryWebhookProcessor(event=MagicMock())
13+
processor._client = MagicMock()
14+
processor._client.get_single_repository = AsyncMock()
15+
return processor
16+
17+
18+
@pytest.mark.asyncio
19+
class TestRepositoryWebhookProcessor:
20+
async def test_repo_modified_emits_deletion_when_slug_changes(
21+
self, repository_webhook_processor: RepositoryWebhookProcessor
22+
) -> None:
23+
repository_webhook_processor._client.get_single_repository.return_value = { # type: ignore[attr-defined]
24+
"slug": "repo-2",
25+
"project": {"key": "PROJ"},
26+
}
27+
28+
payload = {
29+
"eventKey": "repo:modified",
30+
"old": {"slug": "repo-1", "project": {"key": "PROJ"}},
31+
"new": {"slug": "repo-2", "project": {"key": "PROJ"}},
32+
}
33+
34+
result = await repository_webhook_processor.handle_event(payload, MagicMock())
35+
36+
assert result.updated_raw_results == [
37+
{"slug": "repo-2", "project": {"key": "PROJ"}}
38+
]
39+
assert result.deleted_raw_results == [
40+
{"slug": "repo-1", "project": {"key": "PROJ"}}
41+
]
42+
43+
async def test_repo_modified_emits_deletion_when_project_changes(
44+
self, repository_webhook_processor: RepositoryWebhookProcessor
45+
) -> None:
46+
repository_webhook_processor._client.get_single_repository.return_value = { # type: ignore[attr-defined]
47+
"slug": "repo",
48+
"project": {"key": "PROJ_B"},
49+
}
50+
51+
payload = {
52+
"eventKey": "repo:modified",
53+
"old": {"slug": "repo", "project": {"key": "PROJ_A"}},
54+
"new": {"slug": "repo", "project": {"key": "PROJ_B"}},
55+
}
56+
57+
result = await repository_webhook_processor.handle_event(payload, MagicMock())
58+
59+
assert result.updated_raw_results == [
60+
{"slug": "repo", "project": {"key": "PROJ_B"}}
61+
]
62+
assert result.deleted_raw_results == [
63+
{"slug": "repo", "project": {"key": "PROJ_A"}}
64+
]
65+
66+
async def test_repo_modified_does_not_delete_when_identifier_parts_unchanged(
67+
self, repository_webhook_processor: RepositoryWebhookProcessor
68+
) -> None:
69+
repository_webhook_processor._client.get_single_repository.return_value = { # type: ignore[attr-defined]
70+
"slug": "repo",
71+
"project": {"key": "PROJ"},
72+
"name": "New Name",
73+
}
74+
75+
payload = {
76+
"eventKey": "repo:modified",
77+
"old": {"slug": "repo", "project": {"key": "PROJ"}, "name": "Old Name"},
78+
"new": {"slug": "repo", "project": {"key": "PROJ"}, "name": "New Name"},
79+
}
80+
81+
result = await repository_webhook_processor.handle_event(payload, MagicMock())
82+
83+
assert result.updated_raw_results == [
84+
{"slug": "repo", "project": {"key": "PROJ"}, "name": "New Name"}
85+
]
86+
assert result.deleted_raw_results == []
87+
88+
async def test_repo_refs_changed_never_emits_deletion(
89+
self, repository_webhook_processor: RepositoryWebhookProcessor
90+
) -> None:
91+
repository_webhook_processor._client.get_single_repository.return_value = { # type: ignore[attr-defined]
92+
"slug": "repo",
93+
"project": {"key": "PROJ"},
94+
}
95+
96+
payload = {
97+
"eventKey": "repo:refs_changed",
98+
"repository": {"slug": "repo", "project": {"key": "PROJ"}},
99+
}
100+
101+
result = await repository_webhook_processor.handle_event(payload, MagicMock())
102+
103+
assert result.updated_raw_results == [
104+
{"slug": "repo", "project": {"key": "PROJ"}}
105+
]
106+
assert result.deleted_raw_results == []

integrations/bitbucket-server/webhook_processors/processors/repository_webhook_processor.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Any
2+
13
from loguru import logger
24
from port_ocean.core.handlers.port_app_config.models import ResourceConfig
35
from port_ocean.core.handlers.webhook.webhook_event import (
@@ -20,16 +22,23 @@ async def _should_process_event(self, event: WebhookEvent) -> bool:
2022
async def get_matching_kinds(self, event: WebhookEvent) -> list[str]:
2123
return [ObjectKind.REPOSITORY]
2224

25+
async def validate_payload(self, payload: EventPayload) -> bool:
26+
return "eventKey" in payload
27+
2328
async def handle_event(
2429
self, payload: EventPayload, resource: ResourceConfig
2530
) -> WebhookEventRawResults:
26-
event_key = payload.get("eventKey", "")
27-
if event_key == "repo:refs_changed":
28-
repository_slug = payload["repository"]["slug"]
29-
project_key = payload["repository"]["project"]["key"]
30-
else:
31-
repository_slug = payload["new"]["slug"]
32-
project_key = payload["new"]["project"]["key"]
31+
event_key = payload["eventKey"]
32+
deleted_raw_results = self._get_deleted_if_renamed_or_moved(payload, event_key)
33+
34+
repo = (
35+
payload["repository"]
36+
if event_key == "repo:refs_changed"
37+
else payload["new"]
38+
)
39+
40+
project_key = repo["project"]["key"]
41+
repository_slug = repo["slug"]
3342

3443
logger.info(
3544
f"Handling repository webhook event ({event_key}) for project: {project_key} and repository: {repository_slug}"
@@ -41,5 +50,28 @@ async def handle_event(
4150

4251
return WebhookEventRawResults(
4352
updated_raw_results=[repository],
44-
deleted_raw_results=[],
53+
deleted_raw_results=deleted_raw_results,
4554
)
55+
56+
def _get_deleted_if_renamed_or_moved(
57+
self, payload: EventPayload, event_key: str
58+
) -> list[dict[str, Any]]:
59+
"""Return list with old repo if slug or project key changed (rename / move)."""
60+
if event_key != "repo:modified":
61+
return []
62+
63+
old = payload["old"]
64+
new = payload["new"]
65+
66+
old_slug = old.get("slug")
67+
old_project = old.get("project", {}).get("key")
68+
new_slug = new["slug"]
69+
new_project = new["project"]["key"]
70+
71+
if not (old_slug and old_project):
72+
return []
73+
74+
if old_slug != new_slug or old_project != new_project:
75+
return [old]
76+
77+
return []

0 commit comments

Comments
 (0)