Skip to content

Commit aeb4885

Browse files
committed
✨ feat: add issue sync inbound support
1 parent b8ba535 commit aeb4885

File tree

7 files changed

+292
-10
lines changed

7 files changed

+292
-10
lines changed

fixtures/gitlab.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,156 @@ def create_gitlab_repo(
426426
}
427427
"""
428428

429+
ISSUE_CLOSED_EVENT = b"""{
430+
"object_kind": "issue",
431+
"event_type": "issue",
432+
"user": {
433+
"id": 1,
434+
"name": "Administrator",
435+
"username": "root",
436+
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
437+
"email": "admin@example.com"
438+
},
439+
"project": {
440+
"id": 15,
441+
"name": "Sentry",
442+
"description": "",
443+
"web_url": "http://example.com/cool-group/sentry",
444+
"avatar_url": null,
445+
"git_ssh_url": "git@example.com:cool-group/sentry.git",
446+
"git_http_url": "http://example.com/cool-group/sentry.git",
447+
"namespace": "cool-group",
448+
"visibility_level": 0,
449+
"path_with_namespace": "cool-group/sentry",
450+
"default_branch": "master",
451+
"homepage": "http://example.com/cool-group/sentry",
452+
"url": "git@example.com:cool-group/sentry.git",
453+
"ssh_url": "git@example.com:cool-group/sentry.git",
454+
"http_url": "http://example.com/cool-group/sentry.git"
455+
},
456+
"object_attributes": {
457+
"id": 301,
458+
"title": "Test issue",
459+
"assignee_ids": [],
460+
"assignee_id": null,
461+
"author_id": 1,
462+
"project_id": 15,
463+
"created_at": "2023-01-01 00:00:00 UTC",
464+
"updated_at": "2023-01-01 00:00:00 UTC",
465+
"position": 0,
466+
"branch_name": null,
467+
"description": "Test issue description",
468+
"milestone_id": null,
469+
"state": "closed",
470+
"iid": 23,
471+
"url": "http://example.com/cool-group/sentry/issues/23",
472+
"action": "close"
473+
},
474+
"assignees": [],
475+
"labels": []
476+
}
477+
"""
478+
479+
ISSUE_REOPENED_EVENT = b"""{
480+
"object_kind": "issue",
481+
"event_type": "issue",
482+
"user": {
483+
"id": 1,
484+
"name": "Administrator",
485+
"username": "root",
486+
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
487+
"email": "admin@example.com"
488+
},
489+
"project": {
490+
"id": 15,
491+
"name": "Sentry",
492+
"description": "",
493+
"web_url": "http://example.com/cool-group/sentry",
494+
"avatar_url": null,
495+
"git_ssh_url": "git@example.com:cool-group/sentry.git",
496+
"git_http_url": "http://example.com/cool-group/sentry.git",
497+
"namespace": "cool-group",
498+
"visibility_level": 0,
499+
"path_with_namespace": "cool-group/sentry",
500+
"default_branch": "master",
501+
"homepage": "http://example.com/cool-group/sentry",
502+
"url": "git@example.com:cool-group/sentry.git",
503+
"ssh_url": "git@example.com:cool-group/sentry.git",
504+
"http_url": "http://example.com/cool-group/sentry.git"
505+
},
506+
"object_attributes": {
507+
"id": 301,
508+
"title": "Test issue",
509+
"assignee_ids": [],
510+
"assignee_id": null,
511+
"author_id": 1,
512+
"project_id": 15,
513+
"created_at": "2023-01-01 00:00:00 UTC",
514+
"updated_at": "2023-01-01 00:00:00 UTC",
515+
"position": 0,
516+
"branch_name": null,
517+
"description": "Test issue description",
518+
"milestone_id": null,
519+
"state": "opened",
520+
"iid": 23,
521+
"url": "http://example.com/cool-group/sentry/issues/23",
522+
"action": "reopen"
523+
},
524+
"assignees": [],
525+
"labels": []
526+
}
527+
"""
528+
529+
ISSUE_OPENED_EVENT = b"""{
530+
"object_kind": "issue",
531+
"event_type": "issue",
532+
"user": {
533+
"id": 1,
534+
"name": "Administrator",
535+
"username": "root",
536+
"avatar_url": "http://www.gravatar.com/avatar/avatar.jpg",
537+
"email": "admin@example.com"
538+
},
539+
"project": {
540+
"id": 15,
541+
"name": "Sentry",
542+
"description": "",
543+
"web_url": "http://example.com/cool-group/sentry",
544+
"avatar_url": null,
545+
"git_ssh_url": "git@example.com:cool-group/sentry.git",
546+
"git_http_url": "http://example.com/cool-group/sentry.git",
547+
"namespace": "cool-group",
548+
"visibility_level": 0,
549+
"path_with_namespace": "cool-group/sentry",
550+
"default_branch": "master",
551+
"homepage": "http://example.com/cool-group/sentry",
552+
"url": "git@example.com:cool-group/sentry.git",
553+
"ssh_url": "git@example.com:cool-group/sentry.git",
554+
"http_url": "http://example.com/cool-group/sentry.git"
555+
},
556+
"object_attributes": {
557+
"id": 301,
558+
"title": "Test issue",
559+
"assignee_ids": [],
560+
"assignee_id": null,
561+
"author_id": 1,
562+
"project_id": 15,
563+
"created_at": "2023-01-01 00:00:00 UTC",
564+
"updated_at": "2023-01-01 00:00:00 UTC",
565+
"position": 0,
566+
"branch_name": null,
567+
"description": "Test issue description",
568+
"milestone_id": null,
569+
"state": "opened",
570+
"iid": 23,
571+
"url": "http://example.com/cool-group/sentry/issues/23",
572+
"action": "open"
573+
},
574+
"assignees": [],
575+
"labels": []
576+
}
577+
"""
578+
429579
COMPARE_RESPONSE = r"""
430580
{
431581
"commit": {

src/sentry/integrations/gitlab/integration.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,30 @@ def _get_organization_config_default_values(self) -> list[dict[str, Any]]:
226226
"label": _("Sync Sentry Comments to GitLab"),
227227
"help": _("Post comments from Sentry issues to linked GitLab issues"),
228228
},
229+
{
230+
"name": self.inbound_status_key,
231+
"type": "boolean",
232+
"label": _("Sync GitLab Status to Sentry"),
233+
"help": _(
234+
"When a GitLab issue is marked closed, resolve its linked issue in Sentry. "
235+
"When a GitLab issue is reopened, unresolve its linked Sentry issue."
236+
),
237+
"default": False,
238+
},
239+
{
240+
"name": self.resolution_strategy_key,
241+
"label": "Resolve",
242+
"type": "select",
243+
"placeholder": "Resolve",
244+
"choices": [
245+
("resolve", "Resolve"),
246+
("resolve_current_release", "Resolve in Current Release"),
247+
("resolve_next_release", "Resolve in Next Release"),
248+
],
249+
"help": _(
250+
"Select what action to take on Sentry Issue when GitLab ticket is marked Closed."
251+
),
252+
},
229253
]
230254
)
231255

src/sentry/integrations/gitlab/issue_sync.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ class GitlabIssueSyncSpec(IssueSyncIntegration):
2222
comment_key = "sync_comments"
2323
outbound_assignee_key = "sync_forward_assignment"
2424
inbound_assignee_key = "sync_reverse_assignment"
25+
inbound_status_key = "sync_status_reverse"
26+
resolution_strategy_key = "resolution_strategy"
2527

2628
def check_feature_flag(self) -> bool:
2729
"""
@@ -169,6 +171,16 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
169171
Given webhook data, check whether the GitLab issue status changed.
170172
GitLab issues have opened/closed state.
171173
"""
174+
if not self.check_feature_flag():
175+
return ResolveSyncAction.NOOP
176+
177+
action = data.get("action")
178+
179+
if action == "close":
180+
return ResolveSyncAction.RESOLVE
181+
elif action == "reopen":
182+
return ResolveSyncAction.UNRESOLVE
183+
172184
return ResolveSyncAction.NOOP
173185

174186
def get_config_data(self):

src/sentry/integrations/gitlab/types.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
11
from enum import StrEnum
22

33

4-
class GitLabIssueStatus(StrEnum):
5-
OPENED = "opened"
6-
CLOSED = "closed"
7-
8-
@classmethod
9-
def get_choices(cls):
10-
"""Return choices formatted for dropdown selectors"""
11-
return [(status.value, status.value.capitalize()) for status in cls]
12-
13-
144
class GitLabIssueAction(StrEnum):
155
UPDATE = "update"
166
OPEN = "open"
177
REOPEN = "reopen"
8+
CLOSE = "close"
189

1910
@classmethod
2011
def values(cls):

src/sentry/integrations/gitlab/webhooks.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from sentry.api.base import Endpoint, cell_silo_endpoint
2020
from sentry.integrations.base import IntegrationDomain
2121
from sentry.integrations.gitlab.types import GitLabIssueAction
22+
from sentry.integrations.mixins.issues import IssueSyncIntegration
2223
from sentry.integrations.services.integration import integration_service
2324
from sentry.integrations.services.integration.model import RpcIntegration
2425
from sentry.integrations.source_code_management.webhook import SCMWebhook
@@ -148,6 +149,10 @@ def __call__(self, event: Mapping[str, Any], **kwargs):
148149
if action in GitLabIssueAction.values():
149150
self._handle_assignment(integration, event, external_issue_key)
150151

152+
# Handle status changes (CLOSE and REOPEN)
153+
if action in [GitLabIssueAction.CLOSE, GitLabIssueAction.REOPEN]:
154+
self._handle_status_change(integration, external_issue_key, action)
155+
151156
def _handle_assignment(
152157
self,
153158
integration: RpcIntegration,
@@ -216,6 +221,36 @@ def _handle_assignment(
216221
},
217222
)
218223

224+
def _handle_status_change(
225+
self,
226+
integration: RpcIntegration,
227+
external_issue_key: str,
228+
action: str,
229+
) -> None:
230+
"""
231+
Handle issue status changes (close/reopen).
232+
233+
Triggers the sync_status_inbound task to update linked Sentry issues.
234+
"""
235+
org_integrations = integration_service.get_organization_integrations(
236+
integration_id=integration.id
237+
)
238+
for org_integration in org_integrations:
239+
installation = integration.get_installation(org_integration.organization_id)
240+
if isinstance(installation, IssueSyncIntegration):
241+
installation.sync_status_inbound(
242+
external_issue_key,
243+
{"action": action},
244+
)
245+
logger.info(
246+
"gitlab.webhook.status.synced",
247+
extra={
248+
"integration_id": integration.id,
249+
"external_issue_key": external_issue_key,
250+
"action": action,
251+
},
252+
)
253+
219254
def _extract_issue_key(
220255
self, event: Mapping[str, Any], integration: RpcIntegration
221256
) -> str | None:

tests/sentry/integrations/gitlab/test_integration.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,8 @@ def test_get_organization_config(self) -> None:
839839
"sync_reverse_assignment",
840840
"sync_forward_assignment",
841841
"sync_comments",
842+
"sync_status_reverse",
843+
"resolution_strategy",
842844
]
843845

844846
@responses.activate

tests/sentry/integrations/gitlab/test_webhook.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from fixtures.gitlab import (
66
EXTERNAL_ID,
77
ISSUE_ASSIGNED_EVENT,
8+
ISSUE_CLOSED_EVENT,
9+
ISSUE_OPENED_EVENT,
10+
ISSUE_REOPENED_EVENT,
811
ISSUE_UNASSIGNED_EVENT,
912
MERGE_REQUEST_OPENED_EVENT,
1013
PUSH_EVENT,
@@ -490,3 +493,68 @@ def test_issue_unassigned(self, mock_sync: MagicMock) -> None:
490493
call_args[1]["external_issue_key"] == "example.gitlab.com/group-x:cool-group/sentry#23"
491494
)
492495
assert call_args[1]["assign"] is False
496+
497+
498+
class TestIssuesEventWebhookStatusSync(GitLabTestCase):
499+
url = "/extensions/gitlab/webhook/"
500+
501+
@patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
502+
def test_close_event_triggers_sync(self, mock_sync_status: MagicMock) -> None:
503+
response = self.client.post(
504+
self.url,
505+
data=ISSUE_CLOSED_EVENT,
506+
content_type="application/json",
507+
HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
508+
HTTP_X_GITLAB_EVENT="Issue Hook",
509+
)
510+
assert response.status_code == 204
511+
512+
assert mock_sync_status.called
513+
call_args = mock_sync_status.call_args
514+
assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
515+
assert call_args[0][1] == {"action": "close"}
516+
517+
@patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
518+
def test_reopen_event_triggers_sync(self, mock_sync_status: MagicMock) -> None:
519+
response = self.client.post(
520+
self.url,
521+
data=ISSUE_REOPENED_EVENT,
522+
content_type="application/json",
523+
HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
524+
HTTP_X_GITLAB_EVENT="Issue Hook",
525+
)
526+
assert response.status_code == 204
527+
528+
assert mock_sync_status.called
529+
call_args = mock_sync_status.call_args
530+
assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
531+
assert call_args[0][1] == {"action": "reopen"}
532+
533+
@patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
534+
def test_open_event_does_not_trigger_sync(self, mock_sync_status: MagicMock) -> None:
535+
response = self.client.post(
536+
self.url,
537+
data=ISSUE_OPENED_EVENT,
538+
content_type="application/json",
539+
HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
540+
HTTP_X_GITLAB_EVENT="Issue Hook",
541+
)
542+
assert response.status_code == 204
543+
544+
assert not mock_sync_status.called
545+
546+
@patch("sentry.integrations.gitlab.integration.GitlabIntegration.sync_status_inbound")
547+
def test_sync_called_with_correct_params(self, mock_sync_status: MagicMock) -> None:
548+
response = self.client.post(
549+
self.url,
550+
data=ISSUE_CLOSED_EVENT,
551+
content_type="application/json",
552+
HTTP_X_GITLAB_TOKEN=WEBHOOK_TOKEN,
553+
HTTP_X_GITLAB_EVENT="Issue Hook",
554+
)
555+
assert response.status_code == 204
556+
557+
assert mock_sync_status.called
558+
call_args = mock_sync_status.call_args
559+
assert call_args[0][0] == "example.gitlab.com/group-x:cool-group/sentry#23"
560+
assert call_args[0][1]["action"] == "close"

0 commit comments

Comments
 (0)