Skip to content

Commit 28d149d

Browse files
committed
ref(integrations): Move webhook creation to post-create hook
build_repository_config for GitLab, Bitbucket, and Bitbucket Server created webhooks eagerly before checking if the repo already existed in Sentry. This blocked adding those providers to the repo sync task, since it would create webhooks for every repo on every sync run. This introduces on_create_repository hook that fires only after a repo is actually persisted. Webhooks are now created at the right time and skipped if the repo already has one. This unblocks adding all remaining SCM providers to the periodic sync.
1 parent 8eb4dae commit 28d149d

File tree

9 files changed

+210
-91
lines changed

9 files changed

+210
-91
lines changed

src/sentry/integrations/bitbucket/repository.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Mapping
44
from typing import TYPE_CHECKING, Any
55

6+
from sentry.integrations.services.repository.model import RpcRepository
7+
from sentry.integrations.services.repository.service import repository_service
68
from sentry.integrations.types import IntegrationProviderSlug
79
from sentry.locks import locks
810
from sentry.models.apitoken import generate_token
@@ -55,12 +57,23 @@ def get_webhook_secret(self, organization):
5557
def build_repository_config(
5658
self, organization: RpcOrganization, data: Mapping[str, Any]
5759
) -> RepositoryConfig:
58-
installation = self.get_installation(data.get("installation"), organization.id)
60+
return {
61+
"name": data["identifier"],
62+
"external_id": data["external_id"],
63+
"url": "https://bitbucket.org/{}".format(data["name"]),
64+
"config": {"name": data["name"]},
65+
"integration_id": data["installation"],
66+
}
67+
68+
def on_create_repository(self, repo: RpcRepository, organization: RpcOrganization) -> None:
69+
if repo.config.get("webhook_id"):
70+
return
71+
installation = self.get_installation(repo.integration_id, repo.organization_id)
5972
client = installation.get_client()
73+
secret = installation.model.metadata.get("webhook_secret", "")
6074
try:
61-
secret = installation.model.metadata.get("webhook_secret", "")
6275
resp = client.create_hook(
63-
data["identifier"],
76+
repo.config["name"],
6477
{
6578
"description": "sentry-bitbucket-repo-hook",
6679
"url": absolute_uri(
@@ -73,14 +86,8 @@ def build_repository_config(
7386
)
7487
except Exception as e:
7588
installation.raise_error(e)
76-
else:
77-
return {
78-
"name": data["identifier"],
79-
"external_id": data["external_id"],
80-
"url": "https://bitbucket.org/{}".format(data["name"]),
81-
"config": {"name": data["name"], "webhook_id": resp["uuid"]},
82-
"integration_id": data["installation"],
83-
}
89+
repo.config["webhook_id"] = resp["uuid"]
90+
repository_service.update_repository(organization_id=organization.id, update=repo)
8491

8592
def on_delete_repository(self, repo):
8693
installation = self.get_installation(repo.integration_id, repo.organization_id)

src/sentry/integrations/bitbucket_server/repository.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from django.core.cache import cache
88
from django.urls import reverse
99

10+
from sentry.integrations.services.repository.model import RpcRepository
11+
from sentry.integrations.services.repository.service import repository_service
1012
from sentry.integrations.types import IntegrationProviderSlug
1113
from sentry.organizations.services.organization.model import RpcOrganization
1214
from sentry.plugins.providers.integration_repository import (
@@ -46,20 +48,38 @@ def build_repository_config(
4648
self, organization: RpcOrganization, data: Mapping[str, Any]
4749
) -> RepositoryConfig:
4850
installation = self.get_installation(data.get("installation"), organization.id)
51+
return {
52+
"name": data["identifier"],
53+
"external_id": data["external_id"],
54+
"url": installation.model.metadata["base_url"]
55+
+ "/projects/{project}/repos/{repo}/browse".format(
56+
project=data["project"], repo=data["repo"]
57+
),
58+
"config": {
59+
"name": data["identifier"],
60+
"project": data["project"],
61+
"repo": data["repo"],
62+
},
63+
"integration_id": data["installation"],
64+
}
65+
66+
def on_create_repository(self, repo: RpcRepository, organization: RpcOrganization) -> None:
67+
if repo.config.get("webhook_id"):
68+
return
69+
installation = self.get_installation(repo.integration_id, repo.organization_id)
4970
client = installation.get_client()
50-
5171
try:
5272
resp = client.create_hook(
53-
data["project"],
54-
data["repo"],
73+
repo.config["project"],
74+
repo.config["repo"],
5575
{
5676
"name": "sentry-bitbucket-server-repo-hook",
5777
"url": absolute_uri(
5878
reverse(
5979
"sentry-extensions-bitbucketserver-webhook",
6080
kwargs={
6181
"organization_id": organization.id,
62-
"integration_id": data.get("installation"),
82+
"integration_id": repo.integration_id,
6383
},
6484
)
6585
),
@@ -69,22 +89,8 @@ def build_repository_config(
6989
)
7090
except Exception as e:
7191
installation.raise_error(e)
72-
else:
73-
return {
74-
"name": data["identifier"],
75-
"external_id": data["external_id"],
76-
"url": installation.model.metadata["base_url"]
77-
+ "/projects/{project}/repos/{repo}/browse".format(
78-
project=data["project"], repo=data["repo"]
79-
),
80-
"config": {
81-
"name": data["identifier"],
82-
"project": data["project"],
83-
"repo": data["repo"],
84-
"webhook_id": resp["id"],
85-
},
86-
"integration_id": data["installation"],
87-
}
92+
repo.config["webhook_id"] = resp["id"]
93+
repository_service.update_repository(organization_id=organization.id, update=repo)
8894

8995
def on_delete_repository(self, repo):
9096
installation = self.get_installation(repo.integration_id, repo.organization_id)

src/sentry/integrations/gitlab/repository.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Mapping
44
from typing import TYPE_CHECKING, Any
55

6+
from sentry.integrations.services.repository.model import RpcRepository
7+
from sentry.integrations.services.repository.service import repository_service
68
from sentry.integrations.types import IntegrationProviderSlug
79
from sentry.organizations.services.organization.model import RpcOrganization
810
from sentry.plugins.providers import IntegrationRepositoryProvider
@@ -43,25 +45,30 @@ def get_repository_data(self, organization, config):
4345
def build_repository_config(
4446
self, organization: RpcOrganization, data: Mapping[str, Any]
4547
) -> RepositoryConfig:
46-
installation = self.get_installation(data.get("installation"), organization.id)
47-
client = installation.get_client()
48-
try:
49-
hook_id = client.create_project_webhook(data["project_id"])
50-
except Exception as e:
51-
raise installation.raise_error(e)
5248
return {
5349
"name": data["name"],
5450
"external_id": data["external_id"],
5551
"url": data["url"],
5652
"config": {
5753
"instance": data["instance"],
5854
"path": data["path"],
59-
"webhook_id": hook_id,
6055
"project_id": data["project_id"],
6156
},
6257
"integration_id": data["installation"],
6358
}
6459

60+
def on_create_repository(self, repo: RpcRepository, organization: RpcOrganization) -> None:
61+
if repo.config.get("webhook_id"):
62+
return
63+
installation = self.get_installation(repo.integration_id, repo.organization_id)
64+
client = installation.get_client()
65+
try:
66+
hook_id = client.create_project_webhook(repo.config["project_id"])
67+
except Exception as e:
68+
raise installation.raise_error(e)
69+
repo.config["webhook_id"] = hook_id
70+
repository_service.update_repository(organization_id=organization.id, update=repo)
71+
6572
def on_delete_repository(self, repo):
6673
"""Clean up the attached webhook"""
6774
installation = self.get_installation(repo.integration_id, repo.organization_id)

src/sentry/integrations/source_code_management/sync_repos.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@
3939
logger = logging.getLogger(__name__)
4040

4141
# Providers to include in the periodic sync. Each must implement
42-
# get_repositories() returning RepositoryInfo with external_id.
43-
# GitLab, Bitbucket, and Bitbucket Server are excluded because their
44-
# build_repository_config creates webhooks as a side effect, and
45-
# create_repositories calls it for every repo before checking if it already
46-
# exists. This needs to be fixed before adding those providers.
42+
# get_repositories() returning RepositoryInfo with all fields needed
43+
# by their build_repository_config.
4744
# Perforce is excluded because it cannot derive external_id from its API.
4845
SCM_SYNC_PROVIDERS = [
4946
"github",
5047
"github_enterprise",
48+
"gitlab",
49+
"bitbucket",
50+
"bitbucket_server",
5151
"vsts",
5252
]
5353

src/sentry/plugins/providers/integration_repository.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
from sentry.integrations.services.repository import repository_service
2020
from sentry.integrations.services.repository.model import RpcCreateRepository, RpcRepository
2121
from sentry.integrations.source_code_management.repository import RepositoryIntegration
22-
from sentry.models.repository import Repository
2322
from sentry.organizations.services.organization.model import RpcOrganization
2423
from sentry.shared_integrations.exceptions import IntegrationError
2524
from sentry.signals import repo_linked
@@ -132,9 +131,11 @@ def create_repository(
132131
existing_repo.name = name
133132
existing_repo.integration_id = integration_id
134133
existing_repo.url = url
134+
existing_repo.config = result.get("config") or {}
135135
repository_service.update_repository(
136136
organization_id=organization.id, update=existing_repo
137137
)
138+
self.on_create_repository(existing_repo, organization)
138139
metrics.incr("sentry.integration_repo_provider.repo_relink")
139140
return result, existing_repo
140141

@@ -170,6 +171,7 @@ def create_repository(
170171
# also update the status if it was in a bad state
171172
repo.status = ObjectStatus.ACTIVE
172173
repository_service.update_repository(organization_id=organization.id, update=repo)
174+
self.on_create_repository(repo, organization)
173175
else:
174176
create_repository = RpcCreateRepository.parse_obj(
175177
{**repo_update_params, "status": ObjectStatus.ACTIVE}
@@ -178,16 +180,9 @@ def create_repository(
178180
organization_id=organization.id, create=create_repository
179181
)
180182
if new_repository is not None:
183+
self.on_create_repository(new_repository, organization)
181184
return result, new_repository
182185

183-
# Try to delete webhook we just created
184-
try:
185-
self.on_delete_repository(
186-
Repository(organization_id=organization.id, **repo_update_params)
187-
)
188-
except IntegrationError:
189-
pass
190-
191186
# if possible update the repo with matching integration
192187
repositories = repository_service.get_repositories(
193188
organization_id=organization.id,
@@ -272,17 +267,11 @@ def create_repositories(
272267
organization_id=organization.id, create=create_repository
273268
)
274269
if new_repository is not None:
270+
self.on_create_repository(new_repository, organization)
275271
created_repos.append(new_repository)
276272
continue
277273

278274
missing_repos.append(repo_config)
279-
# Try to delete webhook we just created
280-
try:
281-
self.on_delete_repository(
282-
Repository(organization_id=organization.id, **repo_config)
283-
)
284-
except IntegrationError:
285-
pass
286275

287276
# if possible update the repo with matching integration
288277
repositories = repository_service.get_repositories(
@@ -299,6 +288,8 @@ def create_repositories(
299288
organization_id=organization.id,
300289
updates=repos_to_update,
301290
)
291+
for repo in repos_to_update:
292+
self.on_create_repository(repo, organization)
302293

303294
return created_repos, repos_to_update, missing_repos
304295

@@ -382,6 +373,14 @@ def build_repository_config(
382373
"""
383374
raise NotImplementedError
384375

376+
def on_create_repository(self, repo: RpcRepository, organization: RpcOrganization) -> None:
377+
"""Called after a repository is created or reactivated.
378+
379+
Override to perform post-creation setup like webhook creation.
380+
The repo has already been persisted — update repo.config and call
381+
repository_service.update_repository to store any new config values.
382+
"""
383+
385384
def on_delete_repository(self, repo):
386385
pass
387386

tests/sentry/integrations/bitbucket/test_repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def test_build_repository_config(self) -> None:
139139
"external_id": REPO["uuid"],
140140
"url": "https://bitbucket.org/laurynsentry/helloworld",
141141
"integration_id": integration.id,
142-
"config": {"name": full_repo_name, "webhook_id": webhook_id},
142+
"config": {"name": full_repo_name},
143143
}
144144

145145
def test_repository_external_slug(self) -> None:

tests/sentry/integrations/bitbucket_server/test_repository.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,6 @@ def test_build_repository_config(self) -> None:
235235
"name": full_repo_name,
236236
"project": project,
237237
"repo": repo,
238-
"webhook_id": webhook_id,
239238
},
240239
}
241240

tests/sentry/integrations/github/tasks/test_link_all_repos.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -272,29 +272,3 @@ def test_link_all_repos_repo_creation_error(
272272
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
273273
assert end1.args[0] == EventLifecycleOutcome.HALTED
274274
assert_halt_metric(mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value)
275-
276-
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
277-
@patch("sentry.integrations.services.repository.repository_service.create_repository")
278-
@patch("sentry.plugins.providers.IntegrationRepositoryProvider.on_delete_repository")
279-
@responses.activate
280-
def test_link_all_repos_repo_creation_exception(
281-
self, mock_delete_repo, mock_create_repository, mock_record, _
282-
):
283-
mock_create_repository.return_value = None
284-
mock_delete_repo.side_effect = Exception
285-
286-
self._add_responses()
287-
288-
with pytest.raises(Exception):
289-
link_all_repos(
290-
integration_key=self.key,
291-
integration_id=self.integration.id,
292-
organization_id=self.organization.id,
293-
)
294-
295-
assert len(mock_record.mock_calls) == 4
296-
start1, start2, end2, end1 = mock_record.mock_calls
297-
assert start1.args[0] == EventLifecycleOutcome.STARTED
298-
assert start2.args[0] == EventLifecycleOutcome.STARTED
299-
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
300-
assert end1.args[0] == EventLifecycleOutcome.FAILURE

0 commit comments

Comments
 (0)