Skip to content

Commit d5b2b25

Browse files
authored
Merge pull request #1113 from code-corps/github-pagination
GitHub pagination
2 parents a2a12db + e86971d commit d5b2b25

File tree

19 files changed

+747
-29
lines changed

19 files changed

+747
-29
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ export CLOUDEX_SECRET=
66
export CLOUDFRONT_DOMAIN=
77
export GITHUB_APP_CLIENT_ID=
88
export GITHUB_APP_CLIENT_SECRET=
9+
export GITHUB_APP_ID=
910
export GITHUB_APP_PEM=
11+
export GITHUB_TEST_APP_CLIENT_ID=
12+
export GITHUB_TEST_APP_CLIENT_SECRET=
13+
export GITHUB_TEST_APP_ID=
14+
export GITHUB_TEST_APP_PEM=
1015
export INTERCOM_IDENTITY_SECRET_KEY=
1116
export POSTMARK_API_KEY=
1217
export S3_BUCKET=

circle.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test:
1717
if [ ${CIRCLE_PR_USERNAME} ]; then
1818
MIX_ENV=test mix test;
1919
else
20-
MIX_ENV=test mix coveralls.circle;
20+
MIX_ENV=test mix coveralls.circle --include acceptance:true;
2121
fi
2222
post:
2323
- mix inch.report

config/dev.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use Mix.Config
77
# watchers to your application. For example, we use it
88
# with brunch.io to recompile .js and .css sources.
99
config :code_corps, CodeCorpsWeb.Endpoint,
10-
http: [port: 4000, ip: {0, 0, 0, 0, 0, 0, 0, 0}],
10+
http: [port: 4000],
1111
debug_errors: true,
1212
code_reloader: true,
1313
check_origin: false

config/test.exs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,18 @@ config :code_corps, :icon_color_generator, CodeCorps.RandomIconColor.TestGenerat
4141
# Set Corsica logging to output no console warning when rejecting a request
4242
config :code_corps, :corsica_log_level, [rejected: :debug]
4343

44-
# Set the GitHub module
45-
config :code_corps, github: CodeCorps.GitHubTesting
44+
# fall back to sample pem if none is available as an ENV variable
45+
pem = case System.get_env("GITHUB_TEST_APP_PEM") do
46+
nil -> "./test/fixtures/github/app.pem" |> File.read!
47+
encoded_pem -> encoded_pem |> Base.decode64!
48+
end
49+
50+
config :code_corps,
51+
github: CodeCorps.GitHub.SuccessAPI,
52+
github_app_id: System.get_env("GITHUB_TEST_APP_ID"),
53+
github_app_client_id: System.get_env("GITHUB_TEST_APP_CLIENT_ID"),
54+
github_app_client_secret: System.get_env("GITHUB_TEST_APP_CLIENT_SECRET"),
55+
github_app_pem: pem
4656

4757
config :sentry,
4858
environment_name: Mix.env || :test
@@ -57,13 +67,3 @@ config :code_corps,
5767

5868
config :code_corps, :cloudex, CloudexTest
5969
config :cloudex, api_key: "test_key", secret: "test_secret", cloud_name: "test_cloud_name"
60-
61-
# fall back to sample pem if none is available as an ENV variable
62-
pem = case System.get_env("GITHUB_APP_PEM") do
63-
nil -> "./test/fixtures/github/app.pem" |> File.read!
64-
encoded_pem -> encoded_pem |> Base.decode64!
65-
end
66-
67-
config :code_corps,
68-
github: CodeCorps.GitHub.SuccessAPI,
69-
github_app_pem: pem

lib/code_corps/github/api/api.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ defmodule CodeCorps.GitHub.API do
1414
|> marshall_response()
1515
end
1616

17+
defdelegate get_all(url, headers, opts), to: CodeCorps.GitHub.EagerAPI
18+
1719
@doc """
1820
Get access token headers for a given `CodeCorps.User` and
1921
`CodeCorps.GithubAppInstallation`.
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule CodeCorps.GitHub.EagerAPI do
2+
@moduledoc """
3+
Eager loads a resource from the GitHub API by fetching all of its pages in
4+
parallel.
5+
"""
6+
7+
def get_all(url, headers, options) do
8+
HTTPoison.start
9+
{:ok, response} = HTTPoison.get(url, headers, options)
10+
11+
first_page = Poison.decode!(response.body)
12+
case response.headers |> retrieve_total_pages do
13+
1 -> first_page
14+
total ->
15+
first_page ++ get_remaining_pages(total, url, headers, options) |> List.flatten
16+
end
17+
end
18+
19+
defp get_remaining_pages(total, url, headers, options) do
20+
2..total
21+
|> Enum.to_list
22+
|> Enum.map(&Task.async(fn ->
23+
params = options[:params] ++ [page: &1]
24+
HTTPoison.get(url, headers, options ++ [params: params])
25+
end))
26+
|> Enum.map(&Task.await(&1, 10000))
27+
|> Enum.map(&handle_page_response/1)
28+
end
29+
30+
defp handle_page_response({:ok, %{body: body}}), do: Poison.decode!(body)
31+
32+
def retrieve_total_pages(headers) do
33+
case headers |> List.keyfind("Link", 0, nil) do
34+
nil -> 1
35+
{"Link", value} -> value |> extract_total_pages
36+
end
37+
end
38+
39+
defp extract_total_pages(links_string) do
40+
# We use regex to parse the pagination info from the GitHub API response
41+
# headers.
42+
#
43+
# The headers render pages in the following format:
44+
#
45+
# ```
46+
# {"Link", '<https://api.github.com/search/code?q=addClass+user%3Amozilla&page=15>; rel="next",
47+
# <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34>; rel="last",
48+
# <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=1>; rel="first",
49+
# <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=13>; rel="prev"'
50+
# ```
51+
#
52+
# If the response has no list header, then we have received all the records
53+
# from the only possible page.
54+
#
55+
# If the response has a list header, the value will contain at least the
56+
# "last" relation.
57+
links_string
58+
|> String.split(", ")
59+
|> Enum.map(fn link ->
60+
rel = get_rel(link)
61+
page = get_page(link)
62+
{rel, page}
63+
end)
64+
|> Enum.into(%{})
65+
|> Map.get("last")
66+
end
67+
68+
defp get_rel(link) do
69+
# Searches for `rel=`
70+
Regex.run(~r{rel="([a-z]+)"}, link) |> List.last()
71+
end
72+
73+
defp get_page(link) do
74+
# Searches for the following variations:
75+
# ```
76+
# ?page={match}>
77+
# ?page={match}&...
78+
# &page={match}>
79+
# &page={match}&...
80+
# ```
81+
Regex.run(~r{[&/?]page=([^>&]+)}, link) |> List.last |> String.to_integer
82+
end
83+
end

lib/code_corps/github/api/headers.ex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@ defmodule CodeCorps.GitHub.API.Headers do
3333
end
3434

3535
@spec add_access_token_header(%{String.t => String.t}, list) :: %{String.t => String.t}
36-
defp add_access_token_header(%{} = headers, [access_token: nil]), do: headers
37-
defp add_access_token_header(%{} = headers, [access_token: access_token]) do
38-
Map.put(headers, "Authorization", "token #{access_token}")
36+
defp add_access_token_header(%{} = headers, opts) do
37+
case opts[:access_token] do
38+
nil -> headers
39+
token -> headers |> Map.put("Authorization", "token #{token}")
40+
end
3941
end
40-
defp add_access_token_header(headers, []), do: headers
4142

4243
@spec add_jwt_header(%{String.t => String.t}) :: %{String.t => String.t}
4344
defp add_jwt_header(%{} = headers) do
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule CodeCorps.GitHub.API.Repository do
2+
@moduledoc ~S"""
3+
Functions for working with issues on GitHub.
4+
"""
5+
6+
alias CodeCorps.{
7+
GitHub,
8+
GitHub.API,
9+
GithubAppInstallation,
10+
GithubRepo,
11+
}
12+
13+
@spec issues(GithubRepo.t) :: {:ok, list(map)} | {:error, GitHub.api_error_struct}
14+
def issues(%GithubRepo{github_app_installation: %GithubAppInstallation{} = installation} = github_repo) do
15+
with {:ok, access_token} <- API.Installation.get_access_token(installation),
16+
issues <- fetch_issues(github_repo, access_token)
17+
do
18+
{:ok, issues}
19+
else
20+
{:error, error} -> {:error, error}
21+
end
22+
end
23+
24+
defp fetch_issues(%GithubRepo{github_app_installation: %GithubAppInstallation{github_account_login: owner}, name: repo}, access_token) do
25+
per_page = 100
26+
path = "repos/#{owner}/#{repo}/issues"
27+
params = [per_page: per_page, state: "all"]
28+
opts = [access_token: access_token, params: params]
29+
GitHub.get_all(path, %{}, opts)
30+
end
31+
end

lib/code_corps/github/github.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ defmodule CodeCorps.GitHub do
124124
end
125125
end
126126

127+
def get_all(endpoint, headers, options) do
128+
api().get_all(
129+
api_url_for(endpoint),
130+
headers |> Headers.user_request(options),
131+
options |> add_default_options()
132+
)
133+
end
134+
127135
@token_url "https://github.com/login/oauth/access_token"
128136

129137
@doc """

lib/code_corps/github/sync/sync.ex

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ defmodule CodeCorps.GitHub.Sync do
66
GithubPullRequest,
77
GithubRepo,
88
GitHub.Sync.Utils.RepoFinder,
9-
Repo,
10-
Task
9+
Repo
1110
}
1211
alias Ecto.Multi
1312

1413
@type outcome :: {:ok, list(Comment.t)}
1514
| {:ok, GithubPullRequest.t}
16-
| {:ok, list(Task.t)}
15+
| {:ok, list(CodeCorps.Task.t)}
1716
| {:error, :repo_not_found}
1817
| {:error, :fetching_issue}
1918
| {:error, :fetching_pull_request}
@@ -100,6 +99,24 @@ defmodule CodeCorps.GitHub.Sync do
10099
|> transact()
101100
end
102101

102+
def sync_issues(repo) do
103+
{:ok, issues} = GitHub.API.Repository.issues(repo)
104+
Enum.map(issues, &sync_issue(&1, repo))
105+
end
106+
107+
def sync_issue(issue, repo) do
108+
Multi.new
109+
|> Multi.merge(__MODULE__, :return_repo, [repo])
110+
|> Multi.merge(GitHub.Sync.Issue, :sync, [issue])
111+
|> transact()
112+
end
113+
114+
@doc false
115+
def return_repo(_, repo) do
116+
Multi.new
117+
|> Multi.run(:repo, fn _ -> {:ok, repo} end)
118+
end
119+
103120
@doc false
104121
def find_repo(_, payload) do
105122
Multi.new

0 commit comments

Comments
 (0)