Skip to content

Commit 3f8cf58

Browse files
iamrajjoshiclaude
andcommitted
feat(gitlab): Add issue sync outbound support
Propagate Sentry issue resolution and assignment to linked GitLab issues. Includes sync_status_outbound and sync_assignee_outbound implementations, status mapping via IntegrationExternalProject, and a schedule_update_gitlab_project_webhooks RPC method. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5c597cb commit 3f8cf58

File tree

9 files changed

+661
-28
lines changed

9 files changed

+661
-28
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
from rest_framework.fields import BooleanField, CharField, URLField
1314

@@ -24,10 +25,12 @@
2425
IntegrationProvider,
2526
)
2627
from sentry.integrations.gitlab.constants import GITLAB_WEBHOOK_VERSION, GITLAB_WEBHOOK_VERSION_KEY
27-
from sentry.integrations.gitlab.tasks import update_all_project_webhooks
28+
from sentry.integrations.gitlab.types import GitLabIssueStatus
29+
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
2830
from sentry.integrations.pipeline import IntegrationPipeline
2931
from sentry.integrations.referrer_ids import GITLAB_PR_BOT_REFERRER
3032
from sentry.integrations.services.integration import integration_service
33+
from sentry.integrations.services.repository import repository_service
3134
from sentry.integrations.services.repository.model import RpcRepository
3235
from sentry.integrations.source_code_management.commit_context import (
3336
CommitContextIntegration,
@@ -48,6 +51,7 @@
4851
ApiForbiddenError,
4952
ApiUnauthorized,
5053
IntegrationConfigurationError,
54+
IntegrationError,
5155
IntegrationProviderError,
5256
)
5357
from sentry.snuba.referrer import Referrer
@@ -275,6 +279,54 @@ def get_organization_config(self) -> list[dict[str, Any]]:
275279

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

282+
# Add outbound status sync configuration if feature flag is enabled
283+
if self.check_feature_flag():
284+
# Get currently configured external projects to display their labels
285+
current_project_items = []
286+
if self.org_integration:
287+
external_projects = IntegrationExternalProject.objects.filter(
288+
organization_integration_id=self.org_integration.id
289+
)
290+
291+
if external_projects.exists():
292+
current_project_items = [
293+
{"value": project.external_id, "label": project.name}
294+
for project in external_projects
295+
]
296+
297+
config.insert(
298+
0,
299+
{
300+
"name": self.outbound_status_key,
301+
"type": "choice_mapper",
302+
"label": _("Sync Sentry Status to GitLab"),
303+
"help": _(
304+
"When a Sentry issue changes status, change the status of the linked ticket in GitLab."
305+
),
306+
"addButtonText": _("Add GitLab Project"),
307+
"addDropdown": {
308+
"emptyMessage": _("All projects configured"),
309+
"noResultsMessage": _("Could not find GitLab project"),
310+
"items": current_project_items,
311+
"url": reverse(
312+
"sentry-extensions-gitlab-search",
313+
args=[organization.slug, self.model.id],
314+
),
315+
"searchField": "project",
316+
},
317+
"mappedSelectors": {
318+
"on_resolve": {"choices": GitLabIssueStatus.get_choices()},
319+
"on_unresolve": {"choices": GitLabIssueStatus.get_choices()},
320+
},
321+
"columnLabels": {
322+
"on_resolve": _("When resolved"),
323+
"on_unresolve": _("When unresolved"),
324+
},
325+
"mappedColumnLabel": _("GitLab Project"),
326+
"formatMessageValue": False,
327+
},
328+
)
329+
278330
if not has_issue_sync:
279331
for field in config:
280332
field["disabled"] = True
@@ -290,6 +342,43 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
290342

291343
config = self.org_integration.config
292344

345+
# Handle status sync configuration
346+
if "sync_status_forward" in data:
347+
project_mappings = data.pop("sync_status_forward")
348+
349+
# Validate that all mappings have both statuses
350+
if any(
351+
not mapping.get("on_unresolve") or not mapping.get("on_resolve")
352+
for mapping in project_mappings.values()
353+
):
354+
raise IntegrationError("Resolve and unresolve status are required.")
355+
356+
data["sync_status_forward"] = bool(project_mappings)
357+
358+
IntegrationExternalProject.objects.filter(
359+
organization_integration_id=self.org_integration.id
360+
).delete()
361+
362+
for project_path, statuses in project_mappings.items():
363+
# Validate status values
364+
valid_statuses = {GitLabIssueStatus.OPENED.value, GitLabIssueStatus.CLOSED.value}
365+
if statuses["on_resolve"] not in valid_statuses:
366+
raise IntegrationError(
367+
f"Invalid resolve status: {statuses['on_resolve']}. Must be 'opened' or 'closed'."
368+
)
369+
if statuses["on_unresolve"] not in valid_statuses:
370+
raise IntegrationError(
371+
f"Invalid unresolve status: {statuses['on_unresolve']}. Must be 'opened' or 'closed'."
372+
)
373+
374+
IntegrationExternalProject.objects.create(
375+
organization_integration_id=self.org_integration.id,
376+
external_id=project_path,
377+
name=project_path,
378+
resolved_status=statuses["on_resolve"],
379+
unresolved_status=statuses["on_unresolve"],
380+
)
381+
293382
# Check webhook version BEFORE updating config to determine if migration is needed
294383
current_webhook_version = config.get(GITLAB_WEBHOOK_VERSION_KEY, 0)
295384

@@ -302,11 +391,9 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
302391
if org_integration is not None:
303392
self.org_integration = org_integration
304393

305-
# Only update webhooks if:
306-
# 1. A sync setting was enabled, AND
307-
# 2. The webhook version is outdated
394+
# Only update webhooks if the webhook version is outdated
308395
if current_webhook_version < GITLAB_WEBHOOK_VERSION:
309-
update_all_project_webhooks.delay(
396+
repository_service.schedule_update_gitlab_project_webhooks(
310397
integration_id=self.model.id,
311398
organization_id=self.organization_id,
312399
)

src/sentry/integrations/gitlab/issue_sync.py

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

88
from sentry import features
9-
from sentry.integrations.gitlab.types import GitLabIssueAction
9+
from sentry.integrations.gitlab.types import GitLabIssueAction, GitLabIssueStatus
1010
from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction
1111
from sentry.integrations.models.external_actor import ExternalActor
1212
from sentry.integrations.models.external_issue import ExternalIssue
1313
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
14+
from sentry.integrations.services.integration import integration_service
1415
from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum
1516
from sentry.shared_integrations.exceptions import ApiError
1617
from sentry.users.services.user.model import RpcUser
@@ -24,6 +25,7 @@ class GitlabIssueSyncSpec(IssueSyncIntegration):
2425
outbound_assignee_key = "sync_forward_assignment"
2526
inbound_assignee_key = "sync_reverse_assignment"
2627
inbound_status_key = "sync_status_reverse"
28+
outbound_status_key = "sync_status_forward"
2729
resolution_strategy_key = "resolution_strategy"
2830

2931
def check_feature_flag(self) -> bool:
@@ -165,7 +167,93 @@ def sync_status_outbound(
165167
Propagate a sentry issue's status to a linked GitLab issue's status.
166168
For GitLab, we only support opened/closed states.
167169
"""
168-
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)
169257

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

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/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
@@ -175,3 +176,14 @@ def disassociate_organization_integration(
175176
RepositoryProjectPathConfig.objects.filter(
176177
organization_integration_id=organization_integration_id
177178
).delete()
179+
180+
def schedule_update_gitlab_project_webhooks(
181+
self,
182+
*,
183+
organization_id: int,
184+
integration_id: int,
185+
) -> None:
186+
update_all_project_webhooks.delay(
187+
integration_id=integration_id,
188+
organization_id=organization_id,
189+
)

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

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

117+
@cell_rpc_method(resolve=ByOrganizationId())
118+
@abstractmethod
119+
def schedule_update_gitlab_project_webhooks(
120+
self,
121+
*,
122+
organization_id: int,
123+
integration_id: int,
124+
) -> None:
125+
"""
126+
Schedules a task to update all GitLab project webhooks for an integration.
127+
This is used when sync settings change and webhooks need to be updated.
128+
"""
129+
117130

118131
repository_service = RepositoryService.create_delegation()

tests/sentry/integrations/api/endpoints/test_organization_integration_details.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ class OrganizationIntegrationDetailsPostTest(OrganizationIntegrationDetailsTest)
6060

6161
def test_update_config(self) -> None:
6262
config = {"setting": "new_value", "setting2": "baz"}
63-
with patch("sentry.integrations.gitlab.integration.update_all_project_webhooks"):
63+
with patch(
64+
"sentry.integrations.gitlab.integration.repository_service.schedule_update_gitlab_project_webhooks"
65+
):
6466
self.get_success_response(self.organization.slug, self.integration.id, **config)
6567

6668
org_integration = OrganizationIntegration.objects.get(

0 commit comments

Comments
 (0)