git_ops icon indicating copy to clipboard operation
git_ops copied to clipboard

Proposal: Support looking up GitHub users keeping their email address private

Open carlgleisner opened this issue 1 month ago • 4 comments

Current behaviour

Users are fetched using GitHub's /search/users endpoint with the parameter q as [email protected] in:email.

https://github.com/zachdaniel/git_ops/blob/dad561231124dceadad2bfbed62845d572e87dc9/lib/git_ops/github.ex#L40-L43

This works fine for user exposing their email addresses. However, the search endpoint will not return any results for the @users.noreply.github.com addresses on commits made by users using the "Keep my email address private" feature.

As a result of the endpoint not returning any records, the lookup for users using this feature fails.

Proposal

Since the username is in included in the email address – such as: [email protected] – it is possible to extract that username and call the /users/<username> endpoint instead in those cases.

@doc """
Find a GitHub user by their email address.
Returns {:ok, user} if found, where user contains :username, :id, and :url.
Returns {:error, reason} if not found or if there's an error.
"""
def fetch_user_from_api(nil) do
  {:error, "Error making GitHub API request: No email address"}
end

The above would be a new case to catch no email provided since the (proposed) if email do statement would be replaced with an if statement on whether the provided email address is of the "keep my email address private" variety.

def fetch_user_from_api(email) do
  Application.ensure_all_started(:req)

  if String.match?(email, ~r/@users.noreply.github.com$/) do
    case Req.get("#{GitOps.Config.github_api_base_url()}/users/#{username_from_email(email)}") do
      {:ok, %Req.Response{status: 200, body: user}} ->
        {:ok,
         %{
           username: user["login"],
           id: user["id"],
           url: user["html_url"]
         }}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, "GitHub API request failed with status #{status}: #{inspect(body)}"}

      {:error, reason} ->
        {:error, "Error making GitHub API request: #{inspect(reason)}"}
    end

That's the proposed new part, then the rest is as before.

  else
    case Req.get("#{GitOps.Config.github_api_base_url()}/search/users",
           headers: github_headers(),
           params: [q: "#{email} in:email", per_page: 2]
         ) do
      {:ok, %Req.Response{status: 200, body: %{"items" => [first_user | _]}}} ->
        {:ok,
         %{
           username: first_user["login"],
           id: first_user["id"],
           url: first_user["html_url"]
         }}

      {:ok, %Req.Response{status: 200, body: %{"items" => []}}} ->
        {:error, "No user found with email #{email}"}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, "GitHub API request failed with status #{status}: #{inspect(body)}"}

      {:error, reason} ->
        {:error, "Error making GitHub API request: #{inspect(reason)}"}
    end
  end
rescue
  error ->
    {:error, "Error making GitHub API request: #{inspect(error)}"}
end

Since the email addresses can have the format 1234567+username@ I propose a private helper function to extract the username:

defp username_from_email(email) do
  Regex.named_captures(~r/^(\d+\+){0,1}(?<username>\w+)@users.noreply.github.com$/, email)
  |> Map.get("username")
end

I've got a working branch of this in my own fork, for what it's worth.

This proposal is provided most humbly of course! 😇

carlgleisner avatar Nov 08 '25 09:11 carlgleisner