Skip to content

Commit b6a6b60

Browse files
authored
Merge pull request #1057 from code-corps/1043-standardize-output-for-installation-repositories
Standardized output of InstallationRepositories webhook event handler
2 parents cdcf3e4 + 9af9362 commit b6a6b60

File tree

5 files changed

+162
-91
lines changed

5 files changed

+162
-91
lines changed

lib/code_corps/github/event/common/result_aggregator.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ defmodule CodeCorps.GitHub.Event.Common.ResultAggregator do
88
@doc ~S"""
99
Aggregates a list of database commit results into an `:ok`, or `:error` tuple.
1010
11-
All list members are assumed to be either an `{:ok, commited_record}` or
11+
All list members are assumed to be either an `{:ok, committed_record}` or
1212
`{:error, changeset}`.
1313
14-
The aggregate is an `{:ok, commited_records}` if all results are
15-
`{:ok, committed_record}`, or an `{:error, {commited_records, changesets}}` if
16-
any of the results is an `{:error, changeset}`.
14+
The aggregate is an `{:ok, committed_records}` if all results are
15+
`{:ok, committed_record}`, or an `{:error, {committed_records, changesets}}`
16+
if any of the results is an `{:error, changeset}`.
1717
"""
1818
@spec aggregate(list) :: {:ok, list} | {:error, {list, list}}
1919
def aggregate(results) when is_list(results) do
Lines changed: 76 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,97 @@
11
defmodule CodeCorps.GitHub.Event.InstallationRepositories do
2-
@moduledoc """
3-
In charge of dealing with "InstallationRepositories" GitHub Webhook events
2+
@moduledoc ~S"""
3+
In charge of handling a GitHub Webhook payload for the
4+
InstallationRepositories event type.
5+
6+
[https://developer.github.com/v3/activity/events/types/#installationrepositoriesevent](https://developer.github.com/v3/activity/events/types/#installationrepositoriesevent)
47
"""
58

9+
@behaviour CodeCorps.GitHub.Event.Handler
10+
611
alias CodeCorps.{
712
GithubAppInstallation,
813
GithubEvent,
914
GithubRepo,
15+
GitHub.Event.Common.ResultAggregator,
16+
GitHub.Event.InstallationRepositories,
1017
Repo
1118
}
1219

13-
alias Ecto.Changeset
20+
alias Ecto.{Changeset, Multi}
1421

15-
@typep outcome :: {:ok, [GithubRepo.t]} |
16-
{:error, :no_installation} |
17-
{:error, :unexpected_action_or_payload} |
18-
{:error, :unexpected_installation_payload} |
19-
{:error, :unexpected_repo_payload}
22+
@type outcome :: {:ok, list(GithubRepo.t)} |
23+
{:error, :unmatched_installation} |
24+
{:error, :unexpected_action} |
25+
{:error, :unexpected_payload} |
26+
{:error, :validation_error_on_syncing_repos} |
27+
{:error, :unexpected_transaction_outcome}
2028

2129
@doc """
2230
Handles an "InstallationRepositories" GitHub Webhook event. The event could be
2331
of subtype "added" or "removed" and is handled differently based on that.
2432
25-
`InstallationRepositories::added` will
26-
- find `GithubAppInstallation`
27-
- marks event as errored if not found
28-
- marks event as errored if payload does not have keys it needs, meaning
29-
there's something wrong with our code and we need to updated
30-
- find or create `GithubRepo` records affected
33+
- the process of handling the "added" subtype is as follows
34+
- try to match with `CodeCorps.GithubAppInstallation` record
35+
- sync affected `CodeCorps.GithubRepo` records (update, create)
3136
32-
`InstallationRepositories::removed` will
33-
- find `GithubAppInstallation`
34-
- marks event as errored if not found
35-
- marks event as errored if payload does not have keys it needs, meaning
36-
there's something wrong with our code and we need to updated
37-
- find `GithubRepo` records affected and delete them
38-
- if there is no `GithubRepo` to delete, it just skips it
39-
- `ProjectGithubRepo` are deleted automatically, since they are set to
40-
`on_delete: :delete_all`
37+
- the process of handling the "removed" subtype is as follows
38+
- try to match with a `CodeCorps.GithubAppInstallation` record
39+
- delete affected `CodeCorps.GithubRepo` records, respecting the rules
40+
- if the GitHub payload for a repo is not matched with a record in our
41+
database, just skip deleting it
42+
- if the deleted `CodeCorps.GithubRepo` record is associated with
43+
`CodeCorps.ProjectGithubRepo` records, they are deleted automatically,
44+
due to `on_delete: :delete_all` set at the database level.
4145
"""
4246
@spec handle(GithubEvent.t, map) :: outcome
43-
def handle(%GithubEvent{action: action}, payload), do: do_handle(action, payload)
44-
45-
@spec do_handle(String.t, map) :: outcome
46-
defp do_handle("added", %{"installation" => installation_attrs, "repositories_added" => repositories_attr_list}) do
47-
case installation_attrs |> find_installation() do
48-
nil -> {:error, :no_installation}
49-
:unexpected_installation_payload -> {:error, :unexpected_installation_payload}
50-
%GithubAppInstallation{} = installation ->
51-
case repositories_attr_list |> Enum.all?(&valid?/1) do
52-
true -> find_or_create_all(installation, repositories_attr_list)
53-
false -> {:error, :unexpected_repo_payload}
54-
end
55-
end
47+
def handle(%GithubEvent{}, payload) do
48+
Multi.new
49+
|> Multi.run(:payload, fn _ -> payload |> validate_payload() end)
50+
|> Multi.run(:action, fn _ -> payload |> validate_action() end)
51+
|> Multi.run(:installation, fn _ -> payload |> match_installation() end)
52+
|> Multi.run(:repos, fn %{installation: installation} -> installation |> sync_repos(payload) end)
53+
|> Repo.transaction
54+
|> marshall_result()
5655
end
57-
defp do_handle("removed", %{"installation" => installation_attrs, "repositories_removed" => repositories_attr_list}) do
58-
case installation_attrs |> find_installation() do
59-
nil -> {:error, :no_installation}
60-
:unexpected_installation_payload -> {:error, :unexpected_installation_payload}
61-
%GithubAppInstallation{} = installation ->
62-
case repositories_attr_list |> Enum.all?(&valid?/1) do
63-
true -> delete_all(installation, repositories_attr_list)
64-
false -> {:error, :unexpected_repo_payload}
65-
end
56+
57+
@spec validate_payload(map) :: {:ok, :valid} | {:error, :invalid}
58+
defp validate_payload(%{} = payload) do
59+
case payload |> InstallationRepositories.Validator.valid? do
60+
true -> {:ok, :valid}
61+
false -> {:error, :invalid}
6662
end
6763
end
68-
defp do_handle(_action, _payload), do: {:error, :unexpected_action_or_payload}
6964

70-
@spec find_installation(any) :: GithubAppInstallation.t | nil | :unexpected_installation_payload
71-
defp find_installation(%{"id" => github_id}), do: GithubAppInstallation |> Repo.get_by(github_id: github_id)
72-
defp find_installation(_payload), do: :unexpected_installation_payload
65+
@valid_actions ~w(added removed)
66+
@spec validate_action(map) :: {:ok, :implemented} | {:error, :unexpected_action}
67+
defp validate_action(%{"action" => action}) when action in @valid_actions, do: {:ok, :implemented}
68+
defp validate_action(%{}), do: {:error, :unexpected_action}
7369

74-
# should return true if the payload is a map and has the expected keys
75-
@spec valid?(any) :: boolean
76-
defp valid?(%{"id" => _, "name" => _}), do: true
77-
defp valid?(_), do: false
70+
@spec match_installation(map) :: {:ok, GithubAppInstallation.t} |
71+
{:error, :unmatched_installation}
72+
defp match_installation(%{"installation" => %{"id" => github_id}}) do
73+
case GithubAppInstallation |> Repo.get_by(github_id: github_id) do
74+
nil -> {:error, :unmatched_installation}
75+
%GithubAppInstallation{} = installation -> {:ok, installation}
76+
end
77+
end
78+
defp match_installation(%{}), do: {:error, :unmatched_installation}
7879

79-
@spec find_or_create_all(GithubAppInstallation.t, list(map)) :: {:ok, list(GithubRepo.t)}
80-
defp find_or_create_all(%GithubAppInstallation{} = installation, repositories_attr_list) when is_list(repositories_attr_list) do
81-
repositories_attr_list
80+
@spec sync_repos(GithubAppInstallation.t, map) :: {:ok, list(GithubRepo.t)} | {:error, {list(GithubRepo.t), list(Changeset.t)}}
81+
defp sync_repos(%GithubAppInstallation{} = installation, %{"action" => "added", "repositories_added" => repositories}) do
82+
repositories
8283
|> Enum.map(&find_or_create(installation, &1))
83-
|> aggregate()
84+
|> ResultAggregator.aggregate
85+
end
86+
defp sync_repos(%GithubAppInstallation{} = installation, %{"action" => "removed", "repositories_removed" => repositories}) do
87+
repositories
88+
|> Enum.map(&find_repo(installation, &1))
89+
|> Enum.reject(&is_nil/1)
90+
|> Enum.map(&Repo.delete/1)
91+
|> ResultAggregator.aggregate
8492
end
8593

86-
@spec find_or_create(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t}
94+
@spec find_or_create(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t} | {:error, Changeset.t}
8795
defp find_or_create(%GithubAppInstallation{} = installation, %{"id" => github_id, "name" => name} = attrs) do
8896
case find_repo(installation, attrs) do
8997
nil ->
@@ -92,32 +100,23 @@ defmodule CodeCorps.GitHub.Event.InstallationRepositories do
92100
|> Changeset.put_assoc(:github_app_installation, installation)
93101
|> Repo.insert()
94102
%GithubRepo{} = github_repo ->
95-
{:ok, github_repo}
103+
github_repo
104+
|> Changeset.change(%{name: name})
105+
|> Repo.update()
96106
end
97107
end
98108

99-
@spec delete_all(GithubAppInstallation.t, list(map)) :: {:ok, [GithubRepo.t]}
100-
defp delete_all(%GithubAppInstallation{} = installation, repositories_attr_list) when is_list(repositories_attr_list) do
101-
repositories_attr_list
102-
|> Enum.map(&find_repo(installation, &1))
103-
|> Enum.reject(&is_nil/1)
104-
|> Enum.map(&Repo.delete/1)
105-
|> aggregate()
106-
end
107-
108109
@spec find_repo(GithubAppInstallation.t, map) :: GithubRepo.t | nil
109110
defp find_repo(%GithubAppInstallation{id: installation_id}, %{"id" => github_id}) do
110111
GithubRepo
111112
|> Repo.get_by(github_app_installation_id: installation_id, github_id: github_id)
112113
end
113114

114-
# [{:ok, repo_1}, {:ok, repo_2}, {:ok, repo_3}] -> {:ok, [repo_1, repo_2, repo_3]}
115-
@spec aggregate(list({:ok, GithubRepo.t})) :: {:ok, list(GithubRepo.t)}
116-
defp aggregate(results) do
117-
repositories =
118-
results
119-
|> Enum.map(fn {:ok, %GithubRepo{} = github_repo} -> github_repo end)
120-
121-
{:ok, repositories}
122-
end
115+
@spec marshall_result(tuple) :: tuple
116+
defp marshall_result({:ok, %{repos: repos}}), do: {:ok, repos}
117+
defp marshall_result({:error, :payload, :invalid, _steps}), do: {:error, :unexpected_payload}
118+
defp marshall_result({:error, :action, :unexpected_action, _steps}), do: {:error, :unexpected_action}
119+
defp marshall_result({:error, :installation, :unmatched_installation, _steps}), do: {:error, :unmatched_installation}
120+
defp marshall_result({:error, :repos, {_repos, _changesets}, _steps}), do: {:error, :validation_error_on_syncing_repos}
121+
defp marshall_result({:error, _errored_step, _error_response, _steps}), do: {:error, :unexpected_transaction_outcome}
123122
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule CodeCorps.GitHub.Event.InstallationRepositories.Validator do
2+
@moduledoc ~S"""
3+
In charge of validatng a GitHub InstallationRepositories webhook payload.
4+
5+
[https://developer.github.com/v3/activity/events/types/#installationrepositoriesevent](https://developer.github.com/v3/activity/events/types/#installationrepositoriesevent)
6+
"""
7+
8+
@doc ~S"""
9+
Returns `true` if all keys required to properly handle an
10+
InstallationRepositories webhook are present in the provided payload.
11+
"""
12+
@spec valid?(map) :: boolean
13+
def valid?(%{
14+
"action" => _, "installation" => %{"id" => _},
15+
"repositories_added" => added, "repositories_removed" => removed})
16+
when is_list(added) when is_list(removed) do
17+
18+
(added ++ removed) |> Enum.all?(&repository_valid?/1)
19+
end
20+
def valid?(%{
21+
"action" => _, "installation" => %{"id" => _},
22+
"repositories_added" => added}) when is_list(added) do
23+
24+
added |> Enum.all?(&repository_valid?/1)
25+
end
26+
def valid?(%{
27+
"action" => _, "installation" => %{"id" => _},
28+
"repositories_removed" => removed}) when is_list(removed) do
29+
30+
removed |> Enum.all?(&repository_valid?/1)
31+
end
32+
def valid?(_), do: false
33+
34+
@spec repository_valid?(any) :: boolean
35+
defp repository_valid?(%{"id" => _, "name" => _}), do: true
36+
defp repository_valid?(_), do: false
37+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule CodeCorps.GitHub.Event.InstallationRepositories.ValidatorTest do
2+
@moduledoc false
3+
4+
use ExUnit.Case, async: true
5+
6+
import CodeCorps.GitHub.TestHelpers
7+
8+
alias CodeCorps.GitHub.Event.InstallationRepositories.Validator
9+
10+
describe "valid?/1" do
11+
test "returns true for any Issues event fixture" do
12+
assert "installation_repositories_added" |> load_event_fixture() |> Validator.valid?
13+
assert "installation_repositories_removed" |> load_event_fixture() |> Validator.valid?
14+
end
15+
16+
test "returns false for an unsupported structure" do
17+
refute Validator.valid?("foo")
18+
refute Validator.valid?(%{"action" => "foo", "foo" => "bar"})
19+
refute Validator.valid?(%{"action" => "foo", "installation" => %{"bar" => "baz"}})
20+
refute Validator.valid?(%{"action" => "added", "installation" => %{"id" => "foo"}, "repositories_added" => [%{"id" => "foo"}]})
21+
refute Validator.valid?(%{"action" => "removed", "installation" => %{"id" => "foo"}, "repositories_removed" => [%{"id" => "ba"}]})
22+
refute Validator.valid?(%{"action" => "added", "installation" => %{"id" => "foo"}, "repositories_added" => ["foo"]})
23+
refute Validator.valid?(%{"action" => "removed", "installation" => %{"id" => "foo"}, "repositories_removed" => ["bar"]})
24+
refute Validator.valid?(%{"action" => "added", "installation" => %{"id" => "foo"}, "repositories_added" => "foo"})
25+
refute Validator.valid?(%{"action" => "removed", "installation" => %{"id" => "foo"}, "repositories_removed" => "bar"})
26+
end
27+
end
28+
end

test/lib/code_corps/github/event/installation_repositories_test.exs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ defmodule CodeCorps.GitHub.Event.InstallationRepositoriesTest do
1313
}
1414

1515
describe "handle/2" do
16+
@payload load_event_fixture("installation_repositories_added")
17+
1618
test "marks event as errored if invalid action" do
17-
payload = %{}
19+
payload = @payload |> Map.put("action", "foo")
1820
event = build(:github_event, action: "foo", type: "installation_repositories")
19-
assert {:error, :unexpected_action_or_payload} == InstallationRepositories.handle(event, payload)
21+
assert {:error, :unexpected_action} == InstallationRepositories.handle(event, payload)
2022
end
2123

2224
test "marks event as errored if invalid payload" do
23-
payload = %{}
2425
event = build(:github_event, action: "added", type: "installation_repositories")
25-
assert {:error, :unexpected_action_or_payload} == InstallationRepositories.handle(event, payload)
26+
payload = @payload |> Map.delete("action")
27+
assert {:error, :unexpected_payload} ==
28+
InstallationRepositories.handle(event, payload)
2629
end
2730
end
2831

@@ -76,18 +79,18 @@ defmodule CodeCorps.GitHub.Event.InstallationRepositoriesTest do
7679

7780
test "marks event as errored if invalid instalation payload" do
7881
event = insert(:github_event, action: "added", type: "installation_repositories")
79-
assert {:error, :unexpected_installation_payload} == InstallationRepositories.handle(event, @payload |> Map.put("installation", "foo"))
82+
assert {:error, :unexpected_payload} == InstallationRepositories.handle(event, @payload |> Map.put("installation", "foo"))
8083
end
8184

8285
test "marks event as errored if invalid repo payload" do
8386
event = insert(:github_event, action: "added", type: "installation_repositories")
8487
insert(:github_app_installation, github_id: @payload["installation"]["id"])
85-
assert {:error, :unexpected_repo_payload} == InstallationRepositories.handle(event, @payload |> Map.put("repositories_added", ["foo"]))
88+
assert {:error, :unexpected_payload} == InstallationRepositories.handle(event, @payload |> Map.put("repositories_added", ["foo"]))
8689
end
8790

8891
test "marks event as errored if no installation" do
8992
event = insert(:github_event, action: "added", type: "installation_repositories")
90-
assert {:error, :no_installation} == InstallationRepositories.handle(event, @payload)
93+
assert {:error, :unmatched_installation} == InstallationRepositories.handle(event, @payload)
9194
end
9295
end
9396

@@ -131,18 +134,22 @@ defmodule CodeCorps.GitHub.Event.InstallationRepositoriesTest do
131134

132135
test "marks event as errored if invalid instalation payload" do
133136
event = build(:github_event, action: "removed", type: "installation_repositories")
134-
assert {:error, :unexpected_installation_payload} == InstallationRepositories.handle(event, @payload |> Map.put("installation", "foo"))
137+
payload = @payload |> Map.put("installation", "foo")
138+
assert {:error, :unexpected_payload} ==
139+
InstallationRepositories.handle(event, payload)
135140
end
136141

137142
test "marks event as errored if invalid repo payload" do
138143
event = build(:github_event, action: "removed", type: "installation_repositories")
139144
insert(:github_app_installation, github_id: @payload["installation"]["id"])
140-
assert {:error, :unexpected_repo_payload} == InstallationRepositories.handle(event, @payload |> Map.put("repositories_removed", ["foo"]))
145+
payload = @payload |> Map.put("repositories_removed", ["foo"])
146+
assert {:error, :unexpected_payload} ==
147+
InstallationRepositories.handle(event, payload)
141148
end
142149

143150
test "marks event as errored if no installation" do
144151
event = build(:github_event, action: "added", type: "installation_repositories")
145-
assert {:error, :no_installation} == InstallationRepositories.handle(event, @payload)
152+
assert {:error, :unmatched_installation} == InstallationRepositories.handle(event, @payload)
146153
end
147154
end
148155
end

0 commit comments

Comments
 (0)