Skip to content

Commit 6d9c919

Browse files
authored
Merge pull request #1162 from code-corps/1108-add-pagination-support-to-installation-repositories
Add pagination support to GitHub.API.Installation.repositories
2 parents 5bd21f8 + 84c5435 commit 6d9c919

File tree

15 files changed

+234
-66
lines changed

15 files changed

+234
-66
lines changed

lib/code_corps/accounts/accounts.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ defmodule CodeCorps.Accounts do
7171
end
7272
end
7373

74-
@spec do_update_with_github_user(Usert.t, GithubUser.t) :: {:ok, User.t} | {:error, Changeset.t}
74+
@spec do_update_with_github_user(User.t, GithubUser.t) :: {:ok, User.t} | {:error, Changeset.t}
7575
defp do_update_with_github_user(%User{} = user, %GithubUser{} = github_user) do
7676
user
7777
|> Changesets.update_with_github_user_changeset(github_user |> Adapters.User.to_user_attrs())

lib/code_corps/github/adapters/utils/body_decorator.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule CodeCorps.GitHub.Adapters.Utils.BodyDecorator do
55

66
alias CodeCorps.{
77
Comment,
8+
GithubIssue,
89
Task,
910
User,
1011
WebClient
@@ -13,7 +14,7 @@ defmodule CodeCorps.GitHub.Adapters.Utils.BodyDecorator do
1314
@separator "\r\n\r\n[//]: # (Please type your edits below this line)\r\n\r\n---"
1415
@linebreak "\r\n\r\n"
1516

16-
@spec add_code_corps_header(map, Comment.t | Task.t) :: map
17+
@spec add_code_corps_header(map, Comment.t | Task.t | GithubIssue.t) :: map
1718
def add_code_corps_header(%{"body" => body} = attrs, %Comment{user: %User{github_id: nil}} = comment) do
1819
modified_body = build_header(comment) <> @separator <> @linebreak <> body
1920
attrs |> Map.put("body", modified_body)

lib/code_corps/github/api/api.ex

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,43 @@ defmodule CodeCorps.GitHub.API do
22
alias CodeCorps.{
33
GithubAppInstallation,
44
GitHub,
5+
GitHub.API.Errors.PaginationError,
56
GitHub.API.Pagination,
67
GitHub.APIError,
78
GitHub.HTTPClientError,
9+
GitHub.Utils.ResultAggregator,
810
User
911
}
1012

1113
alias HTTPoison.{Error, Response}
1214

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

15-
@spec request(GitHub.method, String.t, GitHub.headers, GitHub.body, list) :: GitHub.response
17+
@typep raw_body :: String.t
18+
@typep raw_headers :: list({String.t, String.t})
19+
@typep raw_options :: Keyword.t
20+
21+
@spec request(GitHub.method, String.t, raw_body, raw_headers, raw_options) :: GitHub.response
1622
def request(method, url, body, headers, options) do
1723
gateway().request(method, url, body, headers, options)
1824
|> marshall_response()
1925
end
2026

21-
@spec get_all(String.t, GitHub.headers, list) :: GitHub.response
27+
@spec get_all(String.t, raw_headers, raw_options) :: {:ok, list(map)} | {:error, PaginationError.t} | {:error, GitHub.api_error_struct}
2228
def get_all(url, headers, options) do
23-
{:ok, %Response{request_url: request_url, headers: response_headers}} =
24-
gateway().request(:head, url, "", headers, options)
25-
26-
response_headers
27-
|> Pagination.retrieve_total_pages()
28-
|> Pagination.to_page_numbers()
29-
|> Enum.map(&Pagination.add_page_param(options, &1))
30-
|> Enum.map(&gateway().request(:get, url, "", headers, &1))
31-
|> Enum.map(&marshall_response/1)
32-
|> Enum.map(&Tuple.to_list/1)
33-
|> Enum.map(&List.last/1)
34-
|> List.flatten
29+
case gateway().request(:head, url, "", headers, options) do
30+
{:ok, %Response{headers: response_headers, status_code: code}} when code in 200..399 ->
31+
response_headers
32+
|> Pagination.retrieve_total_pages()
33+
|> Pagination.to_page_numbers()
34+
|> Enum.map(&Pagination.add_page_param(options, &1))
35+
|> Enum.map(&gateway().request(:get, url, "", headers, &1))
36+
|> Enum.map(&marshall_response/1)
37+
|> ResultAggregator.aggregate
38+
|> marshall_paginated_response()
39+
other
40+
-> other |> marshall_response()
41+
end
3542
end
3643

3744
@doc """
@@ -69,6 +76,9 @@ defmodule CodeCorps.GitHub.API do
6976
@typep http_failure :: {:error, term}
7077

7178
@spec marshall_response(http_success | http_failure) :: GitHub.response
79+
defp marshall_response({:ok, %Response{body: "", status_code: status}}) when status in 200..299 do
80+
{:ok, %{}}
81+
end
7282
defp marshall_response({:ok, %Response{body: body, status_code: status}}) when status in 200..299 do
7383
case body |> Poison.decode do
7484
{:ok, json} ->
@@ -80,6 +90,9 @@ defmodule CodeCorps.GitHub.API do
8090
defp marshall_response({:ok, %Response{body: body, status_code: 404}}) do
8191
{:error, APIError.new({404, %{"message" => body}})}
8292
end
93+
defp marshall_response({:ok, %Response{body: "", status_code: status}}) when status in 400..599 do
94+
{:error, APIError.new({status, %{"message" => "API Error during HEAD request"}})}
95+
end
8396
defp marshall_response({:ok, %Response{body: body, status_code: status}}) when status in 400..599 do
8497
case body |> Poison.decode do
8598
{:ok, json} ->
@@ -94,4 +107,8 @@ defmodule CodeCorps.GitHub.API do
94107
defp marshall_response({:error, reason}) do
95108
{:error, HTTPClientError.new(reason: reason)}
96109
end
110+
111+
@spec marshall_paginated_response(tuple) :: tuple
112+
defp marshall_paginated_response({:ok, pages}), do: {:ok, pages |> List.flatten}
113+
defp marshall_paginated_response({:error, responses}), do: {:error, responses |> PaginationError.new}
97114
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule CodeCorps.GitHub.API.Errors.PaginationError do
2+
alias CodeCorps.GitHub.{HTTPClientError, APIError}
3+
4+
@type t :: %__MODULE__{
5+
message: String.t,
6+
api_errors: list,
7+
client_errors: list,
8+
retrieved_pages: list
9+
}
10+
11+
defstruct [
12+
message: "One or more pages failed to retrieve during a GitHub API Pagination Request",
13+
retrieved_pages: [],
14+
client_errors: [],
15+
api_errors: []
16+
]
17+
18+
def new({pages, errors}) do
19+
%__MODULE__{
20+
retrieved_pages: pages,
21+
client_errors: errors |> Enum.filter(&is_client_error?/1),
22+
api_errors: errors |> Enum.filter(&is_api_error?/1)
23+
}
24+
end
25+
26+
defp is_client_error?(%HTTPClientError{}), do: true
27+
defp is_client_error?(_), do: false
28+
defp is_api_error?(%APIError{}), do: true
29+
defp is_api_error?(_), do: false
30+
end

lib/code_corps/github/api/installation.ex

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,31 @@ defmodule CodeCorps.GitHub.API.Installation do
1414
@doc """
1515
List repositories that are accessible to the authenticated installation.
1616
17+
All pages of records are retrieved.
18+
1719
https://developer.github.com/v3/apps/installations/#list-repositories
1820
"""
19-
@spec repositories(GithubAppInstallation.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
21+
@spec repositories(GithubAppInstallation.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
2022
def repositories(%GithubAppInstallation{} = installation) do
2123
with {:ok, access_token} <- installation |> get_access_token(),
22-
{:ok, %{"repositories" => repositories}} <- fetch_repositories(access_token) do
23-
24-
{:ok, repositories}
24+
{:ok, responses} <- access_token |> fetch_repositories() do
25+
{:ok, responses |> extract_repositories}
2526
else
2627
{:error, error} -> {:error, error}
2728
end
2829
end
2930

31+
@spec fetch_repositories(String.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
3032
defp fetch_repositories(access_token) do
31-
GitHub.request(:get, "installation/repositories", %{}, %{}, [access_token: access_token])
33+
"installation/repositories"
34+
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100]])
35+
end
36+
37+
@spec extract_repositories(list(map)) :: list(map)
38+
defp extract_repositories(responses) do
39+
responses
40+
|> Enum.map(&Map.get(&1, "repositories"))
41+
|> List.flatten
3242
end
3343

3444
@doc """

lib/code_corps/github/api/pagination.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ defmodule CodeCorps.GitHub.API.Pagination do
7070
@doc ~S"""
7171
From the specified page count, generates a list of integers, `1..count`
7272
"""
73-
@spec to_page_numbers(integer) :: list(integer)
73+
@spec to_page_numbers(integer) :: Range.t
7474
def to_page_numbers(total) when is_integer(total), do: 1..total
7575

7676
@doc ~S"""

lib/code_corps/github/api/repository.ex

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule CodeCorps.GitHub.API.Repository do
1717
All pages of records are retrieved.
1818
Closed issues are included.
1919
"""
20-
@spec issues(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
20+
@spec issues(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
2121
def issues(%GithubRepo{
2222
github_app_installation: %GithubAppInstallation{
2323
github_account_login: owner
@@ -27,7 +27,6 @@ defmodule CodeCorps.GitHub.API.Repository do
2727
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
2828
"repos/#{owner}/#{repo}/issues"
2929
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100, state: "all"]])
30-
|> (&{:ok, &1}).()
3130
else
3231
{:error, error} -> {:error, error}
3332
end
@@ -38,7 +37,7 @@ defmodule CodeCorps.GitHub.API.Repository do
3837
3938
All pages of records are retrieved.
4039
"""
41-
@spec pulls(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
40+
@spec pulls(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
4241
def pulls(%GithubRepo{
4342
github_app_installation: %GithubAppInstallation{
4443
github_account_login: owner
@@ -48,7 +47,6 @@ defmodule CodeCorps.GitHub.API.Repository do
4847
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
4948
"repos/#{owner}/#{repo}/pulls"
5049
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100, state: "all"]])
51-
|> (&{:ok, &1}).()
5250
else
5351
{:error, error} -> {:error, error}
5452
end
@@ -57,7 +55,7 @@ defmodule CodeCorps.GitHub.API.Repository do
5755
@doc ~S"""
5856
Retrieves comments from all issues in a github repository.
5957
"""
60-
@spec issue_comments(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
58+
@spec issue_comments(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
6159
def issue_comments(%GithubRepo{
6260
github_app_installation: %GithubAppInstallation{
6361
github_account_login: owner
@@ -67,7 +65,6 @@ defmodule CodeCorps.GitHub.API.Repository do
6765
with {:ok, access_token} <- API.Installation.get_access_token(installation) do
6866
"repos/#{owner}/#{repo}/issues/comments"
6967
|> GitHub.get_all(%{}, [access_token: access_token, params: [per_page: 100]])
70-
|> (&{:ok, &1}).()
7168
else
7269
{:error, error} -> {:error, error}
7370
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ defmodule CodeCorps.GitHub.Event.Installation do
9090
defp marshall_result({:error, :installation, :unexpected_installation_payload, _steps}), do: {:error, :unexpected_payload}
9191
defp marshall_result({:error, :installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_syncing_installation}
9292
defp marshall_result({:error, :installation, :too_many_unprocessed_installations, _steps}), do: {:error, :multiple_unprocessed_installations_found}
93-
defp marshall_result({:error, :api_response, %CodeCorps.GitHub.APIError{}, _steps}), do: {:error, :github_api_error_on_syncing_repos}
93+
defp marshall_result({:error, :api_response, %CodeCorps.GitHub.API.Errors.PaginationError{}, _steps}), do: {:error, :github_api_error_on_syncing_repos}
9494
defp marshall_result({:error, :deleted_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_deleting_removed_repos}
9595
defp marshall_result({:error, :synced_repos, {_results, _changesets}, _steps}), do: {:error, :validation_error_on_syncing_existing_repos}
9696
defp marshall_result({:error, :processed_installation, %Changeset{}, _steps}), do: {:error, :validation_error_on_marking_installation_processed}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
4343
end
4444

4545
# transaction step 1
46-
@spec fetch_api_repo_list(map) :: {:ok, map} | {:error, GitHub.api_error_struct}
46+
@spec fetch_api_repo_list(map) :: {:ok, list(map)} | {:error, GitHub.paginated_endpoint_error}
4747
defp fetch_api_repo_list(%{processing_installation: %GithubAppInstallation{} = installation}) do
4848
installation |> Installation.repositories()
4949
end

lib/code_corps/github/github.ex

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,28 @@ defmodule CodeCorps.GitHub do
8484
end
8585
end
8686

87-
8887
@type method :: :get | :post | :put | :delete | :patch | :head
8988

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

94+
@typedoc ~S"""
95+
Potential errors which can happen when retrieving data from a paginated
96+
endpoint.
97+
98+
If a new access token is required, then it is regenerated and stored into an
99+
installation, which can result in any of
100+
- `Ecto.Changeset.t`
101+
- `CodeCorps.GitHub.APIError.t`
102+
- `CodeCorps.GitHub.HTTPClientError.t`
103+
104+
Once that is done, the actual request is made, which can error out with
105+
- `CodeCorps.GitHub.Errors.PaginationError.t`
106+
"""
107+
@type paginated_endpoint_error :: Ecto.Changeset.t | APIError.t | HTTPClientError.t | API.Errors.PaginationError.t
108+
95109
@doc """
96110
A low level utility function to make a direct request to the GitHub API.
97111
"""
@@ -110,6 +124,20 @@ defmodule CodeCorps.GitHub do
110124
end
111125
end
112126

127+
@doc ~S"""
128+
A low level utility function to make an authenticated request to a GitHub API
129+
endpoint which supports pagination, and fetch all the pages from that endpoint
130+
at once, by making parallel requests to each page and aggregating the results.
131+
"""
132+
@spec get_all(String.t, headers, list) :: {:ok, list(map)} | {:error, API.Errors.PaginationError.t} | {:error, api_error_struct}
133+
def get_all(endpoint, headers, options) do
134+
API.get_all(
135+
api_url_for(endpoint),
136+
headers |> Headers.user_request(options),
137+
options
138+
)
139+
end
140+
113141
@doc """
114142
A low level utility function to make an authenticated request to the
115143
GitHub API on behalf of a GitHub App or integration
@@ -129,14 +157,6 @@ defmodule CodeCorps.GitHub do
129157
end
130158
end
131159

132-
def get_all(endpoint, headers, options) do
133-
API.get_all(
134-
api_url_for(endpoint),
135-
headers |> Headers.user_request(options),
136-
options
137-
)
138-
end
139-
140160
@token_url "https://github.com/login/oauth/access_token"
141161

142162
@doc """

0 commit comments

Comments
 (0)