Skip to content

Commit 26b10ff

Browse files
committed
Implemented handling of Issues webhook, for "opened", "closed", "edited", "reopened"
1 parent 01fff5f commit 26b10ff

34 files changed

+1720
-156
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
defmodule CodeCorps.Adapter.MapTransformer do
2+
@moduledoc ~S"""
3+
Module used to transform maps for the purposes of various adapters used by the
4+
application.
5+
"""
6+
7+
@typedoc ~S"""
8+
A format representing how a single key should be mapped from a source. The
9+
actual format is a 2 element tuple.
10+
11+
The first element is the destination key in the output map.
12+
13+
The second element is a list of keys representing the nested path to the key
14+
in the source map.
15+
16+
For example, the tuple:
17+
18+
`{:target_path, ["nested", "path", "to", "source"]}`
19+
20+
Means that, from the source map, we need to take the nested value under
21+
"nested" => "path" => "to" => "source" and then put it into the output map,
22+
as a value for the key ":target_path".
23+
"""
24+
@type key_mapping :: {atom, list[atom]}
25+
26+
@typedoc """
27+
28+
"""
29+
@type mapping :: list(key_mapping)
30+
31+
@doc ~S"""
32+
Takes a source map and a list of tuples representing how the source map
33+
should be transformed into a new map, then applies the mapping
34+
operation on each field.
35+
"""
36+
@spec transform(map, mapping) :: map
37+
def transform(%{} = source_map, mapping) when is_list(mapping) do
38+
mapping |> Enum.reduce(%{}, &map_field(&1, &2, source_map))
39+
end
40+
41+
@spec map_field(key_mapping, map, map) :: map
42+
defp map_field({target_field, source_path}, %{} = target_map, %{} = source_map) do
43+
value = get_in(source_map, source_path)
44+
target_map |> Map.put(target_field, value)
45+
end
46+
47+
@doc ~S"""
48+
Performs the inverse of `&transform/2`
49+
"""
50+
@spec transform_inverse(map, mapping) :: map
51+
def transform_inverse(%{} = map, mapping) when is_list(mapping) do
52+
mapping |> Enum.reduce(%{}, &map_field_inverse(&1, &2, map))
53+
end
54+
55+
@spec map_field_inverse(key_mapping, map, map) :: map
56+
defp map_field_inverse({source_field, target_path}, target_map, source_map) do
57+
value = source_map |> Map.get(source_field)
58+
list = target_path |> Enum.reverse
59+
result = put_value(list, value, %{})
60+
deep_merge(target_map, result)
61+
end
62+
63+
defp put_value(_, value, map) when is_nil(value), do: map
64+
defp put_value([head | tail], value, map) do
65+
new_value = Map.put(%{}, head, value)
66+
put_value(tail, new_value, map)
67+
end
68+
defp put_value([], new_value, _map), do: new_value
69+
70+
defp deep_merge(left, right) do
71+
Map.merge(left, right, &deep_resolve/3)
72+
end
73+
74+
# Key exists in both maps, and both values are maps as well.
75+
# These can be merged recursively.
76+
defp deep_resolve(_key, left = %{}, right = %{}) do
77+
deep_merge(left, right)
78+
end
79+
80+
# Key exists in both maps, but at least one of the values is
81+
# NOT a map. We fall back to standard merge behavior, preferring
82+
# the value on the right.
83+
defp deep_resolve(_key, _left, right) do
84+
right
85+
end
86+
end
Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
11
defmodule CodeCorps.GitHub.Adapters.GithubRepo do
2-
def from_api(%{
3-
"id" => github_id,
4-
"name" => name,
5-
"owner" => %{
6-
"id" => github_account_id,
7-
"avatar_url" => github_account_avatar_url,
8-
"login" => github_account_login,
9-
"type" => github_account_type
10-
}
11-
}) do
12-
%{
13-
github_id: github_id,
14-
name: name,
15-
github_account_id: github_account_id,
16-
github_account_avatar_url: github_account_avatar_url,
17-
github_account_login: github_account_login,
18-
github_account_type: github_account_type
19-
}
2+
3+
@mapping [
4+
{:github_account_avatar_url, ["owner", "avatar_url"]},
5+
{:github_account_id, ["owner", "id"]},
6+
{:github_account_login, ["owner", "login"]},
7+
{:github_account_type, ["owner", "type"]},
8+
{:github_id, ["id"]},
9+
{:name, ["name"]}
10+
]
11+
12+
@spec from_api(map) :: map
13+
def from_api(%{} = payload) do
14+
payload |> CodeCorps.Adapter.MapTransformer.transform(@mapping)
2015
end
21-
def from_api(_), do: {:error, :invalid_repo_payload}
2216
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
defmodule CodeCorps.GitHub.Adapters.Task do
2+
@moduledoc """
3+
Used to adapt a GitHub issue payload into attributes for creating or updating
4+
a `CodeCorps.Task`.
5+
"""
6+
7+
@mapping [
8+
{:github_id, ["id"]},
9+
{:title, ["title"]},
10+
{:markdown, ["body"]},
11+
{:status, ["state"]}
12+
]
13+
14+
@spec from_issue(map) :: map
15+
def from_issue(%{} = payload) do
16+
payload |> CodeCorps.Adapter.MapTransformer.transform(@mapping)
17+
end
18+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule CodeCorps.GitHub.Adapters.User do
2+
@moduledoc """
3+
Used to adapt a GitHub issue payload into attributes for creating or updating
4+
a `CodeCorps.Task`.
5+
"""
6+
7+
@mapping [
8+
{:github_username, ["login"]},
9+
{:github_id, ["id"]},
10+
{:github_avatar_url, ["avatar_url"]}
11+
]
12+
13+
@spec from_github_user(map) :: map
14+
def from_github_user(%{} = payload) do
15+
payload |> CodeCorps.Adapter.MapTransformer.transform(@mapping)
16+
end
17+
end

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,12 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
7171
end
7272

7373
# transaction step 2
74-
@spec adapt_api_repo_list(%{api_response: any}) :: list(map) | {:error, :invalid_repo_payload}
74+
@spec adapt_api_repo_list(%{api_response: any}) :: {:ok, list(map)}
7575
defp adapt_api_repo_list(%{api_response: repositories}) do
7676
adapter_results = repositories |> Enum.map(&GithubRepoAdapter.from_api/1)
77-
case adapter_results |> Enum.all?(&valid?/1) do
78-
true -> {:ok, adapter_results}
79-
false -> {:error, :invalid_repo_payload}
80-
end
77+
{:ok, adapter_results}
8178
end
8279

83-
@spec valid?(any) :: boolean
84-
defp valid?({:error, _}), do: false
85-
defp valid?(_), do: true
86-
8780
# transaction step 3
8881
@spec delete_repos(%{processing_installation: GithubAppInstallation.t, repo_attrs_list: list(map)}) :: aggregated_result
8982
defp delete_repos(%{
@@ -141,18 +134,28 @@ defmodule CodeCorps.GitHub.Event.Installation.Repos do
141134
@spec create(GithubAppInstallation.t, map) :: {:ok, GithubRepo.t}
142135
defp create(%GithubAppInstallation{} = installation, %{} = repo_attributes) do
143136
%GithubRepo{}
144-
|> Changeset.change(repo_attributes)
137+
|> changeset(repo_attributes)
145138
|> Changeset.put_assoc(:github_app_installation, installation)
146139
|> Repo.insert()
147140
end
148141

149142
@spec update(GithubRepo.t, map) :: {:ok, GithubRepo.t}
150143
defp update(%GithubRepo{} = github_repo, %{} = repo_attributes) do
151144
github_repo
152-
|> Changeset.change(repo_attributes)
145+
|> changeset(repo_attributes)
153146
|> Repo.update()
154147
end
155148

149+
@spec changeset(GithubRepo.t, map) :: Changeset.t
150+
defp changeset(%GithubRepo{} = github_repo, %{} = repo_attributes) do
151+
github_repo
152+
|> Changeset.change(repo_attributes)
153+
|> Changeset.validate_required([
154+
:github_id, :name, :github_account_id,
155+
:github_account_avatar_url, :github_account_login, :github_account_type
156+
])
157+
end
158+
156159
# transaction step 5
157160
@spec mark_processed(%{processing_installation: GithubAppInstallation.t}) :: {:ok, GithubAppInstallation.t}
158161
defp mark_processed(%{processing_installation: %GithubAppInstallation{} = installation}) do
Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,132 @@
11
defmodule CodeCorps.GitHub.Event.Issues do
2-
@moduledoc """
2+
@moduledoc ~S"""
33
In charge of dealing with "Issues" GitHub Webhook events
4+
5+
https://developer.github.com/v3/activity/events/types/#issuesevent
46
"""
57

6-
alias CodeCorps.GithubEvent
8+
alias CodeCorps.{
9+
GithubEvent,
10+
GithubRepo,
11+
GitHub.Event.Issues.ChangesetBuilder,
12+
GitHub.Event.Issues.Validator,
13+
GitHub.Event.Issues.UserLinker,
14+
ProjectGithubRepo,
15+
Repo,
16+
Task,
17+
User
18+
}
19+
alias Ecto.{Changeset, Multi}
20+
21+
@typep outcome :: {:ok, list(Task.t)} |
22+
{:error, :not_fully_implemented} |
23+
{:error, :unexpected_payload} |
24+
{:error, :unexpected_action} |
25+
{:error, :unmatched_repository}
26+
27+
@implemented_actions ~w(opened closed edited reopened)
28+
@unimplemented_actions ~w(assigned unassigned milestoned demilestoned labeled unlabeled)
29+
30+
@doc ~S"""
31+
Handles the "Issues" GitHub webhook
732
8-
@doc """
9-
Handles an "Issues" GitHub Webhook event
33+
The process is as follows
34+
- validate the payload is structured as expected
35+
- try and find the appropriate `GithubRepo` record.
36+
- for each `ProjectGithubRepo` belonging to that `Project`
37+
- find or initialize a new `Task`
38+
- try and find a `User`, associate `Task` with user
39+
- commit the change as an insert or update action
1040
11-
The general idea is
12-
- marked the passed in event as "processing"
13-
- do the work
14-
- marked the passed in event as "processed" or "errored"
41+
Depending on the success of the process, the function will return one of
42+
- `{:ok, list_of_tasks}`
43+
- `{:error, :not_fully_implemented}` - while we're aware of this action, we have not implemented support for it yet
44+
- `{:error, :unexpected_payload}` - the payload was not as expected
45+
- `{:error, :unexpected_action}` - the action was not of type we are aware of
46+
- `{:error, :unmatched_repository}` - the repository for this issue was not found
47+
48+
Note that it is also possible to have a matched GithubRepo, but with that
49+
record not having any ProjectGithubRepo children. The outcome of that case
50+
should NOT be an errored event, since it simply means that the GithubRepo
51+
was not linked to a Project by the Project owner. This is allowed and
52+
relatively common.
1553
"""
16-
def handle(%GithubEvent{}, %{}), do: {:error, :not_fully_implemented}
54+
@spec handle(GithubEvent.t, map) :: outcome
55+
def handle(%GithubEvent{action: action}, payload) when action in @implemented_actions do
56+
case payload |> Validator.valid? do
57+
true -> do_handle(payload)
58+
false -> {:error, :unexpected_payload}
59+
end
60+
end
61+
def handle(%GithubEvent{action: action}, _payload) when action in @unimplemented_actions do
62+
{:error, :not_fully_implemented}
63+
end
64+
def handle(%GithubEvent{action: _action}, _payload), do: {:error, :unexpected_action}
65+
66+
@spec do_handle(map) :: {:ok, list(Task.t)} | {:error, :unmatched_repository}
67+
defp do_handle(%{} = payload) do
68+
multi =
69+
Multi.new
70+
|> Multi.run(:repo, fn _ -> find_repo(payload) end)
71+
|> Multi.run(:user, fn _ -> UserLinker.find_or_create_user(payload) end)
72+
|> Multi.run(:tasks, &sync_all(&1, payload))
73+
74+
case Repo.transaction(multi) do
75+
{:ok, %{tasks: tasks}} -> {:ok, tasks}
76+
{:error, :repo, :unmatched_project, _steps} -> {:ok, []}
77+
{:error, _errored_step, error_response, _steps} -> {:error, error_response}
78+
end
79+
end
80+
81+
@spec find_repo(map) :: {:ok, GithubRepo.t} | {:error, :unmatched_repository} | {:error, :unmatched_project}
82+
defp find_repo(%{"repository" => %{"id" => github_id}}) do
83+
case GithubRepo |> Repo.get_by(github_id: github_id) |> Repo.preload(:project_github_repos) do
84+
# a GithubRepo with at least some ProjectGithubRepo children
85+
%GithubRepo{project_github_repos: [_ | _]} = github_repo -> {:ok, github_repo}
86+
# a GithubRepo with no ProjectGithubRepo children
87+
%GithubRepo{project_github_repos: []} -> {:error, :unmatched_project}
88+
nil -> {:error, :unmatched_repository}
89+
end
90+
end
91+
92+
@spec sync_all(map, map) :: {:ok, list(Task.t)}
93+
defp sync_all(
94+
%{
95+
repo: %GithubRepo{project_github_repos: project_github_repos},
96+
user: %User{} = user
97+
},
98+
%{} = payload) do
99+
100+
project_github_repos
101+
|> Enum.map(&sync(&1, user, payload))
102+
|> aggregate()
103+
end
104+
105+
@spec sync(ProjectGithubRepo.t, User.t, map) :: {:ok, ProjectGithubRepo.t} | {:error, Changeset.t}
106+
defp sync(%ProjectGithubRepo{} = project_github_repo, %User{} = user, %{} = payload) do
107+
project_github_repo
108+
|> find_or_init_task(payload)
109+
|> ChangesetBuilder.build_changeset(payload, project_github_repo, user)
110+
|> commit()
111+
end
112+
113+
@spec find_or_init_task(ProjectGithubRepo.t, map) :: Task.t
114+
defp find_or_init_task(%ProjectGithubRepo{project_id: project_id}, %{"issue" => %{"id" => github_id}}) do
115+
case Task |> Repo.get_by(github_id: github_id, project_id: project_id) do
116+
nil -> %Task{}
117+
%Task{} = task -> task
118+
end
119+
end
120+
121+
@spec commit(Changeset.t) :: {:ok, Task.t} | {:error, Changeset.t}
122+
defp commit(%Changeset{data: %Task{id: nil}} = changeset), do: changeset |> Repo.insert
123+
defp commit(%Changeset{} = changeset), do: changeset |> Repo.update
124+
125+
@spec aggregate(list({:ok, Task.t})) :: {:ok, list(Task.t)}
126+
defp aggregate(results) do
127+
results
128+
|> Enum.map(&Tuple.to_list/1)
129+
|> Enum.map(&List.last/1)
130+
|> (fn tasks -> {:ok, tasks} end).()
131+
end
17132
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
defmodule CodeCorps.GitHub.Event.Issues.ChangesetBuilder do
2+
@moduledoc ~S"""
3+
In charge of building a `Changeset` to update a `Task` with, when handling an
4+
Issues webhook.
5+
"""
6+
7+
alias CodeCorps.{
8+
GitHub.Event.Issues.StateMapper,
9+
Services.MarkdownRendererService,
10+
ProjectGithubRepo,
11+
Task,
12+
User
13+
}
14+
alias CodeCorps.GitHub.Adapters.Task, as: TaskAdapter
15+
alias Ecto.Changeset
16+
17+
@doc ~S"""
18+
Constructs a changeset for syncing a task when processing an Issues webhook
19+
"""
20+
@spec build_changeset(Task.t, map, ProjectGithubRepo.t, User.t) :: Changeset.t
21+
def build_changeset(
22+
%Task{} = task,
23+
%{"issue" => issue_attrs} = payload,
24+
%ProjectGithubRepo{project_id: project_id},
25+
%User{id: user_id}) do
26+
27+
task
28+
|> Changeset.change(issue_attrs |> TaskAdapter.from_issue())
29+
|> Changeset.put_change(:state, payload |> StateMapper.get_state())
30+
|> MarkdownRendererService.render_markdown_to_html(:markdown, :body)
31+
|> Changeset.put_change(:project_id, project_id)
32+
|> Changeset.put_change(:user_id, user_id)
33+
|> Changeset.validate_required([:project_id, :user_id, :markdown, :body, :title])
34+
|> Changeset.assoc_constraint(:project)
35+
|> Changeset.assoc_constraint(:user)
36+
end
37+
end

0 commit comments

Comments
 (0)