11.1 KiB · text History b868907
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