Coverage for polar/integrations/github/service/user.py: 29%

95 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-12-05 17:15 +0000

1from typing import TYPE_CHECKING, TypeAlias 1a

2 

3import structlog 1a

4from httpx_oauth.oauth2 import OAuth2Token 1a

5 

6from polar.exceptions import PolarError 1a

7from polar.integrations.github.client import GitHub, TokenAuthStrategy 1a

8from polar.models import OAuthAccount, User 1a

9from polar.models.user import OAuthPlatform 1a

10from polar.postgres import AsyncSession 1a

11from polar.user.oauth_service import oauth_account_service 1a

12from polar.user.repository import UserRepository 1a

13from polar.user.schemas import UserSignupAttribution 1a

14from polar.worker import enqueue_job 1a

15 

16if TYPE_CHECKING: 16 ↛ 17line 16 didn't jump to line 17 because the condition on line 16 was never true1a

17 from githubkit.versions.latest.models import PrivateUser, PublicUser 

18 

19from .. import client as github 1a

20 

21log = structlog.get_logger() 1a

22 

23 

24GithubUser: TypeAlias = "PrivateUser | PublicUser" 1a

25GithubEmail: TypeAlias = tuple[str, bool] 1a

26 

27 

28class GithubUserServiceError(PolarError): ... 1a

29 

30 

31class NoPrimaryEmailError(GithubUserServiceError): 1a

32 def __init__(self) -> None: 1a

33 super().__init__("GitHub user without primary email set") 

34 

35 

36class CannotLinkUnverifiedEmailError(GithubUserServiceError): 1a

37 def __init__(self, email: str) -> None: 1a

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) 

46 

47 

48class AccountLinkedToAnotherUserError(GithubUserServiceError): 1a

49 def __init__(self) -> None: 1a

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) 

56 

57 

58class GithubUserService: 1a

59 async def get_updated_or_create( 1a

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 ) 

72 

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) 

81 

82 email, email_verified = await self.fetch_authenticated_user_primary_email( 

83 client=client 

84 ) 

85 

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 ) 

94 

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) 

103 

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 ) 

111 

112 session.add(user) 

113 await session.flush() 

114 

115 enqueue_job("user.on_after_signup", user_id=user.id) 

116 

117 return (user, True) 

118 

119 async def link_user( 1a

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) 

125 

126 account_id = str(github_user.id) 

127 

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 ) 

148 

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) 

154 

155 user.avatar_url = github_user.avatar_url 

156 session.add(user) 

157 

158 return user 

159 

160 async def fetch_authenticated_user_primary_email( 1a

161 self, *, client: GitHub[TokenAuthStrategy] 

162 ) -> GithubEmail: 

163 email_response = ( 

164 await client.rest.users.async_list_emails_for_authenticated_user() 

165 ) 

166 

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 

172 

173 emails = email_response.parsed_data 

174 

175 for email in emails: 

176 if email.primary: 

177 return email.email, email.verified 

178 

179 raise NoPrimaryEmailError() 

180 

181 async def _fetch_authenticated_user( 1a

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 

187 

188 

189github_user = GithubUserService() 1a