88from django import forms
99from django .http .request import HttpRequest
1010from django .http .response import HttpResponseBase
11+ from django .urls import reverse
1112from django .utils .translation import gettext_lazy as _
1213
1314from sentry import features
2223)
2324from sentry .integrations .gitlab .constants import GITLAB_WEBHOOK_VERSION , GITLAB_WEBHOOK_VERSION_KEY
2425from 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
2528from sentry .integrations .pipeline import IntegrationPipeline
2629from sentry .integrations .referrer_ids import GITLAB_PR_BOT_REFERRER
2730from sentry .integrations .services .integration import integration_service
4245from sentry .shared_integrations .exceptions import (
4346 ApiError ,
4447 IntegrationConfigurationError ,
48+ IntegrationError ,
4549 IntegrationProviderError ,
4650)
4751from 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 ,
0 commit comments