18.8 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.JSON do
  @moduledoc """
  Wire shapes for the mobile REST API.

  One module rather than per-controller views because the phone client
  pins every field name in its Kotlin DTOs — keeping the shapes in one
  place makes it obvious when a rename would break a shipped app.

  Conventions:

    * timestamps are ISO-8601 UTC strings, or `nil`
    * SHAs are lowercase hex, never the raw 20-byte binary
    * user references collapse to `{handle, display_name}` — the phone
      renders an identicon from the handle, so nothing else is needed
    * markdown bodies ship as both `body` (source, for quote-reply and
      editing) and `body_html` (rendered, for display)
  """

  alias GitGud.Accounts.User
  alias GitGud.Markdown
  alias GitGud.Organizations
  alias GitGud.Packages.Usage
  alias GitGud.Organizations.Organization
  alias GitGud.Repositories.Repository

  # ── primitives ───────────────────────────────────────────────────────

  def user(nil), do: nil

  def user(%User{} = u) do
    %{
      "handle" => u.handle,
      "display_name" => u.display_name,
      "email" => u.public_email
    }
  end

  @doc "A remote ActivityPub actor, rendered like a user so comment lists stay uniform."
  def actor(nil), do: nil

  def actor(%{preferred_username: name, actor_url: url}),
    do: %{"handle" => name, "display_name" => url, "email" => nil}

  def actor(_), do: nil

  def timestamp(nil), do: nil
  def timestamp(%DateTime{} = dt), do: DateTime.to_iso8601(dt)

  @doc """
  An audit entry. `summary` is the phrase the web pages show (without
  the actor); the address and user agent are never included.
  """
  def audit_entry(%GitGud.Audit.Entry{} = e) do
    %{
      "id" => e.id,
      "action" => e.action,
      "category" => e.category,
      "summary" => GitGudWeb.AuditComponents.describe(e),
      "actor" => %{
        "type" => e.actor_type,
        "handle" => if(e.actor_type == "user", do: e.actor_label),
        "label" => e.actor_label,
        "via" => e.via
      },
      "organization" => e.organization_label,
      "repository" => e.repository_label,
      "target" => %{"type" => e.target_type, "label" => e.target_label},
      "metadata" => e.metadata,
      "inserted_at" => timestamp(e.inserted_at)
    }
  end

  def sha(nil), do: nil
  def sha(bin) when is_binary(bin) and byte_size(bin) == 20, do: Base.encode16(bin, case: :lower)
  def sha(hex) when is_binary(hex), do: hex

  def label(%{name: name} = l), do: %{"name" => name, "color" => Map.get(l, :color)}

  def labels(list) when is_list(list), do: Enum.map(list, &label/1)
  def labels(_), do: []

  @doc """
  A label in a repository's pickable set, as `Labels.list_labels/1`
  returns it: `inherited` when it comes from the organization.
  """
  def repo_label(l) do
    l
    |> label()
    |> Map.merge(%{
      "description" => Map.get(l, :description),
      "inherited" => Map.get(l, :inherited?, false)
    })
  end

  # ── namespaces + repositories ────────────────────────────────────────

  def namespace(%Organization{} = org) do
    %{
      "handle" => org.handle,
      "kind" => "org",
      "name" => org.display_name || org.name || org.handle,
      "description" => org.description
    }
  end

  def namespace(%User{} = user) do
    %{
      "handle" => user.handle,
      "kind" => "user",
      "name" => user.display_name || user.handle,
      "description" => user.bio
    }
  end

  @doc """
  A repository row for the list screen. `opts`:

    * `:viewer`    — user whose effective permission to report
    * `:ci_status` — status of the repo's latest run, when already
      batch-loaded by the caller (avoids an N+1 per row)
  """
  def repository(%Repository{} = repo, opts \\ []) do
    viewer = Keyword.get(opts, :viewer)
    owner_handle = owner_handle(repo)

    %{
      "id" => repo.id,
      "name" => repo.name,
      "full_name" => owner_handle && owner_handle <> "/" <> repo.name,
      "owner" => owner_handle,
      "owner_kind" => if(repo.organization_id, do: "org", else: "user"),
      "description" => repo.description,
      "visibility" => repo.visibility,
      "default_branch" => repo.default_branch,
      "archived" => repo.archived,
      "pushed_at" => timestamp(repo.pushed_at),
      "permission" => viewer && Organizations.permission(viewer, repo),
      "ci_status" => Keyword.get(opts, :ci_status),
      "html_url" => html_url(owner_handle, repo)
    }
  end

  defp owner_handle(%Repository{organization: %Organization{handle: h}}), do: h
  defp owner_handle(%Repository{owner: %User{handle: h}}), do: h
  defp owner_handle(_), do: nil

  defp html_url(nil, _repo), do: nil
  defp html_url(handle, repo), do: GitGudWeb.Endpoint.url() <> "/r/" <> handle <> "/" <> repo.name

  # ── CI ───────────────────────────────────────────────────────────────

  def run(run) do
    %{
      "number" => run.number,
      "workflow_name" => run.workflow_name,
      "workflow_path" => run.workflow_path,
      "trigger" => run.trigger,
      "status" => run.status,
      "conclusion" => run.conclusion,
      "head_sha" => sha(run.head_sha),
      "head_ref" => run.head_ref,
      "started_at" => timestamp(run.started_at),
      "completed_at" => timestamp(run.completed_at),
      "inserted_at" => timestamp(run.inserted_at),
      "triggered_by" => user(loaded(run.triggered_by)),
      # The pull request a `pull_request` / `pull_request_target` run is
      # for, so the run screen can link back to where it's approved.
      "pull_request_number" => run.pull_request_number,
      # A fork pull request's run (no secrets, read-only token), and
      # whether it is still waiting for someone with write access to
      # approve it — its jobs are `awaiting_approval` meanwhile.
      "fork" => run.fork,
      "awaiting_approval" => GitGud.Workflows.WorkflowRun.awaiting_approval?(run)
    }
  end

  def job(job) do
    %{
      "id" => job.id,
      "job_id" => job.job_id,
      "name" => job.name,
      # `warning` too: passed, but wants a look (failed under
      # `continue-on-error`, or reported `gitgud-conclusion: warning`).
      # Dependents and the run read it as `success`.
      "status" => job.status,
      "conclusion" => job.conclusion,
      "matrix" => job.matrix,
      "needs" => job.needs,
      # Why it's waiting, skipped or cancelled, or why its matrix
      # couldn't expand — worded for display, nil when there's nothing
      # to explain.
      "status_reason" => job.status_reason,
      # `github.run_attempt`: 1, bumped by each re-run of the job.
      "attempt" => job.attempt,
      # Cancelled by a newer run or job in its concurrency group: news,
      # but not bad news.
      "superseded" => GitGud.Workflows.superseded_job?(job),
      # A dynamic matrix not expanded yet. Its row is replaced by its
      # legs (with new ids) when it expands, so a client holding this id
      # must re-read the run rather than keep polling the job.
      "placeholder" => GitGud.Workflows.matrix_placeholder?(job),
      "started_at" => timestamp(job.started_at),
      "completed_at" => timestamp(job.completed_at),
      "steps" => job.steps |> loaded([]) |> Enum.sort_by(& &1.number) |> Enum.map(&step/1)
    }
  end

  @doc """
  A workflow the viewer can run by hand (`on: workflow_dispatch` on the
  default branch), with the inputs its form needs. `inputs` come sorted
  by name, as the web form lists them; `type` is GitHub's `string`,
  `boolean`, `choice`, `number` or `environment`, and `default` a string
  (or nil) whatever the YAML wrote.
  """
  def dispatch_workflow(path, doc) do
    inputs =
      doc
      |> GitGud.Workflows.DispatchInputs.declared()
      |> Enum.sort_by(&elem(&1, 0))
      |> Enum.map(fn {name, spec} ->
        %{
          "name" => name,
          "type" => GitGud.Workflows.DispatchInputs.type(spec),
          "description" => spec["description"],
          "required" => spec["required"] in [true, "true"],
          "default" => if(spec["default"] == nil, do: nil, else: to_string(spec["default"])),
          "options" => GitGud.Workflows.DispatchInputs.options(spec)
        }
      end)

    %{
      "path" => path,
      "name" => doc.name || Path.basename(path),
      "inputs" => inputs
    }
  end

  def step(step) do
    %{
      "id" => step.id,
      "number" => step.number,
      "name" => step.name,
      "status" => step.status,
      "conclusion" => step.conclusion,
      "started_at" => timestamp(step.started_at),
      "completed_at" => timestamp(step.completed_at)
    }
  end

  def artifact(artifact) do
    %{
      "id" => artifact.id,
      "name" => artifact.name,
      "size" => Map.get(artifact, :size_bytes) || Map.get(artifact, :size),
      "inserted_at" => timestamp(artifact.inserted_at)
    }
  end

  # ── issues ───────────────────────────────────────────────────────────

  def issue_summary(issue) do
    %{
      "number" => issue.number,
      "title" => issue.title,
      "state" => issue.state,
      "author" => user(loaded(issue.author)),
      "labels" => labels(Map.get(issue, :labels)),
      "inserted_at" => timestamp(issue.inserted_at),
      "updated_at" => timestamp(issue.updated_at),
      "closed_at" => timestamp(issue.closed_at)
    }
  end

  def issue(issue, repo) do
    issue
    |> issue_summary()
    |> Map.merge(%{
      "body" => issue.body,
      "body_html" => html(issue.body, repo)
    })
  end

  def comment(comment, repo) do
    deleted? = not is_nil(comment.deleted_at)

    %{
      "id" => comment.id,
      "author" => user(loaded(comment.author)) || actor(loaded(Map.get(comment, :source_actor))),
      "body" => if(deleted?, do: nil, else: comment.body),
      "body_html" => if(deleted?, do: nil, else: html(comment.body, repo)),
      "deleted" => deleted?,
      "edited_at" => timestamp(comment.edited_at),
      "inserted_at" => timestamp(comment.inserted_at),
      "file_path" => Map.get(comment, :file_path),
      "line" => Map.get(comment, :line)
    }
  end

  # ── pull requests ────────────────────────────────────────────────────

  def pull_summary(pr) do
    %{
      "number" => pr.number,
      "title" => pr.title,
      "state" => pr.state,
      "author" => user(loaded(pr.author)) || actor(loaded(Map.get(pr, :source_actor))),
      "labels" => labels(Map.get(pr, :labels)),
      "source_ref" => pr.source_ref,
      "target_ref" => pr.target_ref,
      "mergeable" => pr.mergeable,
      "inserted_at" => timestamp(pr.inserted_at),
      "updated_at" => timestamp(pr.updated_at),
      "merged_at" => timestamp(pr.merged_at),
      "closed_at" => timestamp(pr.closed_at)
    }
  end

  def pull(pr, repo) do
    pr
    |> pull_summary()
    |> Map.merge(%{
      "body" => pr.body,
      "body_html" => html(pr.body, repo),
      "head_sha" => sha(pr.head_sha),
      "base_sha" => sha(pr.base_sha),
      "merge_commit_sha" => sha(pr.merge_commit_sha),
      "merged_by" => user(loaded(pr.merged_by))
    })
  end

  def review(review) do
    %{
      "id" => review.id,
      "state" => review.state,
      "body" => review.body,
      "reviewer" => user(loaded(review.reviewer)) || actor(loaded(review.source_actor)),
      "inserted_at" => timestamp(review.inserted_at)
    }
  end

  def review_request(request) do
    %{
      "reviewer" => user(loaded(request.reviewer)),
      "requested_by" => user(loaded(request.requested_by)),
      "inserted_at" => timestamp(request.inserted_at)
    }
  end

  @doc """
  Merge blockers as flat, already-worded strings. The phone shows them
  verbatim next to a disabled Merge button, so the wording belongs here
  rather than being reassembled from tuples in Kotlin.
  """
  def merge_blocker({:changes_requested, reviewers}),
    do: "Changes requested by " <> Enum.join(reviewers, ", ")

  def merge_blocker({:insufficient_approvals, have, need}),
    do: "#{have} of #{need} required approvals"

  def merge_blocker({:missing_check, pattern}), do: "Required check #{pattern} missing"
  def merge_blocker({:failing_check, context}), do: "Required check #{context} not passing"

  # ── wiki ─────────────────────────────────────────────────────────────

  def wiki_page_meta(page) do
    %{
      "slug" => page.slug,
      "title" => page.title,
      "size" => page.size,
      "sha" => page.sha
    }
  end

  # ── packages ─────────────────────────────────────────────────────────

  @doc """
  One format's section of the packages tab: what it is, and everything
  published under it.

  `summary` and `label` come from the server because the client
  shouldn't carry a table of format names that goes stale the next time
  a format lands.
  """
  def package_format(format, packages, ctx) do
    about = GitGud.Packages.About.for_packages(packages)

    %{
      "format" => format,
      "label" => package_format_label(format),
      "summary" => Usage.summary(format),
      "packages" => Enum.map(packages, &package_summary(&1, ctx, about[&1.id]))
    }
  end

  @doc """
  One package in a list. `title`, `summary` and `icon_url` are
  `GitGud.Packages.About`'s answer — what the package says it is, not
  just what it's called. `icon_url` is session-gated on the web; the
  client fetches it with its bearer token like any other API call.
  """
  def package_summary(package, ctx, about \\ nil) do
    about = about || GitGud.Packages.About.for_package(package)

    %{
      "name" => package.name,
      "title" => about.title,
      "summary" => about.summary,
      "icon_url" => if(about.icon?, do: package_icon_url(package, ctx)),
      "format" => package.format,
      "kind" => package.kind,
      "latest_version" => package.latest_version,
      "install_line" => install_line(package, ctx, package.latest_version),
      "updated_at" => timestamp(package.updated_at)
    }
  end

  # Relative to the API root, like the client's other paths. Named by
  # format and package rather than id, as the rest of this API is.
  defp package_icon_url(package, ctx) do
    "/api/mobile/v1/repos/#{ctx.handle}/#{ctx.repo_name}" <>
      "/packages/#{package.format}/#{URI.encode(package.name, &URI.char_unreserved?/1)}/icon"
  end

  @doc """
  One package, with every version and the files under each.

  Versions carry their own `install_line`, not just the latest: the
  reason to open this screen is usually to install something that isn't
  the newest thing.
  """
  def package_detail(package, ctx) do
    versions = GitGud.Packages.list_versions(package)
    about = GitGud.Packages.About.for_package(package)

    %{
      "name" => package.name,
      "title" => about.title,
      "summary" => about.summary,
      "description" => about.description,
      "icon_url" => if(about.icon?, do: package_icon_url(package, ctx)),
      "format" => package.format,
      "kind" => package.kind,
      "latest_version" => package.latest_version,
      "versions" =>
        Enum.map(versions, fn version ->
          %{
            "version" => version.version,
            "size" => version.size,
            "digest" => version.digest,
            "yanked" => version.yanked,
            "yanked_reason" => version.yanked_reason,
            "pushed_at" => timestamp(version.pushed_at),
            "install_line" => install_line(package, ctx, version.version),
            "files" =>
              version
              |> GitGud.Packages.list_files()
              |> Enum.map(fn file ->
                %{
                  "filename" => file.filename,
                  "size" => file.size,
                  "content_type" => file.content_type,
                  "sha256" => file.sha256
                }
              end)
          }
        end)
    }
  end

  @doc """
  Setup instructions for a format, as labelled blocks the client
  renders verbatim and offers to copy.

  `nil` sections are dropped rather than sent empty — a format with no
  CI snippet should render no CI heading, and deciding that here means
  the client doesn't have to.
  """
  def package_usage(format, ctx) do
    usage = Usage.for_format(format, ctx) || %{}

    %{
      "format" => format,
      "label" => package_format_label(format),
      "summary" => Usage.summary(format),
      "repository_url" => Usage.base_url(ctx, format),
      "namespace_url" => Usage.namespace_url(ctx, format),
      "key_url" => Usage.key_url(ctx, format),
      "sections" =>
        [{"consume", "Install"}, {"publish", "Publish"}, {"ci", "From CI"}]
        |> Enum.flat_map(fn {key, title} ->
          case Map.get(usage, String.to_existing_atom(key)) do
            steps when is_list(steps) and steps != [] ->
              [%{"key" => key, "title" => title, "steps" => Enum.map(steps, &usage_step/1)}]

            _ ->
              []
          end
        end)
    }
  end

  defp usage_step(%{label: label, language: language, code: code}),
    do: %{"label" => label, "language" => language, "code" => code}

  defp install_line(_package, _ctx, nil), do: nil

  defp install_line(package, ctx, version),
    do: Usage.install_line(package.format, ctx, package, version)

  # Kept beside the wire shapes rather than imported from the LiveView:
  # this is the name the phone puts on a tab section, and the web page's
  # labels are free to diverge for space reasons without breaking a
  # shipped app.
  defp package_format_label("oci"), do: "Container images"
  defp package_format_label("pypi"), do: "Python"
  defp package_format_label("hex"), do: "Hex"
  defp package_format_label("cargo"), do: "Crates"
  defp package_format_label("deb"), do: "Debian packages"
  defp package_format_label("rpm"), do: "RPM packages"
  defp package_format_label("fdroid"), do: "Android apps"
  defp package_format_label(format), do: format

  # ── helpers ──────────────────────────────────────────────────────────

  defp html(nil, _repo), do: nil
  defp html("", _repo), do: ""
  defp html(body, repo), do: Markdown.to_html(body, repo: repo) |> to_string()

  # Ecto leaves unloaded associations as %NotLoaded{}; the API returns
  # null for those rather than raising, so a caller that forgot a
  # preload degrades to a missing field instead of a 500.
  defp loaded(value, default \\ nil)
  defp loaded(%Ecto.Association.NotLoaded{}, default), do: default
  defp loaded(value, _default), do: value
end