Skip to content

Commit

Permalink
Implement handling of "InstallationRepositories" "added" and "removed…
Browse files Browse the repository at this point in the history
…" events
  • Loading branch information
begedin committed Jul 7, 2017
1 parent 81575b4 commit a3bee28
Show file tree
Hide file tree
Showing 23 changed files with 662 additions and 91 deletions.
36 changes: 36 additions & 0 deletions lib/code_corps/github/event.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
defmodule CodeCorps.GitHub.Event do
@moduledoc ~S"""
In charge of marking `GithubEvent` records as "processing", "processed" or
"errored", based on the outcome of processing a webhook event payload.
"""

alias CodeCorps.{GithubEvent, Repo}
alias Ecto.Changeset

@type error :: atom | Changeset.t
@type processing_result :: {:ok, any} | {:error, error}

@doc ~S"""
Sets record status to "processing", marking it as being processed at this
moment. Our webhook handling should skip processing payloads for events which
are already being processed.
"""
@spec start_processing(GithubEvent.t) :: {:ok, GithubEvent.t}
def start_processing(%GithubEvent{} = event) do
event |> Changeset.change(%{status: "processing"}) |> Repo.update()
end

@doc ~S"""
Sets record status to "processed" or "errored" based on the first element of
first argument, which is the result tuple. The result tuple should always be
either `{:ok, data}` if the the processing of the event payload went as
expected, or `{:error, reason}` if something went wrong.
"""
@spec stop_processing(processing_result, GithubEvent.t) :: {:ok, GithubEvent.t}
def stop_processing({:ok, _data}, %GithubEvent{} = event) do
event |> Changeset.change(%{status: "processed"}) |> Repo.update
end
def stop_processing({:error, _reason}, %GithubEvent{} = event) do
event |> Changeset.change(%{status: "errored"}) |> Repo.update
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule CodeCorps.GitHub.Events.Installation do
defmodule CodeCorps.GitHub.Event.Installation do
@moduledoc """
In charge of dealing with "Installation" GitHub Webhook events
"""
Expand All @@ -8,6 +8,7 @@ defmodule CodeCorps.GitHub.Events.Installation do
GithubAppInstallation,
GithubEvent,
GithubRepo,
GitHub.Event,
Repo,
User
}
Expand All @@ -25,82 +26,79 @@ defmodule CodeCorps.GitHub.Events.Installation do
- marked the passed in event as "processed" or "errored"
"""
@spec handle(GithubEvent.t, map) :: {:ok, GithubEvent.t}
def handle(%GithubEvent{action: "created"} = event, payload) do
def handle(%GithubEvent{action: action} = event, payload) do
event
|> start_event_processing()
|> do_handle(payload)
|> stop_event_processing(event)
|> Event.start_processing()
|> do_handle(action, payload)
|> Event.stop_processing(event)
end

defp do_handle({:ok, %GithubEvent{}}, %{"installation" => installation_attrs, "sender" => sender_attrs}) do
@spec do_handle({:ok, GithubEvent.t}, String.t, map) :: {:ok, GithubAppInstallation.t} | {:error, :api_error}
defp do_handle({:ok, %GithubEvent{}}, "created", %{"installation" => installation_attrs, "sender" => sender_attrs}) do
case {sender_attrs |> find_user, installation_attrs |> find_installation} do
{nil, nil} -> create_unmatched_user_installation(installation_attrs)
{%User{} = user, nil} -> create_installation_initiated_on_github(user, installation_attrs)
{%User{}, %GithubAppInstallation{} = installation} -> create_matched_installation(installation)
end
end

@spec create_installation_initiated_on_github(User.t, map) :: {:ok, GithubAppInstallation.t}
defp create_installation_initiated_on_github(%User{} = user, %{"id" => github_id}) do
%GithubAppInstallation{}
|> Changeset.change(%{github_id: github_id, installed: true, state: "initiated_on_github"})
|> Changeset.put_assoc(:user, user)
|> Repo.insert()
end

@spec create_matched_installation(GithubAppInstallation.t) :: {:ok, GithubAppInstallation.t}
defp create_matched_installation(%GithubAppInstallation{} = installation) do
installation
|> start_installation_processing()
|> process_repos()
|> stop_installation_processing()
end

@spec create_unmatched_user_installation(map) :: {:ok, GithubAppInstallation.t}
defp create_unmatched_user_installation(%{"id" => github_id}) do
%GithubAppInstallation{}
|> Changeset.change(%{github_id: github_id, installed: true, state: "unmatched_user"})
|> Repo.insert()
end

@spec create_repository(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t}
defp create_repository(%GithubAppInstallation{} = installation, repo_attributes) do
%GithubRepo{}
|> Changeset.change(repo_attributes |> GithubRepoAdapter.from_api)
|> Changeset.put_assoc(:github_app_installation, installation)
|> Repo.insert()
end

defp create_unmatched_user_installation(%{"id" => github_id}) do
%GithubAppInstallation{}
|> Changeset.change(%{github_id: github_id, installed: true, state: "unmatched_user"})
|> Repo.insert()
end

@spec find_installation(map) :: GithubAppInstallation.t | nil
defp find_installation(%{"id" => github_id}), do: GithubAppInstallation |> Repo.get_by(github_id: github_id)

@spec find_user(map) :: User.t | nil
defp find_user(%{"id" => github_id}), do: User |> Repo.get_by(github_id: github_id)

@spec process_repos({:ok, GithubAppInstallation.t}) :: {:ok, GithubAppInstallation.t} | {:error, :api_error}
defp process_repos({:ok, %GithubAppInstallation{} = installation}) do
# TODO: Consider moving into transaction
{:ok, repositories} =
installation
|> GitHub.Installation.repositories()
|> (fn {:ok, repositories} -> repositories end).()
|> Enum.map(&create_repository(installation, &1))
|> Enum.map(fn {:ok, repository} -> repository end)
|> (fn repositories -> {:ok, repositories} end).()

{:ok, installation |> Map.put(:github_repos, repositories)}
end
with {:ok, repo_payloads} <- installation |> GitHub.Installation.repositories() do
repositories =
repo_payloads
|> Enum.map(&create_repository(installation, &1))
|> Enum.map(fn {:ok, repository} -> repository end)

defp start_event_processing(%GithubEvent{} = event) do
event |> Changeset.change(%{status: "processing"}) |> Repo.update()
end

defp stop_event_processing({:ok, %GithubAppInstallation{}}, %GithubEvent{} = event) do
event |> Changeset.change(%{status: "processed"}) |> Repo.update
end
defp stop_event_processing(_, %GithubEvent{} = event) do
event |> Changeset.change(%{status: "errored"}) |> Repo.update
{:ok, installation |> Map.put(:github_repos, repositories)}
end
end

@spec start_installation_processing({:ok, GithubAppInstallation.t}) :: {:ok, GithubAppInstallation.t}
defp start_installation_processing(%GithubAppInstallation{} = installation) do
installation
|> Changeset.change(%{installed: true, state: "processing"})
|> Repo.update()
end

@spec stop_installation_processing({:ok, GithubAppInstallation.t}) :: {:ok, GithubAppInstallation.t}
defp stop_installation_processing({:ok, %GithubAppInstallation{} = installation}) do
installation
|> Changeset.change(%{state: "processed"})
Expand Down
129 changes: 129 additions & 0 deletions lib/code_corps/github/event/installation_repositories.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
defmodule CodeCorps.GitHub.Event.InstallationRepositories do
@moduledoc """
In charge of dealing with "InstallationRepositories" GitHub Webhook events
"""

alias CodeCorps.{
GithubAppInstallation,
GithubEvent,
GithubRepo,
GitHub.Event,
Repo
}

alias Ecto.Changeset

@doc """
Handles an "InstallationRepositories" GitHub Webhook event. The event could be
of subtype "added" or "removed" and is handled differently based on that.
`InstallationRepositories::added` will
- find `GithubAppInstallation`
- marks event as errored if not found
- marks event as errored if payload does not have keys it needs, meaning
there's something wrong with our code and we need to updated
- find or create `GithubRepo` records affected
`InstallationRepositories::removed` will
- find `GithubAppInstallation`
- marks event as errored if not found
- marks event as errored if payload does not have keys it needs, meaning
there's something wrong with our code and we need to updated
- find `GithubRepo` records affected and delete them
- if there is no `GithubRepo` to delete, it just skips it
- `ProjectGithubRepo` are deleted automatically, since they are set to
`on_delete: :delete_all`
"""
@spec handle(GithubEvent.t, map) :: {:ok, GithubEvent.t}
def handle(%GithubEvent{action: action} = event, payload) do
event
|> Event.start_processing()
|> do_handle(action, payload)
|> Event.stop_processing(event)
end

@typep outcome :: {:ok, [GithubRepo.t]} |
{:error, :no_installation} |
{:error, :unexpected_action_or_payload} |
{:error, :unexpected_installation_payload} |
{:error, :unexpected_repo_payload}

@spec do_handle({:ok, GithubEvent.t}, String.t, map) :: outcome
defp do_handle({:ok, %GithubEvent{}}, "added", %{"installation" => installation_attrs, "repositories_added" => repositories_attr_list}) do
case installation_attrs |> find_installation() do
nil -> {:error, :no_installation}
:unexpected_installation_payload -> {:error, :unexpected_installation_payload}
%GithubAppInstallation{} = installation ->
case repositories_attr_list |> Enum.all?(&valid?/1) do
true -> find_or_create_all(installation, repositories_attr_list)
false -> {:error, :unexpected_repo_payload}
end
end
end
defp do_handle({:ok, %GithubEvent{}}, "removed", %{"installation" => installation_attrs, "repositories_removed" => repositories_attr_list}) do
case installation_attrs |> find_installation() do
nil -> {:error, :no_installation}
:unexpected_installation_payload -> {:error, :unexpected_installation_payload}
%GithubAppInstallation{} = installation ->
case repositories_attr_list |> Enum.all?(&valid?/1) do
true -> delete_all(installation, repositories_attr_list)
false -> {:error, :unexpected_repo_payload}
end
end
end
defp do_handle({:ok, %GithubEvent{}}, _action, _payload), do: {:error, :unexpected_action_or_payload}

@spec find_installation(any) :: GithubAppInstallation.t | nil | :unexpected_installation_payload
defp find_installation(%{"id" => github_id}), do: GithubAppInstallation |> Repo.get_by(github_id: github_id)
defp find_installation(_payload), do: :unexpected_installation_payload

# should return true if the payload is a map and has the expected keys
@spec valid?(any) :: boolean
defp valid?(%{"id" => _, "name" => _}), do: true
defp valid?(_), do: false

@spec find_or_create_all(GithubAppInstallation.t, list(map)) :: {:ok, list(GithubRepo.t)}
defp find_or_create_all(%GithubAppInstallation{} = installation, repositories_attr_list) when is_list(repositories_attr_list) do
repositories_attr_list
|> Enum.map(&find_or_create(installation, &1))
|> aggregate()
end

@spec find_or_create(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t}
defp find_or_create(%GithubAppInstallation{} = installation, %{"id" => github_id, "name" => name} = attrs) do
case find_repo(installation, attrs) do
nil ->
%GithubRepo{}
|> Changeset.change(%{github_id: github_id, name: name})
|> Changeset.put_assoc(:github_app_installation, installation)
|> Repo.insert()
%GithubRepo{} = github_repo ->
{:ok, github_repo}
end
end

@spec delete_all(GithubAppInstallation.t, list(map)) :: {:ok, [GithubRepo.t]}
defp delete_all(%GithubAppInstallation{} = installation, repositories_attr_list) when is_list(repositories_attr_list) do
repositories_attr_list
|> Enum.map(&find_repo(installation, &1))
|> Enum.reject(&is_nil/1)
|> Enum.map(&Repo.delete/1)
|> aggregate()
end

@spec find_repo(GithubAppInstallation.t, map) :: GithubRepo.t | nil
defp find_repo(%GithubAppInstallation{id: installation_id}, %{"id" => github_id}) do
GithubRepo
|> Repo.get_by(github_app_installation_id: installation_id, github_id: github_id)
end

# [{:ok, repo_1}, {:ok, repo_2}, {:ok, repo_3}] -> {:ok, [repo_1, repo_2, repo_3]}
@spec aggregate(list({:ok, GithubRepo.t})) :: {:ok, list(GithubRepo.t)}
defp aggregate(results) do
repositories =
results
|> Enum.map(fn {:ok, %GithubRepo{} = github_repo} -> github_repo end)

{:ok, repositories}
end
end
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule CodeCorps.GitHub.Events.IssueComment do
defmodule CodeCorps.GitHub.Event.IssueComment do
@moduledoc """
In charge of dealing with "IssueComment" GitHub Webhook events
"""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule CodeCorps.GitHub.Events.Issues do
defmodule CodeCorps.GitHub.Event.Issues do
@moduledoc """
In charge of dealing with "Issues" GitHub Webhook events
"""
Expand Down
17 changes: 0 additions & 17 deletions lib/code_corps/github/events/installation_repositories.ex

This file was deleted.

8 changes: 4 additions & 4 deletions lib/code_corps/github/webhook/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ defmodule CodeCorps.GitHub.Webhook.Handler do

alias CodeCorps.{
GithubEvent,
GitHub.Events.Installation,
GitHub.Events.InstallationRepositories,
GitHub.Events.IssueComment,
GitHub.Events.Issues,
GitHub.Event.Installation,
GitHub.Event.InstallationRepositories,
GitHub.Event.IssueComment,
GitHub.Event.Issues,
GitHub.Webhook.EventSupport,
Repo
}
Expand Down
14 changes: 14 additions & 0 deletions priv/repo/migrations/20170630140136_create_project_github_repo.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule CodeCorps.Repo.Migrations.CreateProjectGithubRepo do
use Ecto.Migration

def change do
create table(:project_github_repos) do
add :project_id, references(:projects, on_delete: :delete_all)
add :github_repo_id, references(:github_repos, on_delete: :delete_all)

timestamps()
end

create unique_index(:project_github_repos, [:project_id, :github_repo_id])
end
end
Loading

0 comments on commit a3bee28

Please sign in to comment.