Skip to content

Commit cdcf3e4

Browse files
authored
Merge pull request #1053 from code-corps/1042-github-installations
Standardize error output for GitHub.Event.Installation
2 parents 2576c65 + 0e47134 commit cdcf3e4

File tree

13 files changed

+475
-393
lines changed

13 files changed

+475
-393
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
defmodule CodeCorps.GitHub.Event.Common.ResultAggregator do
2+
@moduledoc ~S"""
3+
Module used for the purpose of aggregating results from multiple repository commit actions.
4+
"""
5+
6+
alias Ecto.Changeset
7+
8+
@doc ~S"""
9+
Aggregates a list of database commit results into an `:ok`, or `:error` tuple.
10+
11+
All list members are assumed to be either an `{:ok, commited_record}` or
12+
`{:error, changeset}`.
13+
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}`.
17+
"""
18+
@spec aggregate(list) :: {:ok, list} | {:error, {list, list}}
19+
def aggregate(results) when is_list(results) do
20+
results |> collect() |> summarize()
21+
end
22+
23+
@spec collect(list, list, list) :: tuple
24+
defp collect(results, recods \\ [], changesets \\ [])
25+
defp collect([{:ok, record} | tail], records, changesets) do
26+
collect(tail, records ++ [record], changesets)
27+
end
28+
defp collect([{:error, %Changeset{} = changeset} | tail], records, changesets) do
29+
collect(tail, records, changesets ++ [changeset])
30+
end
31+
defp collect([], records, changesets), do: {records, changesets}
32+
33+
@spec summarize(tuple) :: tuple
34+
defp summarize({records, []}), do: {:ok, records}
35+
defp summarize({records, changesets}), do: {:error, {records, changesets}}
36+
end
Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,114 @@
11
defmodule CodeCorps.GitHub.Event.Installation do
22
@moduledoc """
3-
In charge of dealing with "Installation" GitHub Webhook events
3+
In charge of handling a GitHub Webhook payload for the Installation event type
4+
[https://developer.github.com/v3/activity/events/types/#installationevent](https://developer.github.com/v3/activity/events/types/#installationevent)
45
"""
56

7+
@behaviour CodeCorps.GitHub.Event.Handler
8+
69
alias CodeCorps.{
710
GithubAppInstallation,
811
GithubEvent,
9-
GitHub.Event.Installation.UnmatchedUser,
10-
GitHub.Event.Installation.MatchedUser,
11-
GitHub.Event.Installation.Repos,
12-
GitHub.Event.Installation.Validator,
12+
GitHub.Event.Installation,
1313
Repo,
1414
User
1515
}
1616

17-
@typep outcome :: {:ok, GithubAppInstallation.t, Task.t} | {:error, any}
18-
19-
@doc """
20-
Handles an "Installation" GitHub Webhook event. The event could be
21-
of subtype "created" or "deleted". Only the "created" variant is handled at
22-
the moment.
17+
alias Ecto.{Changeset, Multi}
2318

24-
`Installation::created` will first try to find the `User` using information
25-
from the payload.
19+
@type outcome :: {:ok, GithubAppInstallation.t} |
20+
{:error, :not_yet_implemented} |
21+
{:error, :unexpected_action} |
22+
{:error, :unexpected_payload} |
23+
{:error, :validation_error_on_syncing_installation} |
24+
{:error, :multiple_unprocessed_installations_found} |
25+
{:error, :github_api_error_on_syncing_repos} |
26+
{:error, :validation_error_on_deleting_removed_repos} |
27+
{:error, :validation_error_on_syncing_existing_repos} |
28+
{:error, :validation_error_on_marking_installation_processed}
2629

27-
Depending on the outcame of that operation, it will either call one of
30+
@doc """
31+
Handles the "Installation" GitHub Webhook event.
2832
29-
- `CodeCorps.GitHub.Event.Installation.UnmatchedUser.handle/2`
30-
- `CodeCorps.GitHub.Event.Installation.MatchedUser.handle/2`
33+
The event could be of subtype `created` or `deleted`. Only the `created`
34+
variant is handled at the moment.
3135
32-
These helper modules will create or update the `GithubAppInstallation` with
33-
proper data.
36+
The process of handling the "created" event subtype is as follows
3437
35-
Once that is done, the outcome will be returned.
38+
- try to match the sender with an existing `CodeCorps.User`
39+
- call specific matching module depending on the user being matched or not
40+
- `CodeCorps.GitHub.Event.Installation.MatchedUser.handle/2`
41+
- `CodeCorps.GitHub.Event.Installation.UnmatchedUser.handle/1`
42+
- sync installation repositories using a third modules
43+
- `CodeCorps.GitHub.Event.Installation.Repos.process/1`
3644
37-
Additionally, a background task will launch
38-
`CodeCorps.GitHub.Event.Installation.Repos.process_async` to asynchronously
39-
fetch and process repositories for the installation.
45+
If everything goes as expected, an `:ok` tuple will be returned, with a
46+
`CodeCorps.GithubAppInstallation`, marked as "processed".
4047
41-
The installation will initially be returned with the state "processing", but
42-
in the background, it will eventually switch to either "processed" or
43-
"errored".
48+
If a step in the process failes, an `:error` tuple will be returned, where the
49+
second element is an atom indicating which step of the process failed.
4450
"""
4551
@spec handle(GithubEvent.t, map) :: outcome
46-
def handle(%GithubEvent{action: "created"}, payload) do
47-
case payload |> Validator.valid? do
48-
true -> payload |> do_handle() |> postprocess()
49-
false -> {:error, :unexpected_payload}
50-
end
51-
end
52-
def handle(%GithubEvent{action: "deleted"}, _) do
53-
{:error, :not_fully_implemented}
52+
def handle(%GithubEvent{}, payload) do
53+
Multi.new
54+
|> Multi.run(:payload, fn _ -> payload |> validate_payload() end)
55+
|> Multi.run(:action, fn _ -> payload |> validate_action() end)
56+
|> Multi.run(:user, fn _ -> payload |> find_user() end)
57+
|> Multi.run(:installation, fn %{user: user} -> install_for_user(user, payload) end)
58+
|> Multi.merge(&process_repos/1)
59+
|> Repo.transaction
60+
|> marshall_result()
5461
end
55-
def handle(%GithubEvent{action: _action}, _payload) do
56-
{:error, :unexpected_action}
62+
63+
@spec find_user(map) :: {:ok, User.t} | {:ok, nil} | {:error, :unexpected_user_payload}
64+
defp find_user(%{"sender" => %{"id" => github_id}}) do
65+
user = Repo.get_by(User, github_id: github_id)
66+
{:ok, user}
5767
end
68+
defp find_user(_), do: {:error, :unexpected_user_payload}
5869

59-
@spec do_handle(map) :: outcome
60-
defp do_handle(%{"sender" => sender_attrs} = payload) do
61-
case sender_attrs |> find_user() do
62-
# No user was found with the specified github_id
63-
nil -> UnmatchedUser.handle(payload)
64-
# A user was found, matching the sspecified github_id
65-
%User{} = user -> MatchedUser.handle(user, payload)
66-
end
70+
@spec install_for_user(User.t, map) :: outcome
71+
defp install_for_user(%User{} = user, payload) do
72+
Installation.MatchedUser.handle(user, payload)
73+
end
74+
defp install_for_user(nil, payload) do
75+
Installation.UnmatchedUser.handle(payload)
6776
end
6877

69-
@spec postprocess({:ok, GithubAppInstallation.t} | {:error, any}) :: {:ok, GithubAppInstallation.t} | {:error, any}
70-
defp postprocess({:ok, %GithubAppInstallation{} = installation}) do
78+
@spec process_repos(%{installation: GithubAppInstallation.t}) :: Multi.t
79+
defp process_repos(%{installation: %GithubAppInstallation{} = installation}) do
7180
installation
7281
|> Repo.preload(:github_repos)
73-
|> Repos.process_async
82+
|> Installation.Repos.process
83+
end
84+
85+
@spec marshall_result(tuple) :: tuple
86+
defp marshall_result({:ok, %{processed_installation: installation}}), do: {:ok, installation}
87+
defp marshall_result({:error, :payload, :invalid, _steps}), do: {:error, :unexpected_payload}
88+
defp marshall_result({:error, :action, :unexpected_action, _steps}), do: {:error, :unexpected_action}
89+
defp marshall_result({:error, :action, :not_yet_implemented, _steps}), do: {:error, :not_yet_implemented}
90+
defp marshall_result({:error, :user, :unexpected_user_payload, _steps}), do: {:error, :unexpected_payload}
91+
defp marshall_result({:error, :installation, :unexpected_installation_payload, _steps}), do: {:error, :unexpected_payload}
92+
defp marshall_result({:error, :installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_syncing_installation}
93+
defp marshall_result({:error, :installation, :too_many_unprocessed_installations, _steps}), do: {:error, :multiple_unprocessed_installations_found}
94+
defp marshall_result({:error, :api_response, %CodeCorps.GitHub.APIError{}, _steps}), do: {:error, :github_api_error_on_syncing_repos}
95+
defp marshall_result({:error, :deleted_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_deleting_removed_repos}
96+
defp marshall_result({:error, :synced_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_syncing_existing_repos}
97+
defp marshall_result({:error, :processed_installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_marking_installation_processed}
98+
defp marshall_result({:error, _errored_step, _error_response, _steps}), do: {:error, :unexpected_transaction_outcome}
99+
100+
@spec validate_payload(map) :: {:ok, :valid} | {:error, :invalid}
101+
defp validate_payload(%{} = payload) do
102+
case payload |> Installation.Validator.valid? do
103+
true -> {:ok, :valid}
104+
false -> {:error, :invalid}
105+
end
74106
end
75-
defp postprocess({:error, error}), do: {:error, error}
76107

77-
@spec find_user(any) :: User.t | nil
78-
defp find_user(%{"id" => github_id}), do: User |> Repo.get_by(github_id: github_id)
79-
defp find_user(_), do: :unexpected_user_payload
108+
@spec validate_action(map) :: {:ok, :implemented} |
109+
{:error, :not_yet_implemented} |
110+
{:error, :unexpected_action}
111+
defp validate_action(%{"action" => "created"}), do: {:ok, :implemented}
112+
defp validate_action(%{"action" => "deleted"}), do: {:error, :not_yet_implemented}
113+
defp validate_action(%{}), do: {:error, :unexpected_action}
80114
end

lib/code_corps/github/event/installation/matched_user.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ defmodule CodeCorps.GitHub.Event.Installation.MatchedUser do
1313
}
1414

1515
@typep process_outcome :: {:ok, GithubAppInstallation.t} | {:error, Changeset.t}
16-
@typep outcome :: process_outcome | {:error, :too_many_unprocessed_installations}
16+
@type outcome :: process_outcome | {:error, :too_many_unprocessed_installations}
1717

1818
@doc """
1919
Handles the installation event in the case of a matched user.

lib/code_corps/github/event/installation/repos.ex

Lines changed: 32 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,81 +8,55 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
88
GithubRepo,
99
GitHub.Installation,
1010
GitHub,
11+
GitHub.Event.Common.ResultAggregator,
1112
Repo
1213
}
1314

1415
alias CodeCorps.GitHub.Adapters.GithubRepo, as: GithubRepoAdapter
1516

1617
alias Ecto.{Changeset, Multi}
1718

18-
@typep repo_result :: {:ok | :error, GithubRepo.t | Changeset.t}
19-
@typep aggregated_result :: {:ok | :error, list(GithubRepo.t | Changeset.t)}
19+
@typep aggregated_result :: {:ok, list(GithubRepo.t)} |
20+
{:error, {list(GithubRepo.t), list(Changeset.t)}}
2021

2122
@doc ~S"""
22-
Marks a `GithubAppInstallation` as "processing" and immediately returns it.
23+
Creates and returns a multi used to sync a `CodeCorps.GithubRepo` records
24+
associated with the provided `CodeCorps.GithubAppInstallation` record, based
25+
on freshly retrieved GitHub data.
2326
24-
In the background, it fires a task to asynchronously call &process/1
25-
"""
26-
@spec process_async(GithubAppInstallation.t) :: {:ok, GithubAppInstallation.t, Task.t}
27-
def process_async(%GithubAppInstallation{} = installation) do
28-
{:ok, %GithubAppInstallation{} = updated_installation} =
29-
installation |> set_state("processing")
30-
31-
repo_processing_task = Task.Supervisor.async(
32-
:background_processor, fn -> updated_installation |> process() end)
27+
Note that a GitHub API call is being called as part of this process.
3328
34-
{:ok, {updated_installation, repo_processing_task}}
35-
end
36-
37-
@doc ~S"""
38-
Fetches a list of repositories for a `GithubAppInstallation` from the GitHub
39-
API and matches up `GithubRepo` records stored locally using that list as the
40-
master list.
29+
The list of repositories from the API call is considered the master list.
30+
- Anything existing locally, but not on this list is deleted.
31+
- Anything existing both locally and on this list is updated.
32+
- Anything existing not locally, but on this list is created.
4133
"""
42-
@spec process(GithubAppInstallation.t) :: {:ok | :error, GithubAppInstallation.t}
34+
@spec process(GithubAppInstallation.t) :: Multi.t
4335
def process(%GithubAppInstallation{} = installation) do
44-
multi =
45-
Multi.new
46-
|> Multi.run(:processing_installation, fn _ -> {:ok, installation} end)
47-
|> Multi.run(:api_response, &fetch_api_repo_list/1)
48-
|> Multi.run(:repo_attrs_list, &adapt_api_repo_list/1)
49-
|> Multi.run(:deleted_repos, &delete_repos/1)
50-
|> Multi.run(:synced_repos, &sync_repos/1)
51-
|> Multi.run(:processed_installation, &mark_processed/1)
52-
53-
case Repo.transaction(multi) do
54-
{:ok, %{processed_installation: installation}} ->
55-
{:ok, installation}
56-
{:error, _errored_step, error_response, _steps} ->
57-
{:ok, errored_installation} = installation |> set_state("errored")
58-
{:error, errored_installation, error_response}
59-
end
60-
end
61-
62-
@spec set_state(GithubAppInstallation.t, String.t) :: {:ok, GithubAppInstallation.t}
63-
defp set_state(%GithubAppInstallation{} = installation, state)
64-
when state in ~w(processing processed errored) do
65-
66-
installation
67-
|> Changeset.change(%{state: state})
68-
|> Repo.update
36+
Multi.new
37+
|> Multi.run(:processing_installation, fn _ -> {:ok, installation} end)
38+
|> Multi.run(:api_response, &fetch_api_repo_list/1)
39+
|> Multi.run(:repo_attrs_list, &adapt_api_repo_list/1)
40+
|> Multi.run(:deleted_repos, &delete_repos/1)
41+
|> Multi.run(:synced_repos, &sync_repos/1)
42+
|> Multi.run(:processed_installation, &mark_processed/1)
6943
end
7044

7145
# transaction step 1
72-
@spec fetch_api_repo_list(%{processing_installation: GithubAppInstallation.t}) :: {:ok, map} | {:error, GitHub.api_error_struct}
46+
@spec fetch_api_repo_list(map) :: {:ok, map} | {:error, GitHub.api_error_struct}
7347
defp fetch_api_repo_list(%{processing_installation: %GithubAppInstallation{} = installation}) do
7448
installation |> Installation.repositories()
7549
end
7650

7751
# transaction step 2
78-
@spec adapt_api_repo_list(%{api_response: any}) :: {:ok, list(map)}
52+
@spec adapt_api_repo_list(map) :: {:ok, list(map)}
7953
defp adapt_api_repo_list(%{api_response: repositories}) do
8054
adapter_results = repositories |> Enum.map(&GithubRepoAdapter.from_api/1)
8155
{:ok, adapter_results}
8256
end
8357

8458
# transaction step 3
85-
@spec delete_repos(%{processing_installation: GithubAppInstallation.t, repo_attrs_list: list(map)}) :: aggregated_result
59+
@spec delete_repos(map) :: aggregated_result
8660
defp delete_repos(%{
8761
processing_installation: %GithubAppInstallation{github_repos: github_repos},
8862
repo_attrs_list: attrs_list}) when is_list(attrs_list) do
@@ -97,38 +71,25 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
9771
end
9872
end)
9973
|> Enum.reject(&is_nil/1)
100-
|> aggregate
74+
|> ResultAggregator.aggregate
10175
end
10276

10377
# transaction step 4
104-
@spec sync_repos(%{processing_installation: GithubAppInstallation.t, repo_attrs_list: list(map)}) :: aggregated_result
78+
@spec sync_repos(map) :: aggregated_result
10579
defp sync_repos(%{
10680
processing_installation: %GithubAppInstallation{} = installation,
10781
repo_attrs_list: attrs_list }) when is_list(attrs_list) do
10882

10983
attrs_list
11084
|> Enum.map(&sync(installation, &1))
111-
|> aggregate
112-
end
113-
114-
@spec aggregate(list(repo_result)) :: aggregated_result
115-
defp aggregate(results) do
116-
case results |> Enum.filter(fn {state, _data} -> state == :error end) do
117-
[] -> results |> reduce(:ok)
118-
errors -> errors |> reduce(:error)
119-
end
120-
end
121-
122-
@spec reduce(list(repo_result), :ok | :error) :: aggregated_result
123-
defp reduce(data, state) when state in [:ok, :error] do
124-
data
125-
|> Enum.map(&Tuple.to_list/1) # {:ok, repo} -> [:ok, repo]
126-
|> Enum.map(&Enum.at(&1, 1)) # [:ok, repo] -> repo
127-
|> (fn data -> {state, data} end).() # repos -> {:ok, repos}
85+
|> ResultAggregator.aggregate
12886
end
12987

13088
@spec sync(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t}
131-
defp sync(%GithubAppInstallation{github_repos: github_repos} = installation, %{github_id: github_id} = repo_attributes) do
89+
defp sync(
90+
%GithubAppInstallation{github_repos: github_repos} = installation,
91+
%{github_id: github_id} = repo_attributes) do
92+
13293
case github_repos |> Enum.find(fn %GithubRepo{} = gr -> gr.github_id == github_id end) do
13394
nil -> create(installation, repo_attributes)
13495
%GithubRepo{} = github_repo -> github_repo |> update(repo_attributes)
@@ -163,6 +124,8 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
163124
# transaction step 5
164125
@spec mark_processed(%{processing_installation: GithubAppInstallation.t}) :: {:ok, GithubAppInstallation.t}
165126
defp mark_processed(%{processing_installation: %GithubAppInstallation{} = installation}) do
166-
installation |> set_state("processed")
127+
installation
128+
|> Changeset.change(%{state: "processed"})
129+
|> Repo.update
167130
end
168131
end

lib/code_corps/github/event/installation/unmatched_user.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule CodeCorps.GitHub.Event.Installation.UnmatchedUser do
2-
@moduledoc """
2+
@moduledoc ~S"""
33
In charge of handling the unmatched user case of an installation event
44
"""
55

@@ -10,9 +10,9 @@ defmodule CodeCorps.GitHub.Event.Installation.UnmatchedUser do
1010
}
1111

1212
@typep process_outcome :: {:ok, GithubAppInstallation.t} | {:error, Changeset.t}
13-
@typep outcome :: process_outcome | {:error, :unexpected_installation_payload}
13+
@type outcome :: process_outcome | {:error, :unexpected_installation_payload}
1414

15-
@doc """
15+
@doc ~S"""
1616
Handles the installation event in the case of an unmatched user.
1717
1818
This is done by attempting to find a `GithubAppInstallation` record by it's

0 commit comments

Comments
 (0)