Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/code_corps/accounts/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ defmodule CodeCorps.Accounts do
end
end

@spec do_update_with_github_user(Usert.t, GithubUser.t) :: {:ok, User.t} | {:error, Changeset.t}
@spec do_update_with_github_user(User.t, GithubUser.t) :: {:ok, User.t} | {:error, Changeset.t}
defp do_update_with_github_user(%User{} = user, %GithubUser{} = github_user) do
user
|> Changesets.update_with_github_user_changeset(github_user |> Adapters.User.to_user_attrs())
Expand Down
3 changes: 2 additions & 1 deletion lib/code_corps/github/adapters/utils/body_decorator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule CodeCorps.GitHub.Adapters.Utils.BodyDecorator do

alias CodeCorps.{
Comment,
GithubIssue,
Task,
User,
WebClient
Expand All @@ -13,7 +14,7 @@ defmodule CodeCorps.GitHub.Adapters.Utils.BodyDecorator do
@separator "\r\n\r\n[//]: # (Please type your edits below this line)\r\n\r\n---"
@linebreak "\r\n\r\n"

@spec add_code_corps_header(map, Comment.t | Task.t) :: map
@spec add_code_corps_header(map, Comment.t | Task.t | GithubIssue.t) :: map
def add_code_corps_header(%{"body" => body} = attrs, %Comment{user: %User{github_id: nil}} = comment) do
modified_body = build_header(comment) <> @separator <> @linebreak <> body
attrs |> Map.put("body", modified_body)
Expand Down
45 changes: 31 additions & 14 deletions lib/code_corps/github/api/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,43 @@ defmodule CodeCorps.GitHub.API do
alias CodeCorps.{
GithubAppInstallation,
GitHub,
GitHub.API.Errors.PaginationError,
GitHub.API.Pagination,
GitHub.APIError,
GitHub.HTTPClientError,
GitHub.Utils.ResultAggregator,
User
}

alias HTTPoison.{Error, Response}

def gateway(), do: Application.get_env(:code_corps, :github)

@spec request(GitHub.method, String.t, GitHub.headers, GitHub.body, list) :: GitHub.response
@typep raw_body :: String.t
@typep raw_headers :: list({String.t, String.t})
@typep raw_options :: Keyword.t

@spec request(GitHub.method, String.t, raw_body, raw_headers, raw_options) :: GitHub.response
def request(method, url, body, headers, options) do
gateway().request(method, url, body, headers, options)
|> marshall_response()
end

@spec get_all(String.t, GitHub.headers, list) :: GitHub.response
@spec get_all(String.t, raw_headers, raw_options) :: {:ok, list(map)} | {:error, PaginationError.t} | {:error, GitHub.api_error_struct}
def get_all(url, headers, options) do
{:ok, %Response{request_url: request_url, headers: response_headers}} =
gateway().request(:head, url, "", headers, options)

response_headers
|> Pagination.retrieve_total_pages()
|> Pagination.to_page_numbers()
|> Enum.map(&Pagination.add_page_param(options, &1))
|> Enum.map(&gateway().request(:get, url, "", headers, &1))
|> Enum.map(&marshall_response/1)
|> Enum.map(&Tuple.to_list/1)
|> Enum.map(&List.last/1)
|> List.flatten
case gateway().request(:head, url, "", headers, options) do
{:ok, %Response{headers: response_headers, status_code: code}} when code in 200..399 ->
response_headers
|> Pagination.retrieve_total_pages()
|> Pagination.to_page_numbers()
|> Enum.map(&Pagination.add_page_param(options, &1))
|> Enum.map(&gateway().request(:get, url, "", headers, &1))
|> Enum.map(&marshall_response/1)
|> ResultAggregator.aggregate
|> marshall_paginated_response()
other
-> other |> marshall_response()
end
end

@doc """
Expand Down Expand Up @@ -69,6 +76,9 @@ defmodule CodeCorps.GitHub.API do
@typep http_failure :: {:error, term}

@spec marshall_response(http_success | http_failure) :: GitHub.response
defp marshall_response({:ok, %Response{body: "", status_code: status}}) when status in 200..299 do
{:ok, %{}}
end
defp marshall_response({:ok, %Response{body: body, status_code: status}}) when status in 200..299 do
case body |> Poison.decode do
{:ok, json} ->
Expand All @@ -80,6 +90,9 @@ defmodule CodeCorps.GitHub.API do
defp marshall_response({:ok, %Response{body: body, status_code: 404}}) do
{:error, APIError.new({404, %{"message" => body}})}
end
defp marshall_response({:ok, %Response{body: "", status_code: status}}) when status in 400..599 do
{:error, APIError.new({status, %{"message" => "API Error during HEAD request"}})}
end
defp marshall_response({:ok, %Response{body: body, status_code: status}}) when status in 400..599 do
case body |> Poison.decode do
{:ok, json} ->
Expand All @@ -94,4 +107,8 @@ defmodule CodeCorps.GitHub.API do
defp marshall_response({:error, reason}) do
{:error, HTTPClientError.new(reason: reason)}
end

@spec marshall_paginated_response(tuple) :: tuple
defp marshall_paginated_response({:ok, pages}), do: {:ok, pages |> List.flatten}
defp marshall_paginated_response({:error, responses}), do: {:error, responses |> PaginationError.new}
end
30 changes: 30 additions & 0 deletions lib/code_corps/github/api/errors/pagination_error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule CodeCorps.GitHub.API.Errors.PaginationError do
alias CodeCorps.GitHub.{HTTPClientError, APIError}

@type t :: %__MODULE__{
message: String.t,
api_errors: list,
client_errors: list,
retrieved_pages: list
}

defstruct [
message: "One or more pages failed to retrieve during a GitHub API Pagination Request",
retrieved_pages: [],
client_errors: [],
api_errors: []
]

def new({pages, errors}) do
%__MODULE__{
retrieved_pages: pages,
client_errors: errors |> Enum.filter(&is_client_error?/1),
api_errors: errors |> Enum.filter(&is_api_error?/1)
}
end

defp is_client_error?(%HTTPClientError{}), do: true
defp is_client_error?(_), do: false
defp is_api_error?(%APIError{}), do: true
defp is_api_error?(_), do: false
end
20 changes: 15 additions & 5 deletions lib/code_corps/github/api/installation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,31 @@ defmodule CodeCorps.GitHub.API.Installation do
@doc """
List repositories that are accessible to the authenticated installation.

All pages of records are retrieved.

https://developer.github.com/v3/apps/installations/#list-repositories
"""
@spec repositories(GithubAppInstallation.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
@spec repositories(GithubAppInstallation.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
def repositories(%GithubAppInstallation{} = installation) do
with {:ok, access_token} <- installation |> get_access_token(),
{:ok, %{"repositories" => repositories}} <- fetch_repositories(access_token) do

{:ok, repositories}
{:ok, responses} <- access_token |> fetch_repositories() do
{:ok, responses |> extract_repositories}
else
{:error, error} -> {:error, error}
end
end

@spec fetch_repositories(String.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
defp fetch_repositories(access_token) do
GitHub.request(:get, "installation/repositories", %{}, %{}, [access_token: access_token])
"installation/repositories"
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100]])
end

@spec extract_repositories(list(map)) :: list(map)
defp extract_repositories(responses) do
responses
|> Enum.map(&Map.get(&1, "repositories"))
|> List.flatten
end

@doc """
Expand Down
2 changes: 1 addition & 1 deletion lib/code_corps/github/api/pagination.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ defmodule CodeCorps.GitHub.API.Pagination do
@doc ~S"""
From the specified page count, generates a list of integers, `1..count`
"""
@spec to_page_numbers(integer) :: list(integer)
@spec to_page_numbers(integer) :: Range.t
def to_page_numbers(total) when is_integer(total), do: 1..total

@doc ~S"""
Expand Down
9 changes: 3 additions & 6 deletions lib/code_corps/github/api/repository.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule CodeCorps.GitHub.API.Repository do
All pages of records are retrieved.
Closed issues are included.
"""
@spec issues(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
@spec issues(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
def issues(%GithubRepo{
github_app_installation: %GithubAppInstallation{
github_account_login: owner
Expand All @@ -27,7 +27,6 @@ defmodule CodeCorps.GitHub.API.Repository do
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
"repos/#{owner}/#{repo}/issues"
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100, state: "all"]])
|> (&{:ok, &1}).()
else
{:error, error} -> {:error, error}
end
Expand All @@ -38,7 +37,7 @@ defmodule CodeCorps.GitHub.API.Repository do

All pages of records are retrieved.
"""
@spec pulls(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
@spec pulls(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
def pulls(%GithubRepo{
github_app_installation: %GithubAppInstallation{
github_account_login: owner
Expand All @@ -48,7 +47,6 @@ defmodule CodeCorps.GitHub.API.Repository do
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
"repos/#{owner}/#{repo}/pulls"
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100, state: "all"]])
|> (&{:ok, &1}).()
else
{:error, error} -> {:error, error}
end
Expand All @@ -57,7 +55,7 @@ defmodule CodeCorps.GitHub.API.Repository do
@doc ~S"""
Retrieves comments from all issues in a github repository.
"""
@spec issue_comments(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
@spec issue_comments(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
def issue_comments(%GithubRepo{
github_app_installation: %GithubAppInstallation{
github_account_login: owner
Expand All @@ -67,7 +65,6 @@ defmodule CodeCorps.GitHub.API.Repository do
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
"repos/#{owner}/#{repo}/issues/comments"
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100]])
|> (&{:ok, &1}).()
else
{:error, error} -> {:error, error}
end
Expand Down
2 changes: 1 addition & 1 deletion lib/code_corps/github/event/installation/installation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ defmodule CodeCorps.GitHub.Event.Installation do
defp marshall_result({:error, :installation, :unexpected_installation_payload, _steps}), do: {:error, :unexpected_payload}
defp marshall_result({:error, :installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_syncing_installation}
defp marshall_result({:error, :installation, :too_many_unprocessed_installations, _steps}), do: {:error, :multiple_unprocessed_installations_found}
defp marshall_result({:error, :api_response, %CodeCorps.GitHub.APIError{}, _steps}), do: {:error, :github_api_error_on_syncing_repos}
defp marshall_result({:error, :api_response, %CodeCorps.GitHub.API.Errors.PaginationError{}, _steps}), do: {:error, :github_api_error_on_syncing_repos}
defp marshall_result({:error, :deleted_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_deleting_removed_repos}
defp marshall_result({:error, :synced_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_syncing_existing_repos}
defp marshall_result({:error, :processed_installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_marking_installation_processed}
Expand Down
2 changes: 1 addition & 1 deletion lib/code_corps/github/event/installation/repos.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
end

# transaction step 1
@spec fetch_api_repo_list(map) :: {:ok, map} | {:error, GitHub.api_error_struct}
@spec fetch_api_repo_list(map) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
defp fetch_api_repo_list(%{processing_installation: %GithubAppInstallation{} = installation}) do
installation |> Installation.repositories()
end
Expand Down
38 changes: 29 additions & 9 deletions lib/code_corps/github/github.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,28 @@ defmodule CodeCorps.GitHub do
end
end


@type method :: :get | :post | :put | :delete | :patch | :head

@type body :: {:multipart, list} | map
@type headers :: %{String.t => String.t} | %{}
@type response :: {:ok, map} | {:error, api_error_struct}
@type api_error_struct :: APIError.t | HTTPClientError.t

@typedoc ~S"""
Potential errors which can happen when retrieving data from a paginated
endpoint.

If a new access token is required, then it is regenerated and stored into an
installation, which can result in any of
- `Ecto.Changeset.t`
- `CodeCorps.GitHub.APIError.t`
- `CodeCorps.GitHub.HTTPClientError.t`

Once that is done, the actual request is made, which can error out with
- `CodeCorps.GitHub.Errors.PaginationError.t`
"""
@type paginated_endpoint_error :: Ecto.Changeset.t | APIError.t | HTTPClientError.t | API.Errors.PaginationError.t

@doc """
A low level utility function to make a direct request to the GitHub API.
"""
Expand All @@ -110,6 +124,20 @@ defmodule CodeCorps.GitHub do
end
end

@doc ~S"""
A low level utility function to make an authenticated request to a GitHub API
endpoint which supports pagination, and fetch all the pages from that endpoint
at once, by making parallel requests to each page and aggregating the results.
"""
@spec get_all(String.t, headers, list) :: {:ok, list(map)} | {:error, API.Errors.PaginationError.t} | {:error, api_error_struct}
def get_all(endpoint, headers, options) do
API.get_all(
api_url_for(endpoint),
headers |> Headers.user_request(options),
options
)
end

@doc """
A low level utility function to make an authenticated request to the
GitHub API on behalf of a GitHub App or integration
Expand All @@ -129,14 +157,6 @@ defmodule CodeCorps.GitHub do
end
end

def get_all(endpoint, headers, options) do
API.get_all(
api_url_for(endpoint),
headers |> Headers.user_request(options),
options
)
end

@token_url "https://github.com/login/oauth/access_token"

@doc """
Expand Down
33 changes: 18 additions & 15 deletions lib/code_corps/github/utils/result_aggregator.ex
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
defmodule CodeCorps.GitHub.Utils.ResultAggregator do
@moduledoc ~S"""
Module used for the purpose of aggregating results from multiple repository commit actions.
Used for aggregating a list of results.
"""

alias Ecto.Changeset

@doc ~S"""
Aggregates a list of database commit results into an `:ok`, or `:error` tuple.
Aggregates a list of result tuples into a single result tuple.

A result tuple is a two-element tuple where the first element is `:ok`,
or `:error`, while the second element is the resulting data.

This function goes through a list of such tuples and aggregates the list into
a single tuple where

All list members are assumed to be either an `{:ok, committed_record}` or
`{:error, changeset}`.
- if all tuples in the list are `:ok` tuples, returns `{:ok, results}`
- if any tuple is an `:error` tuple, returns `{:error, {results, errors}}`

The aggregate is an `{:ok, committed_records}` if all results are
`{:ok, committed_record}`, or an `{:error, {committed_records, changesets}}`
if any of the results is an `{:error, changeset}`.
- `results` and `errors` are lists of second tuple elements in their
respective tuples
"""
@spec aggregate(list) :: {:ok, list} | {:error, {list, list}}
def aggregate(results) when is_list(results) do
Expand All @@ -22,15 +25,15 @@ defmodule CodeCorps.GitHub.Utils.ResultAggregator do

@spec collect(list, list, list) :: tuple
defp collect(results, recods \\ [], changesets \\ [])
defp collect([{:ok, record} | tail], records, changesets) do
collect(tail, records ++ [record], changesets)
defp collect([{:ok, record} | tail], records, errors) do
collect(tail, records ++ [record], errors)
end
defp collect([{:error, %Changeset{} = changeset} | tail], records, changesets) do
collect(tail, records, changesets ++ [changeset])
defp collect([{:error, error} | tail], records, errors) do
collect(tail, records, errors ++ [error])
end
defp collect([], records, changesets), do: {records, changesets}
defp collect([], records, errors), do: {records, errors}

@spec summarize(tuple) :: tuple
defp summarize({records, []}), do: {:ok, records}
defp summarize({records, changesets}), do: {:error, {records, changesets}}
defp summarize({records, errors}), do: {:error, {records, errors}}
end
2 changes: 2 additions & 0 deletions lib/code_corps/model/github_user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ defmodule CodeCorps.GithubUser do
use Ecto.Schema
import Ecto.Changeset

@type t :: %__MODULE__{}

schema "github_users" do
field :avatar_url, :string
field :email, :string
Expand Down
2 changes: 1 addition & 1 deletion priv/repo/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-- PostgreSQL database dump
--

-- Dumped from database version 10.0
-- Dumped from database version 9.5.9
-- Dumped by pg_dump version 10.0

SET statement_timeout = 0;
Expand Down
Loading