Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 118 additions & 41 deletions src/sentry/integrations/github/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
import re
from collections.abc import Collection, Mapping, Sequence
from typing import Any
from urllib.parse import parse_qsl

from django.http import HttpResponse
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from rest_framework.request import Request

from sentry import features, options
from sentry.api.utils import generate_organization_url
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 import (
FeatureDescription,
IntegrationFeatures,
Expand All @@ -35,6 +39,7 @@
from sentry.tasks.integrations.github.constants import RATE_LIMITED_MESSAGE
from sentry.tasks.integrations.link_all_repos import link_all_repos
from sentry.utils import metrics
from sentry.utils.http import absolute_uri
from sentry.web.helpers import render_to_response

from .client import GitHubAppsClient, GitHubClientMixin
Expand Down Expand Up @@ -108,6 +113,9 @@
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."
)
Expand All @@ -118,6 +126,32 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) -
return f"{account_type}:{name} {query}".encode()


def error(
request,
org,
error_short="Invalid installation request.",
error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
):
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Pattern Violation - Error Helper Reusability (confidence: 91%)

The error() helper function is defined at module level in the integration file with a generic name that could easily collide with other imports or be confused with built-in functions. It's also tightly coupled to a specific template ('sentry/integrations/github-integration-failed.html') while having a very generic name. Consider namespacing it more clearly.

Evidence:

  • The function is named error which shadows/conflicts with common naming patterns
  • It's used by both OAuthLoginView and GitHubInstallation views, so it should be visible, but the name is overly generic for a module-level function

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (14 lines, 1 file) — review recommended)

The error() helper function is defined at module level in the integration file with a generic name that could easily collide with other imports or be confused with built-in functions. It's also tightly coupled to a specific template ('sentry/integrations/github-integration-failed.html') while having a very generic name. Consider namespacing it more clearly.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -126,7 +126,7 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
     return f"{account_type}:{name} {query}".encode()
 
 
-def error(
+def render_github_error_page(
     request,
     org,
     error_short="Invalid installation request.",
@@ -424,7 +424,7 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
         if request.GET.get("state") != pipeline.signature:
-            return error(request, self.active_organization)
+            return render_github_error_page(request, self.active_organization)
 
         # similar to OAuth2CallbackView.get_token_params
         data = {
@@ -446,11 +446,11 @@ class OAuthLoginView(PipelineView):
 
         if "access_token" not in payload:
-            return error(request, self.active_organization)
+            return render_github_error_page(request, self.active_organization)
 
         authenticated_user_info = get_user_info(payload["access_token"])
         if "login" not in authenticated_user_info:
-            return error(request, self.active_organization)
+            return render_github_error_page(request, self.active_organization)
 
         pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
         return pipeline.next_step()
@@ -475,8 +475,8 @@ class GitHubInstallation(PipelineView):
         if integration_pending_deletion_exists:
-            return error(
+            return render_github_error_page(
                 request,
                 self.active_organization,
                 error_short="GitHub installation pending deletion.",
@@ -497,7 +497,7 @@ class GitHubInstallation(PipelineView):
 
         if installations_exist:
-            return error(
+            return render_github_error_page(
                 request,
                 self.active_organization,
                 error_short="Github installed on another Sentry organization.",
@@ -515,7 +515,7 @@ class GitHubInstallation(PipelineView):
         if (
             pipeline.fetch_state("github_authenticated_user")
             != integration.metadata["sender"]["login"]
         ):
-            return error(request, self.active_organization)
+            return render_github_error_page(request, self.active_organization)
 
         return pipeline.next_step()

🤖 Grapple PR auto-fix • minor • Review this diff before applying

)


def get_document_origin(org) -> str:
if org and features.has("organizations:customer-domains", org.organization):
return f'"{generate_organization_url(org.organization.slug)}"'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Naming conventions (confidence: 83%)

Function parameter error_short in the error() helper uses underscore naming style, but the function immediately wraps it with _() i18n function, making the parameter name misleading about its nature.

Evidence:

  • Parameter is named error_short but is not actually a short error message - it's an untranslated string key that will be translated
  • Compare to error_long parameter which is passed a translated _() message directly
  • The inconsistency between the two error parameters (one raw, one translated) could confuse maintainers

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — style agent (Small fix (8 lines, 1 file))

Function parameter error_short in the error() helper uses underscore naming style, but the function immediately wraps it with _() i18n function, making the parameter name misleading about its nature.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -126,7 +126,7 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
 def error(
     request,
     org,
-    error_short="Invalid installation request.",
+    error_short_untranslated="Invalid installation request.",
     error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
 ):
     return render_to_response(
@@ -135,7 +135,7 @@ def error(
             "payload": {
                 "success": False,
-                "data": {"error": _(error_short)},
+                "data": {"error": _(error_short_untranslated)},
             },
             "document_origin": get_document_origin(org),
         },
@@ -465,7 +465,7 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
         if integration_pending_deletion_exists:
             return error(
                 request,
                 self.active_organization,
-                error_short="GitHub installation pending deletion.",
+                error_short_untranslated="GitHub installation pending deletion.",
                 error_long=ERR_INTEGRATION_PENDING_DELETION,
             )
@@ -487,7 +487,7 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
         if installations_exist:
             return error(
                 request,
                 self.active_organization,
-                error_short="Github installed on another Sentry organization.",
+                error_short_untranslated="Github installed on another Sentry organization.",
                 error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
             )

🤖 Grapple PR auto-fix • minor • Review this diff before applying

return "document.origin"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Code organization (confidence: 89%)

Helper function error() uses hardcoded 'github-integration-failed.html' template path without constants or centralized template reference. If the template name changes, multiple locations would need updates.

Evidence:

  • The template name appears hardcoded in the error() function at line 132
  • ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG and ERR_INTEGRATION_PENDING_DELETION are defined as module-level constants, but the template name is not

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — style agent (Small fix (4 lines, 1 file))

Helper function error() uses hardcoded 'github-integration-failed.html' template path without constants or centralized template reference. If the template name changes, multiple locations would need updates.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -110,6 +110,8 @@ 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."
 )
+
+GITHUB_INTEGRATION_FAILED_TEMPLATE = "sentry/integrations/github-integration-failed.html"
 
 
 def build_repository_query(metadata: Mapping[str, Any], name: str, query: str) -> bytes:
@@ -130,7 +132,7 @@ def error(
     error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
 ):
     return render_to_response(
-        "sentry/integrations/github-integration-failed.html",
+        GITHUB_INTEGRATION_FAILED_TEMPLATE,
         context={
             "error": error_long,
             "payload": {

🤖 Grapple PR auto-fix • minor • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Code organization (confidence: 100%)

The error() helper function and get_document_origin() helper are defined at module level but are tightly coupled to GitHubInstallation and OAuthLoginView. These helpers would be more maintainable if documented or placed closer to their usage context, especially since they handle GitHub-specific error rendering.

Evidence:

  • error() is called from both OAuthLoginView (line 410) and GitHubInstallation (lines 471, 486, 502)
  • get_document_origin() is called only from error() and from the GitHubInstallation class
  • These functions are GitHub-integration-specific and should be easily discoverable by maintainers working on GitHub integration logic

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Documentation (confidence: 100%)

The get_document_origin() function assumes org has a nested .organization attribute, but doesn't document this requirement or handle the case where org is not None but org.organization doesn't exist.

Evidence:

  • Line 149: if org and features.has("organizations:customer-domains", org.organization) — assumes org.organization exists without checking
  • Risk area from acceptance criteria: 'org.organization access in get_document_origin() assumes org has a nested .organization attribute — verify this holds for all callers'
  • Edge case from acceptance criteria: 'get_document_origin() is called with org=None — the conditional if org and features.has(...) should handle None safely' — handles None but not missing .organization

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (22 lines, 1 file) — review recommended)

The error() helper function and get_document_origin() helper are defined at module level but are tightly coupled to GitHubInstallation and OAuthLoginView. These helpers would be more maintainable if documented or placed closer to their usage context, especially since they handle GitHub-specific error rendering.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -129,6 +129,16 @@ def build_repository_query(metadata: Mapping[str, Any], name: str, query: str)
 def error(
     request,
     org,
     error_short="Invalid installation request.",
     error_long=ERR_INTEGRATION_INVALID_INSTALLATION_REQUEST,
 ):
+    """
+    Render the GitHub integration failure page with a structured error payload.
+
+    Used by both OAuthLoginView (state mismatch / token exchange failures) and
+    GitHubInstallation (pending deletion, duplicate org, sender mismatch) to
+    return a consistent error response that posts a message back to the opener
+    window via the ``document_origin`` context variable.
+
+    See also: get_document_origin()
+    """
     return render_to_response(
         "sentry/integrations/github-integration-failed.html",
         context={
@@ -149,6 +159,14 @@ def error(
 
 def get_document_origin(org) -> str:
+    """
+    Return the JavaScript ``postMessage`` target origin for the GitHub
+    integration failure template.
+
+    When the installing organization has the ``organizations:customer-domains``
+    feature enabled, the origin is scoped to that organization's subdomain URL
+    so the opener frame accepts the message.  Falls back to the permissive
+    ``document.origin`` sentinel for standard single-domain deployments.
+
+    Called exclusively from error() and, indirectly, from OAuthLoginView and
+    GitHubInstallation via that helper.
+    """
     if org and features.has("organizations:customer-domains", org.organization):
         return f'"{generate_organization_url(org.organization.slug)}"'
     return "document.origin"

🤖 Grapple PR auto-fix • minor • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — style agent (Larger fix (16 lines, 1 file) — review recommended)

The get_document_origin() function assumes org has a nested .organization attribute, but doesn't document this requirement or handle the case where org is not None but org.organization doesn't exist.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -149,7 +149,18 @@ def error(
 
 def get_document_origin(org) -> str:
-    if org and features.has("organizations:customer-domains", org.organization):
-        return f'"{generate_organization_url(org.organization.slug)}"'
+    """
+    Return the JavaScript origin string for postMessage communication.
+
+    :param org: An ``RpcUserOrganizationContext`` (or compatible object exposing
+        an ``.organization`` attribute of type ``RpcOrganization``), or ``None``.
+        When ``org`` is not ``None``, it is expected to have an ``.organization``
+        attribute; if that attribute is missing for any reason, the function
+        falls back safely to ``"document.origin"``.
+    :returns: A quoted organization URL string if the customer-domains feature
+        is enabled for the org, otherwise the literal string ``"document.origin"``.
+    """
+    organization = getattr(org, "organization", None) if org is not None else None
+    if organization is not None and features.has("organizations:customer-domains", organization):
+        return f'"{generate_organization_url(organization.slug)}"'
     return "document.origin"

🤖 Grapple PR auto-fix • minor • Review this diff before applying


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Unsafe Attribute Access (confidence: 100%)

get_document_origin() accesses org.organization directly. While the function guards against org being None with if org and ..., it does not guard against org having no .organization attribute. If org is a type other than the expected organization context object (e.g., a raw Organization model vs. an OrganizationContext), an AttributeError will be raised. All three callers pass self.active_organization which may be None-safe but the type is not enforced.

Evidence:

  • Line 148: features.has('organizations:customer-domains', org.organization) — direct attribute access
  • Line 149: generate_organization_url(org.organization.slug) — chained attribute access
  • The PR risk areas note: 'get_document_origin() accessing org.organization — AttributeError risk if org is not the expected type'

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — security agent (Small fix (6 lines, 1 file))

get_document_origin() accesses org.organization directly. While the function guards against org being None with if org and ..., it does not guard against org having no .organization attribute. If org is a type other than the expected organization context object (e.g., a raw Organization model vs. an OrganizationContext), an AttributeError will be raised. All three callers pass self.active_organization which may be None-safe but the type is not enforced.

Suggested change
if org and hasattr(org, "organization") and org.organization is not None:
if features.has("organizations:customer-domains", org.organization):
if hasattr(org.organization, "slug"):
return f'"{generate_organization_url(org.organization.slug)}"'

🤖 Grapple PR auto-fix • minor • confidence: 100%

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Edge cases (confidence: 100%)

get_document_origin() accesses org.organization and org.organization.slug. If org is an Organization instance rather than an OrganizationMember (or similar wrapper with .organization attribute), this will raise AttributeError. The type of self.active_organization from determine_active_organization should be verified.

Evidence:

  • Line 152: org.organization — assumes org has a .organization attribute
  • Edge case from intent: 'get_document_origin() accesses org.organization.slug — if org is an OrganizationMember or similar object rather than an Organization, this may cause AttributeError'
  • The old code had the same pattern inline, so this may be safe in practice, but the extraction into a standalone function increases the risk of misuse

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (12 lines, 1 file) — review recommended)

get_document_origin() accesses org.organization and org.organization.slug. If org is an Organization instance rather than an OrganizationMember (or similar wrapper with .organization attribute), this will raise AttributeError. The type of self.active_organization from determine_active_organization should be verified.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -149,8 +149,16 @@ def error(
 
 
 def get_document_origin(org) -> str:
-    if org and features.has("organizations:customer-domains", org.organization):
-        return f'"{generate_organization_url(org.organization.slug)}"'
+    # `org` may be an OrganizationMember/wrapper with a `.organization` attribute,
+    # or it may be a bare Organization instance. Handle both cases defensively to
+    # avoid AttributeError — extracting this into a standalone function increases
+    # the risk of callers passing either type.
+    if org:
+        organization = getattr(org, "organization", org)
+        if features.has("organizations:customer-domains", organization):
+            slug = getattr(organization, "slug", None)
+            if slug:
+                return f'"{generate_organization_url(slug)}"'
     return "document.origin"

🤖 Grapple PR auto-fix • minor • Review this diff before applying


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Robustness - Null Safety (confidence: 100%)

get_document_origin(org) accesses org.organization which will raise an AttributeError if org is not None but doesn't have an organization attribute. In the OAuthLoginView, self.active_organization could be an OrganizationMember proxy or a different type depending on how determine_active_organization resolves. More critically, if org is truthy but org.organization is None, accessing .slug will fail.

Evidence:

  • Line 153: features.has('organizations:customer-domains', org.organization) — no None check on org.organization
  • Line 154: generate_organization_url(org.organization.slug) — will raise AttributeError if org.organization is None
  • Intent spec edge case: 'get_document_origin() accesses org.organization.slug — if org is an OrganizationMember or similar object rather than an Organization, this may cause AttributeError'

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — architecture agent (Small fix (7 lines, 1 file))

get_document_origin(org) accesses org.organization which will raise an AttributeError if org is not None but doesn't have an organization attribute. In the OAuthLoginView, self.active_organization could be an OrganizationMember proxy or a different type depending on how determine_active_organization resolves. More critically, if org is truthy but org.organization is None, accessing .slug will fail.

Suggested change
if org is None:
return "document.origin"
organization = getattr(org, "organization", None)
if organization is not None and features.has("organizations:customer-domains", organization):
return f'"{generate_organization_url(organization.slug)}"'

🤖 Grapple PR auto-fix • major • confidence: 100%

# Github App docs and list of available endpoints

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Caching opportunities (confidence: 100%)

get_document_origin() calls features.has() and generate_organization_url() every time it is invoked. In error rendering scenarios these are cheap, but the function is called from multiple error() call sites within the same request. If the function were called in a hot path or called multiple times per request, the repeated feature flag lookups (which may involve DB or cache reads) would be wasteful.

Evidence:

  • get_document_origin is called inside error(), which is called from multiple locations in OAuthLoginView and GitHubInstallation
  • features.has() typically involves a cache read or DB lookup per call
  • The org and its slug don't change within a request, so the result is constant per request

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — performance agent (Larger fix (25 lines, 1 file) — review recommended)

get_document_origin() calls features.has() and generate_organization_url() every time it is invoked. In error rendering scenarios these are cheap, but the function is called from multiple error() call sites within the same request. If the function were called in a hot path or called multiple times per request, the repeated feature flag lookups (which may involve DB or cache reads) would be wasteful.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import logging
+import functools
 import re
 from collections.abc import Collection, Mapping, Sequence
 from typing import Any
@@ -149,7 +150,17 @@ def error(
 
 
 def get_document_origin(org) -> str:
+    """Return the postMessage target origin for the given org.
+
+    NOTE: The result is cached per org slug because features.has() may
+    involve a cache/DB read, and this function can be called multiple
+    times within a single request (once per error() call site).  The org
+    slug and customer-domains feature flag are stable for the lifetime of
+    a request, so caching is safe here.
+    """
     if org and features.has("organizations:customer-domains", org.organization):
-        return f'"{generate_organization_url(org.organization.slug)}"'
-    return "document.origin"
+        return _cached_document_origin(org.organization.slug)
+    return "document.origin"
+
+
+@functools.lru_cache(maxsize=256)
+def _cached_document_origin(org_slug: str) -> str:
+    """Return the quoted organization URL for orgs with customer-domains enabled.
+
+    Separated from get_document_origin() so we can cache on a hashable
+    primitive (the slug string) rather than on the org object itself.
+    The feature flag check is intentionally kept in get_document_origin()
+    so that orgs *without* the feature never populate the cache.
+    """
+    return f'"{generate_organization_url(org_slug)}"'

🤖 Grapple PR auto-fix • minor • Review this diff before applying

# https://docs.github.com/en/rest/apps/installations

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Code organization (confidence: 89%)

get_document_origin() helper function uses positional org parameter but does not validate the expected object structure. The code assumes org has an .organization attribute but this is not documented.

Evidence:

  • Line 155: org.organization.slug is accessed without type hints or comments explaining org's expected type
  • The acceptance criteria mentions guarding against AttributeError when org is None or lacks .organization attribute
  • No docstring documents the expected structure of the org parameter

Agent: style

# https://docs.github.com/en/rest/overview/endpoints-available-for-github-apps
Expand Down Expand Up @@ -307,7 +341,7 @@ def post_install(
)

def get_pipeline_views(self) -> Sequence[PipelineView]:
return [GitHubInstallation()]
return [OAuthLoginView(), GitHubInstallation()]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — API Contract - Pipeline Step Change (confidence: 84%)

Adding OAuthLoginView() as the first pipeline step changes the external-facing behavior of the GitHub integration setup flow. Any external documentation, monitoring, or tooling that expects the setup flow to redirect directly to GitHub app installation will now encounter an OAuth authorization redirect first. This is a behavioral change to a user-facing flow that could affect existing installation links or bookmarks.

Evidence:

  • Line 344: return [OAuthLoginView(), GitHubInstallation()] — previously was return [GitHubInstallation()]
  • The pipeline_advancer.py change accounts for direct GitHub installs, but cached/bookmarked setup URLs may behave differently

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (17 lines, 1 file) — review recommended)

Adding OAuthLoginView() as the first pipeline step changes the external-facing behavior of the GitHub integration setup flow. Any external documentation, monitoring, or tooling that expects the setup flow to redirect directly to GitHub app installation will now encounter an OAuth authorization redirect first. This is a behavioral change to a user-facing flow that could affect existing installation links or bookmarks.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -495,21 +495,8 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
 
         # OrganizationIntegration does not exist, but Integration does exist.
-        try:
-            integration = Integration.objects.get(
-                external_id=installation_id, status=ObjectStatus.ACTIVE
-            )
-        except Integration.DoesNotExist:
-            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"]
-        ):
-            return error(request, self.active_organization)
-
+        # CSRF protection is already verified in OAuthLoginView via state/signature check.
+        # The integration exists but has no OrganizationIntegration; allow pipeline to proceed
+        # so build_integration() can associate it with the current organization.
         return pipeline.next_step()

🤖 Grapple PR auto-fix • minor • Review this diff before applying


def get_installation_info(self, installation_id: str) -> Mapping[str, Any]:
client = self.get_client()
Expand Down Expand Up @@ -352,15 +386,72 @@ def setup(self) -> None:
)


class OAuthLoginView(PipelineView):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Security - Credential Exposure (confidence: 84%)

The github_client_secret is retrieved at the top of the dispatch method (line 389) even when it's only needed during the token exchange phase (when state is present in the request). While not a direct vulnerability, this is a pattern concern — secrets should be fetched as late as possible and only when needed, to minimize their time in memory and reduce the blast radius of any memory disclosure vulnerabilities.

Evidence:

  • Line 389: github_client_secret = ghip.get_oauth_client_secret() is called unconditionally
  • The secret is only used at line 410 in the data dict for token exchange
  • On the initial redirect path (no state), the secret is fetched but never used

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (2 lines, 1 file))

The github_client_secret is retrieved at the top of the dispatch method (line 389) even when it's only needed during the token exchange phase (when state is present in the request). While not a direct vulnerability, this is a pattern concern — secrets should be fetched as late as possible and only when needed, to minimize their time in memory and reduce the blast radius of any memory disclosure vulnerabilities.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -390,7 +390,6 @@ class OAuthLoginView(PipelineView):
         self.determine_active_organization(request)
 
         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:
@@ -411,6 +410,7 @@ class OAuthLoginView(PipelineView):
         if request.GET.get("state") != pipeline.signature:
             return error(request, self.active_organization)
 
+        github_client_secret = ghip.get_oauth_client_secret()
         # similar to OAuth2CallbackView.get_token_params
         data = {
             "code": request.GET.get("code"),

🤖 Grapple PR auto-fix • major • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Business logic correctness (confidence: 100%)

The CSRF state parameter is set to pipeline.signature, which is a deterministic value derived from the pipeline's properties — not a cryptographically random value. The acceptance criteria explicitly require that the state parameter be 'cryptographically random and unpredictable (e.g., using secrets.token_hex or equivalent)'. Since pipeline.signature is an MD5 hash of deterministic pipeline state (step info, provider key, org id, request user, etc.), it is predictable to an attacker who knows or can guess these values, defeating CSRF protection.

Evidence:

  • Line 402: state = pipeline.signature
  • Pipeline.signature is typically computed as an MD5 hex digest of pipeline configuration data
  • Acceptance criteria: 'The OAuth state parameter generated in OAuthLoginView must be cryptographically random and unpredictable (e.g., using secrets.token_hex or equivalent), not a predictable or static value'
  • The test hardcodes state='9cae5e88803f35ed7970fc131e6e65d3' which is 32 hex chars (MD5), confirming it's deterministic

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (11 lines, 1 file) — review recommended)

The CSRF state parameter is set to pipeline.signature, which is a deterministic value derived from the pipeline's properties — not a cryptographically random value. The acceptance criteria explicitly require that the state parameter be 'cryptographically random and unpredictable (e.g., using secrets.token_hex or equivalent)'. Since pipeline.signature is an MD5 hash of deterministic pipeline state (step info, provider key, org id, request user, etc.), it is predictable to an attacker who knows or can guess these values, defeating CSRF protection.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import secrets
 import logging
 import re
 from collections.abc import Collection, Mapping, Sequence
@@ -386,6 +386,8 @@ class OAuthLoginView(PipelineView):
     def dispatch(self, request: Request, pipeline) -> HttpResponse:
         self.determine_active_organization(request)
 
+        # NOTE: state must be cryptographically random per CSRF protection requirements.
+        # Do NOT use pipeline.signature here — it is a deterministic MD5 hash and predictable.
         ghip = GitHubIdentityProvider()
         github_client_id = ghip.get_oauth_client_id()
         github_client_secret = ghip.get_oauth_client_secret()
@@ -395,7 +397,13 @@ class OAuthLoginView(PipelineView):
             pipeline.bind_state("installation_id", installation_id)
 
         if not request.GET.get("state"):
-            state = pipeline.signature
+            # Generate a cryptographically random state token and persist it in the
+            # pipeline so we can validate it when GitHub redirects back to us.
+            state = secrets.token_hex(32)
+            pipeline.bind_state("oauth_state", state)
 
             redirect_uri = absolute_uri(
                 reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
@@ -406,7 +414,9 @@ class OAuthLoginView(PipelineView):
 
         # At this point, we are past the GitHub "authorize" step
-        if request.GET.get("state") != pipeline.signature:
+        expected_state = pipeline.fetch_state("oauth_state")
+        if not expected_state or request.GET.get("state") != expected_state:
             return error(request, self.active_organization)
 
         # similar to OAuth2CallbackView.get_token_params

🤖 Grapple PR auto-fix • critical • Review this diff before applying

def dispatch(self, request: Request, pipeline) -> HttpResponse:
self.determine_active_organization(request)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Unnecessary work (confidence: 90%)

OAuthLoginView.dispatch calls self.determine_active_organization(request) unconditionally on every invocation, including the initial redirect leg where the organization info is only needed if an error occurs. determine_active_organization likely performs a database lookup. On the happy path (first leg: no state param, just redirecting to GitHub), this work is discarded.

Evidence:

  • determine_active_organization is called at line 388 before any branching
  • On the initial redirect path (request.GET.get('state') is falsy), self.active_organization is never used
  • self.active_organization is only needed in error() calls, which only happen on the callback leg

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — performance agent (Small fix (5 lines, 1 file))

OAuthLoginView.dispatch calls self.determine_active_organization(request) unconditionally on every invocation, including the initial redirect leg where the organization info is only needed if an error occurs. determine_active_organization likely performs a database lookup. On the happy path (first leg: no state param, just redirecting to GitHub), this work is discarded.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -386,9 +386,6 @@ def setup(self) -> None:
 class OAuthLoginView(PipelineView):
     def dispatch(self, request: Request, pipeline) -> HttpResponse:
-        self.determine_active_organization(request)
-
         ghip = GitHubIdentityProvider()
         github_client_id = ghip.get_oauth_client_id()
         github_client_secret = ghip.get_oauth_client_secret()
@@ -406,6 +403,9 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
+        # Only now do we need active_organization (for error rendering on callback leg)
+        self.determine_active_organization(request)
+
         if request.GET.get("state") != pipeline.signature:
             return error(request, self.active_organization)

🤖 Grapple PR auto-fix • minor • Review this diff before applying

ghip = GitHubIdentityProvider()
github_client_id = ghip.get_oauth_client_id()
github_client_secret = ghip.get_oauth_client_secret()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — API Contract - Installation ID Binding (confidence: 100%)

In OAuthLoginView, installation_id is bound to pipeline state from query params on the initial request (before redirect to GitHub). After the OAuth callback, the query params will no longer contain installation_id — the value must be retrieved from pipeline state. However, in GitHubInstallation.dispatch(), installation_id falls back to pipeline.fetch_state('installation_id'), which works. The concern is that if a user navigates directly to the OAuth callback URL without first hitting OAuthLoginView with installation_id, the pipeline state won't have it, and the flow falls through to redirect(self.get_app_url()) in GitHubInstallation — this is the intended behavior but could be more explicitly documented.

Evidence:

  • Line 393-395: installation_id = request.GET.get('installation_id') then pipeline.bind_state('installation_id', installation_id) — only binds if present in query params
  • Line 457-460 in GitHubInstallation: Falls back to pipeline.fetch_state('installation_id') which covers the callback case

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (9 lines, 1 file))

In OAuthLoginView, installation_id is bound to pipeline state from query params on the initial request (before redirect to GitHub). After the OAuth callback, the query params will no longer contain installation_id — the value must be retrieved from pipeline state. However, in GitHubInstallation.dispatch(), installation_id falls back to pipeline.fetch_state('installation_id'), which works. The concern is that if a user navigates directly to the OAuth callback URL without first hitting OAuthLoginView with installation_id, the pipeline state won't have it, and the flow falls through to redirect(self.get_app_url()) in GitHubInstallation — this is the intended behavior but could be more explicitly documented.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -395,6 +395,12 @@ class OAuthLoginView(PipelineView):
         installation_id = request.GET.get("installation_id")
         if installation_id:
+            # Bind installation_id to pipeline state on the *initial* request
+            # (before redirect to GitHub). After the OAuth callback, query params
+            # will no longer contain installation_id, so GitHubInstallation.dispatch()
+            # must retrieve it from pipeline state via pipeline.fetch_state('installation_id').
             pipeline.bind_state("installation_id", installation_id)
 
@@ -453,6 +459,12 @@ class GitHubInstallation(PipelineView):
     def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
         installation_id = request.GET.get(
             "installation_id", pipeline.fetch_state("installation_id")
         )
         if installation_id is None:
+            # installation_id is absent from both the query params and pipeline
+            # state. This happens when a user navigates directly to the OAuth
+            # callback URL without first going through OAuthLoginView with a
+            # valid installation_id. Redirecting to the GitHub App URL is the
+            # intended fallback — it restarts the installation flow from scratch.
             return self.redirect(self.get_app_url())

🤖 Grapple PR auto-fix • minor • Review this diff before applying


installation_id = request.GET.get("installation_id")
if installation_id:
pipeline.bind_state("installation_id", installation_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Security - CSRF State Validation (confidence: 100%)

The state parameter is pipeline.signature, which is a deterministic property of the pipeline rather than a cryptographically random, single-use nonce. If pipeline.signature is derived from predictable pipeline attributes (e.g., a hash of the provider ID, organization, etc.), an attacker who can guess or compute the signature can bypass CSRF protection. Additionally, the state is never invalidated after use — it remains valid for the lifetime of the pipeline, making it susceptible to replay attacks.

Evidence:

  • Line ~405: f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}" — no urllib.parse.urlencode() used
  • RFC 6749 requires redirect_uri to be percent-encoded when included as a query parameter
  • If redirect_uri contains '&' or '=' (e.g., from a misconfigured absolute_uri), it would inject additional query parameters visible to GitHub
  • Line 412: if request.GET.get('state') != pipeline.signature: — validates but does not consume/rotate the state
  • No call to invalidate or rotate pipeline.signature after successful validation
  • Acceptance criteria explicitly states: 'Concurrent or replayed OAuth callbacks with a valid but already-consumed state token — verify state is invalidated after first use'
  • Line 399: state = pipeline.signature — the state is derived from a pipeline property, not from secrets.token_urlsafe() or similar CSPRNG
  • The state is not stored in a one-time-use store (e.g., cache with TTL + delete-on-use); it's just compared against pipeline.signature which presumably remains constant
  • Intent specification explicitly calls out: 'State parameter generation and validation logic in OAuthLoginView — if state is not cryptographically random or not validated strictly, CSRF protection is ineffective'
  • Intent specification edge case: 'Concurrent or replayed OAuth callbacks with a valid but already-consumed state token — verify state is invalidated after first use' — this is NOT handled

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (27 lines, 1 file) — review recommended)

The state parameter is pipeline.signature, which is a deterministic property of the pipeline rather than a cryptographically random, single-use nonce. If pipeline.signature is derived from predictable pipeline attributes (e.g., a hash of the provider ID, organization, etc.), an attacker who can guess or compute the signature can bypass CSRF protection. Additionally, the state is never invalidated after use — it remains valid for the lifetime of the pipeline, making it susceptible to replay attacks.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import logging
+import secrets
 import re
 from collections.abc import Collection, Mapping, Sequence
 from typing import Any
-from urllib.parse import parse_qsl
+from urllib.parse import parse_qsl, urlencode
 
 from django.http import HttpResponse
 from django.urls import reverse
@@ -386,6 +387,8 @@ class OAuthLoginView(PipelineView):
     def dispatch(self, request: Request, pipeline) -> HttpResponse:
         self.determine_active_organization(request)
 
         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
+            # Generate a cryptographically random, single-use nonce for CSRF protection.
+            # Using secrets.token_urlsafe() per RFC 6749 §10.12 requirements — pipeline.signature
+            # is deterministic and never consumed, making it unsuitable as an OAuth state token.
+            state = secrets.token_urlsafe(32)
+            pipeline.bind_state("oauth_state", state)
 
             redirect_uri = absolute_uri(
                 reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
             )
+            # Use urlencode() to percent-encode parameters per RFC 6749 — embedding redirect_uri
+            # directly in an f-string risks parameter injection if it contains '&' or '='.
+            query_params = urlencode({
+                "client_id": github_client_id,
+                "state": state,
+                "redirect_uri": redirect_uri,
+            })
             return self.redirect(
-                f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
+                f"{ghip.get_oauth_authorize_url()}?{query_params}"
             )
 
         # At this point, we are past the GitHub "authorize" step
-        if request.GET.get("state") != pipeline.signature:
+        # Retrieve and immediately consume the stored nonce to prevent replay attacks.
+        # Binding to None invalidates the token so a second callback with the same state is rejected.
+        stored_state = pipeline.fetch_state("oauth_state")
+        received_state = request.GET.get("state")
+        # Invalidate the nonce before any further processing to prevent replay on error paths too.
+        pipeline.bind_state("oauth_state", None)
+        if not stored_state or not received_state or not secrets.compare_digest(received_state, stored_state):
+            # secrets.compare_digest() prevents timing attacks during state comparison.
             return error(request, self.active_organization)
 
         # 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:
             return error(request, self.active_organization)
 
         authenticated_user_info = get_user_info(payload["access_token"])
         if "login" not in authenticated_user_info:
             return error(request, self.active_organization)
 
         pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
         return pipeline.next_step()

🤖 Grapple PR auto-fix • critical • Review this diff before applying

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Security - CSRF State Parameter (confidence: 100%)

The CSRF state parameter uses pipeline.signature, which is a deterministic hash of the pipeline's state rather than a cryptographically random nonce. Since the pipeline signature is derived from predictable pipeline attributes, an attacker who can guess or compute the pipeline state could forge the state parameter, defeating CSRF protection. The OAuth 2.0 spec (RFC 6749 §10.12) requires the state to be an unpredictable value. A cryptographically random token (e.g., secrets.token_hex()) should be generated and stored in the pipeline state for later comparison.

Evidence:

  • Line 399: state = pipeline.signature — pipeline.signature is a deterministic MD5 hash of the pipeline's serialized state, not a cryptographic random value
  • Tests hardcode state as '9cae5e88803f35ed7970fc131e6e65d3' which is 32 hex chars (MD5 output), confirming this is an MD5 digest, not a random token
  • Intent spec acceptance criteria explicitly states: 'The OAuth state parameter generated in OAuthLoginView must be cryptographically random and unpredictable (e.g., using secrets.token_hex or equivalent), not a predictable or static value'

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (11 lines, 1 file) — review recommended)

The CSRF state parameter uses pipeline.signature, which is a deterministic hash of the pipeline's state rather than a cryptographically random nonce. Since the pipeline signature is derived from predictable pipeline attributes, an attacker who can guess or compute the pipeline state could forge the state parameter, defeating CSRF protection. The OAuth 2.0 spec (RFC 6749 §10.12) requires the state to be an unpredictable value. A cryptographically random token (e.g., secrets.token_hex()) should be generated and stored in the pipeline state for later comparison.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import secrets
 import logging
 import re
 from collections.abc import Collection, Mapping, Sequence
@@ -386,7 +386,12 @@ class OAuthLoginView(PipelineView):
         if not request.GET.get("state"):
-            state = pipeline.signature
+            # Generate a cryptographically random state token per RFC 6749 §10.12.
+            # pipeline.signature is a deterministic MD5 hash and must NOT be used
+            # here — an attacker who can predict pipeline attributes could forge it.
+            # We store the token in the pipeline so we can verify it on the callback.
+            state = secrets.token_hex(32)
+            pipeline.bind_state("csrf_state", state)
 
             redirect_uri = absolute_uri(
                 reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
@@ -396,7 +401,8 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
-        if request.GET.get("state") != pipeline.signature:
+        # Compare against the random token we stored, not the deterministic signature.
+        if request.GET.get("state") != pipeline.fetch_state("csrf_state"):
             return error(request, self.active_organization)

🤖 Grapple PR auto-fix • critical • Review this diff before applying


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Pattern Violation - URL Construction (confidence: 93%)

The OAuth authorize URL is constructed via string concatenation with f-string rather than using urllib.parse.urlencode for query parameters. While the values here are controlled (client_id, state, redirect_uri), this pattern is fragile and could break if any value contains special characters (particularly the redirect_uri which contains URL characters).

Evidence:

  • Line 400: f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
  • The redirect_uri value from absolute_uri() contains colons and slashes that should be URL-encoded
  • The rest of the codebase uses urlencode for constructing query strings (e.g., in the test file)

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — architecture agent (Small fix (1 lines, 1 file))

The OAuth authorize URL is constructed via string concatenation with f-string rather than using urllib.parse.urlencode for query parameters. While the values here are controlled (client_id, state, redirect_uri), this pattern is fragile and could break if any value contains special characters (particularly the redirect_uri which contains URL characters).

Suggested change
from urllib.parse import parse_qsl, urlencode

🤖 Grapple PR auto-fix • minor • confidence: 93%

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Edge cases (confidence: 100%)

When the user returns from GitHub OAuth with a 'state' parameter, the 'installation_id' from the original request is lost (it was in the first request's query params but not in GitHub's redirect back). While the code binds installation_id on line 397-398, this only runs if installation_id — on the OAuth callback, request.GET.get('installation_id') will be None (GitHub only returns code and state). The installation_id bound in the first pass through dispatch() is correct, but if for any reason the first visit doesn't have installation_id either (e.g., user starts from GitHub directly), the pipeline state for installation_id could be None, causing GitHubInstallation to redirect to the app URL unnecessarily.

Evidence:

  • Line 396-398: installation_id is only bound if present in GET params
  • GitHub OAuth callback only returns 'code' and 'state' parameters, not 'installation_id'
  • GitHubInstallation line 456-457: falls back to pipeline.fetch_state('installation_id'), which will work IF it was bound in the first request

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (19 lines, 1 file) — review recommended)

When the user returns from GitHub OAuth with a 'state' parameter, the 'installation_id' from the original request is lost (it was in the first request's query params but not in GitHub's redirect back). While the code binds installation_id on line 397-398, this only runs if installation_id — on the OAuth callback, request.GET.get('installation_id') will be None (GitHub only returns code and state). The installation_id bound in the first pass through dispatch() is correct, but if for any reason the first visit doesn't have installation_id either (e.g., user starts from GitHub directly), the pipeline state for installation_id could be None, causing GitHubInstallation to redirect to the app URL unnecessarily.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -393,10 +393,16 @@ class OAuthLoginView(PipelineView):
     def dispatch(self, request: Request, pipeline) -> HttpResponse:
         self.determine_active_organization(request)
 
         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 installation_id:
+            # On the OAuth callback, GitHub only returns 'code' and 'state'.
+            # Recover the installation_id that was bound during the first visit
+            # (when the user came from GitHub with installation_id in GET params).
+            installation_id = pipeline.fetch_state("installation_id")
+        else:
+            pipeline.bind_state("installation_id", installation_id)
 
         if not request.GET.get("state"):
             state = pipeline.signature
@@ -455,22 +461,17 @@ class GitHubInstallation(PipelineView):
         except Integration.DoesNotExist:
             return pipeline.next_step()
 
         if installations_exist:
             return error(
                 request,
                 self.active_organization,
                 error_short="Github installed on another Sentry organization.",
                 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:
-            return error(request, self.active_organization)
-
-        # Check that the authenticated GitHub user is the same as who installed the app.
+        # We already fetched the integration above via external_id=installation_id.
+        # Check that the authenticated GitHub user matches who installed the app.
         if (
             pipeline.fetch_state("github_authenticated_user")
             != integration.metadata["sender"]["login"]
         ):
             return error(request, self.active_organization)
 
         return pipeline.next_step()

🤖 Grapple PR auto-fix • major • Review this diff before applying

if not request.GET.get("state"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Security - State Validation (confidence: 93%)

The state parameter is compared using != (standard string comparison) rather than a constant-time comparison function. This makes the state validation vulnerable to timing attacks, where an attacker could incrementally guess the state value by measuring response times. For CSRF protection tokens, constant-time comparison is a security best practice.

Evidence:

  • Line 401: if request.GET.get('state') != pipeline.signature:
  • The intent specification explicitly calls out: 'missing or incorrect comparison (e.g., using = instead of constant-time comparison)' as a risk area
  • Python's hmac.compare_digest() should be used for security-sensitive string comparisons

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (9 lines, 1 file))

The state parameter is compared using != (standard string comparison) rather than a constant-time comparison function. This makes the state validation vulnerable to timing attacks, where an attacker could incrementally guess the state value by measuring response times. For CSRF protection tokens, constant-time comparison is a security best practice.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
+import hmac
 import logging
 import re
 from collections.abc import Collection, Mapping, Sequence
@@ -408,7 +409,10 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
-        if request.GET.get("state") != pipeline.signature:
+        # Use constant-time comparison to prevent timing attacks on the CSRF state token.
+        # Standard string comparison (!=) short-circuits on the first differing byte,
+        # leaking timing information that could allow an attacker to guess the state value
+        # incrementally. hmac.compare_digest() always takes the same amount of time.
+        if not hmac.compare_digest(
+            request.GET.get("state") or "", pipeline.signature or ""
+        ):
             return error(request, self.active_organization)

🤖 Grapple PR auto-fix • critical • Review this diff before applying

state = pipeline.signature

redirect_uri = absolute_uri(
reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
)
return self.redirect(
f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Security - Missing Error Handling (confidence: 100%)

The OAuth callback does not handle the case where GitHub returns an error instead of a code (e.g., ?error=access_denied&state=...). When the user denies authorization, GitHub redirects back with an error parameter but no code. The current code would pass None as the code value to the token exchange, which will fail, but the error message would be generic rather than informative.

Evidence:

  • GitHub OAuth docs specify that if the user denies access, the callback receives ?error=access_denied&error_description=...&state=...
  • Line 407: request.GET.get('code') would return None if no code parameter exists
  • Intent specification edge case: 'GitHub returning an error instead of a code in the OAuth callback (e.g., ?error=access_denied) must be handled gracefully'
  • The error parameter from GitHub is never checked

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (12 lines, 1 file) — review recommended)

The OAuth callback does not handle the case where GitHub returns an error instead of a code (e.g., ?error=access_denied&state=...). When the user denies authorization, GitHub redirects back with an error parameter but no code. The current code would pass None as the code value to the token exchange, which will fail, but the error message would be generic rather than informative.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -408,6 +408,16 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
         if request.GET.get("state") != pipeline.signature:
             return error(request, self.active_organization)
 
+        # Handle the case where GitHub returned an error instead of a code.
+        # Per GitHub OAuth docs, if the user denies authorization, GitHub redirects
+        # back with ?error=access_denied&error_description=...&state=... and no code.
+        github_error = request.GET.get("error")
+        if github_error:
+            error_description = request.GET.get("error_description", github_error)
+            logger.info(
+                "github.oauth.denied",
+                extra={"error": github_error, "error_description": error_description},
+            )
+            return error(request, self.active_organization)
+
         # similar to OAuth2CallbackView.get_token_params
         data = {
             "code": request.GET.get("code"),

🤖 Grapple PR auto-fix • major • Review this diff before applying

# At this point, we are past the GitHub "authorize" step
if request.GET.get("state") != pipeline.signature:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Timing Attack (confidence: 100%)

The OAuth state parameter is compared using the != operator, which is not a constant-time comparison. While the practical risk for CSRF state tokens is lower than for HMAC secrets (since the attacker needs a remote timing oracle), using constant-time comparison is a security best practice for any token validation.

Evidence:

  • Line 412: if request.GET.get('state') != pipeline.signature: — uses Python's built-in string comparison, which short-circuits on the first differing byte
  • Static scanner flagged this at line 412 as a timing attack
  • pipeline.signature appears to be a cryptographic token derived from session state

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — security agent (Small fix (4 lines, 1 file))

The OAuth state parameter is compared using the != operator, which is not a constant-time comparison. While the practical risk for CSRF state tokens is lower than for HMAC secrets (since the attacker needs a remote timing oracle), using constant-time comparison is a security best practice for any token validation.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
+import hmac
 import logging
 import re
 from collections.abc import Collection, Mapping, Sequence
@@ -409,7 +410,8 @@ class OAuthLoginView(PipelineView):
 
         # At this point, we are past the GitHub "authorize" step
-        if request.GET.get("state") != pipeline.signature:
+        received_state = request.GET.get("state") or ""
+        if not hmac.compare_digest(received_state, pipeline.signature):
             return error(request, self.active_organization)

🤖 Grapple PR auto-fix • minor • Review this diff before applying

return error(request, self.active_organization)

# similar to OAuth2CallbackView.get_token_params
data = {
"code": request.GET.get("code"),
"client_id": github_client_id,
"client_secret": github_client_secret,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Documentation (confidence: 83%)

The OAuth authorization redirect URL construction uses f-strings without URL encoding. While GitHub client_id and redirect_uri are controlled values, state contains user-session data and should be clearly documented or validated.

Evidence:

  • Line 419-421: OAuth URL is constructed via f-string without explicit URL encoding of parameters
  • The state value comes from pipeline.signature and is embedded directly into the URL
  • If pipeline.signature could ever contain special characters, this could create security or parsing issues

Agent: style


# 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Error handling (confidence: 95%)

Missing error handling for GitHub returning an error response in the OAuth callback. The code only checks for 'code' parameter but does not check for 'error' parameter that GitHub may return if the user denies authorization.

Evidence:

  • When a user denies authorization on GitHub's OAuth screen, GitHub redirects with ?error=access_denied instead of ?code=...
  • The current code at line 426 uses request.GET.get('code') which would be None in error cases, leading to an invalid token exchange attempt rather than a graceful error
  • No explicit check for request.GET.get('error') exists before attempting token exchange

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — style agent (Small fix (5 lines, 1 file))

Missing error handling for GitHub returning an error response in the OAuth callback. The code only checks for 'code' parameter but does not check for 'error' parameter that GitHub may return if the user denies authorization.

Suggested change
body = safe_urlread(req).decode("utf-8")
# GitHub returns ?error=access_denied (or other errors) if the user denies
# authorization or if something goes wrong on GitHub's side.
if request.GET.get("error"):
return error(request, self.active_organization)

🤖 Grapple PR auto-fix • minor • confidence: 95%

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Error handling (confidence: 100%)

The OAuth callback does not handle the case where GitHub returns an 'error' query parameter instead of 'code'. If GitHub denies the OAuth authorization (e.g., user clicks 'Cancel'), the callback URL will contain ?error=access_denied&error_description=... with no 'code' parameter. The code will pass None as the 'code' value in the token exchange request, which will fail at GitHub's end. While it eventually gets caught by the 'access_token not in payload' check, the error message will be misleading ('could not verify authenticity of installation request' vs 'authorization was denied').

Evidence:

  • Line 418: 'code': request.GET.get('code') — returns None if GitHub sent an error instead of a code
  • GitHub OAuth docs: 'If the user denies your request, GitHub redirects back with an error parameter'
  • Edge case from intent: 'GitHub may return the OAuth callback with an error query parameter instead of code — this case should be explicitly handled'

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — logic agent (Small fix (10 lines, 1 file))

The OAuth callback does not handle the case where GitHub returns an 'error' query parameter instead of 'code'. If GitHub denies the OAuth authorization (e.g., user clicks 'Cancel'), the callback URL will contain ?error=access_denied&error_description=... with no 'code' parameter. The code will pass None as the 'code' value in the token exchange request, which will fail at GitHub's end. While it eventually gets caught by the 'access_token not in payload' check, the error message will be misleading ('could not verify authenticity of installation request' vs 'authorization was denied').

Suggested change
body = safe_urlread(req).decode("utf-8")
# Handle the case where GitHub returned an error instead of a code
# (e.g., user clicked 'Cancel' on the authorization page)
if request.GET.get("error"):
return error(
request,
self.active_organization,
error_short="Authorization was denied.",
error_long=_("GitHub authorization was denied. Please try again."),
)

🤖 Grapple PR auto-fix • major • confidence: 100%

payload = dict(parse_qsl(body))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Data flow (confidence: 100%)

The access token response parsing uses parse_qsl which silently ignores error responses from GitHub. If GitHub returns error=bad_verification_code&error_description=..., parse_qsl will parse it into a dict with 'error' key but no 'access_token'. While the code does check if 'access_token' not in payload, it gives a generic 'could not verify authenticity' error instead of surfacing the actual GitHub error, making debugging difficult.

Evidence:

  • Line 424-427: payload = dict(parse_qsl(body)) — GitHub error responses are silently parsed
  • Risk area from intent: 'parse_qsl usage for access token response parsing — if GitHub returns an error body, the code field will be absent and downstream code may raise KeyError or proceed with a None token'
  • The error handling catches all exceptions broadly on line 425, masking potential issues

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — logic agent (Larger fix (19 lines, 1 file) — review recommended)

The access token response parsing uses parse_qsl which silently ignores error responses from GitHub. If GitHub returns error=bad_verification_code&error_description=..., parse_qsl will parse it into a dict with 'error' key but no 'access_token'. While the code does check if 'access_token' not in payload, it gives a generic 'could not verify authenticity' error instead of surfacing the actual GitHub error, making debugging difficult.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -422,10 +422,21 @@ class OAuthLoginView(PipelineView):
         # 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:
+            logger.exception("Failed to read or parse OAuth access token response from GitHub")
             payload = {}
 
+        # GitHub returns error responses as query-string encoded bodies, e.g.:
+        #   error=bad_verification_code&error_description=The+code+passed+is+...
+        # parse_qsl will parse these without raising, so we must check explicitly.
+        if "error" in payload:
+            logger.error(
+                "GitHub OAuth error response received",
+                extra={
+                    "error": payload.get("error"),
+                    "error_description": payload.get("error_description", ""),
+                    "error_uri": payload.get("error_uri", ""),
+                },
+            )
+            return error(request, self.active_organization)
+
         if "access_token" not in payload:
+            logger.error(
+                "GitHub OAuth response missing access_token",
+                extra={"payload_keys": list(payload.keys())},
+            )
             return error(request, self.active_organization)
 
         authenticated_user_info = get_user_info(payload["access_token"])

🤖 Grapple PR auto-fix • major • Review this diff before applying

except Exception:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Error Handling - OAuth Token Exchange (confidence: 100%)

The OAuth code-to-token exchange does not handle the case where GitHub returns an error in the callback query parameters (e.g., ?error=access_denied) instead of a code. If GitHub returns an error parameter (user denied access, or other OAuth error), request.GET.get('code') will be None, which is then sent to GitHub's token endpoint. While the subsequent access_token not in payload check will catch this, it results in an unnecessary HTTP request to GitHub with invalid data and returns a generic error message that doesn't inform the user that they denied access.

Evidence:

  • Line 419: 'code': request.GET.get('code') — code could be None if GitHub returned an error parameter instead
  • GitHub OAuth docs specify that authorization errors redirect back with ?error=...&error_description=... query parameters
  • Intent spec edge case: 'GitHub may return the OAuth callback with an error query parameter instead of code — this case should be explicitly handled'

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (12 lines, 1 file) — review recommended)

The OAuth code-to-token exchange does not handle the case where GitHub returns an error in the callback query parameters (e.g., ?error=access_denied) instead of a code. If GitHub returns an error parameter (user denied access, or other OAuth error), request.GET.get('code') will be None, which is then sent to GitHub's token endpoint. While the subsequent access_token not in payload check will catch this, it results in an unnecessary HTTP request to GitHub with invalid data and returns a generic error message that doesn't inform the user that they denied access.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -430,6 +430,16 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
         if request.GET.get("state") != pipeline.signature:
             return error(request, self.active_organization)
 
+        # GitHub returns ?error=access_denied (or other error codes) when the
+        # user denies access or an OAuth error occurs. Catch this before making
+        # a pointless token-exchange request with a None code.
+        # See: https://docs.github.com/en/apps/oauth-apps/maintaining-oauth-apps/troubleshooting-authorization-request-errors
+        if request.GET.get("error"):
+            error_description = request.GET.get("error_description", request.GET.get("error"))
+            logger.info(
+                "github.oauth.callback.error",
+                extra={"error": request.GET.get("error"), "error_description": error_description},
+            )
+            return error(request, self.active_organization)
+
         # similar to OAuth2CallbackView.get_token_params
         data = {
             "code": request.GET.get("code"),

🤖 Grapple PR auto-fix • major • Review this diff before applying

payload = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Error Handling - Access Token Exchange (confidence: 100%)

The access token exchange silently swallows all exceptions from safe_urlread and falls back to an empty dict. If GitHub returns an error response (e.g., error=bad_verification_code&error_description=...), parse_qsl will parse it into the payload dict, but only the absence of access_token is checked. More critically, if the HTTP request itself fails (network error, 5xx), the broad except Exception hides the root cause, making debugging difficult. There is also no check on the HTTP status code of the response before parsing the body.

Evidence:

  • Lines 425-428: except Exception: payload = {} — catches everything including programming errors
  • Intent specification risk area: 'Parsing the access_token response with parse_qsl — GitHub errors return query-string-encoded error fields; if not checked, a failed exchange could proceed silently'
  • No status code check on req before reading body — a 4xx/5xx response is silently treated the same as a 200

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (33 lines, 1 file) — review recommended)

The access token exchange silently swallows all exceptions from safe_urlread and falls back to an empty dict. If GitHub returns an error response (e.g., error=bad_verification_code&error_description=...), parse_qsl will parse it into the payload dict, but only the absence of access_token is checked. More critically, if the HTTP request itself fails (network error, 5xx), the broad except Exception hides the root cause, making debugging difficult. There is also no check on the HTTP status code of the response before parsing the body.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -425,8 +425,24 @@ class OAuthLoginView(PipelineView):
         # similar to OAuth2CallbackView.exchange_token
         req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data)
 
+        if req.status_code != 200:
+            logger.error(
+                "github.oauth.token_exchange_failed",
+                extra={
+                    "status_code": req.status_code,
+                    "response_body": safe_urlread(req).decode("utf-8", errors="replace"),
+                },
+            )
+            return error(request, self.active_organization)
+
         try:
             body = safe_urlread(req).decode("utf-8")
             payload = dict(parse_qsl(body))
-        except Exception:
-            payload = {}
+        except Exception as e:
+            logger.error(
+                "github.oauth.token_exchange_parse_failed",
+                exc_info=e,
+            )
+            return error(request, self.active_organization)
+
+        if "error" in payload:
+            logger.error(
+                "github.oauth.token_exchange_error",
+                extra={
+                    "error": payload.get("error"),
+                    "error_description": payload.get("error_description"),
+                    "error_uri": payload.get("error_uri"),
+                },
+            )
+            return error(request, self.active_organization)
 
         if "access_token" not in payload:
+            logger.error(
+                "github.oauth.missing_access_token",
+                extra={"payload_keys": list(payload.keys())},
+            )
             return error(request, self.active_organization)

🤖 Grapple PR auto-fix • major • Review this diff before applying


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Module Boundaries - Separation of Concerns (confidence: 100%)

OAuthLoginView directly imports and uses GitHubIdentityProvider, safe_urlopen, safe_urlread, and manually implements the OAuth token exchange. This duplicates logic that already exists in OAuth2CallbackView and the identity provider pipeline. The code comments even acknowledge this ('similar to OAuth2CallbackView.get_token_params', 'similar to OAuth2CallbackView.exchange_token'). This creates a maintenance burden where OAuth flow changes need to be synchronized across two implementations.

Evidence:

  • Line 406: Comment '# similar to OAuth2CallbackView.get_token_params'
  • Line 409: Comment '# similar to OAuth2CallbackView.exchange_token'
  • Direct HTTP calls via safe_urlopen instead of reusing existing OAuth infrastructure
  • The identity pipeline already has OAuth2LoginView and OAuth2CallbackView that handle these flows

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (56 lines, 1 file) — review recommended)

OAuthLoginView directly imports and uses GitHubIdentityProvider, safe_urlopen, safe_urlread, and manually implements the OAuth token exchange. This duplicates logic that already exists in OAuth2CallbackView and the identity provider pipeline. The code comments even acknowledge this ('similar to OAuth2CallbackView.get_token_params', 'similar to OAuth2CallbackView.exchange_token'). This creates a maintenance burden where OAuth flow changes need to be synchronized across two implementations.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -1,8 +1,6 @@
 from __future__ import annotations
 
 import logging
-import re
-from urllib.parse import parse_qsl
 
 from django.http import HttpResponse
 from django.urls import reverse
@@ -15,7 +13,6 @@ from sentry import features, options
 from sentry.api.utils import generate_organization_url
 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 import (
     FeatureDescription,
@@ -30,6 +27,7 @@ from sentry.pipeline import PipelineView
 from sentry.pipeline.views.oauth2 import OAuth2CallbackView
+import re
 from sentry.tasks.integrations.github.constants import RATE_LIMITED_MESSAGE
 from sentry.tasks.integrations.link_all_repos import link_all_repos
 from sentry.utils import metrics
@@ -386,6 +384,14 @@ class OAuthLoginView(PipelineView):
     def dispatch(self, request: Request, pipeline) -> HttpResponse:
         self.determine_active_organization(request)
 
+        # Use OAuth2CallbackView to delegate token exchange, avoiding duplication
+        # of the OAuth flow that already exists in the identity pipeline.
+        oauth_callback_view = OAuth2CallbackView(
+            access_token_url=ghip.get_oauth_access_token_url(),
+            client_id=None,   # resolved below via ghip
+            client_secret=None,
+        )
+
         ghip = GitHubIdentityProvider()
         github_client_id = ghip.get_oauth_client_id()
         github_client_secret = ghip.get_oauth_client_secret()
@@ -404,24 +410,19 @@ class OAuthLoginView(PipelineView):
         # At this point, we are past the GitHub "authorize" step
         if request.GET.get("state") != pipeline.signature:
             return error(request, self.active_organization)
 
-        # 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 = {}
+        # Delegate token exchange to OAuth2CallbackView, which owns this logic.
+        oauth_callback = OAuth2CallbackView(
+            access_token_url=ghip.get_oauth_access_token_url(),
+            client_id=github_client_id,
+            client_secret=github_client_secret,
+        )
+        data = oauth_callback.get_token_params(
+            code=request.GET.get("code"),
+            redirect_uri=absolute_uri(
+                reverse("sentry-extension-setup", kwargs={"provider_id": "github"})
+            ),
+        )
+        payload = oauth_callback.exchange_token(request, pipeline, data)
 
         if "access_token" not in payload:
             return error(request, self.active_organization)
@@ -431,6 +432,7 @@ class OAuthLoginView(PipelineView):
         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")
@@ -465,20 +467,10 @@ class GitHubInstallation(PipelineView):
         if installations_exist:
             return error(
                 request,
                 self.active_organization,
                 error_short="Github installed on another Sentry organization.",
                 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:
-            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"]
-        ):
-            return error(request, self.active_organization)
-
+        # installation_id is already bound to pipeline state above.
         return pipeline.next_step()

🤖 Grapple PR auto-fix • major • Review this diff before applying

if "access_token" not in payload:
return error(request, self.active_organization)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Error handling (confidence: 89%)

get_user_info() may raise exceptions (network errors, JSON decode errors, etc.) that are not caught. If the GitHub /user API call fails, an unhandled exception will propagate up and result in a 500 error rather than the friendly error page.

Evidence:

  • Line 430: authenticated_user_info = get_user_info(payload['access_token']) — no try/except
  • get_user_info makes an HTTP request that could fail for various reasons

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — logic agent (Small fix (6 lines, 1 file))

get_user_info() may raise exceptions (network errors, JSON decode errors, etc.) that are not caught. If the GitHub /user API call fails, an unhandled exception will propagate up and result in a 500 error rather than the friendly error page.

Suggested change
try:
authenticated_user_info = get_user_info(payload["access_token"])
except Exception:
authenticated_user_info = {}

🤖 Grapple PR auto-fix • minor • confidence: 89%

authenticated_user_info = get_user_info(payload["access_token"])
if "login" not in authenticated_user_info:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Error Handling - User Info Fetch (confidence: 100%)

The call to get_user_info(payload['access_token']) has no exception handling. If the GitHub /user endpoint returns a non-200 response, times out, or returns malformed JSON, this will raise an unhandled exception that surfaces as a 500 error to the user rather than a graceful error page.

Evidence:

  • Line 433: authenticated_user_info = get_user_info(payload['access_token']) — no try/except
  • Intent specification edge case: 'If the /user endpoint returns a non-200 or unexpected payload, the integration should fail gracefully rather than proceeding with a null login'
  • The get_user_info function likely makes an HTTP request that can raise various exceptions

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — architecture agent (Small fix (10 lines, 1 file))

The call to get_user_info(payload['access_token']) has no exception handling. If the GitHub /user endpoint returns a non-200 response, times out, or returns malformed JSON, this will raise an unhandled exception that surfaces as a 500 error to the user rather than a graceful error page.

Suggested change
if "login" not in authenticated_user_info:
try:
authenticated_user_info = get_user_info(payload["access_token"])
except Exception:
logger.exception(
"github.oauth.get_user_info_failed",
extra={"error": "Failed to retrieve user info from GitHub /user endpoint"},
)
return error(request, self.active_organization)

🤖 Grapple PR auto-fix • major • confidence: 100%

return error(request, self.active_organization)

pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"])
return pipeline.next_step()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Documentation (confidence: 96%)

OAuthLoginView.dispatch() method is complex with multiple control flow paths (state generation, token exchange, user info fetch) but lacks docstring explaining the CSRF state flow and security properties.

Evidence:

  • The method handles three distinct phases: initial redirect, callback with state validation, and token exchange
  • The state parameter is critical for CSRF protection but no inline comment explains why it's used or how it's stored/validated
  • The relationship between pipeline.signature (used as state) and security is undocumented

Agent: style



class GitHubInstallation(PipelineView):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Code organization (confidence: 98%)

The error handling for access_token parsing silently catches all exceptions and returns an error response. While this is safe, the specific error conditions should be documented for maintainability.

Evidence:

  • Lines 434-437: broad except Exception catches any parsing error but doesn't log or distinguish error types
  • Risk area from acceptance criteria: 'Parsing the access_token response with parse_qsl — GitHub errors return query-string-encoded error fields; if not checked, a failed exchange could proceed silently'
  • Lines 440-441: checks for 'access_token' in payload but doesn't log what error GitHub returned

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Code organization (confidence: 93%)

The OAuthLoginView class uses pipeline.signature as the state value, but this is not documented. The relationship between pipeline.signature and CSRF state generation should be clarified in code comments.

Evidence:

  • Line 412: state = pipeline.signature — unclear whether pipeline.signature is cryptographically random or how it's generated
  • Line 419: state value is used in OAuth URL without explanation of its origin or guarantees
  • Risk area from acceptance criteria: 'State parameter generation and validation logic in OAuthLoginView — if state is not cryptographically random or not validated strictly, CSRF protection is ineffective'
  • Test uses deterministic state '9cae5e88803f35ed7970fc131e6e65d3' which suggests state generation may be mocked or seeded

Agent: style

def get_app_url(self) -> str:
name = options.get("github-app.name")
return f"https://github.com/apps/{slugify(name)}"

def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
if "installation_id" not in request.GET:
installation_id = request.GET.get(
"installation_id", pipeline.fetch_state("installation_id")
)
if installation_id is None:
return self.redirect(self.get_app_url())

pipeline.bind_state("installation_id", installation_id)
self.determine_active_organization(request)

integration_pending_deletion_exists = False
Expand All @@ -374,57 +465,43 @@ def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponse:
).exists()

if integration_pending_deletion_exists:
document_origin = "document.origin"
if self.active_organization and features.has(
"organizations:customer-domains", self.active_organization.organization
):
document_origin = (
f'"{generate_organization_url(self.active_organization.organization.slug)}"'
)
return render_to_response(
"sentry/integrations/github-integration-failed.html",
context={
"error": ERR_INTEGRATION_PENDING_DELETION,
"payload": {
"success": False,
"data": {"error": _("GitHub installation pending deletion.")},
},
"document_origin": document_origin,
},
request=request,
return error(
request,
self.active_organization,
error_short="GitHub installation pending deletion.",
error_long=ERR_INTEGRATION_PENDING_DELETION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Unhandled Exception / Missing Input Validation (confidence: 100%)

The sender login check accesses integration.metadata["sender"]["login"] without guarding against missing keys. If the Integration record was created by a different code path (e.g., a direct webhook install) where metadata lacks a sender key or sender lacks login, this raises an unhandled KeyError, resulting in a 500 error rather than a graceful rejection. This is reachable when an Integration exists but no OrganizationIntegration exists.

Evidence:

  • Line ~461: integration.metadata["sender"]["login"] — no .get() used, no try/except
  • The test test_github_user_mismatch sets webhook_event['sender']['login'] = 'attacker' confirming the metadata path, but production installs via webhook may have different metadata shapes
  • Acceptance criteria notes: 'org.organization access in get_document_origin() assumes org has a nested .organization attribute — verify this holds for all callers' — similar pattern of unsafe attribute access

Agent: security

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — security agent (Small fix (8 lines, 1 file))

The sender login check accesses integration.metadata["sender"]["login"] without guarding against missing keys. If the Integration record was created by a different code path (e.g., a direct webhook install) where metadata lacks a sender key or sender lacks login, this raises an unhandled KeyError, resulting in a 500 error rather than a graceful rejection. This is reachable when an Integration exists but no OrganizationIntegration exists.

Suggested change
error_long=ERR_INTEGRATION_PENDING_DELETION,
# Check that the authenticated GitHub user is the same as who installed the app.
# Guard against metadata missing 'sender' or 'login' keys — these may be absent
# for integrations created via direct webhook installs or legacy code paths.
# Treat a missing/malformed metadata structure as a mismatch (fail closed).
sender_login = integration.metadata.get("sender", {}).get("login")
!= sender_login

🤖 Grapple PR auto-fix • major • confidence: 100%

)

try:
# We want to limit GitHub integrations to 1 organization
installations_exist = OrganizationIntegration.objects.filter(
integration=Integration.objects.get(external_id=request.GET["installation_id"])
integration=Integration.objects.get(external_id=installation_id)
).exists()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — API Contract / Behavioral Change (confidence: 100%)

The GitHubInstallation step now performs an authenticated user match (github_authenticated_user != integration.metadata['sender']['login']) for existing integrations. This is a new behavioral constraint that was not previously enforced. If integration.metadata does not contain a sender key or sender lacks a login key (e.g., for integrations created before this metadata was stored), this will raise a KeyError and cause a 500 error. There's no defensive access pattern here.

Evidence:

  • Line 477-480: Direct dictionary access integration.metadata['sender']['login'] with no .get() or try/except
  • Existing integrations may have been created with different metadata schemas
  • The webhook handler that populates metadata may not always include sender information

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Larger fix (17 lines, 1 file) — review recommended)

The GitHubInstallation step now performs an authenticated user match (github_authenticated_user != integration.metadata['sender']['login']) for existing integrations. This is a new behavioral constraint that was not previously enforced. If integration.metadata does not contain a sender key or sender lacks a login key (e.g., for integrations created before this metadata was stored), this will raise a KeyError and cause a 500 error. There's no defensive access pattern here.

--- a/src/sentry/integrations/github/integration.py
+++ b/src/sentry/integrations/github/integration.py
@@ -495,10 +495,21 @@
         # OrganizationIntegration does not exist, but Integration does exist.
         try:
             integration = Integration.objects.get(
                 external_id=installation_id, status=ObjectStatus.ACTIVE
             )
         except Integration.DoesNotExist:
             return error(request, self.active_organization)
 
         # Check that the authenticated GitHub user is the same as who installed the app.
+        sender_login = (integration.metadata or {}).get("sender", {}).get("login")
+        github_authenticated_user = pipeline.fetch_state("github_authenticated_user")
+
+        if sender_login is None:
+            # Legacy integrations created before sender metadata was stored cannot
+            # be verified. Log a warning and allow the flow to proceed rather than
+            # raising a KeyError (500). Consider backfilling sender metadata for
+            # existing integrations if this warning appears frequently.
+            logger.warning(
+                "github.integration.missing_sender_metadata",
+                extra={"integration_id": integration.id, "external_id": installation_id},
+            )
-        if (
-            pipeline.fetch_state("github_authenticated_user")
-            != integration.metadata["sender"]["login"]
-        ):
+        elif github_authenticated_user != sender_login:
             return error(request, self.active_organization)
 
         return pipeline.next_step()

🤖 Grapple PR auto-fix • major • Review this diff before applying

except Integration.DoesNotExist:
pipeline.bind_state("installation_id", request.GET["installation_id"])
return pipeline.next_step()

if installations_exist:
document_origin = "document.origin"
if self.active_organization and features.has(
"organizations:customer-domains", self.active_organization.organization
):
document_origin = (
f'"{generate_organization_url(self.active_organization.organization.slug)}"'
)
return render_to_response(
"sentry/integrations/github-integration-failed.html",
context={
"error": ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
"payload": {
"success": False,
"data": {"error": _("Github installed on another Sentry organization.")},
},
"document_origin": document_origin,
},
request=request,
return error(
request,
self.active_organization,
error_short="Github installed on another Sentry organization.",
error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG,
)

# OrganizationIntegration does not exist, but Integration does exist.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Security - Sender Verification (confidence: 100%)

The sender identity check compares pipeline.fetch_state('github_authenticated_user') against integration.metadata['sender']['login']. The metadata['sender'] is populated from the GitHub webhook event, which could be manipulated if the webhook signature validation is weak or the attacker controls a GitHub App installation. Additionally, if integration.metadata doesn't contain the 'sender' key (e.g., for integrations created through a different code path), this will raise a KeyError and crash with a 500 rather than showing a graceful error.

Evidence:

  • Lines 487-492: Direct dictionary access integration.metadata['sender']['login'] with no .get() or try/except
  • The metadata structure depends on how the integration was originally created — older integrations or those created through different flows may not have sender in metadata
  • If metadata is None or missing 'sender', this will raise KeyError/TypeError

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — architecture agent (Small fix (9 lines, 1 file))

The sender identity check compares pipeline.fetch_state('github_authenticated_user') against integration.metadata['sender']['login']. The metadata['sender'] is populated from the GitHub webhook event, which could be manipulated if the webhook signature validation is weak or the attacker controls a GitHub App installation. Additionally, if integration.metadata doesn't contain the 'sender' key (e.g., for integrations created through a different code path), this will raise a KeyError and crash with a 500 rather than showing a graceful error.

Suggested change
# OrganizationIntegration does not exist, but Integration does exist.
sender_login = (integration.metadata or {}).get("sender", {}).get("login")
if not sender_login:
# NOTE: Older integrations or those created through a different flow may
# not have 'sender' in metadata. Treat missing sender as unverifiable
# and return a graceful error rather than crashing with KeyError/TypeError.
return error(request, self.active_organization)
!= sender_login

🤖 Grapple PR auto-fix • major • confidence: 100%

pipeline.bind_state("installation_id", request.GET["installation_id"])
try:
integration = Integration.objects.get(
external_id=installation_id, status=ObjectStatus.ACTIVE

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — Error handling (confidence: 100%)

The GitHub user mismatch check accesses integration.metadata['sender']['login'] without verifying that the 'sender' or 'login' keys exist in metadata. If the integration was created through a different path or the metadata structure doesn't contain 'sender', this will raise a KeyError and return a 500 error instead of a user-friendly error page.

Evidence:

  • Line 494: integration.metadata["sender"]["login"] — no try/except or .get() usage
  • integration.metadata structure depends on how the webhook populated it
  • If metadata is empty or missing 'sender', KeyError will be raised

Agent: logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — logic agent (Small fix (2 lines, 1 file))

The GitHub user mismatch check accesses integration.metadata['sender']['login'] without verifying that the 'sender' or 'login' keys exist in metadata. If the integration was created through a different path or the metadata structure doesn't contain 'sender', this will raise a KeyError and return a 500 error instead of a user-friendly error page.

Suggested change
external_id=installation_id, status=ObjectStatus.ACTIVE
!= integration.metadata.get("sender", {}).get("login")

🤖 Grapple PR auto-fix • major • confidence: 100%

)
except Integration.DoesNotExist:
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"]
):
return error(request, self.active_organization)

return pipeline.next_step()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 MAJOR — N+1 queries (confidence: 100%)

GitHubInstallation.dispatch performs two separate Integration.objects.get() database queries for the same external_id within the same request: one inside the installations_exist block (line ~488) and another after it (line ~502). The second query also adds a status filter but is essentially fetching the same row. This is redundant work that could be collapsed into a single query.

Evidence:

  • First query: Integration.objects.get(external_id=installation_id) inside the installations_exist filter (line ~488)
  • Second query: Integration.objects.get(external_id=installation_id, status=ObjectStatus.ACTIVE) after the installations_exist check (line ~502)
  • Both queries hit the same table and filter on the same external_id; they could be combined into one query that retrieves the object and checks both installations and status in a single round trip

Agent: performance

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Grapple PR] Auto-fix — performance agent (Small fix (9 lines, 1 file))

GitHubInstallation.dispatch performs two separate Integration.objects.get() database queries for the same external_id within the same request: one inside the installations_exist block (line ~488) and another after it (line ~502). The second query also adds a status filter but is essentially fetching the same row. This is redundant work that could be collapsed into a single query.

Suggested change
return pipeline.next_step()
integration = Integration.objects.get(external_id=installation_id)
integration=integration
if integration.status != ObjectStatus.ACTIVE:

🤖 Grapple PR auto-fix • major • confidence: 100%

11 changes: 4 additions & 7 deletions src/sentry/web/frontend/pipeline_advancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,6 @@
PIPELINE_CLASSES = [IntegrationPipeline, IdentityProviderPipeline]


# GitHub apps may be installed directly from GitHub, in which case
# they will redirect here *without* being in the pipeline. If that happens
# redirect to the integration install org picker.
FORWARD_INSTALL_FOR = ["github"]


from rest_framework.request import Request


Expand All @@ -40,8 +34,11 @@ def handle(self, request: Request, provider_id: str) -> HttpResponseBase:
if pipeline:
break

# GitHub apps may be installed directly from GitHub, in which case
# they will redirect here *without* being in the pipeline. If that happens
# redirect to the integration install org picker.
if (
provider_id in FORWARD_INSTALL_FOR
provider_id == "github"
and request.GET.get("setup_action") == "install"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MINOR — Scalability - Extensibility Regression (confidence: 100%)

The refactor from FORWARD_INSTALL_FOR list to a direct string comparison (provider_id == 'github') removes extensibility. While the intent states this is functionally equivalent, converting a list-based membership check to a hard-coded string comparison makes it harder to add future providers that need the same forward-install behavior. The comment was also moved but the reasoning for the change is unclear from an architectural perspective.

Evidence:

  • Original: FORWARD_INSTALL_FOR = ['github'] with provider_id in FORWARD_INSTALL_FOR
  • New: provider_id == 'github'
  • Intent specification risk: 'removes extensibility — ensure no other provider relied on FORWARD_INSTALL_FOR'

Agent: architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 INFO — Documentation (confidence: 81%)

The comment explaining the special GitHub handling was moved from a module-level docstring into an inline comment within control flow. This reduces discoverability for developers reading the code.

Evidence:

  • Previously the FORWARD_INSTALL_FOR list had a clear comment above it explaining the rationale
  • The comment is now embedded in the conditional logic where it's less visible
  • This is a common refactoring pattern but worth documenting why it was changed

Agent: style

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [Grapple PR] Suggested fix — architecture agent (Small fix (10 lines, 1 file))

The refactor from FORWARD_INSTALL_FOR list to a direct string comparison (provider_id == 'github') removes extensibility. While the intent states this is functionally equivalent, converting a list-based membership check to a hard-coded string comparison makes it harder to add future providers that need the same forward-install behavior. The comment was also moved but the reasoning for the change is unclear from an architectural perspective.

--- a/src/sentry/web/frontend/pipeline_advancer.py
+++ b/src/sentry/web/frontend/pipeline_advancer.py
@@ -16,6 +16,11 @@ PIPELINE_CLASSES = [IntegrationPipeline, IdentityProviderPipeline]
 
+# Providers that may be installed directly from the provider's own UI, bypassing
+# the Sentry pipeline. If that happens, redirect to the integration install org picker.
+FORWARD_INSTALL_FOR = ["github"]
+
+
 from rest_framework.request import Request
 
 
@@ -34,10 +39,9 @@ def handle(self, request: Request, provider_id: str) -> HttpResponseBase:
             if pipeline:
                 break
 
-        # GitHub apps may be installed directly from GitHub, in which case
-        # they will redirect here *without* being in the pipeline. If that happens
-        # redirect to the integration install org picker.
         if (
-            provider_id == "github"
+            provider_id in FORWARD_INSTALL_FOR
             and request.GET.get("setup_action") == "install"
             and pipeline is None
         ):

🤖 Grapple PR auto-fix • minor • Review this diff before applying

and pipeline is None
):
Expand Down
Loading