Skip to content

Commit

Permalink
Add installation event support
Browse files Browse the repository at this point in the history
Minor fixes to router and config for events
  • Loading branch information
joshsmith committed Jun 29, 2017
1 parent 3e48e7d commit 8faef04
Show file tree
Hide file tree
Showing 34 changed files with 864 additions and 69 deletions.
5 changes: 0 additions & 5 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,6 @@ config :code_corps, :corsica_log_level, [rejected: :warn]
{:ok, pem} = (System.get_env("GITHUB_APP_PEM") || "") |> Base.decode64()

config :code_corps,
# GitHub webhook API uses cased header names in their requests
# However, in the test environment, Plug.Conn enforces headers to be
# lowercased and errors out otherwise.
github_event_type_header: ("X-GitHub-Event"),
github_event_id_header: ("X-GitHub-Delivery"),
github_app_id: System.get_env("GITHUB_APP_ID"),
github_app_client_id: System.get_env("GITHUB_APP_CLIENT_ID"),
github_app_client_secret: System.get_env("GITHUB_APP_CLIENT_SECRET"),
Expand Down
7 changes: 0 additions & 7 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,6 @@ config :guardian, Guardian,

config :code_corps, :analytics, CodeCorps.Analytics.TestAPI

config :code_corps,
# GitHub webhook API uses cased header names in their requests
# However, in the test environment, Plug.Conn enforces headers to be
# lowercased and errors out otherwise.
github_event_type_header: ("x-github-event"),
github_event_id_header: ("x-github-delivery")

# Configures stripe for test mode
config :code_corps, :stripe, CodeCorps.StripeTesting
config :code_corps, :stripe_env, :test
Expand Down
17 changes: 8 additions & 9 deletions lib/code_corps/github.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ defmodule CodeCorps.GitHub do
Application.get_env(:code_corps, :github_base_url) || "https://api.github.com/"
end

#
@spec get_token_url() :: String.t
defp get_token_url() do
Application.get_env(:code_corps, :github_oauth_url) || "https://github.com/login/oauth/access_token"
Expand Down Expand Up @@ -132,7 +131,7 @@ defmodule CodeCorps.GitHub do
Used to exchange the JWT for an access token for a given integration, or
for the GitHub App itself.
Expires in 10 minutes.
Expires in 5 minutes.
"""
def generate_jwt do
signer = rsa_key() |> Joken.rs256()
Expand Down Expand Up @@ -163,6 +162,13 @@ defmodule CodeCorps.GitHub do
[:with_body | opts]
end

@spec build_access_token_params(String.t, String.t) :: map
defp build_access_token_params(code, state) do
@base_access_token_params
|> Map.put(:code, code)
|> Map.put(:state, state)
end

@doc """
A low level utility function to make a direct request to the GitHub API.
"""
Expand All @@ -188,13 +194,6 @@ defmodule CodeCorps.GitHub do
|> handle_response()
end

@spec build_access_token_params(String.t, String.t) :: map
defp build_access_token_params(code, state) do
@base_access_token_params
|> Map.put(:code, code)
|> Map.put(:state, state)
end

@doc """
A low level utility function to fetch a GitHub user's OAuth access token
"""
Expand Down
20 changes: 20 additions & 0 deletions lib/code_corps/github/adapters/github_repo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule CodeCorps.GitHub.Adapters.GithubRepo do
def from_api(%{"owner" => owner} = payload) do
%{}
|> Map.merge(payload |> adapt_base())
|> Map.merge(owner |> adapt_owner())
end

defp adapt_base(%{"id" => id, "name" => name}) do
%{github_id: id, name: name}
end

defp adapt_owner(%{"id" => id, "avatar_url" => avatar_url, "login" => login, "type" => type }) do
%{
github_account_id: id,
github_account_avatar_url: avatar_url,
github_account_login: login,
github_account_type: type
}
end
end
96 changes: 94 additions & 2 deletions lib/code_corps/github/events/installation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ defmodule CodeCorps.GitHub.Events.Installation do
In charge of dealing with "Installation" GitHub Webhook events
"""

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

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

alias Ecto.Changeset

@doc """
Handles an "Installation" GitHub Webhook event
Expand All @@ -13,5 +24,86 @@ defmodule CodeCorps.GitHub.Events.Installation do
- do the work
- marked the passed in event as "processed" or "errored"
"""
def handle(%GithubEvent{}, %{}), do: :not_fully_implemented
@spec handle(GithubEvent.t, map) :: {:ok, GithubEvent.t}
def handle(%GithubEvent{action: "created"} = event, payload) do
event
|> start_event_processing()
|> do_handle(payload)
|> stop_event_processing(event)
end

defp do_handle({:ok, %GithubEvent{}}, %{"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

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

defp create_matched_installation(%GithubAppInstallation{} = installation) do
installation
|> start_installation_processing()
|> process_repos()
|> stop_installation_processing()
end

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

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

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

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

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
end

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

defp stop_installation_processing({:ok, %GithubAppInstallation{} = installation}) do
installation
|> Changeset.change(%{state: "processed"})
|> Repo.update()
end
end
84 changes: 84 additions & 0 deletions lib/code_corps/github/installation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
defmodule CodeCorps.GitHub.Installation do
@moduledoc """
Used to perform installation actions on the GitHub API.
"""

alias CodeCorps.{
GitHub,
GitHub.APIError,
GitHub.Request,
GithubAppInstallation,
Repo
}

@doc """
List repositories that are accessible to the authenticated installation.
https://developer.github.com/v3/apps/installations/#list-repositories
"""
@spec repositories(GithubAppInstallation.t) :: {:ok, list} | {:error, APIError.t}
def repositories(%GithubAppInstallation{} = installation) do
endpoint = "installation/repositories"
{:ok, access_token} = installation |> get_access_token()
case Request.retrieve(endpoint, [access_token: access_token]) do
{:error, %APIError{} = error} -> {:error, error}
{:ok, %{"total_count" => _, "repositories" => repositories}} -> {:ok, repositories}
end
end

@doc """
Get the access token for the installation.
Returns either the current access token stored in the database because
it has not yet expired, or makes a request to the GitHub API for a new
access token using the GitHub App's JWT.
https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/#authenticating-as-an-installation
"""
@spec get_access_token(GithubAppInstallation.t) :: {:ok, String.t} | {:error, APIError.t} | {:error, Ecto.Changeset.t}
def get_access_token(%GithubAppInstallation{access_token: token, access_token_expires_at: expires_at} = installation) do
case token_expired?(expires_at) do
true -> installation |> refresh_token()
false -> {:ok, token} # return the existing token
end
end

@doc """
Refreshes the access token for the installation.
Makes a request to the GitHub API for a new access token using the GitHub
App's JWT.
https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/#authenticating-as-an-installation
"""
@spec refresh_token(GithubAppInstallation.t) :: {:ok, String.t} | {:error, APIError.t} | {:error, Ecto.Changeset.t}
def refresh_token(%GithubAppInstallation{github_id: installation_id} = installation) do
endpoint = "installations/#{installation_id}/access_tokens"
with {:ok, %{"token" => token, "expires_at" => expires_at}} <-
GitHub.authenticated_integration_request(%{}, :post, endpoint, %{}, []),
{:ok, %GithubAppInstallation{}} <-
update_token(installation, token, expires_at)
do
{:ok, token}
else
{:error, error} -> {:error, error}
end
end

@spec update_token(GithubAppInstallation.t, String.t, String.t) :: {:ok, GithubAppInstallation.t} | {:error, Ecto.Changeset.t}
defp update_token(%GithubAppInstallation{} = installation, token, expires_at) do
installation
|> GithubAppInstallation.access_token_changeset(%{access_token: token, access_token_expires_at: expires_at})
|> Repo.update
end

@doc false
@spec token_expired?(String.t | DateTime.t | nil) :: true | false
def token_expired?(expires_at) when is_binary(expires_at) do
expires_at
|> Timex.parse!("{ISO:Extended:Z}")
|> token_expired?()
end
def token_expired?(%DateTime{} = expires_at), do: Timex.now > expires_at
def token_expired?(nil), do: true
end
6 changes: 3 additions & 3 deletions lib/code_corps/github/webhook/event_support.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ defmodule CodeCorps.GitHub.Webhook.EventSupport do
@type support_status :: :supported | :unsupported

@doc """
Returns :supported if the GitHub event type is in the list of events we support,
:unsupported otherwise.
Returns :supported if the GitHub event type is in the list of events we
support, :unsupported otherwise.
"""
@spec status(any) :: support_status
def status(event_type) when event_type in @supported_events, do: :supported
def status(_), do: :unsupported

@doc """
Convenience function. Makes the internal list of supported events public
Convenience function. Makes the internal list of supported events public.
"""
@spec supported_events :: list
def supported_events, do: @supported_events
Expand Down
28 changes: 16 additions & 12 deletions lib/code_corps/github/webhook/handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,31 @@ defmodule CodeCorps.GitHub.Webhook.Handler do
%{
action: action,
github_delivery_id: id,
type: type,
source: sender |> get_source(),
status: type |> get_status(),
source: sender |> get_source()
type: type
}
end

defp create_event(params) do
%GithubEvent{} |> GithubEvent.changeset(params) |> Repo.insert
end

defp get_source(_), do: "not implemented"

defp get_status(type) do
case EventSupport.status(type) do
:unsupported -> "unhandled"
:supported -> "unprocessed"
end
end

defp create_event(params) do
%GithubEvent{} |> GithubEvent.changeset(params) |> Repo.insert
end

defp get_source(_), do: "not implemented"

def process_payload(%GithubEvent{type: "installation"} = event, payload), do: Installation.handle(event, payload)
def process_payload(%GithubEvent{type: "installation_repositories"} = event, payload), do: InstallationRepositories.handle(event, payload)
def process_payload(%GithubEvent{type: "issue_comment"} = event, payload), do: IssueComment.handle(event, payload)
def process_payload(%GithubEvent{type: "issues"} = event, payload), do: Issues.handle(event, payload)
defp process_payload(%GithubEvent{type: "installation"} = event, payload),
do: Installation.handle(event, payload)
defp process_payload(%GithubEvent{type: "installation_repositories"} = event, payload),
do: InstallationRepositories.handle(event, payload)
defp process_payload(%GithubEvent{type: "issue_comment"} = event, payload),
do: IssueComment.handle(event, payload)
defp process_payload(%GithubEvent{type: "issues"} = event, payload),
do: Issues.handle(event, payload)
end
20 changes: 20 additions & 0 deletions priv/repo/migrations/20170628092119_add_github_repos.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule CodeCorps.Repo.Migrations.AddGithubRepos do
use Ecto.Migration

def change do
create table(:github_repos) do
add :github_id, :integer
add :name, :string
add :github_account_id, :integer
add :github_account_login, :string
add :github_account_avatar_url, :string
add :github_account_type, :string

add :github_app_installation_id, references(:github_app_installations, on_delete: :nothing)

timestamps()
end

create index(:github_repos, [:github_app_installation_id])
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule CodeCorps.Repo.Migrations.AddAccessTokensToGithubAppInstallations do
use Ecto.Migration

def change do
alter table(:github_app_installations) do
add :access_token, :string
add :access_token_expires_at, :utc_datetime
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule CodeCorps.Repo.Migrations.AddUniqueIndexOnGithubIdToGithubAppInstallations do
use Ecto.Migration

def change do
create unique_index(:github_app_installations, :github_id)
end
end

0 comments on commit 8faef04

Please sign in to comment.