diff --git a/config.py b/config.py index 94b82f8a..f3786292 100644 --- a/config.py +++ b/config.py @@ -40,6 +40,7 @@ def get_env_var(name: str) -> str: ] GITHUB_ISSUE_DIR = ".github/ISSUE_TEMPLATE" GITHUB_ISSUE_TEMPLATES: list[str] = ["bug_report.yml", "feature_request.yml"] +GITHUB_NOREPLY_EMAIL_DOMAIN = "users.noreply.github.com" # https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address GITHUB_PRIVATE_KEY_ENCODED: str = get_env_var(name="GH_PRIVATE_KEY") GITHUB_PRIVATE_KEY: bytes = base64.b64decode(s=GITHUB_PRIVATE_KEY_ENCODED) GITHUB_WEBHOOK_SECRET: str = get_env_var(name="GH_WEBHOOK_SECRET") @@ -102,6 +103,7 @@ def get_env_var(name: str) -> str: OWNER_NAME = "installation-test" EXCEPTION_OWNERS = ["gitautoai", "hiroshinishio"] OWNER_TYPE = "Organization" +TEST_EMAIL = "test@gitauto.ai" UNIQUE_ISSUE_ID = "O/gitautoai/test#1" USER_ID = -1 USER_NAME = "username-test" diff --git a/services/gitauto_handler.py b/services/gitauto_handler.py index 882c3e3d..91aade12 100644 --- a/services/gitauto_handler.py +++ b/services/gitauto_handler.py @@ -27,6 +27,7 @@ create_comment, update_comment, add_reaction_to_issue, + get_user_public_email, ) from services.github.github_types import ( BaseArgs, @@ -92,6 +93,8 @@ async def handle_gitauto(payload: GitHubLabeledPayload, trigger_type: str) -> No github_urls, other_urls = extract_urls(text=issue_body) installation_id: int = payload["installation"]["id"] token: str = get_installation_access_token(installation_id=installation_id) + sender_email = get_user_public_email(username=sender_name, token=token) + base_args: BaseArgs = { "owner": owner_name, "repo": repo_name, @@ -132,8 +135,10 @@ async def handle_gitauto(payload: GitHubLabeledPayload, trigger_type: str) -> No unique_issue_id = f"{owner_type}/{owner_name}/{repo_name}#{issue_number}" usage_record_id = supabase_manager.create_user_request( user_id=sender_id, + user_name=sender_name, installation_id=installation_id, unique_issue_id=unique_issue_id, + email=sender_email, ) add_reaction_to_issue( issue_number=issue_number, content="eyes", base_args=base_args diff --git a/services/github/github_manager.py b/services/github/github_manager.py index bc1f4fb8..4a12148b 100644 --- a/services/github/github_manager.py +++ b/services/github/github_manager.py @@ -240,19 +240,17 @@ def create_comment_on_issue_with_gitauto_button(payload: GitHubLabeledPayload) - issue_number: int = payload["issue"]["number"] user_id: int = payload["sender"]["id"] user_name: str = payload["sender"]["login"] + user_email: str | None = get_user_public_email(username=user_name, token=token) supabase_manager = SupabaseManager(url=SUPABASE_URL, key=SUPABASE_SERVICE_ROLE_KEY) # Proper issue generation comment, create user if not exist (first issue in an orgnanization) first_issue = False - if not supabase_manager.user_exists(user_id=user_id): - supabase_manager.create_user( - user_id=user_id, - user_name=user_name, - installation_id=installation_id, - ) - first_issue = True - elif supabase_manager.is_users_first_issue( + supabase_manager.upsert_user(user_id=user_id, user_name=user_name, email=user_email) + supabase_manager.upsert_user_installation( + user_id=user_id, installation_id=installation_id + ) + if supabase_manager.is_users_first_issue( user_id=user_id, installation_id=installation_id ): first_issue = True @@ -768,3 +766,22 @@ def update_comment( ) response.raise_for_status() return response.json() + + +@handle_exceptions(default_return_value=None, raise_on_error=False) +def get_user_public_email(username: str, token: str) -> str | None: + """https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user""" + # If the user is a bot, the email is not available. + if "[bot]" in username: + return None + + # If the user is not a bot, get the user's email + response: requests.Response = requests.get( + url=f"{GITHUB_API_URL}/users/{username}", + headers=create_headers(token=token), + timeout=TIMEOUT, + ) + response.raise_for_status() + user_data: dict = response.json() + email: str | None = user_data.get("email") + return email diff --git a/services/supabase/gitauto_manager.py b/services/supabase/gitauto_manager.py index c6727abc..66b6ec6f 100644 --- a/services/supabase/gitauto_manager.py +++ b/services/supabase/gitauto_manager.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from supabase import Client from services.stripe.customer import create_stripe_customer, subscribe_to_free_plan +from services.supabase.users_manager import UsersManager from utils.handle_exceptions import handle_exceptions @@ -40,6 +41,7 @@ def create_installation( owner_id: int, user_id: int, user_name: str, + email: str | None, ) -> None: """Create owners record with stripe customerId, subscribe to free plan, create installation record, create users record on Installation Webhook event""" # If owner doesn't exist in owners table, insert owner and stripe customer @@ -79,36 +81,23 @@ def create_installation( } ).execute() - self.client.table(table_name="users").upsert( - json={ - "user_id": user_id, - "user_name": user_name, - }, - on_conflict="user_id", - ).execute() - # Create User, and set is_selected to True if user has no selected account for this installation - is_selected = True - data, _ = ( - self.client.table(table_name="user_installations") - .select("user_id") - .eq(column="user_id", value=user_id) - .eq(column="is_selected", value=True) - .execute() - ) - if len(data[1]) > 0: - is_selected = False + # Upsert user + users_manager = UsersManager(client=self.client) + users_manager.upsert_user(user_id=user_id, user_name=user_name, email=email) - self.client.table(table_name="user_installations").insert( - json={ - "user_id": user_id, - "installation_id": installation_id, - "is_selected": is_selected, - } - ).execute() + # Upsert user installation record + users_manager.upsert_user_installation( + user_id=user_id, installation_id=installation_id + ) @handle_exceptions(default_return_value=None, raise_on_error=True) def create_user_request( - self, user_id: int, installation_id: int, unique_issue_id: str + self, + user_id: int, + user_name: str, + installation_id: int, + unique_issue_id: str, + email: str | None, ) -> int: """Creates record in usage table for this user and issue.""" # If issue doesn't exist, create one @@ -118,13 +107,13 @@ def create_user_request( .eq(column="unique_id", value=unique_issue_id) .execute() ) + + # If no issue exists with that unique_issue_id, create one if not data[1]: self.client.table(table_name="issues").insert( - json={ - "unique_id": unique_issue_id, - "installation_id": installation_id, - } + json={"unique_id": unique_issue_id, "installation_id": installation_id} ).execute() + # Add user request to usage table data, _ = ( self.client.table(table_name="usage") @@ -137,6 +126,13 @@ def create_user_request( ) .execute() ) + + # Upsert user + users_manager = UsersManager(client=self.client) + users_manager.upsert_user(user_id=user_id, user_name=user_name, email=email) + users_manager.upsert_user_installation( + user_id=user_id, installation_id=installation_id + ) return data[1][0]["id"] @handle_exceptions(default_return_value=None, raise_on_error=False) diff --git a/services/supabase/users_manager.py b/services/supabase/users_manager.py index e4833c77..aac5e6fd 100644 --- a/services/supabase/users_manager.py +++ b/services/supabase/users_manager.py @@ -1,13 +1,19 @@ # Standard imports import logging from datetime import datetime +from typing import Any # Third Party imports import stripe from supabase import Client # Local imports -from config import DEFAULT_TIME, STRIPE_FREE_TIER_PRICE_ID, TZ +from config import ( + DEFAULT_TIME, + GITHUB_NOREPLY_EMAIL_DOMAIN, + STRIPE_FREE_TIER_PRICE_ID, + TZ, +) from services.stripe.customer import ( get_subscription, get_request_count_from_product_id_metadata, @@ -22,22 +28,14 @@ class UsersManager: def __init__(self, client: Client) -> None: self.client: Client = client - @handle_exceptions(default_return_value=None, raise_on_error=False) - def create_user(self, user_id: int, user_name: str, installation_id: int) -> None: - """Creates an account for the user in the users table""" - self.client.table(table_name="users").upsert( - json={ - "user_id": user_id, - "user_name": user_name, - } - ).execute() - - self.client.table(table_name="user_installations").insert( - json={ - "user_id": user_id, - "installation_id": installation_id, - } - ).execute() + def check_email_is_valid(self, email: str | None) -> bool: + if email is None: + return False + if "@" not in email or "." not in email: + return False + if str(email).lower().endswith(GITHUB_NOREPLY_EMAIL_DOMAIN): + return False + return True # Check if user has a seat in an org or can be given a seat @handle_exceptions(default_return_value=True, raise_on_error=False) @@ -216,9 +214,9 @@ def get_how_many_requests_left_and_cycle( end_date, ) - @handle_exceptions(default_return_value=False, raise_on_error=False) - def user_exists(self, user_id: int) -> bool: - """Check if user exists in users table""" + @handle_exceptions(default_return_value=None, raise_on_error=False) + def get_user(self, user_id: int): + """Get user info from the users table""" data, _ = ( self.client.table(table_name="users") .select("*") @@ -226,5 +224,34 @@ def user_exists(self, user_id: int) -> bool: .execute() ) if len(data[1]) > 0: - return True - return False + user: dict[str, Any] = data[1][0] + return user + return None + + @handle_exceptions(default_return_value=None, raise_on_error=False) + def upsert_user(self, user_id: int, user_name: str, email: str | None) -> None: + # Check if email is valid + email = email if self.check_email_is_valid(email=email) else None + + # Upsert user + self.client.table(table_name="users").upsert( + json={ + "user_id": user_id, + "user_name": user_name, + **({"email": email} if email else {}), + "created_by": str(user_id), # Because created_by is text + }, + on_conflict="user_id", + ).execute() + + @handle_exceptions(default_return_value=None, raise_on_error=False) + def upsert_user_installation(self, user_id: int, installation_id: int) -> None: + # Insert user installation record + self.client.table(table_name="user_installations").upsert( + json={ + "user_id": user_id, + "installation_id": installation_id, + "is_selected": True, + }, + on_conflict="user_id,installation_id", + ).execute() diff --git a/services/webhook_handler.py b/services/webhook_handler.py index fe04446c..4b002af0 100644 --- a/services/webhook_handler.py +++ b/services/webhook_handler.py @@ -17,6 +17,7 @@ create_comment_on_issue_with_gitauto_button, get_installation_access_token, # turn_on_issue, + get_user_public_email, ) from services.github.github_types import GitHubInstallationPayload from services.supabase import SupabaseManager @@ -38,6 +39,7 @@ async def handle_installation_created(payload: GitHubInstallationPayload) -> Non user_id: int = payload["sender"]["id"] user_name: str = payload["sender"]["login"] token: str = get_installation_access_token(installation_id=installation_id) + user_email: str | None = get_user_public_email(username=user_name, token=token) # Create installation record in Supabase supabase_manager.create_installation( @@ -47,6 +49,7 @@ async def handle_installation_created(payload: GitHubInstallationPayload) -> Non owner_id=owner_id, user_id=user_id, user_name=user_name, + email=user_email, ) # Add issue templates to the repositories diff --git a/tests/services/supabase/test_gitauto_manager.py b/tests/services/supabase/test_gitauto_manager.py index c9bbd348..c1c9dc6a 100644 --- a/tests/services/supabase/test_gitauto_manager.py +++ b/tests/services/supabase/test_gitauto_manager.py @@ -1,6 +1,6 @@ # run this file locally with: python -m tests.services.supabase.test_gitauto_manager import os -from config import OWNER_TYPE +from config import OWNER_TYPE, TEST_EMAIL, USER_NAME from services.supabase import SupabaseManager from tests.services.supabase.wipe_data import ( wipe_installation_owner_user_data, @@ -28,14 +28,17 @@ def test_create_update_user_request_works() -> None: owner_name="gitautoai", owner_id=-1, user_id=user_id, - user_name="test", + user_name=USER_NAME, + email=TEST_EMAIL, ) usage_record_id = supabase_manager.create_user_request( user_id=user_id, + user_name=USER_NAME, installation_id=installation_id, # fake issue creation unique_issue_id="U/gitautoai/test#01", + email=TEST_EMAIL, ) assert isinstance( usage_record_id, @@ -65,6 +68,7 @@ def test_complete_and_update_usage_record_only_updates_one_record() -> None: # using -1 to not conflict with real data user_id = -1 installation_id = -1 + unique_issue_id = "U/gitautoai/test#01" # Clean up at the beginning just in case a prior test failed to clean wipe_installation_owner_user_data() @@ -76,23 +80,28 @@ def test_complete_and_update_usage_record_only_updates_one_record() -> None: owner_name="gitautoai", owner_id=-1, user_id=user_id, - user_name="test", + user_name=USER_NAME, + email=TEST_EMAIL, ) # Creating multiple usage records where is_completed = false. for _ in range(0, 5): supabase_manager.create_user_request( user_id=user_id, + user_name=USER_NAME, installation_id=installation_id, # fake issue creation - unique_issue_id="U/gitautoai/test#01", + unique_issue_id=unique_issue_id, + email=TEST_EMAIL, ) usage_record_id = supabase_manager.create_user_request( user_id=user_id, + user_name=USER_NAME, installation_id=installation_id, # fake issue creation - unique_issue_id="U/gitautoai/test#01", + unique_issue_id=unique_issue_id, + email=TEST_EMAIL, ) assert isinstance( usage_record_id, diff --git a/tests/services/supabase/test_users_manager.py b/tests/services/supabase/test_users_manager.py index 1ae941b0..3adcf080 100644 --- a/tests/services/supabase/test_users_manager.py +++ b/tests/services/supabase/test_users_manager.py @@ -5,6 +5,7 @@ import pytest from config import ( + GITHUB_NOREPLY_EMAIL_DOMAIN, OWNER_ID, OWNER_NAME, OWNER_TYPE, @@ -14,6 +15,7 @@ INSTALLATION_ID, UNIQUE_ISSUE_ID, NEW_INSTALLATION_ID, + TEST_EMAIL, ) from services.stripe.customer import get_subscription from services.supabase import SupabaseManager @@ -47,12 +49,15 @@ def test_create_and_update_user_request_works() -> None: owner_id=OWNER_ID, user_id=USER_ID, user_name=USER_NAME, + email=TEST_EMAIL, ) usage_record_id = supabase_manager.create_user_request( user_id=USER_ID, + user_name=USER_NAME, installation_id=INSTALLATION_ID, unique_issue_id="U/gitautoai/nextjs-website#52", + email=TEST_EMAIL, ) assert isinstance(usage_record_id, int) assert ( @@ -83,6 +88,7 @@ def test_how_many_requests_left() -> None: owner_id=OWNER_ID, user_id=USER_ID, user_name=USER_NAME, + email=TEST_EMAIL, ) # Testing 0 requests have been made on free tier requests_left, request_count, end_date = ( @@ -132,7 +138,9 @@ def test_how_many_requests_left() -> None: assert isinstance(end_date, datetime.datetime) # Clean Up - supabase_manager.delete_installation(installation_id=INSTALLATION_ID) + supabase_manager.delete_installation( + installation_id=INSTALLATION_ID, user_id=USER_ID + ) # test_how_many_requests_left() @@ -154,6 +162,7 @@ def test_is_users_first_issue() -> None: owner_id=OWNER_ID, user_id=USER_ID, user_name=USER_NAME, + email=TEST_EMAIL, ) assert supabase_manager.is_users_first_issue( user_id=USER_ID, installation_id=INSTALLATION_ID @@ -189,6 +198,7 @@ def test_parse_subscription_object() -> None: owner_id=OWNER_ID, user_id=USER_ID, user_name=USER_NAME, + email=TEST_EMAIL, ) def assertion_test(customer_id: str, product_id: str): @@ -390,7 +400,7 @@ async def test_install_uninstall_install() -> None: assert users_data[1][0]["user_id"] == USER_ID assert users_data[1][0]["installation_id"] == NEW_INSTALLATION_ID # Should be selected since it's the only user -> used for account selected in website - assert users_data[1][0]["is_selected"] is False + assert users_data[1][0]["is_selected"] is True assert ( users_data[1][0]["first_issue"] is True ) # first issue since hasn't had an issue @@ -401,3 +411,47 @@ async def test_install_uninstall_install() -> None: # Clean Up wipe_installation_owner_user_data() wipe_installation_owner_user_data(NEW_INSTALLATION_ID) + + +def test_handle_user_email_update() -> None: + """Test updating a user's email in the users table""" + supabase_manager = SupabaseManager(url=SUPABASE_URL, key=SUPABASE_SERVICE_ROLE_KEY) + + # Clean up at the beginning just in case a prior test failed to clean + wipe_installation_owner_user_data() + + # Insert a user into the database + supabase_manager.create_installation( + installation_id=INSTALLATION_ID, + owner_type=OWNER_TYPE, + owner_name=OWNER_NAME, + owner_id=OWNER_ID, + user_id=USER_ID, + user_name=USER_NAME, + email=TEST_EMAIL, + ) + # Verify github no-reply email is not updated + json_data = {"user_id": USER_ID, "user_name": USER_NAME} + json_data["email"] = f"no_reply_email@{GITHUB_NOREPLY_EMAIL_DOMAIN}" + supabase_manager.upsert_user(**json_data) + user_data = supabase_manager.get_user(user_id=USER_ID) + assert user_data["email"] == TEST_EMAIL + + # Verify None email is not updated + json_data["email"] = None + supabase_manager.upsert_user(**json_data) + user_data = supabase_manager.get_user(user_id=USER_ID) + assert user_data["email"] == TEST_EMAIL + + # Verify valid email is updated + new_email = "new_email@example.com" + json_data["email"] = "new_email@example.com" + supabase_manager.upsert_user(**json_data) + user_data = supabase_manager.get_user(user_id=USER_ID) + assert user_data["email"] == new_email + + # Clean Up + wipe_installation_owner_user_data() + + +# test_handle_user_email_update()