Skip to content

Commit 66d9eb1

Browse files
authored
feat: install app on GitHub (#28)
1 parent 4afe25f commit 66d9eb1

File tree

6 files changed

+136
-105
lines changed

6 files changed

+136
-105
lines changed

lib/algora/organizations/organizations.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ defmodule Algora.Organizations do
6464
def get_org(id), do: Repo.get(User, id)
6565
def get_org!(id), do: Repo.get!(User, id)
6666

67+
@spec fetch_org_by(clauses :: Keyword.t() | map()) ::
68+
{:ok, User.t()} | {:error, :not_found}
69+
def fetch_org_by(clauses) do
70+
Repo.fetch_by(User, clauses)
71+
end
72+
6773
def list_orgs(opts) do
6874
Repo.all(
6975
from u in User,

lib/algora/workspace/schemas/installation.ex

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,29 @@ defmodule Algora.Workspace.Installation do
66

77
@derive {Inspect, except: [:provider_meta]}
88
typed_schema "installations" do
9-
field :provider, :string
10-
field :provider_id, :string
11-
field :provider_login, :string
12-
field :provider_meta, :map
9+
field :provider, :string, null: false
10+
field :provider_id, :string, null: false
11+
field :provider_meta, :map, null: false
12+
field :provider_user_id, :string, null: false
1313

1414
field :avatar_url, :string
1515
field :repository_selection, :string
1616

17-
belongs_to :owner, User
18-
belongs_to :connected_user, User
17+
belongs_to :owner, User, null: false
18+
belongs_to :connected_user, User, null: false
1919

2020
timestamps()
2121
end
2222

23-
def changeset(installation, :github, user, org, data) do
23+
def github_changeset(installation, user, provider_user, org, data) do
2424
params = %{
2525
owner_id: user.id,
2626
connected_user_id: org.id,
2727
avatar_url: data["account"]["avatar_url"],
2828
repository_selection: data["repository_selection"],
2929
provider_id: to_string(data["id"]),
30-
provider_login: data["account"]["login"]
30+
provider_user_id: to_string(provider_user.id),
31+
provider_meta: data
3132
}
3233

3334
installation
@@ -37,10 +38,14 @@ defmodule Algora.Workspace.Installation do
3738
:avatar_url,
3839
:repository_selection,
3940
:provider_id,
40-
:provider_login
41+
:provider_user_id,
42+
:provider_meta
4143
])
42-
|> validate_required([:owner_id, :connected_user_id, :provider_id, :provider_login])
44+
|> validate_required([:owner_id, :connected_user_id, :provider_id, :provider_user_id, :provider_meta])
4345
|> generate_id()
46+
|> foreign_key_constraint(:owner_id)
47+
|> foreign_key_constraint(:connected_user_id)
48+
|> unique_constraint([:provider, :provider_id])
4449
|> put_change(:provider, "github")
4550
|> put_change(:provider_meta, data)
4651
end

lib/algora/workspace/workspace.ex

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,28 @@ defmodule Algora.Workspace do
104104
end
105105
end
106106

107-
def create_installation(:github, user, org, data) do
107+
def create_installation(user, provider_user, org, data) do
108108
%Installation{}
109-
|> Installation.changeset(:github, user, org, data)
109+
|> Installation.github_changeset(user, provider_user, org, data)
110110
|> Repo.insert()
111111
end
112112

113-
def update_installation(:github, user, org, installation, data) do
113+
def update_installation(installation, user, provider_user, org, data) do
114114
installation
115-
|> Installation.changeset(:github, user, org, data)
115+
|> Installation.github_changeset(user, provider_user, org, data)
116116
|> Repo.update()
117117
end
118118

119+
def upsert_installation(installation, user, org, provider_user) do
120+
case get_installation_by_provider_id("github", installation["id"]) do
121+
nil ->
122+
create_installation(user, provider_user, org, installation)
123+
124+
existing_installation ->
125+
update_installation(existing_installation, user, provider_user, org, installation)
126+
end
127+
end
128+
119129
def get_installation_by(fields), do: Repo.get_by(Installation, fields)
120130
def get_installation_by!(fields), do: Repo.get_by!(Installation, fields)
121131

@@ -132,6 +142,8 @@ defmodule Algora.Workspace do
132142
def get_installation(id), do: Repo.get(Installation, id)
133143
def get_installation!(id), do: Repo.get!(Installation, id)
134144

145+
def list_installations_by(fields), do: Repo.all(from(i in Installation, where: ^fields))
146+
135147
def list_user_installations(user_id) do
136148
Repo.all(from(i in Installation, where: i.owner_id == ^user_id, preload: [:connected_user]))
137149
end

lib/algora_web/controllers/installation_callback_controller.ex

Lines changed: 34 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -10,128 +10,72 @@ defmodule AlgoraWeb.InstallationCallbackController do
1010

1111
def new(conn, params) do
1212
case validate_query_params(params) do
13-
{:ok, %{setup_action: "install", installation_id: installation_id}} ->
14-
handle_installation(conn, installation_id)
13+
{:ok, %{setup_action: :install, installation_id: installation_id}} ->
14+
handle_installation(conn, :install, installation_id)
1515

16-
# TODO: Implement update
17-
{:ok, %{setup_action: "update"}} ->
18-
redirect(conn, to: "/user/installations")
16+
{:ok, %{setup_action: :update, installation_id: installation_id}} ->
17+
handle_installation(conn, :update, installation_id)
18+
19+
# TODO: Implement request
20+
{:ok, %{setup_action: :request}} ->
21+
conn
22+
|> put_flash(
23+
:info,
24+
"Installation request submitted! The Algora app will be activated upon approval from your organization administrator."
25+
)
26+
|> redirect(to: redirect_url(conn))
1927

2028
{:error, _reason} ->
21-
redirect(conn, to: "/user/installations")
29+
redirect(conn, to: redirect_url(conn))
2230
end
2331
end
2432

2533
defp validate_query_params(params) do
2634
case params do
2735
%{"setup_action" => "install", "installation_id" => installation_id} ->
28-
{:ok, %{setup_action: "install", installation_id: String.to_integer(installation_id)}}
36+
{:ok, %{setup_action: :install, installation_id: String.to_integer(installation_id)}}
2937

3038
%{"setup_action" => "update", "installation_id" => installation_id} ->
31-
{:ok, %{setup_action: "update", installation_id: String.to_integer(installation_id)}}
39+
{:ok, %{setup_action: :update, installation_id: String.to_integer(installation_id)}}
3240

3341
%{"setup_action" => "request"} ->
34-
{:ok, %{setup_action: "request"}}
42+
{:ok, %{setup_action: :request}}
3543

3644
_ ->
3745
{:error, :invalid_params}
3846
end
3947
end
4048

41-
defp handle_installation(conn, installation_id) do
49+
defp handle_installation(conn, setup_action, installation_id) do
4250
user = conn.assigns.current_user
4351

44-
case do_handle_installation(conn, user, installation_id) do
45-
{:ok, org} ->
46-
# TODO: Trigger org joined event
47-
# trigger_org_joined(org)
48-
49-
put_flash(conn, :info, "Organization created successfully: #{org.handle}")
50-
51-
# TODO: Redirect to the org dashboard and set the session context
52-
redirect_url = determine_redirect_url(conn, org, user)
53-
redirect(conn, to: redirect_url)
52+
case do_handle_installation(user, installation_id) do
53+
{:ok, _org} ->
54+
conn
55+
|> put_flash(:info, if(setup_action == :install, do: "Installation successful!", else: "Installation updated!"))
56+
|> redirect(to: redirect_url(conn))
5457

5558
{:error, error} ->
5659
Logger.error("❌ Installation callback failed: #{inspect(error)}")
5760

58-
put_flash(conn, :error, "#{inspect(error)}")
59-
redirect(conn, to: "/user/installations")
61+
conn
62+
|> put_flash(:error, "#{inspect(error)}")
63+
|> redirect(to: redirect_url(conn))
6064
end
6165
end
6266

63-
defp do_handle_installation(conn, user, installation_id) do
67+
defp do_handle_installation(user, installation_id) do
68+
# TODO: replace :last_context with a new :last_installation_target field
69+
# TODO: handle nil user
70+
# TODO: handle nil last_context
6471
with {:ok, access_token} <- Accounts.get_access_token(user),
6572
{:ok, installation} <- Github.find_installation(access_token, installation_id),
66-
{:ok, github_handle} <- extract_github_handle(installation),
67-
{:ok, account} <- Github.get_user_by_username(access_token, github_handle),
68-
{:ok, org} <- upsert_org(conn, user, installation, account),
69-
{:ok, _} <- upsert_installation(user, org, installation) do
70-
{:ok, org}
71-
end
72-
end
73-
74-
defp extract_github_handle(%{"account" => %{"login" => login}}), do: {:ok, login}
75-
defp extract_github_handle(_), do: {:error, 404}
76-
77-
defp upsert_installation(user, org, installation) do
78-
case Workspace.get_installation_by_provider_id("github", installation["id"]) do
79-
nil ->
80-
Workspace.create_installation(:github, user, org, installation)
81-
82-
existing_installation ->
83-
Workspace.update_installation(:github, user, org, existing_installation, installation)
84-
end
85-
end
86-
87-
defp upsert_org(conn, user, installation, account) do
88-
attrs = %{
89-
provider: "github",
90-
provider_id: account["id"],
91-
provider_login: account["login"],
92-
provider_meta: account,
93-
handle: account["login"],
94-
name: account["name"],
95-
description: account["bio"],
96-
website_url: account["blog"],
97-
twitter_url: get_twitter_url(account),
98-
avatar_url: account["avatar_url"],
99-
# TODO:
100-
active: true,
101-
featured: account["type"] != "User",
102-
github_handle: account["login"]
103-
}
104-
105-
case Organizations.get_org_by_handle(account["login"]) do
106-
nil -> create_org(conn, user, attrs, installation)
107-
existing_org -> update_org(conn, user, existing_org, attrs, installation)
108-
end
109-
end
110-
111-
# TODO: handle conflicting handles
112-
defp create_org(_conn, user, attrs, _installation) do
113-
# TODO: trigger org joined event
114-
# trigger_org_joined(org)
115-
with {:ok, org} <- Organizations.create_organization(attrs),
116-
{:ok, _} <- Organizations.create_member(org, user, :admin) do
73+
{:ok, provider_user} <- Workspace.ensure_user(access_token, installation["account"]["login"]),
74+
{:ok, org} <- Organizations.fetch_org_by(handle: user.last_context),
75+
{:ok, _} <- Workspace.upsert_installation(installation, user, org, provider_user) do
11776
{:ok, org}
11877
end
11978
end
12079

121-
defp update_org(_conn, _user, existing_org, attrs, _installation) do
122-
with {:ok, _} <- Organizations.update_organization(existing_org, attrs) do
123-
{:ok, existing_org}
124-
end
125-
end
126-
127-
defp determine_redirect_url(_conn, _org, _user) do
128-
# TODO: Implement
129-
"/user/installations"
130-
end
131-
132-
defp get_twitter_url(%{twitter_username: username}) when is_binary(username) do
133-
"https://twitter.com/#{username}"
134-
end
135-
136-
defp get_twitter_url(_), do: nil
80+
defp redirect_url(conn), do: ~p"/org/#{conn.assigns.current_user.last_context}/settings"
13781
end

lib/algora_web/live/org/settings_live.ex

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule AlgoraWeb.Org.SettingsLive do
44

55
alias Algora.Accounts
66
alias Algora.Accounts.User
7+
alias AlgoraWeb.Components.Logos
78

89
def render(assigns) do
910
~H"""
@@ -13,6 +14,47 @@ defmodule AlgoraWeb.Org.SettingsLive do
1314
<p class="text-muted-foreground">Update your settings and preferences</p>
1415
</div>
1516
17+
<.card>
18+
<.card_header>
19+
<.card_title>GitHub Integration</.card_title>
20+
<.card_description :if={@installations == []}>
21+
Install the Algora app to enable slash commands in your GitHub repositories
22+
</.card_description>
23+
</.card_header>
24+
<.card_content>
25+
<div class="flex flex-col gap-3">
26+
<%= if @installations != [] do %>
27+
<%= for installation <- @installations do %>
28+
<div class="flex items-center gap-2">
29+
<img src={installation.avatar_url} class="w-9 h-9 rounded-lg" />
30+
<div>
31+
<p class="font-medium">{installation.provider_meta["account"]["login"]}</p>
32+
<p class="text-sm text-muted-foreground">
33+
Algora app is installed in <strong>{installation.repository_selection}</strong>
34+
repositories
35+
</p>
36+
</div>
37+
</div>
38+
<% end %>
39+
<.link href={Algora.Github.install_url()} rel="noopener" class="ml-auto gap-2">
40+
<.button>
41+
<Logos.github class="w-4 h-4 mr-2 -ml-1" />
42+
Manage {ngettext("installation", "installations", length(@installations))}
43+
</.button>
44+
</.link>
45+
<% else %>
46+
<div class="flex flex-col gap-2">
47+
<.link href={Algora.Github.install_url()} rel="noopener" class="ml-auto gap-2">
48+
<.button>
49+
<Logos.github class="w-4 h-4 mr-2 -ml-1" /> Install GitHub App
50+
</.button>
51+
</.link>
52+
</div>
53+
<% end %>
54+
</div>
55+
</.card_content>
56+
</.card>
57+
1658
<.card>
1759
<.card_header>
1860
<.card_title>Account</.card_title>
@@ -63,10 +105,11 @@ defmodule AlgoraWeb.Org.SettingsLive do
63105
%{current_org: current_org} = socket.assigns
64106

65107
changeset = User.settings_changeset(current_org, %{})
108+
installations = Algora.Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github")
66109

67110
{:ok,
68111
socket
69-
|> assign(current_org: current_org)
112+
|> assign(:installations, installations)
70113
|> assign_form(changeset)}
71114
end
72115

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
defmodule Algora.Repo.Migrations.UpdateInstallations do
2+
use Ecto.Migration
3+
4+
def up do
5+
alter table(:installations) do
6+
modify :owner_id, :string, null: true
7+
modify :connected_user_id, :string, null: true
8+
add :provider_user_id, :string, null: false
9+
remove :provider_login
10+
end
11+
end
12+
13+
def down do
14+
alter table(:installations) do
15+
modify :owner_id, :string, null: false
16+
modify :connected_user_id, :string, null: false
17+
remove :provider_user_id
18+
add :provider_login, :string, null: false
19+
end
20+
end
21+
end

0 commit comments

Comments
 (0)