|
1 | 1 | defmodule CodeCorps.GitHub.Event.Issues do |
2 | | - @moduledoc """ |
| 2 | + @moduledoc ~S""" |
3 | 3 | In charge of dealing with "Issues" GitHub Webhook events |
| 4 | +
|
| 5 | + https://developer.github.com/v3/activity/events/types/#issuesevent |
4 | 6 | """ |
5 | 7 |
|
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 |
7 | 32 |
|
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 |
10 | 40 |
|
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. |
15 | 53 | """ |
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 |
17 | 132 | end |
0 commit comments