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
{ctx.name}
-
@{ctx.handle}
+
@{ctx.handle}
@@ -791,15 +791,17 @@ defmodule AlgoraWeb.CoreComponents do name={@name} id={@id || @name} value={Phoenix.HTML.Form.normalize_value(@type, @value)} - class={[ - "py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", - "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6", - "border-input focus:border-ring focus:ring-ring", - @errors != [] && - "border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10", - @icon && "pl-10", - @class - ]} + class={ + classes([ + "py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", + "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6", + "border-input focus:border-ring focus:ring-ring", + @errors != [] && + "border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10", + @icon && "pl-10", + @class + ]) + } autocomplete="off" {@rest} /> @@ -1167,7 +1169,7 @@ defmodule AlgoraWeb.CoreComponents do def section(assigns) do ~H"""
-
+

{@title}

{@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""" "bg-secondary hover:bg-secondary/80 text-foreground border-secondary-foreground/20 hover:border-secondary-foreground/40 focus-visible:outline-secondary-foreground shadow border", "ghost" => "hover:bg-accent hover:text-accent-foreground", - "link" => "text-primary underline-offset-4 hover:underline" + "link" => "text-primary underline-offset-4 hover:underline", + "none" => "" }, size: %{ "default" => "h-9 px-4 py-2 text-sm", diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex index 204608ade..6dfc5cf9e 100644 --- a/lib/algora_web/components/ui/drawer.ex +++ b/lib/algora_web/components/ui/drawer.ex @@ -114,7 +114,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do def drawer_content(assigns) do ~H""" -
+
{render_slot(@inner_block)}
""" diff --git a/lib/algora_web/controllers/installation_callback_controller.ex b/lib/algora_web/controllers/installation_callback_controller.ex index 3af6db697..433cc55f2 100644 --- a/lib/algora_web/controllers/installation_callback_controller.ex +++ b/lib/algora_web/controllers/installation_callback_controller.ex @@ -1,10 +1,18 @@ defmodule AlgoraWeb.InstallationCallbackController do use AlgoraWeb, :controller + import Ecto.Changeset + import Ecto.Query + alias Algora.Accounts + alias Algora.Accounts.User alias Algora.Github alias Algora.Organizations + alias Algora.Organizations.Member + alias Algora.Repo + alias Algora.Util alias Algora.Workspace + alias AlgoraWeb.UserAuth require Logger @@ -23,10 +31,10 @@ defmodule AlgoraWeb.InstallationCallbackController do :info, "Installation request submitted! The Algora app will be activated upon approval from your organization administrator." ) - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) {:error, _reason} -> - redirect(conn, to: redirect_url(conn)) + redirect(conn, to: UserAuth.signed_in_path(conn)) end end @@ -49,33 +57,108 @@ defmodule AlgoraWeb.InstallationCallbackController do defp handle_installation(conn, setup_action, installation_id) do user = conn.assigns.current_user - case do_handle_installation(user, installation_id) do - {:ok, _org} -> + case do_handle_installation(conn, user, installation_id) do + {:ok, conn} -> conn |> put_flash(:info, if(setup_action == :install, do: "Installation successful!", else: "Installation updated!")) - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) {:error, error} -> Logger.error("❌ Installation callback failed: #{inspect(error)}") conn |> put_flash(:error, "#{inspect(error)}") - |> redirect(to: redirect_url(conn)) + |> redirect(to: UserAuth.signed_in_path(conn)) + end + end + + defp get_followers_count(token, user) do + if followers_count = user.provider_meta["followers_count"] do + followers_count + else + case Github.get_user(token, user.provider_id) do + {:ok, user} -> user["followers"] + _ -> 0 + end end end - defp do_handle_installation(user, installation_id) do + defp get_total_followers_count(token, users) do + users + |> Enum.map(&get_followers_count(token, &1)) + |> Enum.sum() + end + + defp featured_follower_threshold, do: 50 + + defp do_handle_installation(conn, user, installation_id) do # TODO: replace :last_context with a new :last_installation_target field # TODO: handle nil user # TODO: handle nil last_context with {:ok, access_token} <- Accounts.get_access_token(user), {:ok, installation} <- Github.find_installation(access_token, installation_id), - {:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]), - {:ok, org} <- Organizations.fetch_org_by(handle: user.last_context), - {:ok, _} <- Workspace.upsert_installation(installation, user, org, provider_user) do - {:ok, org} + {:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]) do + total_followers_count = get_total_followers_count(access_token, [user, provider_user]) + + case user.last_context do + "preview/" <> ctx -> + case String.split(ctx, "/") do + [id, _repo_owner, _repo_name] -> + existing_org = + Repo.one( + from(u in User, + where: u.provider == "github", + where: u.provider_id == ^to_string(installation["account"]["id"]) + ) + ) + + {:ok, org} = + case existing_org do + %User{} = org -> {:ok, org} + nil -> Repo.fetch(User, id) + end + + {:ok, _member} = + case Repo.get_by(Member, user_id: user.id, org_id: org.id) do + %Member{} = member -> {:ok, member} + nil -> Repo.insert(%Member{id: Nanoid.generate(), user: user, org: org, role: :admin}) + end + + {:ok, org} = + org + |> change( + handle: Organizations.ensure_unique_org_handle(installation["account"]["login"]), + featured: if(org.featured, do: true, else: total_followers_count > featured_follower_threshold()), + provider: "github", + provider_id: to_string(installation["account"]["id"]), + provider_meta: Util.normalize_struct(installation["account"]) + ) + |> Repo.update() + + {:ok, user} = + user + |> change(last_context: org.handle) + |> Repo.update() + + {:ok, _} = Workspace.upsert_installation(installation, user, org, provider_user) + + {:ok, UserAuth.put_current_user(conn, user)} + + _ -> + {:error, :invalid_last_context} + end + + last_context -> + {:ok, org} = Organizations.fetch_org_by(handle: last_context) + + {:ok, org} = + org + |> change(featured: if(org.featured, do: true, else: total_followers_count > featured_follower_threshold())) + |> Repo.update() + + {:ok, _} = Workspace.upsert_installation(installation, user, org, provider_user) + {:ok, conn} + end end end - - defp redirect_url(conn), do: ~p"/org/#{Accounts.last_context(conn.assigns.current_user)}" end diff --git a/lib/algora_web/controllers/oauth_callback_controller.ex b/lib/algora_web/controllers/oauth_callback_controller.ex index 0aa17f1a5..8124010da 100644 --- a/lib/algora_web/controllers/oauth_callback_controller.ex +++ b/lib/algora_web/controllers/oauth_callback_controller.ex @@ -33,7 +33,7 @@ defmodule AlgoraWeb.OAuthCallbackController do with {:ok, data} <- res, {:ok, info} <- Github.OAuth.exchange_access_token(code: code, state: state), %{info: info, primary_email: primary, emails: emails, token: token} = info, - {:ok, user} <- Accounts.register_github_user(primary, info, emails, token) do + {:ok, user} <- Accounts.register_github_user(conn.assigns[:current_user], primary, info, emails, token) do if socket_id do Phoenix.PubSub.broadcast(Algora.PubSub, "auth:#{socket_id}", {:authenticated, user}) end diff --git a/lib/algora_web/controllers/org_preview_callback_controller.ex b/lib/algora_web/controllers/org_preview_callback_controller.ex new file mode 100644 index 000000000..843207880 --- /dev/null +++ b/lib/algora_web/controllers/org_preview_callback_controller.ex @@ -0,0 +1,37 @@ +defmodule AlgoraWeb.OrgPreviewCallbackController do + use AlgoraWeb, :controller + + import Ecto.Query + + alias Algora.Accounts.User + alias Algora.Repo + + require Logger + + def new(conn, %{"id" => id, "token" => token} = params) do + with {:ok, _login_token} <- AlgoraWeb.UserAuth.verify_preview_code(token, id), + {:ok, user} <- + Repo.fetch_one( + from u in User, + where: u.id == ^id, + where: is_nil(u.handle), + where: is_nil(u.provider_login) + ) do + conn = + if params["return_to"] do + put_session(conn, :user_return_to, String.trim_leading(params["return_to"], AlgoraWeb.Endpoint.url())) + else + conn + end + + AlgoraWeb.UserAuth.log_in_user(conn, user) + else + {:error, reason} -> + Logger.debug("failed preview exchange #{inspect(reason)}") + + conn + |> put_flash(:error, "Something went wrong. Please try again.") + |> redirect(to: "/") + end + end +end diff --git a/lib/algora_web/controllers/user_auth.ex b/lib/algora_web/controllers/user_auth.ex index 4f50d4e34..85ac96978 100644 --- a/lib/algora_web/controllers/user_auth.ex +++ b/lib/algora_web/controllers/user_auth.ex @@ -224,6 +224,14 @@ defmodule AlgoraWeb.UserAuth do defp maybe_store_return_to(conn), do: conn def signed_in_path_from_context("personal"), do: ~p"/home" + + def signed_in_path_from_context("preview/" <> ctx) do + case String.split(ctx, "/") do + [_id, repo_owner, repo_name] -> ~p"/go/#{repo_owner}/#{repo_name}" + _ -> ~p"/home" + end + end + def signed_in_path_from_context(org_handle), do: ~p"/org/#{org_handle}" def signed_in_path(%User{} = user) do @@ -293,6 +301,28 @@ defmodule AlgoraWeb.UserAuth do end end + def sign_preview_code(payload) do + Phoenix.Token.sign(AlgoraWeb.Endpoint, login_code_salt(), payload, max_age: login_code_ttl()) + end + + def verify_preview_code(code, id) do + case Phoenix.Token.verify(AlgoraWeb.Endpoint, login_code_salt(), code, max_age: login_code_ttl()) do + {:ok, token_id} -> + if token_id == id do + {:ok, token_id} + else + {:error, :invalid_id} + end + + {:error, reason} -> + {:error, reason} + end + end + + def preview_path(id, token), do: ~p"/preview?id=#{id}&token=#{token}" + + def preview_path(id, token, return_to), do: ~p"/preview?id=#{id}&token=#{token}&return_to=#{return_to}" + def login_path(email, token), do: ~p"/callbacks/email/oauth?email=#{email}&token=#{token}" def login_path(email, token, return_to), diff --git a/lib/algora_web/forms/bounty_form.ex b/lib/algora_web/forms/bounty_form.ex index 95e898144..7c44c8f88 100644 --- a/lib/algora_web/forms/bounty_form.ex +++ b/lib/algora_web/forms/bounty_form.ex @@ -10,6 +10,8 @@ defmodule AlgoraWeb.Forms.BountyForm do embedded_schema do field :url, :string field :amount, USD + field :visibility, Ecto.Enum, values: [:community, :exclusive, :public], default: :public + field :shared_with, {:array, :string}, default: [] embeds_one :ticket_ref, TicketRef, primary_key: false do field :owner, :string @@ -21,7 +23,7 @@ defmodule AlgoraWeb.Forms.BountyForm do def changeset(form, attrs \\ %{}) do form - |> cast(attrs, [:url, :amount]) + |> cast(attrs, [:url, :amount, :visibility, :shared_with]) |> validate_required([:url, :amount]) |> Validations.validate_money_positive(:amount) |> Validations.validate_ticket_ref(:url, :ticket_ref) diff --git a/lib/algora_web/forms/tip_form.ex b/lib/algora_web/forms/tip_form.ex index 97d0105e9..bb3cbab9e 100644 --- a/lib/algora_web/forms/tip_form.ex +++ b/lib/algora_web/forms/tip_form.ex @@ -8,14 +8,23 @@ defmodule AlgoraWeb.Forms.TipForm do alias Algora.Validations embedded_schema do + field :url, :string field :github_handle, :string field :amount, USD + + embeds_one :ticket_ref, TicketRef, primary_key: false do + field :owner, :string + field :repo, :string + field :number, :integer + field :type, :string + end end def changeset(form, attrs \\ %{}) do form - |> cast(attrs, [:github_handle, :amount]) - |> validate_required([:github_handle, :amount]) + |> cast(attrs, [:url, :github_handle, :amount]) + |> validate_required([:url, :github_handle, :amount]) |> Validations.validate_money_positive(:amount) + |> Validations.validate_ticket_ref(:url, :ticket_ref) end end diff --git a/lib/algora_web/live/bounties_live.ex b/lib/algora_web/live/bounties_live.ex index fa0dc45c0..77b684d53 100644 --- a/lib/algora_web/live/bounties_live.ex +++ b/lib/algora_web/live/bounties_live.ex @@ -76,7 +76,7 @@ defmodule AlgoraWeb.BountiesLive do ~H"""
<.section title="Bounties" subtitle="Open bounties for you"> -
+
<%= for {tech, count} <- @techs do %>
<.badge diff --git a/lib/algora_web/live/community_live.ex b/lib/algora_web/live/community_live.ex index 2a741ef21..7d8681502 100644 --- a/lib/algora_web/live/community_live.ex +++ b/lib/algora_web/live/community_live.ex @@ -37,7 +37,7 @@ defmodule AlgoraWeb.CommunityLive do ~H"""
<.section title="Community" subtitle="Meet the Algora community"> -
+
<%= for tech <- @techs do %>
cast(attrs, [:url]) + |> validate_required([:url]) + end + end + @impl true def mount(%{"country_code" => country_code}, _session, socket) do Gettext.put_locale(AlgoraWeb.Gettext, Algora.Util.locale_from_country_code(country_code)) @@ -44,7 +63,9 @@ defmodule AlgoraWeb.HomeLive do |> assign(:stats, stats) |> assign(:faq_items, get_faq_items()) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) + # TODO: add url |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) + |> assign(:repo_form, to_form(RepoForm.changeset(%RepoForm{}, %{}))) |> assign(:pending_action, nil)} end @@ -63,29 +84,29 @@ defmodule AlgoraWeb.HomeLive do

The open source Upwork for engineers

-

+

Discover GitHub bounties, contract work and jobs

-

+

Hire the top 1% open source developers

-
- <.button - navigate={~p"/onboarding/org"} - variant="default" - class="px-12 py-8 text-xl font-semibold" - > - Companies - - <.button - navigate={~p"/onboarding/dev"} - variant="secondary" - class="px-12 py-8 text-xl font-semibold" - > - Developers - -
+ <.form for={@repo_form} phx-submit="submit_repo" class="mt-10 w-full max-w-2xl"> +
+ <.input + field={@repo_form[:url]} + placeholder="github.com/your/repo" + class="w-full h-16 text-xl sm:text-2xl pl-[3.75rem] pr-48 border-emerald-500 font-display" + /> + + <.button + type="submit" + class="absolute right-2 top-2 bottom-2 px-8 h-[3rem] text-xl font-semibold" + > + Get Started + +
+
@@ -111,129 +132,6 @@ defmodule AlgoraWeb.HomeLive do
- <%!--
- <.pattern /> -
-

- Fund GitHub Issues -

-

- Support open source development with bounties on GitHub issues -

- -
- <.link - href="https://github.com/zed-industries/zed/issues/4440" - rel="noopener" - class="relative flex items-center gap-x-4 rounded-xl bg-black p-6 ring-1 ring-border transition-colors" - > -
- Zed - Scott Chacon -
-
-
- GitHub cofounder funds new feature in Zed Editor -
-
- Zed Editor, Scott Chacon -
-
- <.button size="lg" variant="secondary"> - View issue - - - - <.link - href="https://github.com/PX4/PX4-Autopilot/issues/22464" - rel="noopener" - class="relative flex items-center gap-x-4 rounded-xl bg-black p-6 ring-1 ring-border transition-colors" - > -
- Alex Klimaj - PX4 - Andrew Wilkins -
-
-
- DefenceTech CEOs fund obstacle avoidance in PX4 Drone Autopilot -
-
- Alex Klimaj, Founder of ARK Electronics & Andrew Wilkins, CEO of Ascend Engineering -
-
- <.button size="lg" variant="secondary"> - View issue - - - -
-
-
- Fund any issue in seconds -
-
- Help improve the OSS you love and rely on -
-
-
- <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Pay when PRs are merged -
-
- <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Pool bounties with other sponsors -
-
- <.icon name="tabler-check" class="h-4 w-4 mr-1 text-success-400" /> - Algora handles invoices, payouts, compliance & 1099s -
-
-
- <.form - for={@bounty_form} - phx-submit="create_bounty" - class="col-span-3 grid grid-cols-3 gap-6 w-full" - > - <.input - label="URL" - field={@bounty_form[:url]} - placeholder="https://github.com/owner/repo/issues/1337" - /> - <.input - label="Amount" - icon="tabler-currency-dollar" - field={@bounty_form[:amount]} - class="placeholder:text-success" - /> -
-
No credit card required
- <.button size="lg" class="w-full">Fund issue -
- -
-
-
-
--%> -
<.pattern />
@@ -917,6 +815,37 @@ defmodule AlgoraWeb.HomeLive do end end + @impl true + def handle_event("submit_repo", %{"repo_form" => params}, socket) do + changeset = + %RepoForm{} + |> RepoForm.changeset(params) + |> Map.put(:action, :validate) + + if changeset.valid? do + url = get_field(changeset, :url) + + case Algora.Util.parse_github_url(url) do + {:ok, {repo_owner, repo_name}} -> + token = Github.TokenPool.get_token() + + case Workspace.ensure_repository(token, repo_owner, repo_name) do + {:ok, _repo} -> + {:noreply, push_navigate(socket, to: ~p"/go/#{repo_owner}/#{repo_name}")} + + {:error, reason} -> + Logger.error("Failed to create repository: #{inspect(reason)}") + {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, "Repository not found")))} + end + + {:error, message} -> + {:noreply, assign(socket, :repo_form, to_form(add_error(changeset, :url, message)))} + end + else + {:noreply, assign(socket, :repo_form, to_form(changeset))} + end + end + @impl true def handle_info({:authenticated, user}, socket) do socket = assign(socket, :current_user, user) diff --git a/lib/algora_web/live/onboarding/org.ex b/lib/algora_web/live/onboarding/org.ex index 2ca81ff9a..c22764cc0 100644 --- a/lib/algora_web/live/onboarding/org.ex +++ b/lib/algora_web/live/onboarding/org.ex @@ -7,6 +7,7 @@ defmodule AlgoraWeb.Onboarding.OrgLive do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Organizations alias AlgoraWeb.Components.Wordmarks alias Phoenix.LiveView.AsyncResult alias Swoosh.Email @@ -352,17 +353,7 @@ defmodule AlgoraWeb.Onboarding.OrgLive do handle end - user_handle = - email - |> String.split("@") - |> List.first() - |> String.split("+") - |> List.first() - |> String.replace(~r/[^a-zA-Z0-9]/, "") - |> String.downcase() - - org_unique_handle = org_handle - user_unique_handle = user_handle + user_handle = Organizations.generate_handle_from_email(email) org_params = %{ @@ -372,7 +363,7 @@ defmodule AlgoraWeb.Onboarding.OrgLive do get_in(metadata, [:org, :og_description]) || get_in(metadata, [:org, :og_title]), avatar_url: get_in(metadata, [:org, :avatar_url]) || get_in(metadata, [:org, :favicon_url]), - handle: org_unique_handle, + handle: org_handle, domain: domain, og_title: get_in(metadata, [:org, :og_title]), og_image_url: get_in(metadata, [:org, :og_image_url]), @@ -393,9 +384,9 @@ defmodule AlgoraWeb.Onboarding.OrgLive do email: email, display_name: user_handle, avatar_url: get_in(metadata, [:avatar_url]), - handle: user_unique_handle, + handle: user_handle, tech_stack: tech_stack, - last_context: org_unique_handle, + last_context: org_handle, timezone: socket.assigns.timezone } diff --git a/lib/algora_web/live/org/bounties_live.ex b/lib/algora_web/live/org/bounties_live.ex index f9ed4345c..0301c07d7 100644 --- a/lib/algora_web/live/org/bounties_live.ex +++ b/lib/algora_web/live/org/bounties_live.ex @@ -324,7 +324,7 @@ defmodule AlgoraWeb.Org.BountiesLive do defp to_transaction_rows(transactions), do: transactions - def assign_more_bounties(socket) do + defp assign_more_bounties(socket) do %{rows: rows, current_org: current_org} = socket.assigns last_bounty = List.last(rows).bounty diff --git a/lib/algora_web/live/org/dashboard_live.ex b/lib/algora_web/live/org/dashboard_live.ex index b6f693fe6..7bf264525 100644 --- a/lib/algora_web/live/org/dashboard_live.ex +++ b/lib/algora_web/live/org/dashboard_live.ex @@ -3,6 +3,7 @@ defmodule AlgoraWeb.Org.DashboardLive do use AlgoraWeb, :live_view import AlgoraWeb.Components.Achievement + import AlgoraWeb.Components.Bounties import Ecto.Changeset import Ecto.Query @@ -13,14 +14,18 @@ defmodule AlgoraWeb.Org.DashboardLive do alias Algora.Bounties.Claim alias Algora.Contracts alias Algora.Github + alias Algora.Organizations + alias Algora.Payments alias Algora.Payments.Transaction alias Algora.Repo alias Algora.Workspace + alias Algora.Workspace.Contributor alias Algora.Workspace.Ticket alias AlgoraWeb.Components.Logos alias AlgoraWeb.Forms.BountyForm alias AlgoraWeb.Forms.ContractForm alias AlgoraWeb.Forms.TipForm + alias Swoosh.Email require Logger @@ -29,25 +34,44 @@ defmodule AlgoraWeb.Org.DashboardLive do %{current_org: current_org} = socket.assigns if socket.assigns.current_user_role in [:admin, :mod] do - top_earners = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) - - installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") - if connected?(socket) do Phoenix.PubSub.subscribe(Algora.PubSub, "auth:#{socket.id}") end + _experts = Accounts.list_developers(org_id: current_org.id, earnings_gt: Money.zero(:USD)) + experts = [] + + installations = Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") + + contributors = + case current_org.last_context do + "repo/" <> repo -> + case String.split(repo, "/") do + [repo_owner, repo_name] -> Workspace.list_repository_contributors(repo_owner, repo_name) + _ -> Workspace.list_contributors(current_org.provider_login) + end + + _ -> + Workspace.list_contributors(current_org.provider_login) + end + {:ok, socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) - |> assign(:matching_devs, top_earners) + |> assign(:experts, experts) + |> assign(:contributors, contributors) + |> assign(:developers, contributors |> Enum.map(& &1.user) |> Enum.concat(experts)) + |> assign(:has_more_bounties, false) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign(:bounty_form, to_form(BountyForm.changeset(%BountyForm{}, %{}))) |> assign(:tip_form, to_form(TipForm.changeset(%TipForm{}, %{}))) |> assign(:contract_form, to_form(ContractForm.changeset(%ContractForm{}, %{}))) - |> assign(:show_contract_modal, false) + |> assign(:show_share_drawer, false) + |> assign(:share_drawer_type, nil) |> assign(:selected_developer, nil) + |> assign(:secret_code, nil) + |> assign_login_form(User.login_changeset(%User{}, %{})) |> assign_payable_bounties() |> assign_contracts() |> assign_achievements()} @@ -56,11 +80,31 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_params(params, _uri, socket) do + current_org = socket.assigns.current_org + current_status = get_current_status(params) + + stats = Bounties.fetch_stats(current_org.id) + + bounties = Bounties.list_bounties(owner_id: current_org.id, limit: page_size(), status: :open) + transactions = Payments.list_sent_transactions(current_org.id, limit: page_size()) + + {:noreply, + socket + |> assign(:current_status, current_status) + |> assign(:bounty_rows, to_bounty_rows(bounties)) + |> assign(:transaction_rows, to_transaction_rows(transactions)) + |> assign(:has_more_bounties, length(bounties) >= page_size()) + |> assign(:has_more_transactions, length(transactions) >= page_size()) + |> assign(:stats, stats)} + end + @impl true def render(assigns) do ~H"""
-
+
<.section :if={@payable_bounties != %{}}> <.card> <.card_header> @@ -170,354 +214,231 @@ defmodule AlgoraWeb.Org.DashboardLive do - <.section :if={@installations == []}> - <.card> - <.card_header> - <.card_title>GitHub Integration - <.card_description :if={@installations == []}> - Install the Algora app to enable slash commands in your GitHub repositories - - - <.card_content> -
- <.button phx-click="install_app" class="ml-auto gap-2"> - Install GitHub App - -
- - - - - <.section> -
- {create_bounty(assigns)} - {create_tip(assigns)} + <.section + :if={@contributors != []} + title={"#{@current_org.name} Contributors"} + subtitle="Share bounties, tips or contract opportunities with your top contributors" + > +
+ + + <%= for %Contributor{user: user} <- @contributors do %> + <.developer_card + user={user} + contract_for_user={contract_for_user(@contracts, user)} + current_org={@current_org} + /> + <% end %> + +
-
-
-

- Contracts -

-

- Engage top-performing developers with contract opportunities -

-
-
+ <.section + :if={@experts != []} + title="Algora Experts" + subtitle="Meet Algora experts versed in your tech stack" + > +
- <%= for user <- @matching_devs do %> - <.matching_dev user={user} contracts={@contracts} current_org={@current_org} /> + <%= for user <- @experts do %> + <.developer_card + user={user} + contract_for_user={contract_for_user(@contracts, user)} + current_org={@current_org} + /> <% end %>
-
-
-
- {sidebar(assigns)} - <.drawer show={@show_contract_modal} direction="right" on_cancel="close_contract_drawer"> - <.drawer_header :if={@selected_developer}> - <.drawer_title>Offer Contract - <.drawer_description> - Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. - - - <.drawer_content :if={@selected_developer} class="mt-4"> - <.form for={@contract_form} phx-change="validate_contract" phx-submit="create_contract"> -
- <.card> - <.card_header> - <.card_title>Developer - - <.card_content> -
- <.avatar class="h-20 w-20 rounded-full"> - <.avatar_image - src={@selected_developer.avatar_url} - alt={@selected_developer.name} - /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@selected_developer.name)} - - - -
-
- {@selected_developer.name} -
+ -
+
+
+
+

{@current_org.name} Bounties

+

+ Create new bounties using the + + /bounty $1000 + + command on Github. +

+
+
0} class="pb-4 md:pb-0"> + +
+
+ +
+
+
+
+
+ <%= if Enum.empty?(@bounty_rows) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
+ <.icon name="tabler-diamond" class="h-8 w-8 text-muted-foreground" /> +
+ <.card_title>No bounties yet + <.card_description class="pt-2"> + <%= if @installations == [] do %> + Install Algora in {@current_org.name} to create new bounties using the + + /bounty $1000 + + command on Github + <.button + :if={@installations == []} + phx-click="install_app" + class="mt-4 flex mx-auto" > - <.icon name="tabler-building" class="h-4 w-4" /> - - {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} - + Install Algora + + <% else %> + Create new bounties using the + + /bounty $1000 + + command on Github + <% end %> + + + + <% else %> +
+ <.bounties bounties={@bounty_rows} /> +
+
+ <.icon name="tabler-loader" class="h-6 w-6 animate-spin" /> +
+
+
+ <% end %> +
+
+ <%= if Enum.empty?(@bounty_rows) do %> + <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> + <.card_header> +
+ <.icon name="tabler-diamond" class="h-8 w-8 text-muted-foreground" /> +
+ <.card_title>No completed bounties yet + <.card_description> + Completed bounties will appear here once completed + + + + <% else %> + <%= for %{transaction: transaction, recipient: recipient, ticket: ticket} <- @transaction_rows do %> +
+
+
+
+ {Money.to_string!(transaction.net_amount)} +
+
+ {ticket.repository.user.provider_login}/{ticket.repository.name}#{ticket.number} +
+
+ {ticket.title} +
+
+ {Algora.Util.time_ago(transaction.succeeded_at)}
-
- <%= for tech <- @selected_developer.tech_stack do %> -
- {tech} +
+

+ Awarded to +

+ {recipient.name} +
+ {recipient.name} +
+ {Algora.Misc.CountryEmojis.get(recipient.country)}
- <% end %> +
- - - - <.card> - <.card_header> - <.card_title>Contract Details - - <.card_content> -
- <.input - label="Hourly Rate" - icon="tabler-currency-dollar" - field={@contract_form[:hourly_rate]} - /> - <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> -
- - - -
- <.button variant="secondary" phx-click="close_contract_drawer" type="button"> - Cancel - - <.button type="submit"> - Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> - -
-
- - - - """ - end - - defp matching_dev(assigns) do - ~H""" - - -
-
- <.link navigate={User.url(@user)}> - <.avatar class="h-20 w-20 rounded-full"> - <.avatar_image src={@user.avatar_url} alt={@user.name} /> - <.avatar_fallback class="rounded-lg"> - {Algora.Util.initials(@user.name)} - - - - -
-
- <.link navigate={User.url(@user)} class="font-semibold hover:underline"> - {@user.name} - -
- + <% end %>
- <.link - :if={@user.provider_login} - href={"https://github.com/#{@user.provider_login}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - - {@user.provider_login} - - <.link - :if={@user.provider_meta["twitter_handle"]} - href={"https://x.com/#{@user.provider_meta["twitter_handle"]}"} - target="_blank" - class="flex items-center gap-1 hover:underline" - > - <.icon name="tabler-brand-x" class="h-4 w-4" /> - {@user.provider_meta["twitter_handle"]} - -
- <.icon name="tabler-map-pin" class="h-4 w-4" /> - {@user.provider_meta["location"]} -
-
- <.icon name="tabler-building" class="h-4 w-4" /> - - {@user.provider_meta["company"] |> String.trim_leading("@")} - +
+ <.icon name="tabler-loader" class="h-6 w-6 animate-spin" />
- -
- <%= for tech <- @user.tech_stack do %> -
- {tech} -
- <% end %> -
-
+ <% end %>
- <%= if contract_for_user(@contracts, @user) do %> - <.button - variant="secondary" - navigate={ - ~p"/org/#{@current_org.handle}/contracts/#{contract_for_user(@contracts, @user).id}" - } - > - View contract - - <% else %> - <.button phx-click="offer_contract" phx-value-user_id={@user.id}> - Offer contract - - <% end %> -
- - - """ - end - - defp contract_for_user(contracts, user) do - Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) - end - - defp create_bounty(assigns) do - ~H""" - <.card> - <.card_header> -
- <.icon name="tabler-diamond" class="h-8 w-8" /> -

Post a bounty

- - <.card_content> - <.simple_form for={@bounty_form} phx-submit="create_bounty"> -
- <.input - label="URL" - field={@bounty_form[:url]} - placeholder="https://github.com/swift-lang/swift/issues/1337" - /> - <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> -

- Tip: - You can also comment /bounty $100 - to create a bounty on GitHub - -

-
- <.button>Submit -
-
- - - - """ - end - defp create_tip(assigns) do - ~H""" - <.card> - <.card_header> -
- <.icon name="tabler-gift" class="h-8 w-8" /> -

Tip a developer

-
- - <.card_content> - <.simple_form for={@tip_form} phx-submit="create_tip"> -
- <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> - <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> -

- Tip: - You can also comment /tip $100 @username - to create a tip on GitHub - -

-
- <.button>Submit -
+ <.section + title={"#{@current_org.name} Ecosystem"} + subtitle="Help maintain and grow your ecosystem by creating bounties and tips in your dependencies" + > +
+ {create_bounty(assigns)} + {create_tip(assigns)}
- - - - """ - end - - defp sidebar(assigns) do - ~H""" - +
+ {sidebar(assigns)} + {share_drawer(assigns)} """ end @@ -550,6 +471,26 @@ defmodule AlgoraWeb.Org.DashboardLive do end} end + @impl true + def handle_event("remove_contributor", %{"user_id" => user_id}, socket) do + current_org = socket.assigns.current_org + + if incomplete?(socket.assigns.achievements, :install_app_status) do + {:noreply, put_flash(socket, :error, "Please install the app first")} + else + Repo.delete_all( + from c in Contributor, + where: c.user_id == ^user_id, + join: r in assoc(c, :repository), + join: u in assoc(r, :user), + where: u.provider == ^current_org.provider and u.provider_id == ^current_org.provider_id + ) + + contributors = Enum.reject(socket.assigns.contributors, &(&1.user.id == user_id)) + {:noreply, assign(socket, :contributors, contributors)} + end + end + def handle_event("create_bounty" = event, %{"bounty_form" => params} = unsigned_params, socket) do if socket.assigns.has_fresh_token? do changeset = @@ -562,12 +503,25 @@ defmodule AlgoraWeb.Org.DashboardLive do with %{valid?: true} <- changeset, {:ok, _bounty} <- - Bounties.create_bounty(%{ - creator: socket.assigns.current_user, - owner: socket.assigns.current_org, - amount: amount, - ticket_ref: ticket_ref - }) do + Bounties.create_bounty( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + amount: amount, + ticket_ref: %{ + owner: ticket_ref.owner, + repo: ticket_ref.repo, + number: ticket_ref.number + } + }, + visibility: :exclusive, + shared_with: + case socket.assigns.selected_developer do + %User{handle: nil, provider_id: provider_id} -> [to_string(provider_id)] + %User{id: id} -> [id] + _ -> raise "Developer not selected" + end + ) do {:noreply, socket |> assign_achievements() @@ -590,23 +544,31 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true def handle_event("create_tip" = event, %{"tip_form" => params} = unsigned_params, socket) do if socket.assigns.has_fresh_token? do changeset = %TipForm{} - |> TipForm.changeset(params) + |> TipForm.changeset(Map.put(params, "github_handle", socket.assigns.current_user.provider_login)) |> Map.put(:action, :validate) - with %{valid?: true} <- changeset, - {:ok, token} <- Accounts.get_access_token(socket.assigns.current_user), - {:ok, recipient} <- Workspace.ensure_user(token, get_field(changeset, :github_handle)), + ticket_ref = get_field(changeset, :ticket_ref) + + with %{valid?: true} <- changeset, {:ok, checkout_url} <- - Bounties.create_tip(%{ - creator: socket.assigns.current_user, - owner: socket.assigns.current_org, - recipient: recipient, - amount: get_field(changeset, :amount) - }) do + Bounties.create_tip( + %{ + creator: socket.assigns.current_user, + owner: socket.assigns.current_org, + recipient: socket.assigns.selected_developer, + amount: get_field(changeset, :amount) + }, + ticket_ref: %{ + owner: ticket_ref.owner, + repo: ticket_ref.repo, + number: ticket_ref.number + } + ) do {:noreply, redirect(socket, external: checkout_url)} else %{valid?: false} -> @@ -624,33 +586,28 @@ defmodule AlgoraWeb.Org.DashboardLive do end end - def handle_event("offer_contract", %{"user_id" => user_id}, socket) do - developer = Enum.find(socket.assigns.matching_devs, &(&1.id == user_id)) + @impl true + def handle_event("share_opportunity", %{"user_id" => user_id, "type" => type}, socket) do + developer = Enum.find(socket.assigns.developers, &(&1.id == user_id)) {:noreply, socket |> assign(:selected_developer, developer) - |> assign(:show_contract_modal, true)} + |> assign(:share_drawer_type, type) + |> assign(:show_share_drawer, true)} end - def handle_event("offer_contract", _params, socket) do - # When no user_id is provided, use the user from the current row + @impl true + def handle_event("share_opportunity", _params, socket) do {:noreply, put_flash(socket, :error, "Please select a developer first")} end - def handle_event("close_contract_drawer", _params, socket) do - {:noreply, assign(socket, :show_contract_modal, false)} - end - - def handle_event("validate_contract", %{"contract_form" => params}, socket) do - changeset = - %ContractForm{} - |> ContractForm.changeset(params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :contract_form, to_form(changeset))} + @impl true + def handle_event("close_share_drawer", _params, socket) do + {:noreply, assign(socket, :show_share_drawer, false)} end + @impl true def handle_event("create_contract", %{"contract_form" => params}, socket) do changeset = ContractForm.changeset(%ContractForm{}, params) @@ -669,7 +626,7 @@ defmodule AlgoraWeb.Org.DashboardLive do # TODO: send email {:noreply, socket - |> assign(:show_contract_modal, false) + |> assign(:show_share_drawer, false) |> assign_contracts() |> put_flash(:info, "Contract offer sent to #{socket.assigns.selected_developer.name}")} @@ -682,6 +639,169 @@ defmodule AlgoraWeb.Org.DashboardLive do end end + @impl true + def handle_event("send_login_code", %{"user" => %{"email" => email}}, socket) do + code = Nanoid.generate() + + changeset = User.login_changeset(%User{}, %{}) + + case send_login_code_to_user(email, code) do + {:ok, _id} -> + {:noreply, + socket + |> assign(:secret_code, code) + |> assign(:email, email) + |> assign_login_form(changeset)} + + {:error, reason} -> + Logger.error("Failed to send login code to #{email}: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "We had trouble sending mail to #{email}. Please try again")} + end + end + + @impl true + def handle_event("send_login_code", %{"user" => %{"login_code" => code}}, socket) do + if Plug.Crypto.secure_compare(code, socket.assigns.secret_code) do + handle = + socket.assigns.email + |> Organizations.generate_handle_from_email() + |> Organizations.ensure_unique_handle() + + case Repo.get_by(User, email: socket.assigns.email) do + nil -> + {:ok, user} = + socket.assigns.current_user + |> Ecto.Changeset.change(handle: handle, email: socket.assigns.email) + |> Repo.update() + + {:noreply, + socket + |> assign(:current_user, user) + |> assign_achievements() + |> put_flash(:info, "Logged in successfully!")} + + user -> + token = AlgoraWeb.UserAuth.generate_login_code(user.email) + + {:noreply, + socket + |> redirect(to: AlgoraWeb.UserAuth.login_path(user.email, token)) + |> put_flash(:info, "Logged in successfully!")} + end + else + throttle() + {:noreply, put_flash(socket, :error, "Invalid login code")} + end + end + + @impl true + def handle_event("change-tab", %{"tab" => "completed"}, socket) do + {:noreply, push_patch(socket, to: ~p"/org/#{socket.assigns.current_org.handle}?status=completed")} + end + + @impl true + def handle_event("change-tab", %{"tab" => "open"}, socket) do + {:noreply, push_patch(socket, to: ~p"/org/#{socket.assigns.current_org.handle}?status=open")} + end + + @impl true + def handle_event("load_more", _params, socket) do + {:noreply, + case socket.assigns.current_status do + :open -> assign_more_bounties(socket) + :paid -> assign_more_transactions(socket) + end} + end + + defp throttle, do: :timer.sleep(1000) + + defp assign_login_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :login_form, to_form(changeset)) + end + + defp to_bounty_rows(bounties), do: bounties + + defp to_transaction_rows(transactions), do: transactions + + defp assign_more_bounties(socket) do + %{rows: rows, current_org: current_org} = socket.assigns + + last_bounty = List.last(rows).bounty + + cursor = %{ + inserted_at: last_bounty.inserted_at, + id: last_bounty.id + } + + more_bounties = + Bounties.list_bounties( + owner_id: current_org.id, + limit: page_size(), + status: socket.assigns.current_status, + before: cursor + ) + + socket + |> assign(:bounty_rows, rows ++ to_bounty_rows(more_bounties)) + |> assign(:has_more, length(more_bounties) >= page_size()) + end + + defp assign_more_transactions(socket) do + %{transaction_rows: rows, current_org: current_org} = socket.assigns + + last_transaction = List.last(rows).transaction + + more_transactions = + Payments.list_sent_transactions( + current_org.id, + limit: page_size(), + before: %{ + succeeded_at: last_transaction.succeeded_at, + id: last_transaction.id + } + ) + + socket + |> assign(:transaction_rows, rows ++ to_transaction_rows(more_transactions)) + |> assign(:has_more_transactions, length(more_transactions) >= page_size()) + end + + defp get_current_status(params) do + case params["status"] do + "open" -> :open + "completed" -> :paid + _ -> :open + end + end + + defp page_size, do: 10 + + @from_name "Algora" + @from_email "info@algora.io" + + defp send_login_code_to_user(email, code) do + email = + Email.new() + |> Email.to(email) + |> Email.from({@from_name, @from_email}) + |> Email.subject("Login code for Algora") + |> Email.text_body(""" + Here is your login code for Algora! + + #{code} + + If you didn't request this link, you can safely ignore this email. + + -------------------------------------------------------------------------------- + + For correspondence, please email the Algora founders at ioannis@algora.io and zafer@algora.io + + © 2025 Algora PBC. + """) + + Algora.Mailer.deliver(email) + end + defp assign_payable_bounties(socket) do org = socket.assigns.current_org @@ -711,15 +831,78 @@ defmodule AlgoraWeb.Org.DashboardLive do end defp assign_contracts(socket) do - contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: :draft) + contracts = Contracts.list_contracts(client_id: socket.assigns.current_org.id, status: {:in, [:draft, :active]}) assign(socket, :contracts, contracts) end + defp achievement_todo(%{achievement: %{status: status}} = assigns) when status != :current do + ~H""" + """ + end + + defp achievement_todo(%{achievement: %{id: :complete_signup_status}} = assigns) do + ~H""" + <.simple_form + :if={!@secret_code} + for={@login_form} + id="send_login_code_form" + phx-submit="send_login_code" + > + <.input + field={@login_form[:email]} + type="email" + label="Email" + placeholder="you@example.com" + required + /> + <.button phx-disable-with="Signing up..." class="w-full py-5"> + Sign up + + + <.simple_form + :if={@secret_code} + for={@login_form} + id="send_login_code_form" + phx-submit="send_login_code" + > + <.input field={@login_form[:login_code]} type="text" label="Login code" required /> + <.button phx-disable-with="Signing in..." class="w-full py-5"> + Submit + + + """ + end + + defp achievement_todo(%{achievement: %{id: :connect_github_status}} = assigns) do + ~H""" + <.button :if={!@current_user.provider_login} href={Github.authorize_url()} class="ml-auto gap-2"> + Connect GitHub + + """ + end + + defp achievement_todo(%{achievement: %{id: :install_app_status}} = assigns) do + ~H""" + <.button phx-click="install_app" class="ml-auto gap-2"> + Install GitHub App + + """ + end + + defp achievement_todo(assigns) do + ~H""" + """ + end + defp assign_achievements(socket) do + current_org = socket.assigns.current_org + status_fns = [ {&personalize_status/1, "Personalize Algora", nil}, - {&install_app_status/1, "Install the Algora app", nil}, + {&complete_signup_status/1, "Complete signup", nil}, + {&connect_github_status/1, "Connect GitHub", nil}, + {&install_app_status/1, "Install Algora in #{current_org.name}", nil}, {&create_bounty_status/1, "Create a bounty", nil}, {&reward_bounty_status/1, "Reward a bounty", nil}, {&share_with_friend_status/1, "Share Algora with a friend", nil} @@ -727,23 +910,42 @@ defmodule AlgoraWeb.Org.DashboardLive do {achievements, _} = Enum.reduce_while(status_fns, {[], false}, fn {status_fn, name, path}, {acc, found_current} -> + id = Function.info(status_fn)[:name] status = status_fn.(socket) result = cond do - found_current -> {acc ++ [%{status: status, name: name, path: path}], found_current} - status == :completed -> {acc ++ [%{status: status, name: name, path: path}], false} - true -> {acc ++ [%{status: :current, name: name, path: path}], true} + found_current -> {acc ++ [%{id: id, status: status, name: name, path: path}], found_current} + status == :completed -> {acc ++ [%{id: id, status: status, name: name, path: path}], false} + true -> {acc ++ [%{id: id, status: :current, name: name, path: path}], true} end {:cont, result} end) - assign(socket, :achievements, achievements) + assign(socket, :achievements, Enum.reject(achievements, &(&1.status == :completed))) + end + + defp incomplete?(achievements, id) do + Enum.any?(achievements, &(&1.id == id and &1.status != :completed)) end defp personalize_status(_socket), do: :completed + defp complete_signup_status(socket) do + case socket.assigns.current_user do + %User{handle: handle} when is_binary(handle) -> :completed + _ -> :upcoming + end + end + + defp connect_github_status(socket) do + case socket.assigns.current_user do + %User{provider_login: login} when is_binary(login) -> :completed + _ -> :upcoming + end + end + defp install_app_status(socket) do case socket.assigns.installations do [] -> :upcoming @@ -766,4 +968,503 @@ defmodule AlgoraWeb.Org.DashboardLive do end defp share_with_friend_status(_socket), do: :upcoming + + defp developer_card(assigns) do + ~H""" + + +
+
+ <.link navigate={User.url(@user)}> + <.avatar class="h-12 w-12 rounded-full"> + <.avatar_image src={@user.avatar_url} alt={@user.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@user.name)} + + + + +
+
+ <.link navigate={User.url(@user)} class="font-semibold hover:underline"> + {@user.name} + +
+ +
+ <.link + :if={@user.provider_login} + href={"https://github.com/#{@user.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@user.provider_login} + + <.link + :if={@user.provider_meta["twitter_handle"]} + href={"https://x.com/#{@user.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + {@user.provider_meta["twitter_handle"]} + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + {@user.provider_meta["location"]} +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@user.provider_meta["company"] |> String.trim_leading("@")} + +
+
+ + <%!--
+ <%= for tech <- @user.tech_stack do %> +
+ {tech} +
+ <% end %> +
--%> +
+
+
+ <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="bounty" + variant="none" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-blue-800/10 hover:text-blue-400 hover:drop-shadow-[0_1px_5px_#60a5fa80] focus:bg-blue-800/10 focus:text-blue-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#60a5fa80] border border-white/50 hover:border-blue-400/50 focus:border-blue-400/50" + > + <.icon name="tabler-diamond" class="size-4 text-current mr-2 -ml-1" /> Bounty + + <.button + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="tip" + variant="none" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-red-800/10 hover:text-red-400 hover:drop-shadow-[0_1px_5px_#f8717180] focus:bg-red-800/10 focus:text-red-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-white/50 hover:border-red-400/50 focus:border-red-400/50" + > + <.icon name="tabler-heart" class="size-4 text-current mr-2 -ml-1" /> Tip + + + <.button + :if={@contract_for_user && @contract_for_user.status == :active} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} + variant="none" + class="bg-emerald-800/10 text-emerald-400 drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-emerald-400/50 focus:border-emerald-400/50" + > + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract + + <.button + :if={@contract_for_user && @contract_for_user.status == :draft} + navigate={~p"/org/#{@current_org.handle}/contracts/#{@contract_for_user.id}"} + variant="none" + class="bg-gray-800/10 text-gray-400 drop-shadow-[0_1px_5px_#f8717180] focus:bg-gray-800/10 focus:text-gray-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#f8717180] border border-gray-400/50 focus:border-gray-400/50" + > + <.icon name="tabler-clock" class="size-4 text-current mr-2 -ml-1" /> Contract + + <.button + :if={!@contract_for_user} + phx-click="share_opportunity" + phx-value-user_id={@user.id} + phx-value-type="contract" + variant="none" + class="group bg-card text-foreground transition-colors duration-75 hover:bg-emerald-800/10 hover:text-emerald-400 hover:drop-shadow-[0_1px_5px_#34d39980] focus:bg-emerald-800/10 focus:text-emerald-300 focus:outline-none focus:drop-shadow-[0_1px_5px_#34d39980] border border-white/50 hover:border-emerald-400/50 focus:border-emerald-400/50" + > + <.icon name="tabler-contract" class="size-4 text-current mr-2 -ml-1" /> Contract + + <.dropdown_menu> + <.dropdown_menu_trigger> + <.button variant="ghost" size="icon"> + <.icon name="tabler-dots" class="h-4 w-4" /> + + + <.dropdown_menu_content> + <.dropdown_menu_item> + <.link href={User.url(@user)}> + View Profile + + + <.dropdown_menu_separator /> + <.dropdown_menu_item phx-click="remove_contributor" phx-value-user_id={@user.id}> + Remove + + + +
+
+ + + """ + end + + defp contract_for_user(contracts, user) do + Enum.find(contracts, fn contract -> contract.contractor_id == user.id end) + end + + defp create_bounty(assigns) do + ~H""" + <.card> + <.card_header> +
+ <.icon name="tabler-diamond" class="h-8 w-8" /> +

Post a bounty

+
+ + <.card_content> + <.simple_form for={@bounty_form} phx-submit="create_bounty"> +
+ <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/swift-lang/swift/issues/1337" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> +

+ Tip: + You can also comment /bounty $100 + to create a bounty on GitHub + +

+
+ <.button>Submit +
+
+ + + + """ + end + + defp create_tip(assigns) do + ~H""" + <.card> + <.card_header> +
+ <.icon name="tabler-gift" class="h-8 w-8" /> +

Tip a developer

+
+ + <.card_content> + <.simple_form for={@tip_form} phx-submit="create_tip"> +
+ <.input label="GitHub handle" field={@tip_form[:github_handle]} placeholder="jsmith" /> + <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input + label="URL" + field={@tip_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + helptext="We'll add a comment to the issue to notify the developer." + /> +

+ Tip: + You can also comment /tip $100 @username + to create a tip on GitHub + +

+
+ <.button>Submit +
+
+ + + + """ + end + + defp sidebar(assigns) do + ~H""" + + """ + end + + defp share_drawer_header(%{share_drawer_type: "contract"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Offer Contract + <.drawer_description> + Once you send an offer, {@selected_developer.name} will be notified and can accept or decline. + + + """ + end + + defp share_drawer_header(%{share_drawer_type: "bounty"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Share Bounty + <.drawer_description> + Share a bounty opportunity with {@selected_developer.name}. They will be notified and can choose to work on it. + + + """ + end + + defp share_drawer_header(%{share_drawer_type: "tip"} = assigns) do + ~H""" + <.drawer_header> + <.drawer_title>Send Tip + <.drawer_description> + Send a tip to {@selected_developer.name} to show appreciation for their contributions. + + + """ + end + + defp share_drawer_content(%{share_drawer_type: "contract"} = assigns) do + ~H""" + <.form for={@contract_form} phx-submit="create_contract"> + <.card> + <.card_header> + <.card_title>Contract Details + + <.card_content> +
+ <.input + label="Hourly Rate" + icon="tabler-currency-dollar" + field={@contract_form[:hourly_rate]} + /> + <.input label="Hours per Week" field={@contract_form[:hours_per_week]} /> +
+ + + +
+ <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Contract Offer <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+ + """ + end + + defp share_drawer_content(%{share_drawer_type: "bounty"} = assigns) do + ~H""" + <.form for={@bounty_form} phx-submit="create_bounty"> + <.card> + <.card_header> + <.card_title>Bounty Details + + <.card_content> +
+ <.input + label="URL" + field={@bounty_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + /> + <.input label="Amount" icon="tabler-currency-dollar" field={@bounty_form[:amount]} /> +
+ + + +
+ <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Share Bounty <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+ + """ + end + + defp share_drawer_content(%{share_drawer_type: "tip"} = assigns) do + ~H""" + <.form for={@tip_form} phx-submit="create_tip"> + <.card> + <.card_header> + <.card_title>Tip Details + + <.card_content> +
+ <.input label="Amount" icon="tabler-currency-dollar" field={@tip_form[:amount]} /> + <.input + label="URL" + field={@tip_form[:url]} + placeholder="https://github.com/owner/repo/issues/123" + helptext="We'll add a comment to the issue to notify the developer." + /> +
+ + + +
+ <.button variant="secondary" phx-click="close_share_drawer" type="button"> + Cancel + + <.button type="submit"> + Send Tip <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+ + """ + end + + defp share_drawer_developer_info(assigns) do + ~H""" + <.card> + <.card_header> + <.card_title>Developer + + <.card_content> +
+ <.avatar class="h-20 w-20 rounded-full"> + <.avatar_image src={@selected_developer.avatar_url} alt={@selected_developer.name} /> + <.avatar_fallback class="rounded-lg"> + {Algora.Util.initials(@selected_developer.name)} + + + +
+
+ {@selected_developer.name} +
+ +
+ <.link + :if={@selected_developer.provider_login} + href={"https://github.com/#{@selected_developer.provider_login}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + + {@selected_developer.provider_login} + + <.link + :if={@selected_developer.provider_meta["twitter_handle"]} + href={"https://x.com/#{@selected_developer.provider_meta["twitter_handle"]}"} + target="_blank" + class="flex items-center gap-1 hover:underline" + > + <.icon name="tabler-brand-x" class="h-4 w-4" /> + + {@selected_developer.provider_meta["twitter_handle"]} + + +
+ <.icon name="tabler-map-pin" class="h-4 w-4" /> + + {@selected_developer.provider_meta["location"]} + +
+
+ <.icon name="tabler-building" class="h-4 w-4" /> + + {@selected_developer.provider_meta["company"] |> String.trim_leading("@")} + +
+
+ +
+ <%= for tech <- @selected_developer.tech_stack do %> +
+ {tech} +
+ <% end %> +
+
+
+ + + """ + end + + defp share_drawer(assigns) do + ~H""" + <.drawer show={@show_share_drawer} direction="right" on_cancel="close_share_drawer"> + <.share_drawer_header + :if={@selected_developer} + selected_developer={@selected_developer} + share_drawer_type={@share_drawer_type} + /> + <.drawer_content :if={@selected_developer} class="mt-4"> +
+ <.share_drawer_developer_info selected_developer={@selected_developer} /> + <%= if incomplete?(@achievements, :connect_github_status) do %> +
+
+
+ <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + bounty_form={@bounty_form} + tip_form={@tip_form} + contract_form={@contract_form} + /> +
+ <.alert + variant="default" + class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-20 w-auto flex flex-col items-center justify-center gap-2 text-center" + > + <.alert_title>Connect GitHub + <.alert_description> + Connect your GitHub account to create a {@share_drawer_type}. + + <.button phx-click="close_share_drawer" type="button" variant="subtle"> + Go back + + +
+ <% else %> + <.share_drawer_content + :if={@selected_developer} + share_drawer_type={@share_drawer_type} + bounty_form={@bounty_form} + tip_form={@tip_form} + contract_form={@contract_form} + /> + <% end %> +
+ + + """ + end end diff --git a/lib/algora_web/live/org/preview_nav.ex b/lib/algora_web/live/org/preview_nav.ex new file mode 100644 index 000000000..9be3f7fc1 --- /dev/null +++ b/lib/algora_web/live/org/preview_nav.ex @@ -0,0 +1,61 @@ +defmodule AlgoraWeb.Org.PreviewNav do + @moduledoc false + use Phoenix.Component + use AlgoraWeb, :verified_routes + + import Phoenix.LiveView + + alias Algora.Organizations + + def on_mount(:default, %{"repo_owner" => repo_owner, "repo_name" => repo_name}, _session, socket) do + current_context = socket.assigns[:current_context] + + if current_context && current_context.last_context == "repo/#{repo_owner}/#{repo_name}" do + {:cont, + socket + |> assign(:new_bounty_form, to_form(%{"github_issue_url" => "", "amount" => ""})) + |> assign(:current_org, current_context) + |> assign(:current_user_role, :admin) + |> assign(:nav, nav_items(repo_owner, repo_name)) + |> assign(:contacts, []) + |> attach_hook(:active_tab, :handle_params, &handle_active_tab_params/3)} + else + case Organizations.init_preview(repo_owner, repo_name) do + {:ok, %{user: user, org: _org}} -> + token = AlgoraWeb.UserAuth.sign_preview_code(user.id) + path = AlgoraWeb.UserAuth.preview_path(user.id, token, ~p"/go/#{repo_owner}/#{repo_name}") + + {:halt, redirect(socket, to: path)} + + {:error, reason} -> + {:cont, put_flash(socket, :error, "Failed to initialize preview: #{inspect(reason)}")} + end + end + end + + defp handle_active_tab_params(_params, _url, socket) do + active_tab = + case {socket.view, socket.assigns.live_action} do + {AlgoraWeb.Org.DashboardLive, _} -> :dashboard + {_, _} -> nil + end + + {:cont, assign(socket, :active_tab, active_tab)} + end + + def nav_items(repo_owner, repo_name) do + [ + %{ + title: "Overview", + items: [ + %{ + href: "/go/#{repo_owner}/#{repo_name}", + tab: :dashboard, + icon: "tabler-sparkles", + label: "Dashboard" + } + ] + } + ] + end +end diff --git a/lib/algora_web/live/swift_bounties_live.ex b/lib/algora_web/live/swift_bounties_live.ex index 5121d7ab9..172a2a5a8 100644 --- a/lib/algora_web/live/swift_bounties_live.ex +++ b/lib/algora_web/live/swift_bounties_live.ex @@ -590,6 +590,7 @@ defmodule AlgoraWeb.SwiftBountiesLive do end def handle_event("create_tip" = event, %{"tip_form" => params} = unsigned_params, socket) do + # TODO: add url changeset = %TipForm{} |> TipForm.changeset(params) diff --git a/lib/algora_web/live/user/dashboard_live.ex b/lib/algora_web/live/user/dashboard_live.ex index 7e1bdd206..9cf26275a 100644 --- a/lib/algora_web/live/user/dashboard_live.ex +++ b/lib/algora_web/live/user/dashboard_live.ex @@ -27,6 +27,7 @@ defmodule AlgoraWeb.User.DashboardLive do query_opts = [ status: :open, limit: page_size(), + current_user: socket.assigns.current_user, tech_stack: socket.assigns.current_user.tech_stack, amount_gt: Money.new(:USD, 200) ] @@ -50,7 +51,7 @@ defmodule AlgoraWeb.User.DashboardLive do ~H"""
-
+
<.section> <.card> <.card_header> @@ -70,7 +71,7 @@ defmodule AlgoraWeb.User.DashboardLive do
-
0} class="relative h-full p-4 sm:p-6 md:p-8"> +
0} class="p-4 sm:p-6 md:p-8">

@@ -92,7 +93,7 @@ defmodule AlgoraWeb.User.DashboardLive do

-
0} class="relative h-full p-4 sm:p-6 md:p-8"> +
0} class="p-4 sm:p-6 md:p-8"> <.section title="Open bounties" subtitle="Bounties for you">
<.bounties bounties={@bounties} /> @@ -276,7 +277,8 @@ defmodule AlgoraWeb.User.DashboardLive do {:noreply, socket |> assign(:tech_stack, tech_stack) - |> assign(:bounties, Bounties.list_bounties(tech_stack: tech_stack, limit: 10)) + |> assign(:query_opts, Keyword.put(socket.assigns.query_opts, :tech_stack, tech_stack)) + |> assign_bounties() |> push_event("clear-input", %{selector: "[phx-keydown='handle_tech_input']"})} end @@ -290,7 +292,8 @@ defmodule AlgoraWeb.User.DashboardLive do {:noreply, socket |> assign(:tech_stack, tech_stack) - |> assign(:bounties, Bounties.list_bounties(tech_stack: tech_stack, limit: 10))} + |> assign(:query_opts, Keyword.put(socket.assigns.query_opts, :tech_stack, tech_stack)) + |> assign_bounties()} end def handle_event("view_mode", %{"value" => mode}, socket) do diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 35a8243b2..860a99f26 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -63,6 +63,7 @@ defmodule AlgoraWeb.Router do get "/a/:table_prefix/:activity_id", ActivityController, :get get "/auth/logout", OAuthCallbackController, :sign_out get "/tip", TipController, :create + get "/preview", OrgPreviewCallbackController, :new scope "/callbacks" do get "/stripe/refresh", StripeCallbackController, :refresh @@ -71,6 +72,14 @@ defmodule AlgoraWeb.Router do get "/:provider/installation", InstallationCallbackController, :new end + scope "/go/:repo_owner/:repo_name" do + live_session :preview, + layout: {AlgoraWeb.Layouts, :user}, + on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Org.PreviewNav] do + live "/", Org.DashboardLive, :preview + end + end + scope "/org/:org_handle" do live_session :org, layout: {AlgoraWeb.Layouts, :user}, diff --git a/priv/repo/migrations/20250320150931_create_contributors.exs b/priv/repo/migrations/20250320150931_create_contributors.exs new file mode 100644 index 000000000..d92b9645e --- /dev/null +++ b/priv/repo/migrations/20250320150931_create_contributors.exs @@ -0,0 +1,20 @@ +defmodule Algora.Repo.Migrations.CreateContributors do + use Ecto.Migration + + def change do + create table(:contributors) do + add :contributions, :integer, null: false, default: 0 + add :repository_id, references(:repositories, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:contributors, [:repository_id]) + create index(:contributors, [:user_id]) + + create unique_index(:contributors, [:repository_id, :user_id], + name: :contributors_repository_id_user_id_index + ) + end +end diff --git a/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs new file mode 100644 index 000000000..cadaf4327 --- /dev/null +++ b/priv/repo/migrations/20250321152115_add_shared_with_to_bounties.exs @@ -0,0 +1,9 @@ +defmodule Algora.Repo.Migrations.AddSharedWithToBounties do + use Ecto.Migration + + def change do + alter table(:bounties) do + add :shared_with, {:array, :citext}, default: "{}", null: false + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 82ecae125..89dbfadd4 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -45,19 +45,6 @@ erich = |> Map.merge(if(github_user && github_user["email"], do: %{email: github_user["email"]}, else: %{})) ) -upsert!( - :identity, - [:provider, :provider_id], - %{ - user_id: erich.id, - provider: erich.provider, - provider_id: erich.provider_id, - provider_email: erich.email, - provider_login: erich.handle, - provider_name: erich.name - } -) - richard = upsert!( :user, diff --git a/test/algora/accounts_test.exs b/test/algora/accounts_test.exs index a95351ba6..0f42d51bc 100644 --- a/test/algora/accounts_test.exs +++ b/test/algora/accounts_test.exs @@ -14,10 +14,11 @@ defmodule Algora.AccountsTest do "name" => "Github User" } - {:ok, user} = Accounts.register_github_user(email, info, [email], "token123") - {:ok, user_again} = Accounts.register_github_user(email, info, [email], "token123") + {:ok, user} = Accounts.register_github_user(nil, email, info, [email], "token123") + {:ok, user_again} = Accounts.register_github_user(user, email, info, [email], "token123") - assert_activity_names([:identity_created]) + assert user.id == user_again.id + assert_activity_names([:identity_created, :identity_created]) assert_activity_names_for_user(user.id, [:identity_created]) assert_activity_names_for_user(user_again.id, [:identity_created]) end diff --git a/test/algora/organizations_test.exs b/test/algora/organizations_test.exs index 13fd3f83b..8e3775361 100644 --- a/test/algora/organizations_test.exs +++ b/test/algora/organizations_test.exs @@ -187,4 +187,18 @@ defmodule Algora.OrganizationsTest do assert result3.org.handle == "piedpiperhq" end end + + describe "init_preview/1" do + test "creates a new user and org if they don't exist" do + assert {:ok, %{user: user, org: org}} = Algora.Organizations.init_preview("acme", "repo") + + assert is_nil(org.handle) + assert org.type == :organization + assert org.last_context == "repo/acme/repo" + + assert is_nil(user.handle) + assert user.type == :individual + assert user.last_context == "preview/#{org.id}/acme/repo" + end + end end diff --git a/test/algora/shared/util_test.exs b/test/algora/shared/util_test.exs index 9c60e6850..fc2acc717 100644 --- a/test/algora/shared/util_test.exs +++ b/test/algora/shared/util_test.exs @@ -18,4 +18,40 @@ defmodule Algora.UtilTest do assert Util.format_pct(Decimal.new("0.1050")) == "10.5%" end end + + describe "parse_github_url/1" do + test "parses full GitHub URLs" do + assert Util.parse_github_url("https://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url("http://github.com/owner/repo") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url("github.com/owner/repo") == {:ok, {"owner", "repo"}} + end + + test "parses owner/repo format" do + assert Util.parse_github_url("owner/repo") == {:ok, {"owner", "repo"}} + end + + test "handles URLs with dashes and underscores" do + assert Util.parse_github_url("my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + assert Util.parse_github_url("github.com/my-org/my_repo") == {:ok, {"my-org", "my_repo"}} + end + + test "handles numeric characters" do + assert Util.parse_github_url("owner123/repo456") == {:ok, {"owner123", "repo456"}} + end + + test "rejects invalid formats" do + error_msg = "Must be a valid GitHub repository URL (e.g. github.com/owner/repo) or owner/repo format" + + assert Util.parse_github_url("") == {:error, error_msg} + assert Util.parse_github_url("invalid") == {:error, error_msg} + assert Util.parse_github_url("owner") == {:error, error_msg} + assert Util.parse_github_url("owner/") == {:error, error_msg} + assert Util.parse_github_url("/repo") == {:error, error_msg} + end + + test "handles whitespace" do + assert Util.parse_github_url(" owner/repo ") == {:ok, {"owner", "repo"}} + assert Util.parse_github_url(" github.com/owner/repo ") == {:ok, {"owner", "repo"}} + end + end end diff --git a/test/algora_web/controllers/user_auth_test.exs b/test/algora_web/controllers/user_auth_test.exs index 687d9e480..8578d9e1c 100644 --- a/test/algora_web/controllers/user_auth_test.exs +++ b/test/algora_web/controllers/user_auth_test.exs @@ -81,4 +81,47 @@ defmodule AlgoraWeb.UserAuthTest do assert {:error, :invalid} = UserAuth.verify_login_code("", "test@example.com") end end + + describe "verify_preview_code/2" do + test "successfully verifies simple id token" do + id = "123" + code = UserAuth.sign_preview_code(id) + + assert {:ok, result} = UserAuth.verify_preview_code(code, id) + assert result == id + end + + test "rejects invalid id" do + id = "123" + code = UserAuth.sign_preview_code(id) + + assert {:error, :invalid_id} = UserAuth.verify_preview_code(code, "wrong") + end + + test "rejects tampered tokens" do + code = "tampered.token.here" + assert {:error, :invalid} = UserAuth.verify_preview_code(code, "123") + end + + test "rejects expired tokens" do + id = "123" + original_config = Application.get_env(:algora, :login_code) + Application.put_env(:algora, :login_code, Keyword.put(original_config, :ttl, 1)) + + code = UserAuth.sign_preview_code(id) + Process.sleep(1500) + + assert {:error, :expired} = UserAuth.verify_preview_code(code, id) + + Application.put_env(:algora, :login_code, original_config) + end + + test "handles nil input" do + assert {:error, :missing} = UserAuth.verify_preview_code(nil, "123") + end + + test "handles empty string input" do + assert {:error, :invalid} = UserAuth.verify_preview_code("", "123") + end + end end diff --git a/test/support/github_mock.ex b/test/support/github_mock.ex index 9771d5bc9..da610e6ad 100644 --- a/test/support/github_mock.ex +++ b/test/support/github_mock.ex @@ -136,6 +136,11 @@ defmodule Algora.Support.GithubMock do {:ok, []} end + @impl true + def list_repository_contributors(_access_token, _owner, _repo) do + {:ok, []} + end + @impl true def add_labels(_access_token, _owner, _repo, _number, _labels) do {:ok, []}