Skip to content

Commit 4ea97ae

Browse files
authored
Merge pull request #1074 from code-corps/add-github-pull-request
Add GitHub pull request
2 parents 74065e1 + f8deb99 commit 4ea97ae

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3898
-28
lines changed

lib/code_corps/github/adapters/github_app_installation.ex renamed to lib/code_corps/github/adapters/app_installation.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
defmodule CodeCorps.GitHub.Adapters.GithubAppInstallation do
1+
defmodule CodeCorps.GitHub.Adapters.AppInstallation do
22
@moduledoc """
33
Module used to convert GitHub payloads into attributes for a
44
`GithubAppInstallation`.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
defmodule CodeCorps.GitHub.Adapters.PullRequest do
2+
3+
@mapping [
4+
{:additions, ["additions"]},
5+
{:body, ["body"]},
6+
{:changed_files, ["changed_files"]},
7+
{:closed_at, ["closed_at"]},
8+
{:comments, ["comments"]},
9+
{:comments_url, ["comments_url"]},
10+
{:commits, ["commits"]},
11+
{:commits_url, ["commits_url"]},
12+
{:deletions, ["deletions"]},
13+
{:diff_url, ["diff_url"]},
14+
{:github_created_at, ["created_at"]},
15+
{:github_id, ["id"]},
16+
{:github_updated_at, ["updated_at"]},
17+
{:html_url, ["html_url"]},
18+
{:issue_url, ["issue_url"]},
19+
{:locked, ["locked"]},
20+
{:merge_commit_sha, ["merge_commit_sha"]},
21+
{:mergeable_state, ["mergeable_state"]},
22+
{:merged, ["merged"]},
23+
{:merged_at, ["merged_at"]},
24+
{:number, ["number"]},
25+
{:patch_url, ["patch_url"]},
26+
{:review_comment_url, ["review_comment_url"]},
27+
{:review_comments, ["review_comments"]},
28+
{:review_comments_url, ["review_comments_url"]},
29+
{:state, ["state"]},
30+
{:statuses_url, ["statuses_url"]},
31+
{:title, ["title"]},
32+
{:url, ["url"]}
33+
]
34+
35+
@spec from_api(map) :: map
36+
def from_api(%{} = payload) do
37+
payload |> CodeCorps.Adapter.MapTransformer.transform(@mapping)
38+
end
39+
end

lib/code_corps/github/adapters/github_repo.ex renamed to lib/code_corps/github/adapters/repo.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
defmodule CodeCorps.GitHub.Adapters.GithubRepo do
1+
defmodule CodeCorps.GitHub.Adapters.Repo do
22

33
@mapping [
44
{:github_account_avatar_url, ["owner", "avatar_url"]},

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ defmodule CodeCorps.GitHub.Event.Installation.ChangesetBuilder do
88
GithubAppInstallation,
99
User
1010
}
11-
alias CodeCorps.GitHub.Adapters.GithubAppInstallation, as: GithubAppInstallationAdapter
11+
alias CodeCorps.GitHub.Adapters.AppInstallation, as: AppInstallationAdapter
1212
alias Ecto.Changeset
1313

1414
@doc """
@@ -20,7 +20,7 @@ defmodule CodeCorps.GitHub.Event.Installation.ChangesetBuilder do
2020
%GithubAppInstallation{} = github_app_installation,
2121
%{} = payload) do
2222

23-
attrs = GithubAppInstallationAdapter.from_installation_event(payload)
23+
attrs = AppInstallationAdapter.from_installation_event(payload)
2424

2525
github_app_installation
2626
|> Changeset.change(attrs)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
1212
Repo
1313
}
1414

15-
alias CodeCorps.GitHub.Adapters.GithubRepo, as: GithubRepoAdapter
15+
alias CodeCorps.GitHub.Adapters.Repo, as: RepoAdapter
1616

1717
alias Ecto.{Changeset, Multi}
1818

@@ -51,7 +51,7 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
5151
# transaction step 2
5252
@spec adapt_api_repo_list(map) :: {:ok, list(map)}
5353
defp adapt_api_repo_list(%{api_response: repositories}) do
54-
adapter_results = repositories |> Enum.map(&GithubRepoAdapter.from_api/1)
54+
adapter_results = repositories |> Enum.map(&RepoAdapter.from_api/1)
5555
{:ok, adapter_results}
5656
end
5757

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
defmodule CodeCorps.GitHub.Event.PullRequest do
2+
@moduledoc ~S"""
3+
In charge of handling a GitHub Webhook payload for the PullRequest event type
4+
5+
[https://developer.github.com/v3/activity/events/types/#pullrequestevent](https://developer.github.com/v3/activity/events/types/#pullrequestevent)
6+
"""
7+
8+
@behaviour CodeCorps.GitHub.Event.Handler
9+
10+
alias CodeCorps.{
11+
GitHub.Event.Common.RepoFinder,
12+
GitHub.Event.PullRequest.PullRequestLinker,
13+
GitHub.Event.PullRequest.TaskSyncer,
14+
GitHub.Event.PullRequest.UserLinker,
15+
GitHub.Event.PullRequest.Validator,
16+
Repo,
17+
Task
18+
}
19+
alias Ecto.Multi
20+
21+
@type outcome :: {:ok, list(Task.t)} |
22+
{:error, :not_fully_implemented} |
23+
{:error, :unexpected_action} |
24+
{:error, :unexpected_payload} |
25+
{:error, :repository_not_found} |
26+
{:error, :validation_error_on_inserting_user} |
27+
{:error, :multiple_github_users_matched_same_cc_user} |
28+
{:error, :validation_error_on_syncing_tasks} |
29+
{:error, :unexpected_transaction_outcome}
30+
31+
@doc ~S"""
32+
Handles the "PullRequest" GitHub webhook
33+
34+
The process is as follows
35+
- validate the payload is structured as expected
36+
- validate the action is properly supported
37+
- match payload with affected `CodeCorps.GithubRepo` record using
38+
`CodeCorps.GitHub.Event.Common.RepoFinder`
39+
- match with a `CodeCorps.User` using
40+
`CodeCorps.GitHub.Event.PullRequest.UserLinker`
41+
- for each `CodeCorps.ProjectGithubRepo` belonging to matched repo
42+
- match and update, or create a `CodeCorps.Task` on the associated
43+
`CodeCorps.Project`
44+
45+
If the process runs all the way through, the function will return an `:ok`
46+
tuple with a list of affected (created or updated) tasks.
47+
48+
If it fails, it will instead return an `:error` tuple, where the second
49+
element is the atom indicating a reason.
50+
"""
51+
@spec handle(map) :: outcome
52+
def handle(payload) do
53+
Multi.new
54+
|> Multi.run(:payload, fn _ -> payload |> validate_payload() end)
55+
|> Multi.run(:action, fn _ -> payload |> validate_action() end)
56+
|> Multi.run(:repo, fn _ -> RepoFinder.find_repo(payload) end)
57+
|> Multi.run(:pull_request, fn %{repo: github_repo} -> link_pull_request(github_repo, payload) end)
58+
|> Multi.run(:user, fn %{pull_request: github_pull_request} -> UserLinker.find_or_create_user(github_pull_request, payload) end)
59+
|> Multi.run(:tasks, fn %{pull_request: github_pull_request, user: user} -> github_pull_request |> TaskSyncer.sync_all(user, payload) end)
60+
|> Repo.transaction
61+
|> marshall_result()
62+
end
63+
64+
@spec link_pull_request(GithubRepo.t, map) :: {:ok, GithubIssue.t} | {:error, Ecto.Changeset.t}
65+
defp link_pull_request(github_repo, %{"pull_request" => attrs}) do
66+
PullRequestLinker.create_or_update_pull_request(github_repo, attrs)
67+
end
68+
69+
@spec marshall_result(tuple) :: tuple
70+
defp marshall_result({:ok, %{tasks: tasks}}), do: {:ok, tasks}
71+
defp marshall_result({:error, :payload, :invalid, _steps}), do: {:error, :unexpected_payload}
72+
defp marshall_result({:error, :action, :not_fully_implemented, _steps}), do: {:error, :not_fully_implemented}
73+
defp marshall_result({:error, :action, :unexpected_action, _steps}), do: {:error, :unexpected_action}
74+
defp marshall_result({:error, :repo, :unmatched_project, _steps}), do: {:ok, []}
75+
defp marshall_result({:error, :repo, :unmatched_repository, _steps}), do: {:error, :repository_not_found}
76+
defp marshall_result({:error, :user, %Ecto.Changeset{}, _steps}), do: {:error, :validation_error_on_inserting_user}
77+
defp marshall_result({:error, :user, :multiple_users, _steps}), do: {:error, :multiple_github_users_matched_same_cc_user}
78+
defp marshall_result({:error, :tasks, {_tasks, _errors}, _steps}), do: {:error, :validation_error_on_syncing_tasks}
79+
defp marshall_result({:error, _errored_step, _error_response, _steps}), do: {:error, :unexpected_transaction_outcome}
80+
81+
@implemented_actions ~w(opened closed edited reopened)
82+
@unimplemented_actions ~w(assigned unassigned review_requested review_request_removed labeled unlabeled)
83+
84+
@spec validate_action(map) :: {:ok, :implemented} | {:error, :not_fully_implemented | :unexpected_action}
85+
defp validate_action(%{"action" => action}) when action in @implemented_actions, do: {:ok, :implemented}
86+
defp validate_action(%{"action" => action}) when action in @unimplemented_actions, do: {:error, :not_fully_implemented}
87+
defp validate_action(_payload), do: {:error, :unexpected_action}
88+
89+
@spec validate_payload(map) :: {:ok, :valid} | {:error, :invalid}
90+
defp validate_payload(%{} = payload) do
91+
case payload |> Validator.valid? do
92+
true -> {:ok, :valid}
93+
false -> {:error, :invalid}
94+
end
95+
end
96+
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
defmodule CodeCorps.GitHub.Event.PullRequest.BodyParser do
2+
@moduledoc ~S"""
3+
In charge of extracting ids from markdown content, paired to a predefined list
4+
of keywords.
5+
"""
6+
7+
@doc ~S"""
8+
Searchs for GitHub closing keyword format inside a content string. Returns all
9+
unique ids matched, as integers.
10+
"""
11+
@spec extract_closing_ids(String.t) :: list(integer)
12+
def extract_closing_ids(content) when is_binary(content) do
13+
~w(close closes closed fix fixes fixed resolve resolves resolved)
14+
|> matching_regex()
15+
|> Regex.scan(content) # [["closes #1", "closes", "1"], ["fixes #2", "fixes", "2"]]
16+
|> Enum.map(&List.last/1) # ["1", "2"]
17+
|> Enum.map(&String.to_integer/1) # [1, 2]
18+
|> Enum.uniq
19+
end
20+
21+
defp matching_regex(keywords) do
22+
matches = keywords |> Enum.join("|")
23+
~r/(?:(#{matches}))\s+#(\d+)/i
24+
end
25+
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
defmodule CodeCorps.GitHub.Event.PullRequest.ChangesetBuilder do
2+
@moduledoc ~S"""
3+
In charge of building a `Changeset` to update a `Task` with, when handling an
4+
PullRequest webhook.
5+
"""
6+
7+
alias CodeCorps.{
8+
GithubPullRequest,
9+
ProjectGithubRepo,
10+
Repo,
11+
Services.MarkdownRendererService,
12+
Task,
13+
TaskList,
14+
User,
15+
Validators.TimeValidator
16+
}
17+
alias CodeCorps.GitHub.Adapters.Task, as: TaskAdapter
18+
alias Ecto.Changeset
19+
20+
@doc ~S"""
21+
Constructs a changeset for syncing a `Task` when processing a PullRequest
22+
webhook.
23+
24+
The changeset can be used to create or update a `Task`
25+
"""
26+
@spec build_changeset(Task.t, map, GithubPullRequest.t, ProjectGithubRepo.t, User.t) :: Changeset.t
27+
def build_changeset(
28+
%Task{id: task_id} = task,
29+
%{"pull_request" => pull_request_attrs},
30+
%GithubPullRequest{} = github_pull_request,
31+
%ProjectGithubRepo{} = project_github_repo,
32+
%User{} = user) do
33+
34+
case is_nil(task_id) do
35+
true -> create_changeset(task, pull_request_attrs, github_pull_request, project_github_repo, user)
36+
false -> update_changeset(task, pull_request_attrs)
37+
end
38+
end
39+
40+
@create_attrs ~w(created_at markdown modified_at status title)a
41+
@spec create_changeset(Task.t, map, GithubPullRequest.t, ProjectGithubRepo.t, User.t) :: Changeset.t
42+
defp create_changeset(
43+
%Task{} = task,
44+
%{} = pull_request_attrs,
45+
%GithubPullRequest{id: github_pull_request_id},
46+
%ProjectGithubRepo{project_id: project_id, github_repo_id: github_repo_id},
47+
%User{id: user_id}) do
48+
49+
%TaskList{id: task_list_id} =
50+
TaskList |> Repo.get_by(project_id: project_id, inbox: true)
51+
52+
task
53+
|> Changeset.cast(TaskAdapter.from_api(pull_request_attrs), @create_attrs)
54+
|> MarkdownRendererService.render_markdown_to_html(:markdown, :body)
55+
|> Changeset.put_change(:created_from, "github")
56+
|> Changeset.put_change(:modified_from, "github")
57+
|> Changeset.put_change(:github_pull_request_id, github_pull_request_id)
58+
|> Changeset.put_change(:github_repo_id, github_repo_id)
59+
|> Changeset.put_change(:project_id, project_id)
60+
|> Changeset.put_change(:task_list_id, task_list_id)
61+
|> Changeset.put_change(:user_id, user_id)
62+
|> Changeset.validate_required([:project_id, :task_list_id, :title, :user_id])
63+
|> Changeset.assoc_constraint(:github_pull_request)
64+
|> Changeset.assoc_constraint(:github_repo)
65+
|> Changeset.assoc_constraint(:project)
66+
|> Changeset.assoc_constraint(:task_list)
67+
|> Changeset.assoc_constraint(:user)
68+
end
69+
70+
@update_attrs ~w(markdown modified_at status title)a
71+
@spec update_changeset(Task.t, map) :: Changeset.t
72+
defp update_changeset(%Task{} = task, %{} = pull_request_attrs) do
73+
task
74+
|> Changeset.cast(TaskAdapter.from_api(pull_request_attrs), @update_attrs)
75+
|> MarkdownRendererService.render_markdown_to_html(:markdown, :body)
76+
|> Changeset.put_change(:modified_from, "github")
77+
|> TimeValidator.validate_time_after(:modified_at)
78+
|> Changeset.validate_required([:project_id, :title, :user_id])
79+
|> Changeset.assoc_constraint(:github_repo)
80+
|> Changeset.assoc_constraint(:project)
81+
|> Changeset.assoc_constraint(:user)
82+
end
83+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
defmodule CodeCorps.GitHub.Event.PullRequest.PullRequestLinker do
2+
@moduledoc ~S"""
3+
In charge of finding a pull request to link with a `GithubPullRequest` record
4+
when processing the PullRequest webhook.
5+
6+
The only entry point is `create_or_update_pull_request/1`.
7+
"""
8+
9+
alias CodeCorps.{
10+
GithubPullRequest,
11+
GithubRepo,
12+
Repo
13+
}
14+
15+
alias CodeCorps.GitHub.Adapters.PullRequest, as: PullRequestAdapter
16+
17+
@typep linking_result :: {:ok, GithubPullRequest.t} |
18+
{:error, Ecto.Changeset.t}
19+
20+
@doc ~S"""
21+
Finds or creates a `GithubPullRequest` using the data in a GitHub PullRequest
22+
payload.
23+
24+
The process is as follows:
25+
26+
- Search for the pull request in our database with the payload data.
27+
- If we return a single `GithubPullRequest`, then the `GithubPullRequest`
28+
should be updated.
29+
- If there are no matching `GithubPullRequest` records, then a
30+
`GithubPullRequest`should be created.
31+
"""
32+
@spec create_or_update_pull_request(GithubRepo.t, map) :: linking_result
33+
def create_or_update_pull_request(%GithubRepo{} = github_repo, %{"id" => github_pull_request_id} = attrs) do
34+
params = PullRequestAdapter.from_api(attrs)
35+
36+
case Repo.get_by(GithubPullRequest, github_id: github_pull_request_id) do
37+
nil -> create_pull_request(github_repo, params)
38+
%GithubPullRequest{} = pull_request -> update_pull_request(pull_request, params)
39+
end
40+
end
41+
42+
defp create_pull_request(%GithubRepo{id: github_repo_id}, params) do
43+
params = Map.put(params, :github_repo_id, github_repo_id)
44+
45+
%GithubPullRequest{}
46+
|> GithubPullRequest.create_changeset(params)
47+
|> Repo.insert
48+
end
49+
50+
defp update_pull_request(%GithubPullRequest{} = github_pull_request, params) do
51+
github_pull_request
52+
|> GithubPullRequest.update_changeset(params)
53+
|> Repo.update
54+
end
55+
end

0 commit comments

Comments
 (0)