Skip to content

Commit 7744c8b

Browse files
authored
✨ feat(github): outbound status sync (#102124)
1 parent 037b126 commit 7744c8b

File tree

4 files changed

+463
-23
lines changed

4 files changed

+463
-23
lines changed

src/sentry/integrations/github/client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,13 +536,20 @@ def create_issue(self, repo: str, data: Mapping[str, Any]) -> Any:
536536
endpoint = f"/repos/{repo}/issues"
537537
return self.post(endpoint, data=data)
538538

539-
def update_issue(self, repo: str, issue_number: str, assignees: list[str]) -> Any:
539+
def update_issue_assignees(self, repo: str, issue_number: str, assignees: list[str]) -> Any:
540540
"""
541541
https://docs.github.com/en/rest/issues/issues#update-an-issue
542542
"""
543543
endpoint = f"/repos/{repo}/issues/{issue_number}"
544544
return self.patch(endpoint, data={"assignees": assignees})
545545

546+
def update_issue_status(self, repo: str, issue_number: str, status: str) -> Any:
547+
"""
548+
https://docs.github.com/en/rest/issues/issues#update-an-issue
549+
"""
550+
endpoint = f"/repos/{repo}/issues/{issue_number}"
551+
return self.patch(endpoint, data={"state": status})
552+
546553
def create_comment(self, repo: str, issue_id: str, data: dict[str, Any]) -> Any:
547554
"""
548555
https://docs.github.com/en/rest/issues/comments#create-an-issue-comment

src/sentry/integrations/github/integration.py

Lines changed: 221 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@
3030
from sentry.integrations.github.constants import ISSUE_LOCKED_ERROR_MESSAGE, RATE_LIMITED_MESSAGE
3131
from sentry.integrations.github.tasks.codecov_account_link import codecov_account_link
3232
from sentry.integrations.github.tasks.link_all_repos import link_all_repos
33+
from sentry.integrations.github.types import GitHubIssueStatus
3334
from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction
3435
from sentry.integrations.models.external_actor import ExternalActor
3536
from sentry.integrations.models.external_issue import ExternalIssue
3637
from sentry.integrations.models.integration import Integration
38+
from sentry.integrations.models.integration_external_project import IntegrationExternalProject
3739
from sentry.integrations.models.organization_integration import OrganizationIntegration
3840
from sentry.integrations.pipeline import IntegrationPipeline
3941
from 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,

src/sentry/integrations/github/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,13 @@ class IssueEvenntWebhookActionType(StrEnum):
66
UNASSIGNED = "unassigned"
77
CLOSED = "closed"
88
REOPENED = "reopened"
9+
10+
11+
class GitHubIssueStatus(StrEnum):
12+
OPEN = "open"
13+
CLOSED = "closed"
14+
15+
@classmethod
16+
def get_choices(cls):
17+
"""Return choices formatted for dropdown selectors"""
18+
return [(status.value, status.value.capitalize()) for status in cls]

0 commit comments

Comments
 (0)