Coverage for polar/integrations/github/service/user.py: 28%
95 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1from typing import TYPE_CHECKING, TypeAlias
3import structlog
4from httpx_oauth.oauth2 import OAuth2Token
6from polar.exceptions import PolarError
7from polar.integrations.github.client import GitHub, TokenAuthStrategy
8from polar.models import OAuthAccount, User
9from polar.models.user import OAuthPlatform
10from polar.postgres import AsyncSession
11from polar.user.oauth_service import oauth_account_service
12from polar.user.repository import UserRepository
13from polar.user.schemas import UserSignupAttribution
14from polar.worker import enqueue_job
16if TYPE_CHECKING: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true
17 from githubkit.versions.latest.models import PrivateUser, PublicUser
19from .. import client as github
21log = structlog.get_logger()
24GithubUser: TypeAlias = "PrivateUser | PublicUser"
25GithubEmail: TypeAlias = tuple[str, bool]
28class GithubUserServiceError(PolarError): ... 28 ↛ 31line 28 didn't jump to line 31 because
31class NoPrimaryEmailError(GithubUserServiceError):
32 def __init__(self) -> None:
33 super().__init__("GitHub user without primary email set")
36class CannotLinkUnverifiedEmailError(GithubUserServiceError):
37 def __init__(self, email: str) -> None:
38 message = (
39 f"An account already exists on Polar under the email {email}. "
40 "We cannot automatically link it to your GitHub account since "
41 "this email address is not verified on GitHub. "
42 "Either verify your email address on GitHub and try again "
43 "or sign in using your email."
44 )
45 super().__init__(message, 403)
48class AccountLinkedToAnotherUserError(GithubUserServiceError):
49 def __init__(self) -> None:
50 message = (
51 "This GitHub account is already linked to another user on Polar. "
52 "You may have already created another account "
53 "with a different email address."
54 )
55 super().__init__(message, 403)
58class GithubUserService:
59 async def get_updated_or_create(
60 self,
61 session: AsyncSession,
62 *,
63 token: OAuth2Token,
64 signup_attribution: UserSignupAttribution | None = None,
65 ) -> tuple[User, bool]:
66 client = github.get_client(access_token=token["access_token"])
67 authenticated = await self._fetch_authenticated_user(client=client)
68 user_repository = UserRepository.from_session(session)
69 user = await user_repository.get_by_oauth_account(
70 OAuthPlatform.github, str(authenticated.id)
71 )
73 if user is not None:
74 oauth_account = user.get_oauth_account(OAuthPlatform.github)
75 assert oauth_account is not None
76 oauth_account.access_token = token["access_token"]
77 oauth_account.expires_at = token["expires_at"]
78 oauth_account.account_username = authenticated.login
79 session.add(oauth_account)
80 return (user, False)
82 email, email_verified = await self.fetch_authenticated_user_primary_email(
83 client=client
84 )
86 oauth_account = OAuthAccount(
87 platform=OAuthPlatform.github,
88 account_id=str(authenticated.id),
89 account_email=email,
90 account_username=authenticated.login,
91 access_token=token["access_token"],
92 expires_at=token["expires_at"],
93 )
95 user = await user_repository.get_by_email(email)
96 if user is not None:
97 if email_verified:
98 user.oauth_accounts.append(oauth_account)
99 session.add(user)
100 return (user, False)
101 else:
102 raise CannotLinkUnverifiedEmailError(email)
104 user = User(
105 email=email,
106 email_verified=email_verified,
107 avatar_url=authenticated.avatar_url,
108 oauth_accounts=[oauth_account],
109 signup_attribution=signup_attribution,
110 )
112 session.add(user)
113 await session.flush()
115 enqueue_job("user.on_after_signup", user_id=user.id)
117 return (user, True)
119 async def link_user(
120 self, session: AsyncSession, *, user: User, token: OAuth2Token
121 ) -> User:
122 client = github.get_client(access_token=token["access_token"])
123 github_user = await self._fetch_authenticated_user(client=client)
124 email, _ = await self.fetch_authenticated_user_primary_email(client=client)
126 account_id = str(github_user.id)
128 oauth_account = await oauth_account_service.get_by_platform_and_account_id(
129 session, OAuthPlatform.github, account_id
130 )
131 if oauth_account is not None:
132 if oauth_account.user_id != user.id:
133 raise AccountLinkedToAnotherUserError()
134 else:
135 oauth_account = OAuthAccount(
136 platform=OAuthPlatform.github,
137 account_id=account_id,
138 account_email=email,
139 user_id=user.id,
140 )
141 session.add(oauth_account)
142 log.info(
143 "oauth_account.connect",
144 user_id=user.id,
145 platform="github",
146 account_email=email,
147 )
149 oauth_account.access_token = token["access_token"]
150 oauth_account.expires_at = token["expires_at"]
151 oauth_account.account_email = email
152 oauth_account.account_username = github_user.login
153 session.add(oauth_account)
155 user.avatar_url = github_user.avatar_url
156 session.add(user)
158 return user
160 async def fetch_authenticated_user_primary_email(
161 self, *, client: GitHub[TokenAuthStrategy]
162 ) -> GithubEmail:
163 email_response = (
164 await client.rest.users.async_list_emails_for_authenticated_user()
165 )
167 try:
168 github.ensure_expected_response(email_response)
169 except Exception as e:
170 log.error("fetch_authenticated_user_primary_email.failed", err=e)
171 raise NoPrimaryEmailError() from e
173 emails = email_response.parsed_data
175 for email in emails:
176 if email.primary:
177 return email.email, email.verified
179 raise NoPrimaryEmailError()
181 async def _fetch_authenticated_user(
182 self, *, client: GitHub[TokenAuthStrategy]
183 ) -> GithubUser:
184 response = await client.rest.users.async_get_authenticated()
185 github.ensure_expected_response(response)
186 return response.parsed_data
189github_user = GithubUserService()