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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
18.8 KiB · text
History
b868907