diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index b23ba1be4..1aa33d55b 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -6,11 +6,15 @@ defmodule Algora.Accounts do alias Algora.Accounts.Identity alias Algora.Accounts.User alias Algora.Bounties.Bounty + alias Algora.Contracts.Contract alias Algora.Github alias Algora.Organizations alias Algora.Organizations.Member alias Algora.Payments.Transaction alias Algora.Repo + alias Algora.Workspace.Contributor + alias Algora.Workspace.Installation + alias Algora.Workspace.Repository require Algora.SQL @@ -246,7 +250,7 @@ defmodule Algora.Accounts do @doc """ Registers a user from their GitHub information. """ - def register_github_user(primary_email, info, emails, token) do + def register_github_user(current_user, primary_email, info, emails, token) do query = from(u in User, left_join: i in Identity, @@ -257,10 +261,16 @@ defmodule Algora.Accounts do select: {u, i} ) - case Repo.one(query) do + account = + case Repo.all(query) do + [] -> nil + [{user, identity}] -> {user, identity} + records -> Enum.find(records, fn {user, _} -> user.id == current_user.id end) + end + + case account do nil -> create_user(info, primary_email, emails, token) - {user, nil} -> update_user(user, info, primary_email, emails, token) - {user, _identity} -> update_github_token(user, token) + {user, identity} -> update_user(user, identity, info, primary_email, emails, token) end end @@ -274,15 +284,74 @@ defmodule Algora.Accounts do |> Repo.insert() end - def update_user(user, info, primary_email, emails, token) do - with {:ok, _} <- - user - |> Identity.github_registration_changeset(info, primary_email, emails, token) - |> Repo.insert() do - user - |> User.github_registration_changeset(info, primary_email, emails, token) - |> Repo.update() - end + def update_user(user, identity, info, primary_email, emails, token) do + old_user = Repo.get_by(User, provider: "github", provider_id: to_string(info["id"])) + + identity_changeset = Identity.github_registration_changeset(user, info, primary_email, emails, token) + + user_changeset = User.github_registration_changeset(user, info, primary_email, emails, token) + + Repo.transact(fn -> + delete_result = + if identity do + Repo.delete(identity) + else + {:ok, nil} + end + + migrate_result = + if old_user && old_user.id != user.id do + {:ok, old_user} = + old_user + |> change(provider: nil, provider_id: nil, provider_login: nil, provider_meta: nil) + |> Repo.update() + + # TODO: enqueue job + migrate_user(old_user, user) + + {:ok, old_user} + else + {:ok, nil} + end + + with {:ok, _} <- delete_result, + {:ok, _} <- migrate_result, + {:ok, _} <- Repo.insert(identity_changeset) do + Repo.update(user_changeset) + end + end) + end + + def migrate_user(old_user, new_user) do + Repo.update_all( + from(r in Repository, where: r.user_id == ^old_user.id), + set: [user_id: new_user.id] + ) + + Repo.update_all( + from(c in Contributor, where: c.user_id == ^old_user.id), + set: [user_id: new_user.id] + ) + + Repo.update_all( + from(c in Contract, where: c.contractor_id == ^old_user.id), + set: [contractor_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.owner_id == ^old_user.id), + set: [owner_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.provider_user_id == ^old_user.id), + set: [provider_user_id: new_user.id] + ) + + Repo.update_all( + from(i in Installation, where: i.connected_user_id == ^old_user.id), + set: [connected_user_id: new_user.id] + ) end # def get_user_by_provider_email(provider, email) when provider in [:github] do @@ -352,19 +421,6 @@ defmodule Algora.Accounts do end end - defp update_github_token(%User{} = user, new_token) do - identity = - Repo.one!(from(i in Identity, where: i.user_id == ^user.id and i.provider == "github")) - - {:ok, _} = - identity - |> change() - |> put_change(:provider_token, new_token) - |> Repo.update() - - {:ok, Repo.preload(user, :identities, force: true)} - end - def last_context(nil), do: "nil" def last_context(%User{last_context: nil} = user) do @@ -413,8 +469,20 @@ defmodule Algora.Accounts do def get_last_context_user(%User{} = user) do case last_context(user) do - "personal" -> user - last_context -> get_user_by_handle(last_context) + "personal" -> + user + + "preview/" <> ctx -> + case String.split(ctx, "/") do + [id, _repo_owner, _repo_name] -> get_user(id) + _ -> nil + end + + "repo/" <> _repo_full_name -> + user + + last_context -> + get_user_by_handle(last_context) end end diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 16057d41f..a6e743a23 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -129,7 +129,7 @@ defmodule Algora.Accounts.User do params = %{ "handle" => info["login"], "email" => primary_email, - "display_name" => get_change(identity_changeset, :provider_name), + "display_name" => info["name"], "bio" => info["bio"], "location" => info["location"], "avatar_url" => info["avatar_url"], @@ -176,21 +176,33 @@ defmodule Algora.Accounts.User do Identity.github_registration_changeset(user, info, primary_email, emails, token) if identity_changeset.valid? do - params = %{ - "display_name" => user.display_name || get_change(identity_changeset, :provider_name), - "bio" => user.bio || info["bio"], - "location" => user.location || info["location"], - "avatar_url" => user.avatar_url || info["avatar_url"], - "website_url" => user.website_url || info["blog"], - "github_url" => user.github_url || info["html_url"], - "provider" => "github", - "provider_id" => to_string(info["id"]), - "provider_login" => info["login"], - "provider_meta" => info - } + params = + %{ + "handle" => user.handle || Algora.Organizations.ensure_unique_handle(info["login"]), + "email" => user.email || primary_email, + "display_name" => user.display_name || info["name"], + "bio" => user.bio || info["bio"], + "location" => user.location || info["location"], + "avatar_url" => user.avatar_url || info["avatar_url"], + "website_url" => user.website_url || info["blog"], + "github_url" => user.github_url || info["html_url"], + "provider" => "github", + "provider_id" => to_string(info["id"]), + "provider_login" => info["login"], + "provider_meta" => info + } + + params = + if is_nil(user.provider_id) do + Map.put(params, "display_name", info["name"]) + else + params + end user |> cast(params, [ + :handle, + :email, :display_name, :bio, :location, @@ -203,8 +215,11 @@ defmodule Algora.Accounts.User do :provider_meta ]) |> generate_id() - |> validate_required([:display_name]) + |> validate_required([:email, :display_name, :handle]) + |> validate_handle() |> validate_email() + |> unique_constraint(:email) + |> unique_constraint(:handle) else user |> change() @@ -270,7 +285,7 @@ defmodule Algora.Accounts.User do def validate_handle(changeset) do reserved_words = - ~w(personal org admin support help security team staff official auth tip home dashboard bounties community user payment claims orgs projects jobs leaderboard onboarding pricing developers companies contracts community blog docs open hiring sdk api) + ~w(personal org admin support help security team staff official auth tip home dashboard bounties community user payment claims orgs projects jobs leaderboard onboarding pricing developers companies contracts community blog docs open hiring sdk api repo go preview) changeset |> validate_format(:handle, ~r/^[a-zA-Z0-9_-]{2,32}$/) @@ -320,8 +335,9 @@ defmodule Algora.Accounts.User do validate_inclusion(changeset, :timezone, Tzdata.zone_list()) end - defp type_from_provider(:github, "Organization"), do: :organization - defp type_from_provider(:github, _), do: :individual + def type_from_provider(:github, "Bot"), do: :bot + def type_from_provider(:github, "Organization"), do: :organization + def type_from_provider(:github, _), do: :individual def handle(%{handle: handle}) when is_binary(handle), do: handle def handle(%{provider_login: handle}), do: handle diff --git a/lib/algora/admin/admin.ex b/lib/algora/admin/admin.ex index 8a94553aa..25982da76 100644 --- a/lib/algora/admin/admin.ex +++ b/lib/algora/admin/admin.ex @@ -16,6 +16,17 @@ defmodule Algora.Admin do require Logger + def alert(message) do + %{ + title: "Alert: #{message}", + body: message, + name: "Algora Alert", + email: "info@algora.io" + } + |> Algora.Activities.SendEmail.changeset() + |> Repo.insert() + end + def token!, do: System.fetch_env!("ADMIN_GITHUB_TOKEN") def run(worker) do diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 26a614349..83f43f45b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -35,6 +35,7 @@ defmodule Algora.Bounties do | {:tech_stack, [String.t()]} | {:before, %{inserted_at: DateTime.t(), id: String.t()}} | {:amount_gt, Money.t()} + | {:current_user, User.t()} def broadcast do Phoenix.PubSub.broadcast(Algora.PubSub, "bounties:all", :bounties_updated) @@ -44,16 +45,24 @@ defmodule Algora.Bounties do Phoenix.PubSub.subscribe(Algora.PubSub, "bounties:all") end - @spec do_create_bounty(%{creator: User.t(), owner: User.t(), amount: Money.t(), ticket: Ticket.t()}) :: + @spec do_create_bounty(%{ + creator: User.t(), + owner: User.t(), + amount: Money.t(), + ticket: Ticket.t(), + visibility: Bounty.visibility(), + shared_with: [String.t()] + }) :: {:ok, Bounty.t()} | {:error, atom()} - defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}) do + defp do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket} = params) do changeset = Bounty.changeset(%Bounty{}, %{ amount: amount, ticket_id: ticket.id, owner_id: owner.id, creator_id: creator.id, - visibility: owner.bounty_mode + visibility: params[:visibility] || owner.bounty_mode, + shared_with: params[:shared_with] || [] }) changeset @@ -96,7 +105,9 @@ defmodule Algora.Bounties do strategy: strategy(), installation_id: integer(), command_id: integer(), - command_source: :ticket | :comment + command_source: :ticket | :comment, + visibility: Bounty.visibility() | nil, + shared_with: [String.t()] | nil ] ) :: {:ok, Bounty.t()} | {:error, atom()} @@ -110,6 +121,7 @@ defmodule Algora.Bounties do opts \\ [] ) do command_id = opts[:command_id] + shared_with = opts[:shared_with] || [] Repo.transact(fn -> with {:ok, %{installation_id: installation_id, token: token}} <- @@ -119,9 +131,33 @@ defmodule Algora.Bounties do {:ok, strategy} <- strategy_to_action(existing, opts[:strategy]), {:ok, bounty} <- (case strategy do - :create -> do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}) - :set -> existing |> Bounty.changeset(%{amount: amount}) |> Repo.update() - :increase -> existing |> Bounty.changeset(%{amount: Money.add!(existing.amount, amount)}) |> Repo.update() + :create -> + do_create_bounty(%{ + creator: creator, + owner: owner, + amount: amount, + ticket: ticket, + visibility: opts[:visibility], + shared_with: shared_with + }) + + :set -> + existing + |> Bounty.changeset(%{ + amount: amount, + visibility: opts[:visibility], + shared_with: shared_with + }) + |> Repo.update() + + :increase -> + existing + |> Bounty.changeset(%{ + amount: Money.add!(existing.amount, amount), + visibility: opts[:visibility], + shared_with: shared_with + }) + |> Repo.update() end), {:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, @@ -315,7 +351,10 @@ defmodule Algora.Bounties do ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, installation_id: opts[:installation_id], command_id: opts[:command_id], - command_source: opts[:command_source] + command_source: opts[:command_source], + bounty_id: bounty.id, + visibility: bounty.visibility, + shared_with: bounty.shared_with } |> Jobs.NotifyBounty.new() |> Oban.insert() @@ -979,13 +1018,37 @@ defmodule Algora.Bounties do :open -> query = where(query, [t: t], t.state == :open) - case criteria[:owner_id] do - nil -> - where(query, [b, o: o], b.visibility == :public and o.featured == true) + query = + case criteria[:current_user] do + nil -> + where(query, [b], b.visibility != :exclusive) + + user -> + where( + query, + [b], + b.visibility != :exclusive or + (b.visibility == :exclusive and + fragment( + "? && ARRAY[?, ?, ?]::citext[]", + b.shared_with, + ^user.id, + ^user.email, + ^to_string(user.provider_id) + )) + ) + end + + query = + case criteria[:owner_id] do + nil -> + where(query, [b, o: o], (b.visibility == :public and o.featured == true) or b.visibility == :exclusive) + + _org_id -> + query + end - _org_id -> - where(query, [b], b.visibility in [:public, :community]) - end + query _ -> query @@ -996,6 +1059,9 @@ defmodule Algora.Bounties do where: {b.inserted_at, b.id} < {^inserted_at, ^id} ) + {:tech_stack, []}, query -> + query + {:tech_stack, tech_stack}, query -> from([b, r: r] in query, where: fragment("? && ?::citext[]", r.tech_stack, ^tech_stack) diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index 48774232a..518898b90 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -11,6 +11,10 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do require Logger @impl Oban.Worker + def perform(%Oban.Job{args: %{"bounty_id" => bounty_id, "visibility" => "exclusive", "shared_with" => shared_with}}) do + Algora.Admin.alert("Notify exclusive bounty #{bounty_id} to #{inspect(shared_with)}") + end + def perform(%Oban.Job{ args: %{ "owner_login" => owner_login, diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index 7d623d4c6..07f2a5ed1 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -5,12 +5,15 @@ defmodule Algora.Bounties.Bounty do alias Algora.Accounts.User alias Algora.Bounties.Bounty + @type visibility :: :community | :exclusive | :public + typed_schema "bounties" do field :amount, Algora.Types.Money field :status, Ecto.Enum, values: [:open, :cancelled, :paid] field :number, :integer, default: 0 field :autopay_disabled, :boolean, default: false - field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public + field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], null: false, default: :public + field :shared_with, {:array, :string}, null: false, default: [] belongs_to :ticket, Algora.Workspace.Ticket belongs_to :owner, User @@ -29,7 +32,7 @@ defmodule Algora.Bounties.Bounty do def changeset(bounty, attrs) do bounty - |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility]) + |> cast(attrs, [:amount, :ticket_id, :owner_id, :creator_id, :visibility, :shared_with]) |> validate_required([:amount, :ticket_id, :owner_id, :creator_id]) |> generate_id() |> foreign_key_constraint(:ticket) diff --git a/lib/algora/contracts/contracts.ex b/lib/algora/contracts/contracts.ex index 96e501eab..2e4f6f8be 100644 --- a/lib/algora/contracts/contracts.ex +++ b/lib/algora/contracts/contracts.ex @@ -31,7 +31,7 @@ defmodule Algora.Contracts do | {:open?, true} | {:active_or_paid?, true} | {:original?, true} - | {:status, :draft | :active | :paid} + | {:status, Contract.status() | {:in, [Contract.status()]}} | {:after, non_neg_integer()} | {:before, non_neg_integer()} | {:order, :asc | :desc} @@ -684,6 +684,9 @@ defmodule Algora.Contracts do {:original?, true}, query -> from([c] in query, where: c.id == c.original_contract_id) + {:status, {:in, statuses}}, query -> + from([c] in query, where: c.status in ^statuses) + {:status, status}, query -> from([c] in query, where: c.status == ^status) diff --git a/lib/algora/contracts/schemas/contract.ex b/lib/algora/contracts/schemas/contract.ex index 631542520..592f54180 100644 --- a/lib/algora/contracts/schemas/contract.ex +++ b/lib/algora/contracts/schemas/contract.ex @@ -8,6 +8,8 @@ defmodule Algora.Contracts.Contract do alias Algora.MoneyUtils alias Algora.Validations + @type status :: :draft | :active | :paid | :cancelled | :disputed + typed_schema "contracts" do field :status, Ecto.Enum, values: [:draft, :active, :paid, :cancelled, :disputed] field :sequence_number, :integer, default: 1 diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index 27ba71e8e..cff8f36c5 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -23,5 +23,6 @@ defmodule Algora.Github.Behaviour do {:ok, map()} | {:error, String.t()} @callback list_repository_events(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} @callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: {:ok, [map()]} | {:error, String.t()} + @callback list_repository_contributors(token(), String.t(), String.t()) :: {:ok, [map()]} | {:error, String.t()} @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: {:ok, [map()]} | {:error, String.t()} end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index 240b4d0e1..0a8fcbd7a 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -241,6 +241,11 @@ defmodule Algora.Github.Client do fetch(access_token, "/repos/#{owner}/#{repo}/issues/comments#{build_query(opts)}") end + @impl true + def list_repository_contributors(access_token, owner, repo) do + fetch(access_token, "/repos/#{owner}/#{repo}/contributors") + end + @impl true def add_labels(access_token, owner, repo, number, labels) do fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels", "POST", %{ diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 394d38bf5..2c95aa063 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -125,6 +125,9 @@ defmodule Algora.Github do def list_repository_comments(token, owner, repo, opts \\ []), do: client().list_repository_comments(token, owner, repo, opts) + @impl true + def list_repository_contributors(token, owner, repo), do: client().list_repository_contributors(token, owner, repo) + @impl true def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels) end diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 7a101fc9f..55a3bc5db 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -3,9 +3,11 @@ defmodule Algora.Organizations do import Ecto.Query alias Algora.Accounts.User + alias Algora.Github.TokenPool alias Algora.Organizations.Member alias Algora.Organizations.Org alias Algora.Repo + alias Algora.Workspace def create_organization(params) do %User{type: :organization} @@ -26,24 +28,24 @@ defmodule Algora.Organizations do end def onboard_organization(params) do - Repo.transact(fn repo -> + Repo.transact(fn -> {:ok, user} = - case repo.get_by(User, email: params.user.email) do + case Repo.get_by(User, email: params.user.email) do nil -> - handle = generate_unique_handle(repo, params.user.handle) + handle = ensure_unique_handle(params.user.handle) %User{type: :individual} |> User.org_registration_changeset(Map.put(params.user, :handle, handle)) - |> repo.insert() + |> Repo.insert() existing_user -> existing_user |> User.org_registration_changeset(Map.delete(params.user, :handle)) - |> repo.update() + |> Repo.update() end {:ok, org} = - case repo.one( + case Repo.one( from o in User, join: m in assoc(o, :members), join: u in assoc(m, :user), @@ -52,39 +54,49 @@ defmodule Algora.Organizations do limit: 1 ) do nil -> - handle = generate_unique_org_handle(repo, params.organization.handle) + handle = ensure_unique_org_handle(params.organization.handle) %User{type: :organization} |> Org.changeset(Map.put(params.organization, :handle, handle)) - |> repo.insert() + |> Repo.insert() existing_org -> existing_org |> Org.changeset(Map.delete(params.organization, :handle)) - |> repo.update() + |> Repo.update() end {:ok, member} = - case repo.get_by(Member, user_id: user.id, org_id: org.id) do + case Repo.get_by(Member, user_id: user.id, org_id: org.id) do nil -> %Member{} |> Member.changeset(Map.merge(params.member, %{user_id: user.id, org_id: org.id})) - |> repo.insert() + |> Repo.insert() existing_member -> existing_member |> Member.changeset(Map.merge(params.member, %{user_id: user.id, org_id: org.id})) - |> repo.update() + |> Repo.update() end {:ok, %{org: org, user: user, member: member}} end) end - defp generate_unique_handle(repo, base_handle) do + def generate_handle_from_email(email) do + email + |> String.split("@") + |> List.first() + |> String.split("+") + |> List.first() + |> String.replace(~r/[^a-zA-Z0-9]/, "") + |> String.downcase() + end + + def ensure_unique_handle(base_handle) do 0 |> Stream.iterate(&(&1 + 1)) - |> Enum.reduce_while(base_handle, fn i, _handle -> {:halt, increment_handle(repo, base_handle, i)} end) + |> Enum.reduce_while(base_handle, fn i, _handle -> {:halt, increment_handle(base_handle, i)} end) end defp generate_unique_org_handle_candidates(base_handle) do @@ -98,25 +110,25 @@ defmodule Algora.Organizations do ) end - defp generate_unique_org_handle(repo, base_handle) do - case try_candidates(repo, base_handle) do - nil -> increment_handle(repo, base_handle, 1) + def ensure_unique_org_handle(base_handle) do + case try_candidates(base_handle) do + nil -> increment_handle(base_handle, 1) handle -> handle end end - defp try_candidates(repo, base_handle) do + defp try_candidates(base_handle) do candidates = generate_unique_org_handle_candidates(base_handle) Enum.reduce_while(candidates, nil, fn candidate, _acc -> - case repo.get_by(User, handle: candidate) do + case Repo.get_by(User, handle: candidate) do nil -> {:halt, candidate} _user -> {:cont, nil} end end) end - defp increment_handle(repo, base_handle, n) do + defp increment_handle(base_handle, n) do candidate = case n do 0 -> base_handle @@ -124,9 +136,9 @@ defmodule Algora.Organizations do _ -> raise "Too many attempts to generate unique handle" end - case repo.get_by(User, handle: candidate) do + case Repo.get_by(User, handle: candidate) do nil -> candidate - _user -> increment_handle(repo, base_handle, n + 1) + _user -> increment_handle(base_handle, n + 1) end end @@ -228,4 +240,33 @@ defmodule Algora.Organizations do where: c.client_id == ^org.id and c.contractor_id == u.id ) end + + def init_preview(repo_owner, repo_name) do + token = TokenPool.get_token() + + {:ok, _repo} = Workspace.ensure_repository(token, repo_owner, repo_name) + {:ok, owner} = Workspace.ensure_user(token, repo_owner) + {:ok, _contributors} = Workspace.ensure_contributors(token, repo_owner, repo_name) + + Repo.transact(fn repo -> + {:ok, org} = + repo.insert(%User{ + type: :organization, + id: Nanoid.generate(), + display_name: owner.name, + avatar_url: owner.avatar_url, + last_context: "repo/#{repo_owner}/#{repo_name}" + }) + + {:ok, user} = + repo.insert(%User{ + type: :individual, + id: Nanoid.generate(), + display_name: "You", + last_context: "preview/#{org.id}/#{repo_owner}/#{repo_name}" + }) + + {:ok, %{org: org, user: user}} + end) + end end diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex index b0683a903..aad4d2e75 100644 --- a/lib/algora/shared/util.ex +++ b/lib/algora/shared/util.ex @@ -122,4 +122,11 @@ defmodule Algora.Util do # TODO: Implement this for all countries def locale_from_country_code("gr"), do: "el" def locale_from_country_code(country_code), do: country_code + + def parse_github_url(url) do + case Regex.run(~r{(?:github\.com/)?([^/\s]+)/([^/\s]+)}, url) do + [_, owner, repo] -> {:ok, {owner, repo}} + _ -> {:error, "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format"} + end + end end diff --git a/lib/algora/workspace/schemas/contributor.ex b/lib/algora/workspace/schemas/contributor.ex new file mode 100644 index 000000000..6f8ebaccf --- /dev/null +++ b/lib/algora/workspace/schemas/contributor.ex @@ -0,0 +1,51 @@ +defmodule Algora.Workspace.Contributor do + @moduledoc false + use Algora.Schema + + alias Algora.Accounts.User + alias Algora.Workspace.Repository + + typed_schema "contributors" do + field :contributions, :integer, default: 0 + + belongs_to :repository, Repository + belongs_to :user, User + + timestamps() + end + + def github_user_changeset(meta) do + params = %{ + provider_id: to_string(meta["id"]), + provider_login: meta["login"], + type: User.type_from_provider(:github, meta["type"]), + display_name: meta["login"], + avatar_url: meta["avatar_url"], + github_url: meta["html_url"] + } + + %User{provider: "github", provider_meta: meta} + |> cast(params, [:provider_id, :provider_login, :type, :display_name, :avatar_url, :github_url]) + |> generate_id() + |> validate_required([:provider_id, :provider_login, :type]) + |> unique_constraint([:provider, :provider_id]) + end + + def changeset(contributor, params) do + contributor + |> cast(params, [:contributions, :repository_id, :user_id]) + |> validate_required([:repository_id, :user_id]) + |> foreign_key_constraint(:repository_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:repository_id, :user_id]) + |> generate_id() + end + + def filter_by_repository_id(query, nil), do: query + + def filter_by_repository_id(query, repository_id) do + from c in query, + join: r in assoc(c, :repository), + where: r.id == ^repository_id + end +end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index c7558c88c..dfb7c2c0e 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -9,6 +9,7 @@ defmodule Algora.Workspace do alias Algora.Repo alias Algora.Util alias Algora.Workspace.CommandResponse + alias Algora.Workspace.Contributor alias Algora.Workspace.Installation alias Algora.Workspace.Jobs alias Algora.Workspace.Repository @@ -447,4 +448,81 @@ defmodule Algora.Workspace do {:ok, command_response} end end + + def ensure_contributors(token, owner, repo) do + case list_repository_contributors(owner, repo) do + [] -> + with {:ok, repository} <- ensure_repository(token, owner, repo), + {:ok, contributors} <- Github.list_repository_contributors(token, owner, repo) do + Repo.transact(fn -> + Enum.reduce_while(contributors, {:ok, []}, fn contributor, {:ok, acc} -> + case create_contributor_from_github(repository, contributor) do + {:ok, created} -> {:cont, {:ok, [created | acc]}} + error -> {:halt, error} + end + end) + end) + end + + contributors -> + {:ok, contributors} + end + end + + defp ensure_user_by_contributor(contributor) do + case Repo.get_by(User, provider: "github", provider_id: to_string(contributor["id"])) do + %User{} = user -> + {:ok, user} + + nil -> + contributor + |> Contributor.github_user_changeset() + |> Repo.insert() + end + end + + def create_contributor_from_github(repository, contributor) do + with {:ok, user} <- ensure_user_by_contributor(contributor) do + %Contributor{} + |> Contributor.changeset(%{ + contributions: contributor["contributions"], + repository_id: repository.id, + user_id: user.id + }) + |> Repo.insert() + end + end + + def list_repository_contributors(repo_owner, repo_name) do + Repo.all( + from(c in Contributor, + join: r in assoc(c, :repository), + where: r.provider == "github", + where: r.name == ^repo_name, + join: ro in assoc(r, :user), + where: ro.provider_login == ^repo_owner, + join: u in assoc(c, :user), + select_merge: %{user: u}, + order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] + ) + ) + end + + def list_contributors(repo_owner) do + Repo.all( + from(c in Contributor, + join: r in assoc(c, :repository), + where: r.provider == "github", + join: ro in assoc(r, :user), + where: ro.provider_login == ^repo_owner, + join: u in assoc(c, :user), + select_merge: %{user: u}, + order_by: [desc: c.contributions, asc: c.inserted_at, asc: c.id] + ) + ) + end + + def fetch_contributor(repository_id, user_id) do + Repo.fetch_by(Contributor, repository_id: repository_id, user_id: user_id) + end end diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 10e9402cd..a21f9638b 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -232,7 +232,7 @@ defmodule AlgoraWeb.CoreComponents do
{@subtitle}
diff --git a/lib/algora_web/components/ui/avatar.ex b/lib/algora_web/components/ui/avatar.ex index 7deecf34c..6d79f8da8 100644 --- a/lib/algora_web/components/ui/avatar.ex +++ b/lib/algora_web/components/ui/avatar.ex @@ -14,7 +14,7 @@ defmodule AlgoraWeb.Components.UI.Avatar do end attr :class, :string, default: nil - attr :src, :string, required: true + attr :src, :string, default: nil attr :rest, :global def avatar_image(assigns) do @@ -23,7 +23,7 @@ defmodule AlgoraWeb.Components.UI.Avatar do ~H"""