diff --git a/lib/algora/organizations/organizations.ex b/lib/algora/organizations/organizations.ex index 19264dc49..f0e17fbd6 100644 --- a/lib/algora/organizations/organizations.ex +++ b/lib/algora/organizations/organizations.ex @@ -64,6 +64,12 @@ defmodule Algora.Organizations do def get_org(id), do: Repo.get(User, id) def get_org!(id), do: Repo.get!(User, id) + @spec fetch_org_by(clauses :: Keyword.t() | map()) :: + {:ok, User.t()} | {:error, :not_found} + def fetch_org_by(clauses) do + Repo.fetch_by(User, clauses) + end + def list_orgs(opts) do Repo.all( from u in User, diff --git a/lib/algora/workspace/schemas/installation.ex b/lib/algora/workspace/schemas/installation.ex index 7921ea426..f2db6ac4a 100644 --- a/lib/algora/workspace/schemas/installation.ex +++ b/lib/algora/workspace/schemas/installation.ex @@ -6,28 +6,29 @@ defmodule Algora.Workspace.Installation do @derive {Inspect, except: [:provider_meta]} typed_schema "installations" do - field :provider, :string - field :provider_id, :string - field :provider_login, :string - field :provider_meta, :map + field :provider, :string, null: false + field :provider_id, :string, null: false + field :provider_meta, :map, null: false + field :provider_user_id, :string, null: false field :avatar_url, :string field :repository_selection, :string - belongs_to :owner, User - belongs_to :connected_user, User + belongs_to :owner, User, null: false + belongs_to :connected_user, User, null: false timestamps() end - def changeset(installation, :github, user, org, data) do + def github_changeset(installation, user, provider_user, org, data) do params = %{ owner_id: user.id, connected_user_id: org.id, avatar_url: data["account"]["avatar_url"], repository_selection: data["repository_selection"], provider_id: to_string(data["id"]), - provider_login: data["account"]["login"] + provider_user_id: to_string(provider_user.id), + provider_meta: data } installation @@ -37,10 +38,14 @@ defmodule Algora.Workspace.Installation do :avatar_url, :repository_selection, :provider_id, - :provider_login + :provider_user_id, + :provider_meta ]) - |> validate_required([:owner_id, :connected_user_id, :provider_id, :provider_login]) + |> validate_required([:owner_id, :connected_user_id, :provider_id, :provider_user_id, :provider_meta]) |> generate_id() + |> foreign_key_constraint(:owner_id) + |> foreign_key_constraint(:connected_user_id) + |> unique_constraint([:provider, :provider_id]) |> put_change(:provider, "github") |> put_change(:provider_meta, data) end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index cf41007e5..f7aa8feba 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -104,18 +104,28 @@ defmodule Algora.Workspace do end end - def create_installation(:github, user, org, data) do + def create_installation(user, provider_user, org, data) do %Installation{} - |> Installation.changeset(:github, user, org, data) + |> Installation.github_changeset(user, provider_user, org, data) |> Repo.insert() end - def update_installation(:github, user, org, installation, data) do + def update_installation(installation, user, provider_user, org, data) do installation - |> Installation.changeset(:github, user, org, data) + |> Installation.github_changeset(user, provider_user, org, data) |> Repo.update() end + def upsert_installation(installation, user, org, provider_user) do + case get_installation_by_provider_id("github", installation["id"]) do + nil -> + create_installation(user, provider_user, org, installation) + + existing_installation -> + update_installation(existing_installation, user, provider_user, org, installation) + end + end + def get_installation_by(fields), do: Repo.get_by(Installation, fields) def get_installation_by!(fields), do: Repo.get_by!(Installation, fields) @@ -132,6 +142,8 @@ defmodule Algora.Workspace do def get_installation(id), do: Repo.get(Installation, id) def get_installation!(id), do: Repo.get!(Installation, id) + def list_installations_by(fields), do: Repo.all(from(i in Installation, where: ^fields)) + def list_user_installations(user_id) do Repo.all(from(i in Installation, where: i.owner_id == ^user_id, preload: [:connected_user])) end diff --git a/lib/algora_web/controllers/installation_callback_controller.ex b/lib/algora_web/controllers/installation_callback_controller.ex index 5ca3a46a7..db4b5b821 100644 --- a/lib/algora_web/controllers/installation_callback_controller.ex +++ b/lib/algora_web/controllers/installation_callback_controller.ex @@ -10,128 +10,72 @@ defmodule AlgoraWeb.InstallationCallbackController do def new(conn, params) do case validate_query_params(params) do - {:ok, %{setup_action: "install", installation_id: installation_id}} -> - handle_installation(conn, installation_id) + {:ok, %{setup_action: :install, installation_id: installation_id}} -> + handle_installation(conn, :install, installation_id) - # TODO: Implement update - {:ok, %{setup_action: "update"}} -> - redirect(conn, to: "/user/installations") + {:ok, %{setup_action: :update, installation_id: installation_id}} -> + handle_installation(conn, :update, installation_id) + + # TODO: Implement request + {:ok, %{setup_action: :request}} -> + conn + |> put_flash( + :info, + "Installation request submitted! The Algora app will be activated upon approval from your organization administrator." + ) + |> redirect(to: redirect_url(conn)) {:error, _reason} -> - redirect(conn, to: "/user/installations") + redirect(conn, to: redirect_url(conn)) end end defp validate_query_params(params) do case params do %{"setup_action" => "install", "installation_id" => installation_id} -> - {:ok, %{setup_action: "install", installation_id: String.to_integer(installation_id)}} + {:ok, %{setup_action: :install, installation_id: String.to_integer(installation_id)}} %{"setup_action" => "update", "installation_id" => installation_id} -> - {:ok, %{setup_action: "update", installation_id: String.to_integer(installation_id)}} + {:ok, %{setup_action: :update, installation_id: String.to_integer(installation_id)}} %{"setup_action" => "request"} -> - {:ok, %{setup_action: "request"}} + {:ok, %{setup_action: :request}} _ -> {:error, :invalid_params} end end - defp handle_installation(conn, installation_id) do + defp handle_installation(conn, setup_action, installation_id) do user = conn.assigns.current_user - case do_handle_installation(conn, user, installation_id) do - {:ok, org} -> - # TODO: Trigger org joined event - # trigger_org_joined(org) - - put_flash(conn, :info, "Organization created successfully: #{org.handle}") - - # TODO: Redirect to the org dashboard and set the session context - redirect_url = determine_redirect_url(conn, org, user) - redirect(conn, to: redirect_url) + case do_handle_installation(user, installation_id) do + {:ok, _org} -> + conn + |> put_flash(:info, if(setup_action == :install, do: "Installation successful!", else: "Installation updated!")) + |> redirect(to: redirect_url(conn)) {:error, error} -> Logger.error("❌ Installation callback failed: #{inspect(error)}") - put_flash(conn, :error, "#{inspect(error)}") - redirect(conn, to: "/user/installations") + conn + |> put_flash(:error, "#{inspect(error)}") + |> redirect(to: redirect_url(conn)) end end - defp do_handle_installation(conn, user, installation_id) do + defp do_handle_installation(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, github_handle} <- extract_github_handle(installation), - {:ok, account} <- Github.get_user_by_username(access_token, github_handle), - {:ok, org} <- upsert_org(conn, user, installation, account), - {:ok, _} <- upsert_installation(user, org, installation) do - {:ok, org} - end - end - - defp extract_github_handle(%{"account" => %{"login" => login}}), do: {:ok, login} - defp extract_github_handle(_), do: {:error, 404} - - defp upsert_installation(user, org, installation) do - case Workspace.get_installation_by_provider_id("github", installation["id"]) do - nil -> - Workspace.create_installation(:github, user, org, installation) - - existing_installation -> - Workspace.update_installation(:github, user, org, existing_installation, installation) - end - end - - defp upsert_org(conn, user, installation, account) do - attrs = %{ - provider: "github", - provider_id: account["id"], - provider_login: account["login"], - provider_meta: account, - handle: account["login"], - name: account["name"], - description: account["bio"], - website_url: account["blog"], - twitter_url: get_twitter_url(account), - avatar_url: account["avatar_url"], - # TODO: - active: true, - featured: account["type"] != "User", - github_handle: account["login"] - } - - case Organizations.get_org_by_handle(account["login"]) do - nil -> create_org(conn, user, attrs, installation) - existing_org -> update_org(conn, user, existing_org, attrs, installation) - end - end - - # TODO: handle conflicting handles - defp create_org(_conn, user, attrs, _installation) do - # TODO: trigger org joined event - # trigger_org_joined(org) - with {:ok, org} <- Organizations.create_organization(attrs), - {:ok, _} <- Organizations.create_member(org, user, :admin) do + {: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} end end - defp update_org(_conn, _user, existing_org, attrs, _installation) do - with {:ok, _} <- Organizations.update_organization(existing_org, attrs) do - {:ok, existing_org} - end - end - - defp determine_redirect_url(_conn, _org, _user) do - # TODO: Implement - "/user/installations" - end - - defp get_twitter_url(%{twitter_username: username}) when is_binary(username) do - "https://twitter.com/#{username}" - end - - defp get_twitter_url(_), do: nil + defp redirect_url(conn), do: ~p"/org/#{conn.assigns.current_user.last_context}/settings" end diff --git a/lib/algora_web/live/org/settings_live.ex b/lib/algora_web/live/org/settings_live.ex index 817bb3d4d..d4cd5c3ef 100644 --- a/lib/algora_web/live/org/settings_live.ex +++ b/lib/algora_web/live/org/settings_live.ex @@ -4,6 +4,7 @@ defmodule AlgoraWeb.Org.SettingsLive do alias Algora.Accounts alias Algora.Accounts.User + alias AlgoraWeb.Components.Logos def render(assigns) do ~H""" @@ -13,6 +14,47 @@ defmodule AlgoraWeb.Org.SettingsLive do

Update your settings and preferences

+ <.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> +
+ <%= if @installations != [] do %> + <%= for installation <- @installations do %> +
+ +
+

{installation.provider_meta["account"]["login"]}

+

+ Algora app is installed in {installation.repository_selection} + repositories +

+
+
+ <% end %> + <.link href={Algora.Github.install_url()} rel="noopener" class="ml-auto gap-2"> + <.button> + + Manage {ngettext("installation", "installations", length(@installations))} + + + <% else %> +
+ <.link href={Algora.Github.install_url()} rel="noopener" class="ml-auto gap-2"> + <.button> + Install GitHub App + + +
+ <% end %> +
+ + + <.card> <.card_header> <.card_title>Account @@ -63,10 +105,11 @@ defmodule AlgoraWeb.Org.SettingsLive do %{current_org: current_org} = socket.assigns changeset = User.settings_changeset(current_org, %{}) + installations = Algora.Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") {:ok, socket - |> assign(current_org: current_org) + |> assign(:installations, installations) |> assign_form(changeset)} end diff --git a/priv/repo/migrations/20250110150238_update_installations.exs b/priv/repo/migrations/20250110150238_update_installations.exs new file mode 100644 index 000000000..9d9f2f173 --- /dev/null +++ b/priv/repo/migrations/20250110150238_update_installations.exs @@ -0,0 +1,21 @@ +defmodule Algora.Repo.Migrations.UpdateInstallations do + use Ecto.Migration + + def up do + alter table(:installations) do + modify :owner_id, :string, null: true + modify :connected_user_id, :string, null: true + add :provider_user_id, :string, null: false + remove :provider_login + end + end + + def down do + alter table(:installations) do + modify :owner_id, :string, null: false + modify :connected_user_id, :string, null: false + remove :provider_user_id + add :provider_login, :string, null: false + end + end +end