3030from sentry .integrations .github .constants import ISSUE_LOCKED_ERROR_MESSAGE , RATE_LIMITED_MESSAGE
3131from sentry .integrations .github .tasks .codecov_account_link import codecov_account_link
3232from sentry .integrations .github .tasks .link_all_repos import link_all_repos
33+ from sentry .integrations .github .types import GitHubIssueStatus
3334from sentry .integrations .mixins .issues import IssueSyncIntegration , ResolveSyncAction
3435from sentry .integrations .models .external_actor import ExternalActor
3536from sentry .integrations .models .external_issue import ExternalIssue
3637from sentry .integrations .models .integration import Integration
38+ from sentry .integrations .models .integration_external_project import IntegrationExternalProject
3739from sentry .integrations .models .organization_integration import OrganizationIntegration
3840from sentry .integrations .pipeline import IntegrationPipeline
3941from sentry .integrations .referrer_ids import GITHUB_PR_BOT_REFERRER
@@ -241,7 +243,7 @@ class GitHubIntegration(
241243
242244 # IssueSyncIntegration configuration keys
243245 comment_key = "sync_comments"
244- outbound_status_key = None
246+ outbound_status_key = "sync_status_forward"
245247 inbound_status_key = "sync_status_reverse"
246248 outbound_assignee_key = "sync_forward_assignment"
247249 inbound_assignee_key = "sync_reverse_assignment"
@@ -408,6 +410,26 @@ def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: s
408410
409411 # IssueSyncIntegration methods
410412
413+ def split_external_issue_key (
414+ self , external_issue_key : str
415+ ) -> tuple [str , str ] | tuple [None , None ]:
416+ """
417+ Split the external issue key into repo and issue number.
418+ """
419+ # Parse the external issue key to get repo and issue number
420+ # Format is "{repo_full_name}#{issue_number}"
421+ try :
422+ repo_id , issue_num = external_issue_key .split ("#" )
423+ return repo_id , issue_num
424+ except ValueError :
425+ logger .exception (
426+ "github.assignee-outbound.invalid-key" ,
427+ extra = {
428+ "external_issue_key" : external_issue_key ,
429+ },
430+ )
431+ return None , None
432+
411433 def sync_assignee_outbound (
412434 self ,
413435 external_issue : ExternalIssue ,
@@ -421,12 +443,10 @@ def sync_assignee_outbound(
421443 """
422444 client = self .get_client ()
423445
424- # Parse the external issue key to get repo and issue number
425- # Format is "{repo_full_name}#{issue_number}"
426- try :
427- repo , issue_num = external_issue .key .split ("#" )
428- except ValueError :
429- logger .exception (
446+ repo_id , issue_num = self .split_external_issue_key (external_issue .key )
447+
448+ if not repo_id or not issue_num :
449+ logger .error (
430450 "github.assignee-outbound.invalid-key" ,
431451 extra = {
432452 "integration_id" : external_issue .integration_id ,
@@ -465,7 +485,9 @@ def sync_assignee_outbound(
465485 # Only update GitHub if we have a username to assign or if we're explicitly deassigning
466486 if github_username or not assign :
467487 try :
468- client .update_issue (repo , issue_num , [github_username ] if github_username else [])
488+ client .update_issue_assignees (
489+ repo_id , issue_num , [github_username ] if github_username else []
490+ )
469491 except Exception as e :
470492 self .raise_error (e )
471493
@@ -474,9 +496,75 @@ def sync_status_outbound(
474496 ) -> None :
475497 """
476498 Propagate a sentry issue's status to a linked GitHub issue's status.
499+ For GitHub, we only support open/closed states.
477500 """
478- # Not implemented yet
479- pass
501+ client = self .get_client ()
502+
503+ repo_id , issue_num = self .split_external_issue_key (external_issue .key )
504+
505+ if not repo_id or not issue_num :
506+ logger .error (
507+ "github.status-outbound.invalid-key" ,
508+ extra = {
509+ "external_issue_key" : external_issue .key ,
510+ },
511+ )
512+ return
513+
514+ # Get the project mapping to determine what status to use
515+ external_project = integration_service .get_integration_external_project (
516+ organization_id = external_issue .organization_id ,
517+ integration_id = external_issue .integration_id ,
518+ external_id = repo_id ,
519+ )
520+
521+ log_context = {
522+ "integration_id" : external_issue .integration_id ,
523+ "is_resolved" : is_resolved ,
524+ "issue_key" : external_issue .key ,
525+ "repo_id" : repo_id ,
526+ }
527+
528+ if not external_project :
529+ logger .info ("github.external-project-not-found" , extra = log_context )
530+ return
531+
532+ desired_state = (
533+ external_project .resolved_status if is_resolved else external_project .unresolved_status
534+ )
535+
536+ try :
537+ issue_data = client .get_issue (repo_id , issue_num )
538+ except ApiError as e :
539+ self .raise_error (e )
540+
541+ current_state = issue_data .get ("state" )
542+
543+ # Don't update if it's already in the desired state
544+ if current_state == desired_state :
545+ logger .info (
546+ "github.sync_status_outbound.unchanged" ,
547+ extra = {
548+ ** log_context ,
549+ "current_state" : current_state ,
550+ "desired_state" : desired_state ,
551+ },
552+ )
553+ return
554+
555+ # Update the issue state
556+ try :
557+ client .update_issue_status (repo_id , issue_num , desired_state )
558+ logger .info (
559+ "github.sync_status_outbound.success" ,
560+ extra = {
561+ ** log_context ,
562+ "old_state" : current_state ,
563+ "new_state" : desired_state ,
564+ },
565+ )
566+ except ApiError as e :
567+ self .raise_error (e )
480568
481569 def get_resolve_sync_action (self , data : Mapping [str , Any ]) -> ResolveSyncAction :
482570 """
@@ -494,7 +582,22 @@ def get_resolve_sync_action(self, data: Mapping[str, Any]) -> ResolveSyncAction:
494582 return ResolveSyncAction .UNRESOLVE
495583 return ResolveSyncAction .NOOP
496584
497- def get_organization_config (self ) -> list [dict [str , Any ]]:
585+ def get_config_data (self ):
586+ config = self .org_integration .config
587+ project_mappings = IntegrationExternalProject .objects .filter (
588+ organization_integration_id = self .org_integration .id
589+ )
590+ sync_status_forward = {}
591+
592+ for pm in project_mappings :
593+ sync_status_forward [pm .external_id ] = {
594+ "on_unresolve" : pm .unresolved_status ,
595+ "on_resolve" : pm .resolved_status ,
596+ }
597+ config ["sync_status_forward" ] = sync_status_forward
598+ return config
599+
600+ def _get_organization_config_default_values (self ) -> list [dict [str , Any ]]:
498601 """
499602 Return configuration options for the GitHub integration.
500603 """
@@ -504,30 +607,30 @@ def get_organization_config(self) -> list[dict[str, Any]]:
504607 config .extend (
505608 [
506609 {
507- "name" : self .inbound_assignee_key ,
610+ "name" : self .inbound_status_key ,
508611 "type" : "boolean" ,
509- "label" : _ ("Sync Github Assignment to Sentry" ),
612+ "label" : _ ("Sync GitHub Status to Sentry" ),
510613 "help" : _ (
511- "When an issue is assigned in GitHub, assign its linked Sentry issue to the same user."
614+ "When a GitHub issue is marked closed, resolve its linked issue in Sentry. "
615+ "When a GitHub issue is reopened, unresolve its linked Sentry issue."
512616 ),
513617 "default" : False ,
514618 },
515619 {
516- "name" : self .outbound_assignee_key ,
620+ "name" : self .inbound_assignee_key ,
517621 "type" : "boolean" ,
518- "label" : _ ("Sync Sentry Assignment to GitHub " ),
622+ "label" : _ ("Sync Github Assignment to Sentry " ),
519623 "help" : _ (
520- "When an issue is assigned in Sentry , assign its linked GitHub issue to the same user."
624+ "When an issue is assigned in GitHub , assign its linked Sentry issue to the same user."
521625 ),
522626 "default" : False ,
523627 },
524628 {
525- "name" : self .inbound_status_key ,
629+ "name" : self .outbound_assignee_key ,
526630 "type" : "boolean" ,
527- "label" : _ ("Sync GitHub Status to Sentry " ),
631+ "label" : _ ("Sync Sentry Assignment to GitHub " ),
528632 "help" : _ (
529- "When a GitHub issue is marked closed, resolve its linked issue in Sentry. "
530- "When a GitHub issue is reopened, unresolve its linked Sentry issue."
633+ "When an issue is assigned in Sentry, assign its linked GitHub issue to the same user."
531634 ),
532635 "default" : False ,
533636 },
@@ -554,6 +657,64 @@ def get_organization_config(self) -> list[dict[str, Any]]:
554657 ]
555658 )
556659
660+ return config
661+
662+ def get_organization_config (self ) -> list [dict [str , Any ]]:
663+ """
664+ Return configuration options for the GitHub integration.
665+ """
666+ config = self ._get_organization_config_default_values ()
667+
668+ if features .has ("organizations:integrations-github-project-management" , self .organization ):
669+ config .insert (
670+ 0 ,
671+ {
672+ "name" : self .outbound_status_key ,
673+ "type" : "choice_mapper" ,
674+ "label" : _ ("Sync Sentry Status to Github" ),
675+ "help" : _ (
676+ "When a Sentry issue changes status, change the status of the linked ticket in Github."
677+ ),
678+ "addButtonText" : _ ("Add Github Project" ),
679+ "addDropdown" : {
680+ "emptyMessage" : _ ("All projects configured" ),
681+ "noResultsMessage" : _ ("Could not find Github project" ),
682+ "items" : [], # Populated with projects
683+ },
684+ "mappedSelectors" : {},
685+ "columnLabels" : {
686+ "on_resolve" : _ ("When resolved" ),
687+ "on_unresolve" : _ ("When unresolved" ),
688+ },
689+ "mappedColumnLabel" : _ ("Github Project" ),
690+ "formatMessageValue" : False ,
691+ },
692+ )
693+ try :
694+ # Fetch all repositories and add them to the config
695+ repositories = self .get_client ().get_repos ()
696+
697+ # Format repositories for the dropdown
698+ formatted_repos = [
699+ {"value" : repository ["full_name" ], "label" : repository ["name" ]}
700+ for repository in repositories
701+ if not repository .get ("archived" )
702+ ]
703+ config [0 ]["addDropdown" ]["items" ] = formatted_repos
704+
705+ status_choices = GitHubIssueStatus .get_choices ()
706+
707+ # Add mappedSelectors for each repository with GitHub status choices
708+ config [0 ]["mappedSelectors" ] = {
709+ "on_resolve" : {"choices" : status_choices },
710+ "on_unresolve" : {"choices" : status_choices },
711+ }
712+ except ApiError :
713+ config [0 ]["disabled" ] = True
714+ config [0 ]["disabledReason" ] = _ (
715+ "Unable to communicate with the GitHub instance. You may need to reinstall the integration."
716+ )
717+
557718 context = organization_service .get_organization_by_id (
558719 id = self .organization_id , include_projects = False , include_teams = False
559720 )
@@ -579,6 +740,45 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None:
579740 return
580741
581742 config = self .org_integration .config
743+
744+ # Handle status sync configuration
745+ if "sync_status_forward" in data :
746+ project_mappings = data .pop ("sync_status_forward" )
747+
748+ if any (
749+ not mapping ["on_unresolve" ] or not mapping ["on_resolve" ]
750+ for mapping in project_mappings .values ()
751+ ):
752+ raise IntegrationError ("Resolve and unresolve status are required." )
753+
754+ data ["sync_status_forward" ] = bool (project_mappings )
755+
756+ IntegrationExternalProject .objects .filter (
757+ organization_integration_id = self .org_integration .id
758+ ).delete ()
759+
760+ for repo_id , statuses in project_mappings .items ():
761+ # For GitHub, we only support open/closed states
762+ # Validate that the statuses are valid GitHub states
763+ if statuses ["on_resolve" ] not in [
764+ GitHubIssueStatus .OPEN .value ,
765+ GitHubIssueStatus .CLOSED .value ,
766+ ]:
767+ raise IntegrationError (
768+ f"Invalid resolve status: { statuses ['on_resolve' ]} . Must be 'open' or 'closed'."
769+ )
770+ if statuses ["on_unresolve" ] not in ["open" , "closed" ]:
771+ raise IntegrationError (
772+ f"Invalid unresolve status: { statuses ['on_unresolve' ]} . Must be 'open' or 'closed'."
773+ )
774+
775+ IntegrationExternalProject .objects .create (
776+ organization_integration_id = self .org_integration .id ,
777+ external_id = repo_id ,
778+ resolved_status = statuses ["on_resolve" ],
779+ unresolved_status = statuses ["on_unresolve" ],
780+ )
781+
582782 config .update (data )
583783 org_integration = integration_service .update_organization_integration (
584784 org_integration_id = self .org_integration .id ,
0 commit comments