Skip to content

Commit c8dfe02

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

File tree

5 files changed

+598
-5
lines changed

5 files changed

+598
-5
lines changed

src/sentry/integrations/gitlab/integration.py

Lines changed: 90 additions & 3 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
@@ -22,6 +23,8 @@
2223
)
2324
from sentry.integrations.gitlab.constants import GITLAB_WEBHOOK_VERSION, GITLAB_WEBHOOK_VERSION_KEY
2425
from sentry.integrations.gitlab.tasks import update_all_project_webhooks
26+
from sentry.integrations.gitlab.types import GitLabIssueStatus
27+
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
2528
from sentry.integrations.pipeline import IntegrationPipeline
2629
from sentry.integrations.referrer_ids import GITLAB_PR_BOT_REFERRER
2730
from sentry.integrations.services.integration import integration_service
@@ -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,9 +373,7 @@ 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:
291378
update_all_project_webhooks.delay(
292379
integration_id=self.model.id,

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/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]

0 commit comments

Comments
 (0)