-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
/
Copy pathintegration.py
817 lines (685 loc) · 31.2 KB
/
integration.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
from __future__ import annotations
import logging
import re
from collections.abc import Callable, Mapping
from enum import StrEnum
from typing import Any
from urllib.parse import parse_qsl
from django.http.request import HttpRequest
from django.http.response import HttpResponseBase, HttpResponseRedirect
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from sentry import features, options
from sentry.constants import ObjectStatus
from sentry.http import safe_urlopen, safe_urlread
from sentry.identity.github import GitHubIdentityProvider, get_user_info
from sentry.integrations.base import (
FeatureDescription,
IntegrationData,
IntegrationDomain,
IntegrationFeatures,
IntegrationInstallation,
IntegrationMetadata,
IntegrationProvider,
)
from sentry.integrations.github.constants import ISSUE_LOCKED_ERROR_MESSAGE, RATE_LIMITED_MESSAGE
from sentry.integrations.github.tasks.link_all_repos import link_all_repos
from sentry.integrations.github.tasks.utils import GithubAPIErrorType
from sentry.integrations.models.integration import Integration
from sentry.integrations.models.organization_integration import OrganizationIntegration
from sentry.integrations.services.repository import RpcRepository, repository_service
from sentry.integrations.source_code_management.commit_context import (
OPEN_PR_MAX_FILES_CHANGED,
OPEN_PR_MAX_LINES_CHANGED,
OPEN_PR_METRICS_BASE,
CommitContextIntegration,
CommitContextOrganizationOptionKeys,
CommitContextReferrerIds,
CommitContextReferrers,
PullRequestFile,
PullRequestIssue,
)
from sentry.integrations.source_code_management.language_parsers import PATCH_PARSERS
from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration
from sentry.integrations.source_code_management.repository import RepositoryIntegration
from sentry.integrations.tasks.migrate_repo import migrate_repo
from sentry.integrations.utils.metrics import (
IntegrationPipelineViewEvent,
IntegrationPipelineViewType,
)
from sentry.models.group import Group
from sentry.models.organization import Organization
from sentry.models.pullrequest import PullRequest
from sentry.models.repository import Repository
from sentry.organizations.absolute_url import generate_organization_url
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.pipeline import Pipeline, PipelineView
from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED
from sentry.shared_integrations.exceptions import ApiError, IntegrationError
from sentry.snuba.referrer import Referrer
from sentry.templatetags.sentry_helpers import small_count
from sentry.types.referrer_ids import GITHUB_OPEN_PR_BOT_REFERRER, GITHUB_PR_BOT_REFERRER
from sentry.utils import metrics
from sentry.utils.http import absolute_uri
from sentry.web.frontend.base import determine_active_organization
from sentry.web.helpers import render_to_response
from .client import GitHubApiClient, GitHubBaseClient
from .issues import GitHubIssuesSpec
from .repository import GitHubRepositoryProvider
logger = logging.getLogger("sentry.integrations.github")
DESCRIPTION = """
Connect your Sentry organization into your GitHub organization or user account.
Take a step towards augmenting your sentry issues with commits from your
repositories ([using releases](https://docs.sentry.io/learn/releases/)) and
linking up your GitHub issues and pull requests directly to issues in Sentry.
"""
FEATURES = [
FeatureDescription(
"""
Authorize repositories to be added to your Sentry organization to augment
sentry issues with commit data with [deployment
tracking](https://docs.sentry.io/learn/releases/).
""",
IntegrationFeatures.COMMITS,
),
FeatureDescription(
"""
Create and link Sentry issue groups directly to a GitHub issue or pull
request in any of your repositories, providing a quick way to jump from
Sentry bug to tracked issue or PR!
""",
IntegrationFeatures.ISSUE_BASIC,
),
FeatureDescription(
"""
Link your Sentry stack traces back to your GitHub source code with stack
trace linking.
""",
IntegrationFeatures.STACKTRACE_LINK,
),
FeatureDescription(
"""
Import your GitHub [CODEOWNERS file](https://docs.sentry.io/product/integrations/source-code-mgmt/github/#code-owners) and use it alongside your ownership rules to assign Sentry issues.
""",
IntegrationFeatures.CODEOWNERS,
),
FeatureDescription(
"""
Automatically create GitHub issues based on Issue Alert conditions.
""",
IntegrationFeatures.TICKET_RULES,
),
]
metadata = IntegrationMetadata(
description=DESCRIPTION.strip(),
features=FEATURES,
author="The Sentry Team",
noun=_("Installation"),
issue_url="https://github.com/getsentry/sentry/issues/new?assignees=&labels=Component:%20Integrations&template=bug.yml&title=GitHub%20Integration%20Problem",
source_url="https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/github",
aspects={},
)
API_ERRORS = {
404: "If this repository exists, ensure"
" that your installation has permission to access this repository"
" (https://github.com/settings/installations).",
401: ERR_UNAUTHORIZED,
}
ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG = _(
"It seems that your GitHub account has been installed on another Sentry organization. Please uninstall and try again."
)
ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST = _(
"We could not verify the authenticity of the installation request. We recommend restarting the installation process."
)
ERR_INTEGRATION_PENDING_DELETION = _(
"It seems that your Sentry organization has an installation pending deletion. Please wait ~15min for the uninstall to complete and try again."
)
def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) -> bytes:
account_type = "user" if metadata["account_type"] == "User" else "org"
return f"{account_type}:{name} {query}".encode()
def error(
request,
org,
error_short="Invalid installation request.",
error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
):
logger.error(
"github.installation_error",
extra={"org_id": org.organization.id, "error_short": error_short},
)
return render_to_response(
"sentry/integrations/github-integration-failed.html",
context={
"error": error_long,
"payload": {
"success": False,
"data": {"error": _(error_short)},
},
"document_origin": get_document_origin(org),
},
request=request,
)
def get_document_origin(org) -> str:
if org and features.has("system:multi-region"):
return f'"{generate_organization_url(org.organization.slug)}"'
return "document.origin"
# Github App docs and list of available endpoints
# https://docs.github.com/en/rest/apps/installations
# https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps
class GitHubIntegration(
RepositoryIntegration, GitHubIssuesSpec, CommitContextIntegration, RepoTreesIntegration
):
integration_name = "github"
codeowners_locations = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"]
def get_client(self) -> GitHubBaseClient:
if not self.org_integration:
raise IntegrationError("Organization Integration does not exist")
return GitHubApiClient(integration=self.model, org_integration_id=self.org_integration.id)
# IntegrationInstallation methods
def is_rate_limited_error(self, exc: Exception) -> bool:
if exc.json and RATE_LIMITED_MESSAGE in exc.json.get("message", ""):
metrics.incr("github.link_all_repos.rate_limited_error")
return True
return False
def message_from_error(self, exc: Exception) -> str:
if not isinstance(exc, ApiError):
return ERR_INTERNAL
if not exc.code:
message = ""
else:
message = API_ERRORS.get(exc.code, "")
if exc.code == 404 and exc.url and re.search(r"/repos/.*/(compare|commits)", exc.url):
message += (
" Please also confirm that the commits associated with "
f"the following URL have been pushed to GitHub: {exc.url}"
)
if not message:
message = exc.json.get("message", "unknown error") if exc.json else "unknown error"
return f"Error Communicating with GitHub (HTTP {exc.code}): {message}"
# RepositoryIntegration methods
def source_url_matches(self, url: str) -> bool:
return url.startswith("https://{}".format(self.model.metadata["domain_name"]))
def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str:
# Must format the url ourselves since `check_file` is a head request
# "https://github.com/octokit/octokit.rb/blob/master/README.md"
return f"https://github.com/{repo.name}/blob/{branch}/{filepath}"
def extract_branch_from_source_url(self, repo: Repository, url: str) -> str:
url = url.replace(f"{repo.url}/blob/", "")
branch, _, _ = url.partition("/")
return branch
def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str:
url = url.replace(f"{repo.url}/blob/", "")
_, _, source_path = url.partition("/")
return source_path
def get_repositories(self, query: str | None = None) -> list[dict[str, Any]]:
"""
args:
* query - a query to filter the repositories by
This fetches all repositories accessible to the Github App
https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation
"""
if not query:
all_repos = self.get_client().get_repos()
return [
{
"name": i["name"],
"identifier": i["full_name"],
"default_branch": i.get("default_branch"),
}
for i in all_repos
if not i.get("archived")
]
full_query = build_repository_query(self.model.metadata, self.model.name, query)
response = self.get_client().search_repositories(full_query)
return [
{
"name": i["name"],
"identifier": i["full_name"],
"default_branch": i.get("default_branch"),
}
for i in response.get("items", [])
]
def get_unmigratable_repositories(self) -> list[RpcRepository]:
accessible_repos = self.get_repositories()
accessible_repo_names = [r["identifier"] for r in accessible_repos]
existing_repos = repository_service.get_repositories(
organization_id=self.organization_id, providers=["github"]
)
return [repo for repo in existing_repos if repo.name not in accessible_repo_names]
def has_repo_access(self, repo: RpcRepository) -> bool:
client = self.get_client()
try:
# make sure installation has access to this specific repo
# use hooks endpoint since we explicitly ask for those permissions
# when installing the app (commits can be accessed for public repos)
# https://docs.github.com/en/rest/webhooks/repo-config#list-hooks
client.repo_hooks(repo.config["name"])
except ApiError:
return False
return True
def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]:
resp = self.get_client().search_issues(query)
assert isinstance(resp, dict)
return resp
# CommitContextIntegration methods
commit_context_referrers = CommitContextReferrers(
pr_comment_bot=Referrer.GITHUB_PR_COMMENT_BOT,
)
commit_context_referrer_ids = CommitContextReferrerIds(
pr_bot=GITHUB_PR_BOT_REFERRER,
open_pr_bot=GITHUB_OPEN_PR_BOT_REFERRER,
)
commit_context_organization_option_keys = CommitContextOrganizationOptionKeys(
pr_bot="sentry:github_pr_bot",
)
def format_comment_url(self, url: str, referrer: str) -> str:
return url + "?referrer=" + referrer
def format_pr_comment(self, issue_ids: list[int]) -> str:
single_issue_template = "- ‼️ **{title}** `{subtitle}` [View Issue]({url})"
comment_body_template = """\
## Suspect Issues
This pull request was deployed and Sentry observed the following issues:
{issue_list}
<sub>Did you find this useful? React with a 👍 or 👎</sub>"""
def format_subtitle(subtitle: str) -> str:
return subtitle[:47] + "..." if len(subtitle) > 50 else subtitle
issues = Group.objects.filter(id__in=issue_ids).order_by("id").all()
issue_list = "\n".join(
single_issue_template.format(
title=issue.title,
subtitle=format_subtitle(issue.culprit),
url=self.format_comment_url(
issue.get_absolute_url(), referrer=self.commit_context_referrer_ids.pr_bot
),
)
for issue in issues
)
return comment_body_template.format(issue_list=issue_list)
def build_pr_comment_data(
self,
organization: Organization,
repo: Repository,
pr_key: str,
comment_body: str,
issue_ids: list[int],
) -> dict[str, Any]:
enabled_copilot = features.has("organizations:gen-ai-features", organization)
comment_data = {
"body": comment_body,
}
if enabled_copilot:
comment_data["actions"] = [
{
"name": f"Root cause #{i + 1}",
"type": "copilot-chat",
"prompt": f"@sentry root cause issue {str(issue_id)} with PR URL https://github.com/{repo.name}/pull/{str(pr_key)}",
}
for i, issue_id in enumerate(issue_ids[:3])
]
return comment_data
def queue_comment_task(self, pullrequest_id: int, project_id: int) -> None:
from sentry.integrations.github.tasks.pr_comment import github_comment_workflow
github_comment_workflow.delay(pullrequest_id=pullrequest_id, project_id=project_id)
def on_create_or_update_comment_error(self, api_error: ApiError, metrics_base: str) -> bool:
if api_error.json:
if ISSUE_LOCKED_ERROR_MESSAGE in api_error.json.get("message", ""):
metrics.incr(
metrics_base.format(integration=self.integration_name, key="error"),
tags={"type": "issue_locked_error"},
)
return True
elif RATE_LIMITED_MESSAGE in api_error.json.get("message", ""):
metrics.incr(
metrics_base.format(integration=self.integration_name, key="error"),
tags={"type": "rate_limited_error"},
)
return True
return False
def get_pr_files_safe_for_comment(
self, repo: Repository, pr: PullRequest
) -> list[dict[str, str]]:
client = self.get_client()
logger.info("github.open_pr_comment.check_safe_for_comment")
try:
pr_files = client.get_pullrequest_files(repo=repo, pr=pr)
except ApiError as e:
logger.info("github.open_pr_comment.api_error")
if e.json and RATE_LIMITED_MESSAGE in e.json.get("message", ""):
metrics.incr(
OPEN_PR_METRICS_BASE.format(integration="github", key="api_error"),
tags={"type": GithubAPIErrorType.RATE_LIMITED.value, "code": e.code},
)
elif e.code == 404:
metrics.incr(
OPEN_PR_METRICS_BASE.format(integration="github", key="api_error"),
tags={"type": GithubAPIErrorType.MISSING_PULL_REQUEST.value, "code": e.code},
)
else:
metrics.incr(
OPEN_PR_METRICS_BASE.format(integration="github", key="api_error"),
tags={"type": GithubAPIErrorType.UNKNOWN.value, "code": e.code},
)
logger.exception(
"github.open_pr_comment.unknown_api_error", extra={"error": str(e)}
)
return []
changed_file_count = 0
changed_lines_count = 0
filtered_pr_files = []
patch_parsers = PATCH_PARSERS
# NOTE: if we are testing beta patch parsers, add check here
for file in pr_files:
filename = file["filename"]
# we only count the file if it's modified and if the file extension is in the list of supported file extensions
# we cannot look at deleted or newly added files because we cannot extract functions from the diffs
if file["status"] != "modified" or filename.split(".")[-1] not in patch_parsers:
continue
changed_file_count += 1
changed_lines_count += file["changes"]
filtered_pr_files.append(file)
if changed_file_count > OPEN_PR_MAX_FILES_CHANGED:
metrics.incr(
OPEN_PR_METRICS_BASE.format(integration="github", key="rejected_comment"),
tags={"reason": "too_many_files"},
)
return []
if changed_lines_count > OPEN_PR_MAX_LINES_CHANGED:
metrics.incr(
OPEN_PR_METRICS_BASE.format(integration="github", key="rejected_comment"),
tags={"reason": "too_many_lines"},
)
return []
return filtered_pr_files
def get_pr_files(self, pr_files: list[dict[str, str]]) -> list[PullRequestFile]:
# new files will not have sentry issues associated with them
# only fetch Python files
pullrequest_files = [
PullRequestFile(filename=file["filename"], patch=file["patch"])
for file in pr_files
if "patch" in file
]
logger.info("github.open_pr_comment.pr_filenames", extra={"count": len(pullrequest_files)})
return pullrequest_files
def format_open_pr_comment(self, issue_tables: list[str]) -> str:
comment_body_template = """\
## 🔍 Existing Issues For Review
Your pull request is modifying functions with the following pre-existing issues:
{issue_tables}
---
<sub>Did you find this useful? React with a 👍 or 👎</sub>"""
return comment_body_template.format(issue_tables="\n".join(issue_tables))
def format_issue_table(
self,
diff_filename: str,
issues: list[PullRequestIssue],
patch_parsers: dict[str, Any],
toggle: bool,
) -> str:
description_length = 52
issue_table_template = """\
📄 File: **{filename}**
| Function | Unhandled Issue |
| :------- | :----- |
{issue_rows}"""
issue_table_toggle_template = """\
<details>
<summary><b>📄 File: {filename} (Click to Expand)</b></summary>
| Function | Unhandled Issue |
| :------- | :----- |
{issue_rows}
</details>"""
def format_subtitle(title_length: int, subtitle: str) -> str:
# the title length + " " + subtitle should be <= 52
subtitle_length = description_length - title_length - 1
return (
subtitle[: subtitle_length - 3] + "..."
if len(subtitle) > subtitle_length
else subtitle
)
language_parser = patch_parsers.get(diff_filename.split(".")[-1], None)
if not language_parser:
return ""
issue_row_template = language_parser.issue_row_template
issue_rows = "\n".join(
[
issue_row_template.format(
title=issue.title,
subtitle=format_subtitle(len(issue.title), issue.subtitle),
url=self.format_comment_url(
issue.url, referrer=self.commit_context_referrer_ids.open_pr_bot
),
event_count=small_count(issue.event_count),
function_name=issue.function_name,
affected_users=small_count(issue.affected_users),
)
for issue in issues
]
)
if toggle:
return issue_table_toggle_template.format(filename=diff_filename, issue_rows=issue_rows)
return issue_table_template.format(filename=diff_filename, issue_rows=issue_rows)
class GitHubIntegrationProvider(IntegrationProvider):
key = "github"
name = "GitHub"
metadata = metadata
integration_cls: type[IntegrationInstallation] = GitHubIntegration
features = frozenset(
[
IntegrationFeatures.COMMITS,
IntegrationFeatures.ISSUE_BASIC,
IntegrationFeatures.STACKTRACE_LINK,
IntegrationFeatures.CODEOWNERS,
]
)
setup_dialog_config = {"width": 1030, "height": 1000}
def get_client(self) -> GitHubBaseClient:
# XXX: This is very awkward behaviour as we're not passing the client an Integration
# object it expects. Instead we're passing the Installation object and hoping the client
# doesn't try to invoke any bad fields/attributes on it.
return GitHubApiClient(integration=self.integration_cls)
def post_install(
self,
integration: Integration,
organization: RpcOrganization,
*,
extra: dict[str, Any],
) -> None:
repos = repository_service.get_repositories(
organization_id=organization.id,
providers=["github", "integrations:github"],
has_integration=False,
)
for repo in repos:
migrate_repo.apply_async(
kwargs={
"repo_id": repo.id,
"integration_id": integration.id,
"organization_id": organization.id,
}
)
link_all_repos.apply_async(
kwargs={
"integration_key": self.key,
"integration_id": integration.id,
"organization_id": organization.id,
}
)
def get_pipeline_views(self) -> list[PipelineView | Callable[[], PipelineView]]:
return [OAuthLoginView(), GitHubInstallation()]
def get_installation_info(self, installation_id: str) -> Mapping[str, Any]:
client = self.get_client()
resp: Mapping[str, Any] = client.get(f"/app/installations/{installation_id}")
return resp
def build_integration(self, state: Mapping[str, str]) -> IntegrationData:
try:
installation = self.get_installation_info(state["installation_id"])
except ApiError as api_error:
if api_error.code == 404:
raise IntegrationError("The GitHub installation could not be found.")
raise
integration: IntegrationData = {
"name": installation["account"]["login"],
# TODO(adhiraj): This should be a constant representing the entire github cloud.
"external_id": installation["id"],
# GitHub identity is associated directly to the application, *not*
# to the installation itself.
"idp_external_id": installation["app_id"],
"metadata": {
# The access token will be populated upon API usage
"access_token": None,
"expires_at": None,
"icon": installation["account"]["avatar_url"],
"domain_name": installation["account"]["html_url"].replace("https://", ""),
"account_type": installation["account"]["type"],
},
}
if state.get("sender"):
integration["metadata"]["sender"] = state["sender"]
return integration
def setup(self) -> None:
from sentry.plugins.base import bindings
bindings.add(
"integration-repository.provider", GitHubRepositoryProvider, id="integrations:github"
)
class GitHubInstallationError(StrEnum):
INVALID_STATE = "Invalid state"
MISSING_TOKEN = "Missing access token"
MISSING_LOGIN = "Missing login info"
PENDING_DELETION = "GitHub installation pending deletion."
INSTALLATION_EXISTS = "Github installed on another Sentry organization."
USER_MISMATCH = "Authenticated user is not the same as who installed the app."
MISSING_INTEGRATION = "Integration does not exist."
def record_event(event: IntegrationPipelineViewType):
return IntegrationPipelineViewEvent(
event, IntegrationDomain.SOURCE_CODE_MANAGEMENT, GitHubIntegrationProvider.key
)
class OAuthLoginView(PipelineView):
def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase:
with record_event(IntegrationPipelineViewType.OAUTH_LOGIN).capture() as lifecycle:
self.active_organization = determine_active_organization(request)
lifecycle.add_extra(
"organization_id",
self.active_organization.organization.id if self.active_organization else None,
)
ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()
installation_id = request.GET.get("installation_id")
if installation_id:
pipeline.bind_state("installation_id", installation_id)
if not request.GET.get("state"):
state = pipeline.signature
redirect_uri = absolute_uri(
reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
)
return HttpResponseRedirect(
f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
)
# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:
lifecycle.record_failure(GitHubInstallationError.INVALID_STATE)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.INVALID_STATE,
)
# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),
"client_id": github_client_id,
"client_secret": github_client_secret,
}
# similar to OAuth2CallbackView.exchange_token
req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
try:
body = safe_urlread(req).decode("utf-8")
payload = dict(parse_qsl(body))
except Exception:
payload = {}
if "access_token" not in payload:
lifecycle.record_failure(GitHubInstallationError.MISSING_TOKEN)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.MISSING_TOKEN,
)
authenticated_user_info = get_user_info(payload["access_token"])
if "login" not in authenticated_user_info:
lifecycle.record_failure(GitHubInstallationError.MISSING_LOGIN)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.MISSING_LOGIN,
)
pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
return pipeline.next_step()
class GitHubInstallation(PipelineView):
def get_app_url(self) -> str:
name = options.get("github-app.name")
return f"https://github.com/apps/{slugify(name)}"
def dispatch(self, request: HttpRequest, pipeline: Pipeline) -> HttpResponseBase:
with record_event(IntegrationPipelineViewType.GITHUB_INSTALLATION).capture() as lifecycle:
installation_id = request.GET.get(
"installation_id", pipeline.fetch_state("installation_id")
)
if installation_id is None:
return HttpResponseRedirect(self.get_app_url())
pipeline.bind_state("installation_id", installation_id)
self.active_organization = determine_active_organization(request)
lifecycle.add_extra(
"organization_id",
self.active_organization.organization.id if self.active_organization else None,
)
integration_pending_deletion_exists = False
if self.active_organization:
# We want to wait until the scheduled deletions finish or else the
# post install to migrate repos do not work.
integration_pending_deletion_exists = OrganizationIntegration.objects.filter(
integration__provider=GitHubIntegrationProvider.key,
organization_id=self.active_organization.organization.id,
status=ObjectStatus.PENDING_DELETION,
).exists()
if integration_pending_deletion_exists:
lifecycle.record_failure(GitHubInstallationError.PENDING_DELETION)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.PENDING_DELETION,
error_long=ERR_INTEGRATION_PENDING_DELETION,
)
try:
# We want to limit GitHub integrations to 1 organization
installations_exist = OrganizationIntegration.objects.filter(
integration=Integration.objects.get(external_id=installation_id)
).exists()
except Integration.DoesNotExist:
return pipeline.next_step()
if installations_exist:
lifecycle.record_failure(GitHubInstallationError.INSTALLATION_EXISTS)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.INSTALLATION_EXISTS,
error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
)
# OrganizationIntegration does not exist, but Integration does exist.
try:
integration = Integration.objects.get(
external_id=installation_id, status=ObjectStatus.ACTIVE
)
except Integration.DoesNotExist:
lifecycle.record_failure(GitHubInstallationError.MISSING_INTEGRATION)
return error(request, self.active_organization)
# Check that the authenticated GitHub user is the same as who installed the app.
if (
pipeline.fetch_state("github_authenticated_user")
!= integration.metadata["sender"]["login"]
):
lifecycle.record_failure(GitHubInstallationError.USER_MISMATCH)
return error(
request,
self.active_organization,
error_short=GitHubInstallationError.USER_MISMATCH,
)
return pipeline.next_step()