8.7 KiB · text History a9bbffe
defmodule GitGud.Workflows.EventPayload do
  @moduledoc """
  The event payload a job reads as `github.event`: GitHub's webhook shapes,
  trimmed to the fields workflows actually read
  (`github.event.head_commit.message`, `github.event.pull_request.number`,
  `github.event.inputs.*`, …).

  Built once, when a run is created, and stored on it
  (`WorkflowRun.event_payload`); `RunnerController.build_task/1` sends it
  as the `event` object of the task's context. Keys are strings, shas are
  lowercase hex. Pure: the repository must have `:owner` and
  `:organization` preloaded, and users are passed in already loaded.

  `sender` is the triggering user's handle, or the repository owner's when
  the trigger isn't attributable — the same rule as `github.actor`.
  """

  alias GitGud.Accounts.User
  alias GitGud.Git
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage
  alias GitGud.Workflows.Ref

  @zero_sha String.duplicate("0", 40)

  @doc """
  A `push`. `before`/`after` are binary shas (`before` nil or all zeros
  for a new ref); `commits` are `Git.commit()` maps, newest first, as
  `Git.log/3` returns them; `pusher`/`sender` are `%User{}` or nil.
  """
  def push(%Repository{} = repo, %{ref: ref, before: before, after: after_sha} = event) do
    commits = Enum.map(Map.get(event, :commits, []), &commit(repo, &1))
    after_hex = Git.to_hex(after_sha)
    before_hex = sha_hex(before)
    created = before_hex == @zero_sha

    %{
      "ref" => ref,
      "before" => before_hex,
      "after" => after_hex,
      "created" => created,
      "deleted" => false,
      # Not tracked: the post-receive hook doesn't say whether the update
      # was a fast-forward, and working it out costs a merge-base per push.
      "forced" => false,
      "base_ref" => nil,
      "compare" =>
        if(created,
          do: commit_url(repo, after_hex),
          else: repo_url(repo) <> "/compare?base=#{before_hex}&head=#{after_hex}"
        ),
      # GitHub lists oldest first.
      "commits" => Enum.reverse(commits),
      "head_commit" => List.first(commits),
      "pusher" => pusher(Map.get(event, :pusher)),
      "repository" => repository(repo),
      "sender" => sender(repo, Map.get(event, :sender))
    }
  end

  @doc """
  A `pull_request`. `pr` is the `%PullRequest{}` (its `author` preloaded
  or nil), `action` the activity type; `target_sha` is the target branch's
  head (binary sha or nil), `sender` a `%User{}` or nil.

  `head.repo` is the target repository: fork and federated pull requests
  aren't dispatched (`Workflows.dispatch_pull_request/3`).
  """
  def pull_request(%Repository{} = repo, pr, action, %{} = opts) do
    repository = repository(repo)
    owner = repository["owner"]["login"]
    source = short_branch(pr.source_ref)
    target = short_branch(pr.target_ref)

    %{
      "action" => action,
      "number" => pr.number,
      "pull_request" => %{
        "number" => pr.number,
        "title" => pr.title || "",
        "body" => pr.body || "",
        # GitHub's states are open and closed; merged is closed + merged.
        "state" => if(pr.state in ["closed", "merged"], do: "closed", else: "open"),
        "html_url" => repo_url(repo) <> "/pulls/#{pr.number}",
        "draft" => false,
        # A real boolean: forgejo-runner reads it to scope the cache of a
        # `closed` run.
        "merged" => pr.state == "merged",
        "merged_at" => iso8601(Map.get(pr, :merged_at)),
        "merge_commit_sha" =>
          if(pr.state == "merged", do: sha_hex(Map.get(pr, :merge_commit_sha)), else: nil),
        "user" => %{"login" => author_login(repo, Map.get(pr, :author))},
        "head" => %{
          "ref" => source,
          "sha" => sha_hex(pr.head_sha),
          "label" => owner <> ":" <> source,
          "repo" => repository
        },
        "base" => %{
          "ref" => target,
          "sha" => sha_hex(Map.get(opts, :target_sha)),
          "label" => owner <> ":" <> target,
          "repo" => repository
        }
      },
      "repository" => repository,
      "sender" => sender(repo, Map.get(opts, :sender))
    }
  end

  @doc """
  A `workflow_dispatch`. `inputs` is a string → string map; `ref` the full
  ref; `workflow_path` the file's path in the repository.
  """
  def workflow_dispatch(%Repository{} = repo, ref, workflow_path, inputs, sender) do
    %{
      "inputs" => Map.new(inputs || %{}, fn {k, v} -> {to_string(k), to_string(v)} end),
      "ref" => ref,
      "workflow" => workflow_path,
      "repository" => repository(repo),
      "sender" => sender(repo, sender)
    }
  end

  @doc """
  A `create` or `delete`: `ref` is the full ref created or deleted; the
  payload carries its short name and `branch` / `tag`.
  """
  def ref_event(%Repository{} = repo, ref, sender) do
    %{
      "ref" => Ref.name(ref),
      "ref_type" => Ref.type(ref),
      "pusher_type" => "user",
      "master_branch" => repo.default_branch,
      "repository" => repository(repo),
      "sender" => sender(repo, sender)
    }
  end

  @doc """
  A `workflow_run`: `run` (a `%WorkflowRun{}`) reached `action` —
  `"requested"`, `"in_progress"` or `"completed"`. Also the body of the
  `workflow_run` webhook.
  """
  def workflow_run(%Repository{} = repo, run, action, sender) do
    %{
      "action" => action,
      "workflow_run" => workflow_run_object(repo, run, action),
      "workflow" => %{
        "name" => run.workflow_name || run.workflow_path,
        "path" => run.workflow_path
      },
      "repository" => repository(repo),
      "sender" => sender(repo, sender)
    }
  end

  defp workflow_run_object(repo, run, action) do
    status =
      case action do
        "requested" -> "queued"
        other -> other
      end

    %{
      "id" => run.id,
      "run_number" => run.number,
      "name" => run.workflow_name || run.workflow_path,
      "path" => run.workflow_path,
      "event" => run.trigger,
      "status" => status,
      "conclusion" => if(action == "completed", do: run.conclusion || run.status),
      "head_sha" => sha_hex(run.head_sha),
      "head_branch" => run_head_branch(run),
      "html_url" => repo_url(repo) <> "/actions/#{run.number}",
      "run_attempt" => 1,
      "run_started_at" => iso8601(run.started_at),
      "created_at" => iso8601(run.inserted_at),
      "updated_at" => iso8601(run.completed_at || run.updated_at)
    }
  end

  @doc """
  A run's `head_branch`, as GitHub reports it: the branch (or tag) name,
  and a pull request run's source branch.
  """
  def run_head_branch(%{trigger: "pull_request", pr_head_ref: b}) when is_binary(b), do: b
  def run_head_branch(%{head_ref: ref}), do: Ref.name(ref)

  @doc "A `schedule`: the cron that fired. Nobody triggered it, so `sender` is the owner."
  def schedule(%Repository{} = repo, cron) do
    %{
      "schedule" => cron,
      "repository" => repository(repo),
      "sender" => sender(repo, nil)
    }
  end

  @doc "The `repository` object every payload carries."
  def repository(%Repository{} = repo) do
    owner = Storage.repo_handle(repo)

    %{
      "id" => repo.id,
      "name" => repo.name,
      "full_name" => owner <> "/" <> repo.name,
      "owner" => %{"login" => owner},
      "default_branch" => repo.default_branch,
      "private" => repo.visibility != "public",
      "html_url" => repo_url(repo),
      "clone_url" => GitGudWeb.Endpoint.url() <> "/" <> owner <> "/" <> repo.name <> ".git"
    }
  end

  defp commit(repo, c) do
    hex = Git.to_hex(c.sha)

    %{
      "id" => hex,
      "message" => c.message,
      "timestamp" => c.committed_at && DateTime.to_iso8601(c.committed_at),
      "url" => commit_url(repo, hex),
      "author" => %{"name" => c.author_name, "email" => c.author_email},
      "committer" => %{"name" => c.committer_name, "email" => c.committer_email}
    }
  end

  # The account's public email only: a job's payload ends up in logs.
  defp pusher(%User{} = u), do: %{"name" => u.handle, "email" => u.public_email || ""}
  defp pusher(_), do: nil

  defp sender(_repo, %User{handle: h, id: id}) when is_binary(h) and h != "",
    do: %{"login" => h, "id" => id}

  defp sender(repo, _), do: %{"login" => Storage.repo_handle(repo)}

  defp author_login(_repo, %User{handle: h}) when is_binary(h) and h != "", do: h
  defp author_login(repo, _), do: Storage.repo_handle(repo)

  defp repo_url(repo),
    do: GitGudWeb.Endpoint.url() <> "/r/" <> Storage.repo_handle(repo) <> "/" <> repo.name

  defp commit_url(repo, hex), do: repo_url(repo) <> "/commit/" <> hex

  defp short_branch("refs/heads/" <> b), do: b
  defp short_branch(b) when is_binary(b), do: b
  defp short_branch(_), do: ""

  defp iso8601(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  defp iso8601(_), do: nil

  defp sha_hex(<<_::binary-size(20)>> = sha), do: Git.to_hex(sha)
  defp sha_hex(_), do: @zero_sha
end