Skip to content
Merged
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,10 @@ ssh -i infrastructure/nat-instance-ssh-private-key.pem ec2-user@54.176.165.89
- NO TYPE: IGNORE: Do not use # type: ignore comments to suppress type errors. This silences the type checker without fixing the actual type mismatch. Fix the underlying type issues instead.
- NO CAST: Do not use typing.cast() to suppress type errors. Cast doesn't validate or guarantee the type is correct - it just tells the type checker to trust you without verification. Fix the underlying type issues instead. **EXCEPTIONS**: (1) Use cast when external libraries (e.g., Supabase) return `Any` and you need proper type inference. Example: `return cast(str, result.data["token"])` when `result.data` is typed as `Any` by the library. (2) Use cast in test files when creating test fixtures that need to satisfy TypedDict requirements.
- NO SUPPRESSION: Do not use # pyright: reportArgumentType=false or any other pyright suppression comments. Fix the underlying type issues instead.
- NO ANY: Do not use Any type. Fix the specific type issues instead. When you have to use `as any` in TypeScript/JavaScript, ALWAYS add a comment above explaining why it's needed. Example:
- NO ANY: Do not use Any type. Fix the specific type issues instead. `response.json()` returns `Any` - always assign it to a fully typed variable (e.g., `commits: list[dict[str, dict[str, str]]] = response.json()`). `dict` without value types makes `.get()` return `Any` - always specify value types so downstream `.get()` calls return concrete types, not `Any`. Similarly, chaining 2+ `.get()` calls propagates `Any` - break the chain into separate variables.
- NO `.get()` DEFAULTS: Do not use `.get("key", {})` or `.get("key", "")`. Use `.get("key")` and handle `None` explicitly. Defaults hide missing data and make bugs harder to find. When you have to use `as any` in TypeScript/JavaScript, ALWAYS add a comment above explaining why it's needed. Example:
- No annotations: Don't use annotations like var: type = value. Fix the root cause of type issues instead.
- NO UNNECESSARY HELPER FUNCTIONS: Do not extract code into helper functions unless the code is used multiple times. Single-use helper functions add unnecessary indirection and make code harder to follow. Keep logic inline when it's only used once.
- SINGLE RESPONSIBILITY: One file, one function. Don't create 2+ functions in a file. If a helper is used multiple times within the file, keep it as a private function in that file. If it's used only once, inline it. If it's needed by other files, move it to its own file.
- ALWAYS USE `@handle_exceptions` DECORATOR: Every function (sync or async) must use the `@handle_exceptions` decorator from `utils.error.handle_exceptions`. This ensures consistent error handling, Sentry reporting, and logging across the codebase. Use `@handle_exceptions(default_return_value=..., raise_on_error=False)` with an appropriate default return value.
- NO `__init__.py`: Do not create `__init__.py` files. Python 3.3+ supports implicit namespace packages, so `__init__.py` is not required. This project uses Python 3.13.

Expand Down
28 changes: 28 additions & 0 deletions services/github/users/get_email_from_commits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import requests

from config import GITHUB_API_URL, TIMEOUT
from services.github.utils.create_headers import create_headers
from utils.error.handle_exceptions import handle_exceptions

NOREPLY_SUFFIX = "@users.noreply.github.com"


@handle_exceptions(default_return_value=None, raise_on_error=False)
def get_email_from_commits(owner: str, repo: str, username: str, token: str):
"""Fallback: get email from recent commits when the public profile has no email.
https://docs.github.com/en/rest/commits/commits#list-commits"""
response = requests.get(
url=f"{GITHUB_API_URL}/repos/{owner}/{repo}/commits",
headers=create_headers(token=token),
params={"author": username, "per_page": 5},
timeout=TIMEOUT,
)
response.raise_for_status()
commits: list[dict[str, dict[str, dict[str, str]]]] = response.json()
for commit in commits:
commit_obj = commit.get("commit", {})
author = commit_obj.get("author", {})
email = author.get("email", "")
if email and not email.endswith(NOREPLY_SUFFIX):
return email
return None
4 changes: 3 additions & 1 deletion services/github/users/get_user_public_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ def get_user_public_info(username: str, token: str):
)
response.raise_for_status()
user_data: dict[str, str | None] = response.json()
email = user_data.get("email")

name: str = user_data.get("name") or ""
# Title-case if all lowercase or all uppercase (e.g., "wes nishio" -> "Wes Nishio", "HIROSHI" -> "Hiroshi")
# Cross-ref: website/app/api/auth/[...nextauth]/route.ts
if name == name.lower() or name == name.upper():
name = name.title()
return UserPublicInfo(email=user_data.get("email"), display_name=name)
return UserPublicInfo(email=email, display_name=name)
55 changes: 55 additions & 0 deletions services/github/users/test_get_email_from_commits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from unittest.mock import MagicMock, patch

from services.github.users.get_email_from_commits import get_email_from_commits


@patch("services.github.users.get_email_from_commits.requests.get")
def test_returns_first_non_noreply_email(mock_get: MagicMock):
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status = MagicMock()
mock_get.return_value.json.return_value = [
{"commit": {"author": {"email": "wes@gitauto.ai"}}},
]
result = get_email_from_commits(
owner="gitautoai", repo="gitauto", username="wes", token="tok"
)
assert result == "wes@gitauto.ai"


@patch("services.github.users.get_email_from_commits.requests.get")
def test_skips_noreply_email(mock_get: MagicMock):
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status = MagicMock()
mock_get.return_value.json.return_value = [
{"commit": {"author": {"email": "123+user@users.noreply.github.com"}}},
{"commit": {"author": {"email": "real@example.com"}}},
]
result = get_email_from_commits(owner="o", repo="r", username="u", token="t")
assert result == "real@example.com"


@patch("services.github.users.get_email_from_commits.requests.get")
def test_returns_none_when_all_noreply(mock_get: MagicMock):
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status = MagicMock()
mock_get.return_value.json.return_value = [
{"commit": {"author": {"email": "123+user@users.noreply.github.com"}}},
]
result = get_email_from_commits(owner="o", repo="r", username="u", token="t")
assert result is None


@patch("services.github.users.get_email_from_commits.requests.get")
def test_returns_none_when_no_commits(mock_get: MagicMock):
mock_get.return_value.status_code = 200
mock_get.return_value.raise_for_status = MagicMock()
mock_get.return_value.json.return_value = []
result = get_email_from_commits(owner="o", repo="r", username="u", token="t")
assert result is None


@patch("services.github.users.get_email_from_commits.requests.get")
def test_returns_none_on_api_error(mock_get: MagicMock):
mock_get.side_effect = Exception("API error")
result = get_email_from_commits(owner="o", repo="r", username="u", token="t")
assert result is None
98 changes: 78 additions & 20 deletions services/github/users/test_get_user_public_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ def test_get_user_public_info_successful_request(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.email == "test@example.com"
assert result.display_name == "Test User"

Expand All @@ -56,7 +59,8 @@ def test_get_user_public_info_bot_user_skips_api_call(sample_token):
"""Test that bot usernames skip the API call (bots have name=null, email=null)."""
with patch("services.github.users.get_user_public_email.requests.get") as mock_get:
result = get_user_public_info(
username="github-actions[bot]", token=sample_token
username="github-actions[bot]",
token=sample_token,
)
assert result == DEFAULT_RETURN
mock_get.assert_not_called()
Expand All @@ -71,7 +75,10 @@ def test_get_user_public_info_calls_correct_api_endpoint(sample_username, sample
}
mock_get.return_value.raise_for_status.return_value = None

get_user_public_info(username=sample_username, token=sample_token)
get_user_public_info(
username=sample_username,
token=sample_token,
)

expected_url = f"{GITHUB_API_URL}/users/{sample_username}"
mock_get.assert_called_once()
Expand All @@ -94,7 +101,10 @@ def test_get_user_public_info_uses_correct_headers(sample_username, sample_token
}
mock_get.return_value.raise_for_status.return_value = None

get_user_public_info(username=sample_username, token=sample_token)
get_user_public_info(
username=sample_username,
token=sample_token,
)

mock_create_headers.assert_called_once_with(token=sample_token)
mock_get.assert_called_once()
Expand All @@ -111,7 +121,10 @@ def test_get_user_public_info_uses_correct_timeout(sample_username, sample_token
}
mock_get.return_value.raise_for_status.return_value = None

get_user_public_info(username=sample_username, token=sample_token)
get_user_public_info(
username=sample_username,
token=sample_token,
)

mock_get.assert_called_once()
_, kwargs = mock_get.call_args
Expand All @@ -126,7 +139,10 @@ def test_get_user_public_info_calls_raise_for_status(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
get_user_public_info(username=sample_username, token=sample_token)
get_user_public_info(
username=sample_username,
token=sample_token,
)
mock_response.raise_for_status.assert_called_once()


Expand All @@ -147,7 +163,10 @@ def test_get_user_public_info_extracts_email_and_name_from_response(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.email == expected_email
assert result.display_name == "Some User"

Expand All @@ -173,7 +192,10 @@ def test_get_user_public_info_with_different_usernames():
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=username, token="test-token")
result = get_user_public_info(
username=username,
token="test-token",
)
assert result.email == expected_email
assert result.display_name == expected_name

Expand Down Expand Up @@ -223,7 +245,10 @@ def test_get_user_public_info_http_error_returns_default(sample_username, sample
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result == DEFAULT_RETURN


Expand All @@ -235,7 +260,10 @@ def test_get_user_public_info_request_exception_returns_default(
"services.github.users.get_user_public_email.requests.get",
side_effect=RequestException("Network error"),
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result == DEFAULT_RETURN


Expand All @@ -245,7 +273,10 @@ def test_get_user_public_info_timeout_returns_default(sample_username, sample_to
"services.github.users.get_user_public_email.requests.get",
side_effect=Timeout("Request timed out"),
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result == DEFAULT_RETURN


Expand All @@ -261,7 +292,10 @@ def test_get_user_public_info_json_decode_error_returns_default(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result == DEFAULT_RETURN


Expand All @@ -280,7 +314,10 @@ def test_get_user_public_info_missing_email_key_returns_none_email(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.email is None
assert result.display_name == "Test User"

Expand All @@ -301,7 +338,10 @@ def test_get_user_public_info_null_email_value_returns_none_email(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.email is None
assert result.display_name == "Test User"

Expand All @@ -321,7 +361,10 @@ def test_get_user_public_info_missing_name_key_returns_empty_display_name(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.email == "test@example.com"
assert result.display_name == ""

Expand All @@ -339,7 +382,10 @@ def test_get_user_public_info_title_cases_lowercase_name(sample_username, sample
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.display_name == "Wes Nishio"


Expand All @@ -356,7 +402,10 @@ def test_get_user_public_info_title_cases_uppercase_name(sample_username, sample
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.display_name == "Hiroshi"


Expand All @@ -373,7 +422,10 @@ def test_get_user_public_info_preserves_mixed_case_name(sample_username, sample_
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.display_name == "Wes Nishio"


Expand All @@ -392,7 +444,10 @@ def test_get_user_public_info_empty_name_returns_empty_display_name(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.display_name == ""


Expand All @@ -411,5 +466,8 @@ def test_get_user_public_info_null_name_returns_empty_display_name(
"services.github.users.get_user_public_email.requests.get",
return_value=mock_response,
):
result = get_user_public_info(username=sample_username, token=sample_token)
result = get_user_public_info(
username=sample_username,
token=sample_token,
)
assert result.display_name == ""
5 changes: 5 additions & 0 deletions services/github/utils/deconstruct_github_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from services.github.branches.check_branch_exists import check_branch_exists
from services.github.types.github_types import BaseArgs, PrLabeledPayload
from services.github.token.get_installation_token import get_installation_access_token
from services.github.users.get_email_from_commits import get_email_from_commits
from services.github.users.get_user_public_email import get_user_public_info
from services.supabase.repositories.get_repository import get_repository
from utils.error.handle_exceptions import handle_exceptions
Expand Down Expand Up @@ -77,6 +78,10 @@ def deconstruct_github_payload(
github_urls, other_urls = extract_urls(text=pr_body)
sender_info = get_user_public_info(username=sender_name, token=token)
sender_email = sender_info.email
if not sender_email:
sender_email = get_email_from_commits(
owner=owner_name, repo=repo_name, username=sender_name, token=token
)
sender_display_name = sender_info.display_name

base_args: BaseArgs = {
Expand Down
5 changes: 5 additions & 0 deletions services/webhook/check_suite_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from services.github.workflow_runs.get_workflow_run_logs import get_workflow_run_logs
from services.claude.tools.tools import TOOLS_FOR_PRS
from services.slack.slack_notify import slack_notify
from services.github.users.get_email_from_commits import get_email_from_commits
from services.github.users.get_user_public_email import get_user_public_info
from services.supabase.check_suites.insert_check_suite import insert_check_suite
from services.supabase.codecov_tokens.get_codecov_token import get_codecov_token
Expand Down Expand Up @@ -173,6 +174,10 @@ async def handle_check_suite(
sender_id = payload["sender"]["id"]
sender_name = payload["sender"]["login"]
sender_info = get_user_public_info(username=sender_name, token=token)
if not sender_info.email:
sender_info.email = get_email_from_commits(
owner=owner_name, repo=repo_name, username=sender_name, token=token
)

# Extract PR related variables and return if no PR is associated with this check suite
pull_requests = check_suite["pull_requests"]
Expand Down
Loading