Skip to content

Commit ce960bb

Browse files
authored
feat(github): Handle installation_repositories webhook (#112227)
Re-do of #111864 This failed due to [SENTRY-5MSP](https://sentry.sentry.io/issues/?project=1&query=SENTRY-5MSP). This was caused by us not deploying routings to control first, and so we tried to fire a control task from a cell. That's fixed in #112226. Other than that, this is the same as the previous pr and doesn't need re-review Currently, we only sync the available repositories from Github on installing the integration. So over time, if new repositories are added to the github organization, or access to specific repositories is added or removed, we end up out of sync with which repositories we store in Sentry. To fix this, we are going to start handling the `installation_repositories` webhook. This is fired whenever the repositories that a github app can access change. This allows us to keep all the repos in sync. Note that when access to a repo is removed, we only ever disable the repo and never delete it. This allows us to keep the history of commits and so on so far.
1 parent 3faec9a commit ce960bb

File tree

24 files changed

+994
-15
lines changed

24 files changed

+994
-15
lines changed

src/sentry/conf/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str:
882882
"sentry.integrations.github.tasks.codecov_account_unlink",
883883
"sentry.integrations.github.tasks.link_all_repos",
884884
"sentry.integrations.github.tasks.pr_comment",
885+
"sentry.integrations.github.tasks.sync_repos_on_install_change",
885886
"sentry.integrations.gitlab.tasks",
886887
"sentry.integrations.jira.tasks",
887888
"sentry.integrations.opsgenie.tasks",

src/sentry/features/temporary.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def register_temporary_features(manager: FeatureManager) -> None:
137137
manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
138138
manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
139139
manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
140+
manager.add("organizations:github-repo-auto-sync", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
140141
manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
141142
manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
142143
manager.add("organizations:scm-source-context", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)

src/sentry/integrations/bitbucket/repository.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Mapping
12
from typing import Any
23

34
from sentry.integrations.types import IntegrationProviderSlug
@@ -47,7 +48,7 @@ def get_webhook_secret(self, organization):
4748
return secret
4849

4950
def build_repository_config(
50-
self, organization: RpcOrganization, data: dict[str, Any]
51+
self, organization: RpcOrganization, data: Mapping[str, Any]
5152
) -> RepositoryConfig:
5253
installation = self.get_installation(data.get("installation"), organization.id)
5354
client = installation.get_client()

src/sentry/integrations/bitbucket_server/repository.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Mapping
12
from datetime import datetime, timezone
23
from typing import Any
34

@@ -35,7 +36,7 @@ def get_repository_data(self, organization, config):
3536
return config
3637

3738
def build_repository_config(
38-
self, organization: RpcOrganization, data: dict[str, Any]
39+
self, organization: RpcOrganization, data: Mapping[str, Any]
3940
) -> RepositoryConfig:
4041
installation = self.get_installation(data.get("installation"), organization.id)
4142
client = installation.get_client()

src/sentry/integrations/github/repository.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def get_repository_data(
5252
return config
5353

5454
def build_repository_config(
55-
self, organization: RpcOrganization, data: dict[str, Any]
55+
self, organization: RpcOrganization, data: Mapping[str, Any]
5656
) -> RepositoryConfig:
5757
return {
5858
"name": data["identifier"],

src/sentry/integrations/github/tasks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
from .codecov_account_unlink import codecov_account_unlink
33
from .link_all_repos import link_all_repos
44
from .pr_comment import github_comment_workflow
5+
from .sync_repos_on_install_change import sync_repos_on_install_change
56

67
__all__ = (
78
"codecov_account_link",
89
"codecov_account_unlink",
910
"github_comment_workflow",
1011
"link_all_repos",
12+
"sync_repos_on_install_change",
1113
)

src/sentry/integrations/github/tasks/link_all_repos.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from collections.abc import Mapping
23
from typing import Any
34

45
from taskbroker_client.retry import Retry
@@ -13,6 +14,7 @@
1314
from sentry.organizations.services.organization import organization_service
1415
from sentry.plugins.providers.integration_repository import (
1516
RepoExistsError,
17+
RepositoryInputConfig,
1618
get_integration_repository_provider,
1719
)
1820
from sentry.shared_integrations.exceptions import ApiError
@@ -23,9 +25,9 @@
2325
logger = logging.getLogger(__name__)
2426

2527

26-
def get_repo_config(repo, integration_id):
28+
def get_repo_config(repo: Mapping[str, Any], integration_id: int) -> RepositoryInputConfig:
2729
return {
28-
"external_id": repo["id"],
30+
"external_id": str(repo["id"]),
2931
"integration_id": integration_id,
3032
"identifier": repo["full_name"],
3133
}
@@ -77,7 +79,7 @@ def link_all_repos(
7779

7880
integration_repo_provider = get_integration_repository_provider(integration)
7981

80-
repo_configs: list[dict[str, Any]] = []
82+
repo_configs: list[RepositoryInputConfig] = []
8183
missing_repos = []
8284
for repo in repositories:
8385
try:
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import logging
2+
from typing import Literal
3+
4+
from taskbroker_client.retry import Retry
5+
6+
from sentry import features
7+
from sentry.constants import ObjectStatus
8+
from sentry.integrations.github.webhook_types import GitHubInstallationRepo
9+
from sentry.integrations.services.integration import integration_service
10+
from sentry.integrations.services.integration.model import RpcIntegration
11+
from sentry.integrations.services.repository.service import repository_service
12+
from sentry.integrations.source_code_management.metrics import (
13+
SCMIntegrationInteractionEvent,
14+
SCMIntegrationInteractionType,
15+
)
16+
from sentry.organizations.services.organization import organization_service
17+
from sentry.organizations.services.organization.model import RpcOrganization
18+
from sentry.plugins.providers.integration_repository import (
19+
RepoExistsError,
20+
RepositoryInputConfig,
21+
get_integration_repository_provider,
22+
)
23+
from sentry.silo.base import SiloMode
24+
from sentry.tasks.base import instrumented_task, retry
25+
from sentry.taskworker.namespaces import integrations_control_tasks
26+
27+
from .link_all_repos import get_repo_config
28+
29+
logger = logging.getLogger(__name__)
30+
31+
32+
@instrumented_task(
33+
name="sentry.integrations.github.tasks.sync_repos_on_install_change",
34+
namespace=integrations_control_tasks,
35+
retry=Retry(times=3, delay=120),
36+
processing_deadline_duration=120,
37+
silo_mode=SiloMode.CONTROL,
38+
)
39+
@retry(exclude=(RepoExistsError, KeyError))
40+
def sync_repos_on_install_change(
41+
integration_id: int,
42+
action: str,
43+
repos_added: list[GitHubInstallationRepo],
44+
repos_removed: list[GitHubInstallationRepo],
45+
repository_selection: Literal["all", "selected"],
46+
) -> None:
47+
"""
48+
Handle GitHub installation_repositories webhook events.
49+
50+
Creates Repository records for newly accessible repos and disables
51+
records for repos that are no longer accessible, across all orgs
52+
linked to the integration.
53+
"""
54+
result = integration_service.organization_contexts(integration_id=integration_id)
55+
integration = result.integration
56+
org_integrations = result.organization_integrations
57+
58+
if integration is None or integration.status != ObjectStatus.ACTIVE:
59+
logger.info(
60+
"sync_repos_on_install_change.missing_or_inactive_integration",
61+
extra={"integration_id": integration_id},
62+
)
63+
return
64+
65+
if not org_integrations:
66+
logger.info(
67+
"sync_repos_on_install_change.no_org_integrations",
68+
extra={"integration_id": integration_id},
69+
)
70+
return
71+
72+
provider = f"integrations:{integration.provider}"
73+
74+
for oi in org_integrations:
75+
organization_id = oi.organization_id
76+
rpc_org = organization_service.get(id=organization_id)
77+
78+
if rpc_org is None:
79+
logger.info(
80+
"sync_repos_on_install_change.missing_organization",
81+
extra={"organization_id": organization_id},
82+
)
83+
continue
84+
85+
if not features.has("organizations:github-repo-auto-sync", rpc_org):
86+
continue
87+
88+
with SCMIntegrationInteractionEvent(
89+
interaction_type=SCMIntegrationInteractionType.SYNC_REPOS_ON_INSTALL_CHANGE,
90+
integration_id=integration_id,
91+
organization_id=organization_id,
92+
provider_key=integration.provider,
93+
).capture():
94+
_sync_repos_for_org(
95+
integration=integration,
96+
rpc_org=rpc_org,
97+
provider=provider,
98+
repos_added=repos_added,
99+
repos_removed=repos_removed,
100+
)
101+
102+
103+
def _sync_repos_for_org(
104+
*,
105+
integration: RpcIntegration,
106+
rpc_org: RpcOrganization,
107+
provider: str,
108+
repos_added: list[GitHubInstallationRepo],
109+
repos_removed: list[GitHubInstallationRepo],
110+
) -> None:
111+
if repos_added:
112+
integration_repo_provider = get_integration_repository_provider(integration)
113+
repo_configs: list[RepositoryInputConfig] = []
114+
for repo in repos_added:
115+
try:
116+
repo_configs.append(get_repo_config(repo, integration.id))
117+
except KeyError:
118+
logger.exception("Failed to translate repository config")
119+
continue
120+
121+
if repo_configs:
122+
try:
123+
integration_repo_provider.create_repositories(
124+
configs=repo_configs, organization=rpc_org
125+
)
126+
except RepoExistsError:
127+
pass
128+
129+
if repos_removed:
130+
external_ids = [str(repo["id"]) for repo in repos_removed]
131+
repository_service.disable_repositories_by_external_ids(
132+
organization_id=rpc_org.id,
133+
integration_id=integration.id,
134+
provider=provider,
135+
external_ids=external_ids,
136+
)

src/sentry/integrations/github/webhook.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from sentry.integrations.github.webhook_types import (
3131
GITHUB_WEBHOOK_TYPE_HEADER_KEY,
3232
GithubWebhookType,
33+
InstallationRepositoriesEvent,
3334
)
3435
from sentry.integrations.pipeline import ensure_integration
3536
from sentry.integrations.services.integration.model import (
@@ -418,6 +419,57 @@ def _handle_organization_deletion(
418419
)
419420

420421

422+
class InstallationRepositoriesEventWebhook(GitHubWebhook):
423+
"""
424+
Handles installation_repositories events when repos are added to or
425+
removed from the GitHub App installation. Runs in control silo.
426+
427+
https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation_repositories
428+
"""
429+
430+
EVENT_TYPE = IntegrationWebhookEventType.INSTALLATION_REPOSITORIES
431+
432+
def __call__( # type: ignore[override]
433+
self, event: InstallationRepositoriesEvent, host: str | None = None, **kwargs: Any
434+
) -> None:
435+
external_id = get_github_external_id(event=event, host=host)
436+
if external_id is None:
437+
return
438+
439+
result = integration_service.organization_contexts(
440+
provider=self.provider,
441+
external_id=external_id,
442+
)
443+
integration = result.integration
444+
445+
if integration is None:
446+
logger.warning(
447+
"github.installation_repositories.missing_integration",
448+
extra={"external_id": str(external_id)},
449+
)
450+
return
451+
452+
action = event["action"]
453+
repos_added = event["repositories_added"]
454+
repos_removed = event["repositories_removed"]
455+
repository_selection = event["repository_selection"]
456+
457+
if not repos_added and not repos_removed:
458+
return
459+
460+
from .tasks.sync_repos_on_install_change import sync_repos_on_install_change
461+
462+
sync_repos_on_install_change.apply_async(
463+
kwargs={
464+
"integration_id": integration.id,
465+
"action": action,
466+
"repos_added": repos_added,
467+
"repos_removed": repos_removed,
468+
"repository_selection": repository_selection,
469+
}
470+
)
471+
472+
421473
class PushEventWebhook(GitHubWebhook):
422474
"""https://developer.github.com/v3/activity/events/types/#pushevent"""
423475

@@ -958,6 +1010,7 @@ class GitHubIntegrationsWebhookEndpoint(Endpoint):
9581010
_handlers: dict[GithubWebhookType, type[GitHubWebhook]] = {
9591011
GithubWebhookType.CHECK_RUN: CheckRunEventWebhook,
9601012
GithubWebhookType.INSTALLATION: InstallationEventWebhook,
1013+
GithubWebhookType.INSTALLATION_REPOSITORIES: InstallationRepositoriesEventWebhook,
9611014
GithubWebhookType.ISSUE: IssuesEventWebhook,
9621015
GithubWebhookType.ISSUE_COMMENT: IssueCommentEventWebhook,
9631016
GithubWebhookType.PULL_REQUEST: PullRequestEventWebhook,

src/sentry/integrations/github/webhook_types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from enum import StrEnum
4+
from typing import Any, Literal, TypedDict
45

56
GITHUB_WEBHOOK_TYPE_HEADER = "HTTP_X_GITHUB_EVENT"
67
GITHUB_WEBHOOK_TYPE_HEADER_KEY = "X-GITHUB-EVENT"
@@ -29,3 +30,18 @@ class GithubWebhookType(StrEnum):
2930
CELL_PROCESSED_GITHUB_EVENTS = frozenset(
3031
t.value for t in GithubWebhookType if t not in _CONTROL_ONLY_EVENTS
3132
)
33+
34+
35+
class GitHubInstallationRepo(TypedDict):
36+
id: int
37+
full_name: str
38+
private: bool
39+
40+
41+
class InstallationRepositoriesEvent(TypedDict):
42+
action: Literal["added", "removed"]
43+
installation: dict[str, Any]
44+
repositories_added: list[GitHubInstallationRepo]
45+
repositories_removed: list[GitHubInstallationRepo]
46+
repository_selection: Literal["all", "selected"]
47+
sender: dict[str, Any]

0 commit comments

Comments
 (0)