defmodule GitGudWeb.Api.Mobile.CiController do
@moduledoc """
Workflow runs, jobs, step logs, and the three things you actually want
to do from a phone when CI goes red: re-run everything, re-run the one
job that failed, or cancel a run that's wedged.
Reads need `ci:read`; re-run and cancel need `ci:write` on the token
*and* `write` on the repository — a scope says what the token may ask
for, not what its owner is allowed to do.
"""
use GitGudWeb, :controller
import GitGudWeb.Api.Mobile.Helpers
alias GitGud.Workflows
alias GitGudWeb.Api.Mobile.Auth
alias GitGudWeb.Api.Mobile.JSON
alias GitGudWeb.ErrorMessage
# A phone can't usefully show more than this, and a chatty job can
# emit six figures of lines.
@default_log_lines 2_000
@max_log_lines 10_000
def index(conn, %{"owner" => owner, "name" => name} = params) do
with {:ok, user} <- Auth.require_scope(conn, "ci:read"),
{:ok, repo} <- fetch_repo(conn, user, owner, name) do
runs = Workflows.list_runs(repo, limit: int_param(params, "limit", 50, 200))
json(conn, %{
"runs" => runs |> GitGud.Repo.preload(:triggered_by) |> Enum.map(&JSON.run/1)
})
else
{:error, conn} -> conn
end
end
def show(conn, %{"owner" => owner, "name" => name, "number" => number}) do
with {:ok, user} <- Auth.require_scope(conn, "ci:read"),
{:ok, repo} <- fetch_repo(conn, user, owner, name),
{:ok, n} <- number_param(conn, number),
{:ok, run} <- get_run(conn, repo, n) do
json(conn, %{
"run" => JSON.run(GitGud.Repo.preload(run, :triggered_by)),
"jobs" => run.jobs |> Enum.sort_by(& &1.id) |> Enum.map(&JSON.job/1),
"artifacts" => run |> Workflows.list_artifacts() |> Enum.map(&JSON.artifact/1),
"can_manage" => GitGud.Organizations.can?(user, repo, :write)
})
else
{:error, conn} -> conn
end
end
@doc """
A job's logs, grouped by step.
The runner streams one flat log per job and each step owns a slice of
it (`Workflows.list_step_logs/1` untangles that), so the grouping here
is presentational — it's what lets the phone collapse the passing
steps and open straight to the one that failed.
"""
def logs(conn, %{"owner" => owner, "name" => name, "job_id" => job_id} = params) do
with {:ok, user} <- Auth.require_scope(conn, "ci:read"),
{:ok, repo} <- fetch_repo(conn, user, owner, name),
{:ok, job} <- get_job(conn, repo, job_id) do
limit = int_param(params, "limit", @default_log_lines, @max_log_lines)
{steps, total} = collect_logs(job, limit)
json(conn, %{
"job" => JSON.job(job),
"steps" => steps,
"truncated" => total > limit
})
else
{:error, conn} -> conn
end
end
def rerun(conn, %{"owner" => owner, "name" => name, "number" => number}) do
with {:ok, user} <- Auth.require_scope(conn, "ci:write"),
{:ok, repo} <- fetch_repo(conn, user, owner, name, :write),
{:ok, n} <- number_param(conn, number),
{:ok, run} <- get_run(conn, repo, n) do
case Workflows.rerun(run) do
{:ok, new_run} ->
conn |> put_status(201) |> json(%{"run" => JSON.run(new_run)})
{:error, %Ecto.Changeset{} = cs} ->
send_error(conn, 422, changeset_message(cs))
{:error, reason} ->
send_error(conn, 422, ErrorMessage.describe(reason))
end
else
{:error, conn} -> conn
end
end
@doc """
Re-run one job in place. `job` is the workflow-level job id (the key
in the YAML), not a row id — a matrix's legs share it, and "re-run
build" means every leg of build.
"""
def rerun_job(conn, %{"owner" => owner, "name" => name, "number" => number, "job" => job}) do
with {:ok, user} <- Auth.require_scope(conn, "ci:write"),
{:ok, repo} <- fetch_repo(conn, user, owner, name, :write),
{:ok, n} <- number_param(conn, number),
{:ok, run} <- get_run(conn, repo, n) do
case Workflows.rerun_job(run, job) do
{:ok, _} -> json(conn, reloaded(repo, n))
{:error, :not_found} -> send_error(conn, 404, "no job '#{job}' in run ##{n}")
{:error, reason} -> send_error(conn, 422, ErrorMessage.describe(reason))
end
else
{:error, conn} -> conn
end
end
def cancel(conn, %{"owner" => owner, "name" => name, "number" => number}) do
with {:ok, user} <- Auth.require_scope(conn, "ci:write"),
{:ok, repo} <- fetch_repo(conn, user, owner, name, :write),
{:ok, n} <- number_param(conn, number),
{:ok, run} <- get_run(conn, repo, n) do
case Workflows.cancel(run) do
{:ok, _} -> json(conn, reloaded(repo, n))
{:error, reason} -> send_error(conn, 422, ErrorMessage.describe(reason))
end
else
{:error, conn} -> conn
end
end
@doc """
The workflows that can be run by hand — the default branch's files
with `on: workflow_dispatch` — with their declared inputs, the
branches to run them on, and whether the viewer may (write access, as
on the web's "Run workflow" form).
"""
def workflows(conn, %{"owner" => owner, "name" => name}) do
with {:ok, user} <- Auth.require_scope(conn, "ci:read"),
{:ok, repo} <- fetch_repo(conn, user, owner, name) do
json(conn, %{
"workflows" =>
for(
{path, doc} <- Workflows.dispatchable_workflows(repo),
do: JSON.dispatch_workflow(path, doc)
),
"default_branch" => repo.default_branch,
"branches" => branches(repo),
"can_dispatch" => GitGud.Organizations.can?(user, repo, :write) == true
})
else
{:error, conn} -> conn
end
end
@doc """
`workflow_dispatch`: run `workflow` (a path from `workflows/2`, or a
basename) on `ref` (default: the default branch) with `inputs`.
Answers 201 with the new run; 422 with `message` and per-input
`errors` when the inputs don't fit what the workflow declares.
"""
def dispatch(conn, %{"owner" => owner, "name" => name} = params) do
with {:ok, user} <- Auth.require_scope(conn, "ci:write"),
{:ok, repo} <- fetch_repo(conn, user, owner, name, :write),
{:ok, workflow} <- require_string(conn, params, "workflow"),
{:ok, inputs} <- inputs_param(conn, params["inputs"]) do
ref = blank_to_nil(params["ref"]) || repo.default_branch || ""
result =
Workflows.dispatch_manual(repo, workflow, ref, inputs: inputs, triggered_by_id: user.id)
dispatch_reply(conn, result, workflow, ref)
else
{:error, conn} -> conn
end
end
defp dispatch_reply(conn, {:ok, run}, _workflow, _ref) do
conn
|> put_status(201)
|> json(%{"run" => JSON.run(GitGud.Repo.preload(run, :triggered_by))})
end
defp dispatch_reply(conn, {:error, {:invalid_inputs, errors}}, _workflow, _ref) do
conn
|> put_status(422)
|> json(%{
"message" => Workflows.DispatchInputs.describe(errors),
"errors" => Map.new(errors)
})
end
defp dispatch_reply(conn, {:error, :unknown_ref}, _workflow, ref),
do: send_error(conn, 422, "no such branch or tag: #{ref}")
defp dispatch_reply(conn, {:error, :unknown_workflow}, workflow, _ref),
do: send_error(conn, 404, "no such workflow: #{workflow}")
defp dispatch_reply(conn, {:error, {:ambiguous_workflow, paths}}, workflow, _ref),
do: send_error(conn, 422, "#{workflow} could mean #{Enum.join(paths, " or ")}; give its path")
defp dispatch_reply(conn, {:error, :not_dispatchable}, workflow, ref),
do: send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger on #{ref}")
defp dispatch_reply(conn, {:error, %Ecto.Changeset{} = cs}, _workflow, _ref),
do: send_error(conn, 422, changeset_message(cs))
defp dispatch_reply(conn, {:error, reason}, _workflow, _ref),
do: send_error(conn, 422, ErrorMessage.describe(reason))
# ── internals ────────────────────────────────────────────────────────
defp branches(repo) do
case GitGud.Git.list_refs(repo.disk_path) do
{:ok, refs} ->
refs
|> Enum.flat_map(fn
%{name: "refs/heads/" <> b} -> [b]
_ -> []
end)
|> Enum.sort()
_ ->
[]
end
end
defp require_string(conn, params, key) do
case blank_to_nil(params[key]) do
nil -> {:error, send_error(conn, 422, "#{key} is required")}
value -> {:ok, value}
end
end
defp blank_to_nil(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp blank_to_nil(_value), do: nil
# Strings, as the web form and the v1 API send them; booleans and
# numbers too, since a JSON client may well send a switch as `true`.
# `DispatchInputs` checks each against its declared type.
defp inputs_param(_conn, nil), do: {:ok, %{}}
defp inputs_param(conn, %{} = inputs) do
if Enum.all?(inputs, fn {_k, v} -> is_binary(v) or is_boolean(v) or is_number(v) end),
do: {:ok, inputs},
else: {:error, send_error(conn, 422, "inputs must be an object of strings")}
end
defp inputs_param(conn, _inputs),
do: {:error, send_error(conn, 422, "inputs must be an object of strings")}
defp get_run(conn, repo, number) do
{:ok, Workflows.get_run!(repo, number)}
rescue
Ecto.NoResultsError -> {:error, send_error(conn, 404, "no run ##{number}")}
end
# Jobs are addressed by row id, but the id alone says nothing about
# which repo it belongs to — re-derive the run and compare, so a token
# can't read another repo's logs by guessing an integer.
defp get_job(conn, repo, raw_id) do
with {id, ""} <- Integer.parse(to_string(raw_id)),
%_{} = job <- safe_get_job(id),
run when not is_nil(run) <- GitGud.Repo.get(Workflows.WorkflowRun, job.workflow_run_id),
true <- run.repository_id == repo.id do
{:ok, job}
else
_ -> {:error, send_error(conn, 404, "job not found")}
end
end
defp safe_get_job(id) do
Workflows.get_job!(id)
rescue
Ecto.NoResultsError -> nil
end
# Walk steps in order, taking lines until the budget runs out. Also
# reports the untruncated total so the client can say how much it's
# not showing rather than silently ending mid-build.
defp collect_logs(job, limit) do
job.steps
|> Enum.sort_by(& &1.number)
|> Enum.map_reduce(0, fn step, taken ->
lines = Workflows.list_step_logs(step)
budget = max(limit - taken, 0)
rendered = %{
"step" => JSON.step(step),
"lines" => lines |> Enum.take(budget) |> Enum.map(&%{"seq" => &1.seq, "body" => &1.body})
}
{{rendered, length(lines)}, taken + min(length(lines), budget)}
end)
|> then(fn {pairs, _taken} ->
{Enum.map(pairs, &elem(&1, 0)), Enum.sum(Enum.map(pairs, &elem(&1, 1)))}
end)
end
# Both re-run-job and cancel mutate rows in place, so the client wants
# the run back as it now stands rather than a bare ok.
defp reloaded(repo, number) do
run = Workflows.get_run!(repo, number)
%{
"run" => JSON.run(GitGud.Repo.preload(run, :triggered_by)),
"jobs" => run.jobs |> Enum.sort_by(& &1.id) |> Enum.map(&JSON.job/1)
}
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
11.1 KiB · text
History
b868907