Skip to content

Commit e5e81c2

Browse files
committed
✨ feat: add issue sync outbound support
1 parent 778b71d commit e5e81c2

File tree

8 files changed

+638
-12
lines changed

8 files changed

+638
-12
lines changed

src/sentry/integrations/gitlab/integration.py

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django import forms
99
from django.http.request import HttpRequest
1010
from django.http.response import HttpResponseBase
11+
from django.urls import reverse
1112
from django.utils.translation import gettext_lazy as _
1213

1314
from sentry import features
@@ -21,10 +22,12 @@
2122
IntegrationProvider,
2223
)
2324
from sentry.integrations.gitlab.constants import GITLAB_WEBHOOK_VERSION, GITLAB_WEBHOOK_VERSION_KEY
24-
from sentry.integrations.gitlab.tasks import update_all_project_webhooks
25+
from sentry.integrations.gitlab.types import GitLabIssueStatus
26+
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
2527
from sentry.integrations.pipeline import IntegrationPipeline
2628
from sentry.integrations.referrer_ids import GITLAB_PR_BOT_REFERRER
2729
from sentry.integrations.services.integration import integration_service
30+
from sentry.integrations.services.repository import repository_service
2831
from sentry.integrations.services.repository.model import RpcRepository
2932
from sentry.integrations.source_code_management.commit_context import (
3033
CommitContextIntegration,
@@ -42,6 +45,7 @@
4245
from sentry.shared_integrations.exceptions import (
4346
ApiError,
4447
IntegrationConfigurationError,
48+
IntegrationError,
4549
IntegrationProviderError,
4650
)
4751
from sentry.snuba.referrer import Referrer
@@ -257,6 +261,54 @@ def get_organization_config(self) -> list[dict[str, Any]]:
257261

258262
has_issue_sync = features.has("organizations:integrations-issue-sync", organization)
259263

264+
# Add outbound status sync configuration if feature flag is enabled
265+
if self.check_feature_flag():
266+
# Get currently configured external projects to display their labels
267+
current_project_items = []
268+
if self.org_integration:
269+
external_projects = IntegrationExternalProject.objects.filter(
270+
organization_integration_id=self.org_integration.id
271+
)
272+
273+
if external_projects.exists():
274+
current_project_items = [
275+
{"value": project.external_id, "label": project.name}
276+
for project in external_projects
277+
]
278+
279+
config.insert(
280+
0,
281+
{
282+
"name": self.outbound_status_key,
283+
"type": "choice_mapper",
284+
"label": _("Sync Sentry Status to GitLab"),
285+
"help": _(
286+
"When a Sentry issue changes status, change the status of the linked ticket in GitLab."
287+
),
288+
"addButtonText": _("Add GitLab Project"),
289+
"addDropdown": {
290+
"emptyMessage": _("All projects configured"),
291+
"noResultsMessage": _("Could not find GitLab project"),
292+
"items": current_project_items,
293+
"url": reverse(
294+
"sentry-extensions-gitlab-search",
295+
args=[organization.slug, self.model.id],
296+
),
297+
"searchField": "project",
298+
},
299+
"mappedSelectors": {
300+
"on_resolve": {"choices": GitLabIssueStatus.get_choices()},
301+
"on_unresolve": {"choices": GitLabIssueStatus.get_choices()},
302+
},
303+
"columnLabels": {
304+
"on_resolve": _("When resolved"),
305+
"on_unresolve": _("When unresolved"),
306+
},
307+
"mappedColumnLabel": _("GitLab Project"),
308+
"formatMessageValue": False,
309+
},
310+
)
311+
260312
if not has_issue_sync:
261313
for field in config:
262314
field["disabled"] = True
@@ -272,6 +324,43 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
272324

273325
config = self.org_integration.config
274326

327+
# Handle status sync configuration
328+
if "sync_status_forward" in data:
329+
project_mappings = data.pop("sync_status_forward")
330+
331+
# Validate that all mappings have both statuses
332+
if any(
333+
not mapping.get("on_unresolve") or not mapping.get("on_resolve")
334+
for mapping in project_mappings.values()
335+
):
336+
raise IntegrationError("Resolve and unresolve status are required.")
337+
338+
data["sync_status_forward"] = bool(project_mappings)
339+
340+
IntegrationExternalProject.objects.filter(
341+
organization_integration_id=self.org_integration.id
342+
).delete()
343+
344+
for project_path, statuses in project_mappings.items():
345+
# Validate status values
346+
valid_statuses = {GitLabIssueStatus.OPENED.value, GitLabIssueStatus.CLOSED.value}
347+
if statuses["on_resolve"] not in valid_statuses:
348+
raise IntegrationError(
349+
f"Invalid resolve status: {statuses['on_resolve']}. Must be 'opened' or 'closed'."
350+
)
351+
if statuses["on_unresolve"] not in valid_statuses:
352+
raise IntegrationError(
353+
f"Invalid unresolve status: {statuses['on_unresolve']}. Must be 'opened' or 'closed'."
354+
)
355+
356+
IntegrationExternalProject.objects.create(
357+
organization_integration_id=self.org_integration.id,
358+
external_id=project_path,
359+
name=project_path,
360+
resolved_status=statuses["on_resolve"],
361+
unresolved_status=statuses["on_unresolve"],
362+
)
363+
275364
# Check webhook version BEFORE updating config to determine if migration is needed
276365
current_webhook_version = config.get(GITLAB_WEBHOOK_VERSION_KEY, 0)
277366

@@ -284,11 +373,9 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
284373
if org_integration is not None:
285374
self.org_integration = org_integration
286375

287-
# Only update webhooks if:
288-
# 1. A sync setting was enabled, AND
289-
# 2. The webhook version is outdated
376+
# Only update webhooks if the webhook version is outdated
290377
if current_webhook_version < GITLAB_WEBHOOK_VERSION:
291-
update_all_project_webhooks.delay(
378+
repository_service.schedule_update_gitlab_project_webhooks(
292379
integration_id=self.model.id,
293380
organization_id=self.organization_id,
294381
)

src/sentry/integrations/gitlab/issue_sync.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from urllib.parse import quote
77

88
from sentry import features
9+
from sentry.integrations.gitlab.types import GitLabIssueAction, GitLabIssueStatus
910
from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction
1011
from sentry.integrations.models.external_actor import ExternalActor
1112
from sentry.integrations.models.external_issue import ExternalIssue
1213
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
14+
from sentry.integrations.services.integration import integration_service
1315
from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum
1416
from sentry.shared_integrations.exceptions import ApiError
1517
from sentry.users.services.user.model import RpcUser
@@ -23,6 +25,7 @@ class GitlabIssueSyncSpec(IssueSyncIntegration):
2325
outbound_assignee_key = "sync_forward_assignment"
2426
inbound_assignee_key = "sync_reverse_assignment"
2527
inbound_status_key = "sync_status_reverse"
28+
outbound_status_key = "sync_status_forward"
2629
resolution_strategy_key = "resolution_strategy"
2730

2831
def check_feature_flag(self) -> bool:
@@ -164,7 +167,93 @@ def sync_status_outbound(
164167
Propagate a sentry issue's status to a linked GitLab issue's status.
165168
For GitLab, we only support opened/closed states.
166169
"""
167-
raise NotImplementedError
170+
171+
client = self.get_client()
172+
project_path, issue_iid = self.split_external_issue_key(external_issue.key)
173+
174+
if not project_path or not issue_iid:
175+
logger.warning(
176+
"status-outbound.invalid-key",
177+
extra={
178+
"external_issue_key": external_issue.key,
179+
"provider": self.model.provider,
180+
},
181+
)
182+
return
183+
184+
# Get the project mapping to determine what status to use
185+
external_project = integration_service.get_integration_external_project(
186+
organization_id=external_issue.organization_id,
187+
integration_id=external_issue.integration_id,
188+
external_id=project_path,
189+
)
190+
191+
log_context = {
192+
"provider": self.model.provider,
193+
"integration_id": external_issue.integration_id,
194+
"is_resolved": is_resolved,
195+
"issue_key": external_issue.key,
196+
"project_path": project_path,
197+
}
198+
199+
if not external_project:
200+
logger.info("external-project-not-found", extra=log_context)
201+
return
202+
203+
desired_state = (
204+
external_project.resolved_status if is_resolved else external_project.unresolved_status
205+
)
206+
207+
# Fetch current GitLab issue state
208+
try:
209+
# URL-encode project_path since it's a path_with_namespace
210+
encoded_project_path = quote(project_path, safe="")
211+
issue_data = client.get_issue(encoded_project_path, issue_iid)
212+
except ApiError as e:
213+
self.raise_error(e)
214+
215+
current_state = issue_data.get("state")
216+
217+
# Don't update if it's already in the desired state
218+
if current_state == desired_state:
219+
logger.info(
220+
"sync_status_outbound.unchanged",
221+
extra={
222+
**log_context,
223+
"current_state": current_state,
224+
"desired_state": desired_state,
225+
},
226+
)
227+
return
228+
229+
# Determine state_event parameter for GitLab API
230+
# GitLab uses "close" to transition to closed, "reopen" to transition to opened
231+
if desired_state == GitLabIssueStatus.CLOSED.value:
232+
state_event = GitLabIssueAction.CLOSE.value
233+
elif desired_state == GitLabIssueStatus.OPENED.value:
234+
state_event = GitLabIssueAction.REOPEN.value
235+
else:
236+
logger.warning(
237+
"sync_status_outbound.invalid-desired-state",
238+
extra={**log_context, "desired_state": desired_state},
239+
)
240+
return
241+
242+
# Update the issue state
243+
try:
244+
encoded_project_path = quote(project_path, safe="")
245+
client.update_issue_status(encoded_project_path, issue_iid, state_event)
246+
logger.info(
247+
"sync_status_outbound.success",
248+
extra={
249+
**log_context,
250+
"old_state": current_state,
251+
"new_state": desired_state,
252+
"state_event": state_event,
253+
},
254+
)
255+
except ApiError as e:
256+
self.raise_error(e)
168257

169258
def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
170259
"""

src/sentry/integrations/gitlab/search.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ def handle_search_repositories(
7777
return Response({"detail": str(e)}, status=400)
7878
return Response(
7979
[
80-
{"label": project["name_with_namespace"], "value": project["id"]}
80+
{
81+
"label": project["name_with_namespace"],
82+
"value": project["path_with_namespace"],
83+
}
8184
for project in response
8285
]
8386
)

src/sentry/integrations/gitlab/tasks.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
GitLabTaskInteractionType,
1010
GitLabWebhookUpdateHaltReason,
1111
)
12+
from sentry.integrations.models.integration import Integration
1213
from sentry.integrations.services.integration import integration_service
1314
from sentry.integrations.services.repository import repository_service
1415
from sentry.silo.base import SiloMode
15-
from sentry.tasks.base import instrumented_task
16+
from sentry.tasks.base import instrumented_task, retry
1617
from sentry.taskworker.namespaces import integrations_tasks
18+
from sentry.taskworker.retry import Retry
1719

1820
logger = logging.getLogger("sentry.tasks.integrations.gitlab")
1921

@@ -22,15 +24,17 @@
2224
name="sentry.tasks.integrations.gitlab.update_project_webhook",
2325
namespace=integrations_tasks,
2426
silo_mode=SiloMode.REGION,
25-
max_retries=3,
26-
default_retry_delay=60,
27+
retry=Retry(times=3, delay=60),
2728
)
29+
@retry(exclude=(Integration.DoesNotExist,))
2830
def update_project_webhook(integration_id: int, organization_id: int, repository_id: int) -> None:
2931
"""
3032
Update a single project webhook for a GitLab integration.
3133
This task is spawned by update_all_project_webhooks for each repository.
3234
"""
33-
integration = integration_service.get_integration(integration_id=integration_id)
35+
integration = integration_service.get_integration(
36+
integration_id=integration_id, status=ObjectStatus.ACTIVE
37+
)
3438
if not integration:
3539
logger.warning(
3640
"update-project-webhook.integration-not-found",
@@ -98,14 +102,18 @@ def update_project_webhook(integration_id: int, organization_id: int, repository
98102
name="sentry.tasks.integrations.gitlab.update_all_project_webhooks",
99103
namespace=integrations_tasks,
100104
silo_mode=SiloMode.REGION,
105+
retry=Retry(times=3, delay=60),
101106
)
107+
@retry(exclude=(Integration.DoesNotExist,))
102108
def update_all_project_webhooks(integration_id: int, organization_id: int) -> None:
103109
"""
104110
Spawn individual tasks to update all project webhooks for a GitLab integration.
105111
This is triggered when sync settings are changed to ensure all webhooks have the correct permissions.
106112
"""
107113

108-
integration = integration_service.get_integration(integration_id=integration_id)
114+
integration = integration_service.get_integration(
115+
integration_id=integration_id, status=ObjectStatus.ACTIVE
116+
)
109117
if not integration:
110118
logger.warning(
111119
"update-all-project-webhooks.integration-not-found",

src/sentry/integrations/gitlab/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,13 @@ class GitLabIssueAction(StrEnum):
1010
@classmethod
1111
def values(cls):
1212
return [action.value for action in cls]
13+
14+
15+
class GitLabIssueStatus(StrEnum):
16+
OPENED = "opened"
17+
CLOSED = "closed"
18+
19+
@classmethod
20+
def get_choices(cls):
21+
"""Return choices formatted for dropdown selectors"""
22+
return [(status.value, status.value.capitalize()) for status in cls]

src/sentry/integrations/services/repository/impl.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry.api.serializers import serialize
88
from sentry.constants import ObjectStatus
99
from sentry.db.postgres.transactions import enforce_constraints
10+
from sentry.integrations.gitlab.tasks import update_all_project_webhooks
1011
from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
1112
from sentry.integrations.services.repository import RepositoryService, RpcRepository
1213
from sentry.integrations.services.repository.model import RpcCreateRepository
@@ -152,3 +153,14 @@ def disassociate_organization_integration(
152153
RepositoryProjectPathConfig.objects.filter(
153154
organization_integration_id=organization_integration_id
154155
).delete()
156+
157+
def schedule_update_gitlab_project_webhooks(
158+
self,
159+
*,
160+
organization_id: int,
161+
integration_id: int,
162+
) -> None:
163+
update_all_project_webhooks.delay(
164+
integration_id=integration_id,
165+
organization_id=organization_id,
166+
)

src/sentry/integrations/services/repository/service.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,18 @@ def disassociate_organization_integration(
9999
This will also delete code owners, and code mapping associated with matching repositories.
100100
"""
101101

102+
@regional_rpc_method(resolve=ByOrganizationId())
103+
@abstractmethod
104+
def schedule_update_gitlab_project_webhooks(
105+
self,
106+
*,
107+
organization_id: int,
108+
integration_id: int,
109+
) -> None:
110+
"""
111+
Schedules a task to update all GitLab project webhooks for an integration.
112+
This is used when sync settings change and webhooks need to be updated.
113+
"""
114+
102115

103116
repository_service = RepositoryService.create_delegation()

0 commit comments

Comments
 (0)