lib/openagents_web/live/issue_show_live.ex

436d37facc96 · 58 KB

defmodule OpenAgentsWeb.IssueShowLive do
  @moduledoc """
  One issue: its description, its history, and the properties you can change.

  Built from `OpenAgentsWeb.UI.Circle`, so the page and the component library
  cannot drift. Every field it shows or edits is one GitHub already has —
  `state`, `state_reason`, `labels`, `assignees`, `milestone`, `locked`, and
  comments. See `docs/2026-08-20-linear-design-github-shape.md`.

  Two structural decisions:

    * **Reading is the default state.** Editing the title and body is behind
      `Edit`, and everything else is edited in place from the rail, so the page
      is a description of the issue rather than a form that happens to be
      showing one.

    * **The history is one feed.** Comments and state changes interleave in a
      single timeline rather than sitting in separate sections, because they
      answer the same question and their order relative to each other is most
      of the answer.

  This schema has no `issue_events` table, so the events are derived from the
  fields it does store: opened from `inserted_at` and `user`, closed from
  `closed_at` and `state_reason`. That is why a close has no actor — nothing
  records who did it, and naming somebody would be a guess.

  Agent work is derived the same way, from the durable assignments that bound
  an attempt to this issue. The issue remains the requested outcome; the page
  reads the attempt records rather than storing a second copy of them.

  Starting that work is the one write here that leaves this application. It
  reaches `OpenAgents.Forge.Assignments.create/1` — the same admission the API
  route uses — so there is no second executor, no queue, and no second work
  record. The objective is read from the issue rather than typed again, and
  every refusal the context returns is shown as itself rather than as a
  generic failure.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Computer
  alias OpenAgents.Conversations
  alias OpenAgents.Forge.Assignments
  alias OpenAgents.Issues
  alias OpenAgents.Issues.ClosingReferences
  alias OpenAgents.Issues.Comment
  alias OpenAgents.Issues.TaskReferences
  alias OpenAgents.Issues.Issue
  alias OpenAgents.Issues.Activity
  alias OpenAgents.Issues.CompletionClaims
  alias OpenAgents.Issues.WorkScope
  alias OpenAgents.Labels
  alias OpenAgents.Machines
  alias OpenAgents.Markdown
  alias OpenAgents.Milestones
  alias OpenAgents.Notifications
  alias OpenAgents.PullRequests
  alias OpenAgents.Repositories
  alias OpenAgents.Transparency.WorkDisclosure
  alias OpenAgentsWeb.LiveRefresh
  alias OpenAgentsWeb.OG
  alias OpenAgentsWeb.RelativeTime
  alias OpenAgentsWeb.UI.Circle

  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do
    repository =
      try do
        Repositories.get_visible_by_path!(owner, repo, socket.assigns.current_user)
      rescue
        Ecto.NoResultsError ->
          raise OpenAgentsWeb.PublicNotFoundError, message: "repository not found"
      end

    issue = Issues.get_issue_by_number!(repository, String.to_integer(number))

    user = socket.assigns.current_user
    can_write = Repositories.writable?(repository, user)

    # Three topics, because three different writes change this page. The issue
    # topic carries the issue and its timeline; the label and milestone topics
    # carry the pickers, which until now moved only when an issue happened to
    # move as well.
    if connected?(socket) do
      Repositories.subscribe_issues(repository.id)
      Assignments.subscribe_attempts(issue.id)
      Labels.subscribe_labels(repository.id)
      Milestones.subscribe_milestones(repository.id)
    end

    {:ok,
     socket
     |> LiveRefresh.init()
     |> assign(:current_scope, socket.assigns[:current_scope])
     |> assign(:owner, owner)
     |> assign(:repo, repo)
     |> assign(:repository, repository)
     |> assign(:current_user, user)
     |> assign(:can_write, can_write)
     |> assign(
       :can_participate,
       Repositories.issue_participant?(repository, user)
     )
     |> assign(:can_edit, can_write || author?(issue, user))
     |> assign(:editing, false)
     |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
     |> assign_repository_options()
     |> assign(
       :og,
       OG.meta(OG.issue(repository.namespace.slug, repository.name, issue))
     )
     |> load(issue)
     |> assign_work_options()}
  end

  def handle_event("toggle_subscription", _params, socket) do
    with_authority(socket, :can_participate, "You can no longer follow this issue.", fn socket ->
      issue = socket.assigns.issue
      user = socket.assigns.current_user

      result =
        if socket.assigns.subscribed? do
          Notifications.unsubscribe(issue, user)
        else
          Notifications.subscribe(issue, user, "manual")
        end

      case result do
        {:ok, _subscription} -> {:noreply, load(socket, issue)}
        {:error, _reason} -> {:noreply, put_flash(socket, :error, "That did not work.")}
      end
    end)
  end

  def handle_event("toggle_edit", _params, socket) do
    with_authority(socket, :can_edit, "You can no longer edit this issue.", fn socket ->
      issue = socket.assigns.issue

      {:noreply,
       socket
       |> assign(:editing, !socket.assigns.editing)
       |> assign(:form, to_form(Issues.change_issue(issue)))}
    end)
  end

  def handle_event("save", %{"issue" => issue_params}, socket) do
    with_authority(socket, :can_edit, "You can no longer edit this issue.", fn socket ->
      issue = socket.assigns.issue
      attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}

      case Issues.update_issue(issue, attrs, socket.assigns.current_user) do
        {:ok, updated} ->
          {:noreply,
           socket
           |> assign(:editing, false)
           |> put_flash(:info, "Issue updated")
           |> load(updated)}

        {:error, changeset} ->
          {:noreply, assign(socket, :form, to_form(changeset))}
      end
    end)
  end

  # A viewer without authority who hand-crafts an event gets a refusal rather
  # than a silent success; the UI never shows them the control in the first
  # place.
  def handle_event("close", _params, socket),
    do: with_edit_authority(socket, &set_state(&1, "closed", "completed"))

  def handle_event("reopen", _params, socket),
    do: with_edit_authority(socket, &set_state(&1, "open", nil))

  # The rail's state menu picks a close reason as well as a state, which the
  # header's two buttons cannot. Both end in the same write.
  def handle_event("set_state", %{"state" => "open"}, socket),
    do: with_edit_authority(socket, &set_state(&1, "open", nil))

  def handle_event("set_state", %{"state" => "closed"} = params, socket),
    do: with_edit_authority(socket, &set_state(&1, "closed", params["reason"] || "completed"))

  # The computer decides which working directories and which agents are on
  # offer, so choosing one re-reads both rather than leaving a stale list the
  # admission would refuse a moment later.
  def handle_event("preview_work", %{"work" => params}, socket) do
    {:noreply, assign(socket, :work_form, to_form(work_params(socket, params), as: :work))}
  end

  def handle_event("start_work", %{"work" => params}, socket) do
    with_authority(socket, :can_write, "You can no longer start work on this issue.", fn socket ->
      {:noreply, start_work(socket, params)}
    end)
  end

  def handle_event("cancel_work", _params, socket) do
    with_authority(socket, :can_write, "You cannot cancel work on this issue.", fn socket ->
      {:noreply, cancel_work(socket)}
    end)
  end

  def handle_event("toggle_label", %{"name" => name}, socket) do
    with_authority(
      socket,
      :can_write,
      "Only repository members can change issue labels.",
      fn socket ->
        issue = socket.assigns.issue

        {:ok, updated} =
          if Enum.any?(issue.labels || [], &(&1["name"] == name)) do
            Issues.remove_label(issue, name, socket.assigns.current_user)
          else
            Issues.add_labels(issue, [name], socket.assigns.current_user)
          end

        {:noreply, load(socket, updated)}
      end
    )
  end

  def handle_event("toggle_assignee", %{"login" => login}, socket) do
    with_authority(
      socket,
      :can_write,
      "Only repository members can change issue assignees.",
      fn socket ->
        issue = socket.assigns.issue

        {:ok, updated} =
          if Enum.any?(issue.assignees || [], &(&1["login"] == login)) do
            Issues.remove_assignees(issue, [login], socket.assigns.current_user)
          else
            Issues.add_assignees(issue, [login], socket.assigns.current_user)
          end

        {:noreply, load(socket, updated)}
      end
    )
  end

  def handle_event("set_milestone", %{"number" => number}, socket) do
    with_authority(
      socket,
      :can_write,
      "Only repository members can change issue milestones.",
      fn socket ->
        {:ok, updated} = Issues.set_milestone(socket.assigns.issue, number_or_nil(number))
        {:noreply, load(socket, updated)}
      end
    )
  end

  def handle_event("add_comment", %{"comment" => %{"body" => body}}, socket) do
    with_authority(socket, :can_participate, "Sign in to comment on this issue.", fn socket ->
      issue = socket.assigns.issue

      if issue.locked do
        {:noreply, put_flash(socket, :error, "This conversation is locked.")}
      else
        case Issues.create_comment(issue, %{body: body}, socket.assigns.current_user) do
          {:ok, _comment} ->
            {:noreply,
             socket
             |> assign(:comment_form, to_form(Comment.changeset(%Comment{}, %{})))
             |> put_flash(:info, "Comment added")
             |> load(%{issue | comments: issue.comments + 1})}

          {:error, changeset} ->
            {:noreply, assign(socket, :comment_form, to_form(changeset))}
        end
      end
    end)
  end

  def handle_event(_unsupported_event, _params, socket) do
    {:noreply, put_flash(socket, :error, "That issue action is not available.")}
  end

  # Live updates: someone else's write re-reads this issue through the same
  # visibility check the mount used. A burst -- an import walking a repository,
  # or a script closing a milestone's worth of issues -- collapses into one
  # re-read rather than one repaint per row.
  def handle_info({:issues_changed, repository_id}, socket)
      when repository_id == socket.assigns.repository.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :issue, &refresh_panel/2)}

  def handle_info({:issues_changed, _other_repository}, socket), do: {:noreply, socket}

  def handle_info({message, repository_id}, socket)
      when message in [:labels_changed, :milestones_changed] and
             repository_id == socket.assigns.repository.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :pickers, &refresh_panel/2)}

  def handle_info({message, _other_repository}, socket)
      when message in [:labels_changed, :milestones_changed],
      do: {:noreply, socket}

  # An attempt moved. The message carries the issue id and nothing else, so
  # this re-reads through `refresh_panel/2` — which re-resolves the repository
  # for this viewer and rebuilds the attempts at this viewer's rung — rather
  # than assigning anything the announcement handed it.
  def handle_info({:attempts_changed, issue_id}, socket)
      when issue_id == socket.assigns.issue.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :issue, &refresh_panel/2)}

  def handle_info({:attempts_changed, _other_issue}, socket), do: {:noreply, socket}

  # The clock, not a poll. It re-reads nothing: elapsed time is `now` minus the
  # attempt's own start, and the attempt itself moves only when its topic says
  # so. The timer stops itself when the attempt ends.
  def handle_info(:attempt_tick, socket) do
    if socket.assigns[:live_attempt] do
      {:noreply, socket |> assign(:now, DateTime.utc_now()) |> schedule_tick()}
    else
      {:noreply, assign(socket, :ticking?, false)}
    end
  end

  def handle_info(:live_refresh, socket),
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}

  defp refresh_panel(socket, :issue) do
    socket = refresh_authority(socket)
    load(socket, socket.assigns.issue)
  end

  # A label or a milestone written elsewhere re-reads the pickers, and reads
  # the authority that decides whether to offer them in the same call, so the
  # two cannot disagree. It leaves the timeline alone: nothing about the issue
  # moved.
  defp refresh_panel(socket, :pickers), do: refresh_authority(socket)

  defp author?(%Issue{author_user_id: author_id}, %OpenAgents.Accounts.User{id: user_id})
       when is_binary(author_id),
       do: author_id == user_id

  defp author?(_issue, _user), do: false

  defp set_state(socket, state, reason) do
    attrs = %{"state" => state, "state_reason" => reason}

    {:ok, updated} =
      Issues.update_issue(socket.assigns.issue, attrs, socket.assigns.current_user)

    flash = if state == "closed", do: "Issue closed", else: "Issue reopened"

    {:noreply,
     socket
     |> put_flash(:info, flash)
     |> load(updated)}
  end

  defp with_edit_authority(socket, operation) do
    with_authority(socket, :can_edit, "You can no longer change this issue's state.", operation)
  end

  defp with_authority(socket, permission, message, operation) do
    socket = refresh_authority(socket)

    if socket.assigns[permission] do
      operation.(socket)
    else
      {:noreply, put_flash(socket, :error, message)}
    end
  end

  defp refresh_authority(socket) do
    user = socket.assigns.current_user

    visible_repository = Repositories.get_visible_repository(socket.assigns.repository.id, user)
    repository = visible_repository || socket.assigns.repository
    visible? = not is_nil(visible_repository)
    can_write = visible? and Repositories.writable?(repository, user)
    can_participate = visible? and Repositories.issue_participant?(repository, user)
    issue = Issues.get_issue!(socket.assigns.repository, socket.assigns.issue.id)
    can_edit = can_write || (can_participate and author?(issue, user))

    socket
    |> assign(:repository, repository)
    |> assign(:issue, issue)
    |> assign(:can_write, can_write)
    |> assign(:can_participate, can_participate)
    |> assign(:can_edit, can_edit)
    |> assign(:editing, socket.assigns.editing and can_edit)
    |> assign_repository_options()
    |> assign_work_options()
  end

  # The pickers the editor offers. They were read at mount and never again, so
  # a label or a milestone created in another tab was missing from this page
  # until it was reloaded, and a viewer whose write access was revoked kept
  # being offered the assignable list. They are read beside the authority that
  # decides whether to offer them at all, so the two cannot disagree.
  defp assign_repository_options(socket) do
    %{repository: repository, can_write: can_write} = socket.assigns

    socket
    |> assign(:repo_labels, if(can_write, do: Labels.list_labels(repository), else: []))
    |> assign(
      :repo_milestones,
      if(can_write, do: Milestones.list_milestones(repository), else: [])
    )
    |> assign(
      :assignable,
      if(can_write, do: Repositories.list_assignable_users(repository), else: [])
    )
  end

  defp agent_options(computers, computer_id) do
    computers |> Enum.find(&(&1.id == computer_id)) |> acp_agents() |> Enum.map(&{&1, &1})
  end

  defp root_options(computers, computer_id) do
    case Enum.find(computers, &(&1.id == computer_id)) do
      %{roots: roots} when is_list(roots) -> Enum.map(roots, &{&1, &1})
      _absent -> []
    end
  end

  # ── starting bounded agent work ─────────────────────────────────────────

  # Everything the start control offers, read beside the authority that
  # decides whether to offer it at all. A viewer without write access is
  # offered nothing, and a computer that went offline between renders is not
  # in the list — and would still be refused by name if it were.
  defp assign_work_options(socket, params \\ %{}) do
    computers = eligible_computers(socket.assigns)

    socket
    |> assign(:work_computers, computers)
    |> assign(
      :work_form,
      to_form(work_params(computers, socket.assigns.issue, params), as: :work)
    )
  end

  defp eligible_computers(%{can_write: true, current_user: %{id: user_id}}) do
    user_id
    |> Machines.list_machines()
    |> Enum.filter(&startable?/1)
  end

  defp eligible_computers(_assigns), do: []

  # A computer can take this work only if it is connected, has somewhere it is
  # allowed to work, and runs an agent that can be named. Offering one that
  # fails any of the three would produce a refusal a person cannot act on.
  defp startable?(machine) do
    machine.status == "active" and Computer.online?(machine.id) and
      machine.roots not in [nil, []] and acp_agents(machine) != []
  end

  defp acp_agents(%{last_probe: %{"acp_agents" => agents}}) when is_list(agents) do
    for agent <- agents, is_map(agent), is_binary(agent["id"]), agent["id"] != "", do: agent["id"]
  end

  defp acp_agents(_machine), do: []

  defp work_params(socket_or_computers, params_or_issue, params \\ nil)

  defp work_params(%Phoenix.LiveView.Socket{} = socket, params, nil),
    do: work_params(socket.assigns.work_computers, socket.assigns.issue, params)

  defp work_params(computers, issue, params) do
    params = params || %{}
    computer = chosen_computer(computers, params["computer_id"])

    %{
      "computer_id" => computer && computer.id,
      "agent_id" => chosen(acp_agents(computer), params["agent_id"]),
      "cwd" => chosen(computer && computer.roots, params["cwd"]),
      "branch" => branch_or_default(params["branch"], issue)
    }
  end

  defp chosen_computer(computers, id) do
    Enum.find(computers, &(&1.id == id)) || List.first(computers)
  end

  defp chosen(nil, _requested), do: nil
  defp chosen([], _requested), do: nil
  defp chosen(values, requested), do: if(requested in values, do: requested, else: hd(values))

  # The branch is never the default or a protected one — `Assignments` refuses
  # both — so the suggestion names the issue it is for.
  defp branch_or_default(branch, _issue) when is_binary(branch) and branch != "", do: branch
  defp branch_or_default(_branch, issue), do: WorkScope.branch(issue)

  # The one attempt that may be live per issue, per
  # `forge_assignments_one_active_issue_index`. Naming it is what turns a
  # 409 into something a person can read.
  defp live_attempt(attempts) do
    Enum.find(attempts, &(&1.state in ["admitted", "running"]))
  end

  defp start_work(socket, params) do
    %{issue: issue, repository: repository, current_user: user} = socket.assigns
    params = work_params(socket, params)

    with {:ok, computer} <- chosen_or_refuse(socket.assigns.work_computers, params["computer_id"]),
         {:ok, conversation} <- Conversations.ensure_conversation(user) do
      attrs = %{
        "target_kind" => "computer",
        "machine_id" => computer.id,
        "conversation_id" => conversation.id,
        "repository_id" => repository.id,
        "issue_number" => issue.number,
        "branch" => params["branch"],
        "agent_id" => params["agent_id"],
        "cwd" => params["cwd"],
        "prompt" => WorkScope.objective(issue, params["branch"]),
        "requesting_user" => user,
        "requesting_principal" => user
      }

      case Assignments.create(attrs) do
        {:ok, _assignment, _credential} ->
          socket
          |> put_flash(:info, "Work started on #{computer.name}, on branch #{params["branch"]}.")
          |> assign_work_options()
          |> load(Issues.get_issue!(repository, issue.id))

        {:error, reason} ->
          socket
          |> put_flash(:error, refusal(reason, socket))
          |> assign_work_options(params)
      end
    else
      {:error, reason} ->
        socket
        |> put_flash(:error, refusal(reason, socket))
        |> assign_work_options(params)
    end
  end

  defp chosen_or_refuse(computers, id) do
    case Enum.find(computers, &(&1.id == id)) do
      nil -> {:error, :machine_offline}
      computer -> {:ok, computer}
    end
  end

  # The objective comes from the issue, not from free text typed beside it, so
  # what the agent was asked to do and what the issue asked for cannot drift.
  # Every refusal the admission already returns, said as itself. A generic
  # failure here would hide the one fact that tells someone what to do next.
  defp refusal(:assignment_issue_claimed, socket) do
    case live_attempt(socket.assigns[:attempts] || []) do
      %{branch: branch} when is_binary(branch) ->
        "Work is already running on this issue, on branch #{branch}. " <>
          "One attempt may be live at a time."

      _none ->
        "Work is already running on this issue. One attempt may be live at a time."
    end
  end

  defp refusal(:assignment_box_busy, _socket), do: "That computer is already running work."
  defp refusal(:assignment_machine_busy, _socket), do: "That computer is already running work."

  defp refusal(:machine_offline, _socket),
    do: "That computer is not connected. Connect it and try again."

  defp refusal(:machine_revoked, _socket), do: "That computer's access was revoked."
  defp refusal(:machine_not_found, _socket), do: "That computer is no longer paired."

  defp refusal(:protected_branch, _socket),
    do: "That branch is protected. Agent work needs a branch of its own."

  defp refusal(:invalid_assignment_branch, _socket), do: "That is not a usable branch name."

  defp refusal(:repository_not_writable, _socket),
    do: "You can no longer start work on this repository."

  defp refusal(:agent_not_available, _socket),
    do: "That computer is not running the agent you chose."

  defp refusal(:cwd_not_allowed, _socket),
    do: "That working directory is outside what the computer allows."

  defp refusal(:computer_controller_disabled, _socket),
    do: "Starting work on a computer is turned off on this deployment."

  defp refusal(:invalid_delegation_request, _socket),
    do: "This issue does not describe enough to work on. Add a description first."

  defp refusal(:conversation_not_found, _socket),
    do: "Your account has no conversation to run this work in."

  defp refusal(reason, _socket) when is_atom(reason),
    do: "Work was refused: #{reason |> Atom.to_string() |> String.replace("_", " ")}."

  defp refusal(_reason, _socket), do: "Work was refused."

  # The control is hidden without write authority and the event is refused
  # without it too: `Assignments.cancel/2` reads the authority from the
  # attempt's own repository, so a socket that kept a stale `can_write` cannot
  # end anybody's work.
  defp cancel_work(socket) do
    case live_attempt(socket.assigns[:attempts] || []) do
      %{id: id} ->
        case Assignments.cancel(id, socket.assigns.current_user) do
          {:ok, _assignment} ->
            socket
            |> put_flash(:info, "Work cancelled.")
            |> load(socket.assigns.issue)

          {:error, reason} ->
            put_flash(socket, :error, cancel_refusal(reason))
        end

      nil ->
        put_flash(socket, :error, "No work is running on this issue.")
    end
  end

  defp cancel_refusal(:assignment_not_live), do: "That work already finished."
  defp cancel_refusal(:assignment_not_found), do: "That work no longer exists."

  defp cancel_refusal(:repository_not_writable),
    do: "You cannot cancel work on this repository."

  defp cancel_refusal(_reason), do: "Work could not be cancelled."

  defp schedule_tick(socket) do
    Process.send_after(self(), :attempt_tick, 1_000)
    socket
  end

  # How long the live attempt has been running, from the fields `pulse`
  # already publishes. A reader who cannot see the branch can still see this.
  defp elapsed(%{} = attempt, %DateTime{} = now) do
    case attempt[:started_at] || attempt[:admitted_at] do
      %DateTime{} = start -> humanize_seconds(max(DateTime.diff(now, start, :second), 0))
      _unknown -> nil
    end
  end

  defp elapsed(_attempt, _now), do: nil

  defp humanize_seconds(seconds) when seconds < 60, do: "#{seconds}s"

  defp humanize_seconds(seconds) when seconds < 3_600,
    do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s"

  defp humanize_seconds(seconds), do: "#{div(seconds, 3_600)}h #{div(rem(seconds, 3_600), 60)}m"

  # One place rebuilds everything derived from the issue, so a write cannot
  # leave the timeline describing the previous version of the page.
  defp load(socket, issue) do
    comments = Issues.list_comments(issue)
    # An issue can become a pull request while the page is open, and the badge
    # that says so was read at mount only.

    # The page reads attempts at the rung this reader is on, from the same
    # schedule and the same viewer descriptor the API uses. That is what makes
    # "the same viewer gets the same answer on the page and in the API" a
    # property of one function rather than of two lists kept in step by hand.
    attempts =
      Assignments.attempts_for_issue(
        issue,
        WorkDisclosure.viewer(socket.assigns.repository, socket.assigns.current_user)
      )

    references = ClosingReferences.for_issue(issue)
    syncs = TaskReferences.for_issue(issue)
    base = "/#{socket.assigns.owner}/#{socket.assigns.repo}"

    socket
    |> assign(:issue, issue)
    |> assign(:comments, comments)
    |> assign(:attempts, attempts)
    |> assign(:live_attempt, live_attempt(attempts))
    |> assign(:pull_request_state, pull_request_state(issue))
    |> assign(:form, to_form(Issues.change_issue(issue)))
    |> assign(:events, timeline(issue, comments, attempts, references, syncs, base))
    |> assign(:subscribed?, subscribed?(issue, socket.assigns.current_user))
    |> assign(:activity, Activity.for_issue(issue, socket.assigns.current_user))
    |> assign(:claims, CompletionClaims.for_issue(issue))
    |> assign(:scope, WorkScope.for_issue(issue))
    |> assign(:now, DateTime.utc_now())
    |> arm_tick()
  end

  # ── the evidence a reader can act on ────────────────────────────────────
  #
  # The page shows the same assembly the activity endpoint returns, read
  # through the same functions with the same viewer, so the two cannot come
  # apart. Nothing here is a second store: releases are computed from the
  # commit graph, receipts are reached from the issue's own closing
  # references, traces are projected through their own two gates, and the
  # verdict is the row `OUTCOME-001` already wrote.

  defp released_in(%{releases: %{released_in: %{} = release}}), do: release
  defp released_in(_activity), do: nil

  defp receipt_families(%{receipts: receipts}) do
    receipts
    |> Enum.frequencies_by(& &1.family)
    |> Enum.sort_by(&elem(&1, 0))
  end

  defp receipt_families(_activity), do: []

  defp visible_traces(%{traces: traces}) when is_list(traces), do: traces
  defp visible_traces(_activity), do: []

  # The verdict a reader needs is the most recent one. Earlier verdicts stay on
  # the record and are read through the API; repeating every attempt's grade in
  # the rail would bury the one that decided whether the issue closed.
  defp latest_claim([]), do: nil
  defp latest_claim(claims), do: List.last(claims)

  defp claim_tone(%{state: "accepted"}), do: "success"
  defp claim_tone(%{state: "not_applicable"}), do: "muted"
  defp claim_tone(_claim), do: "warning"

  defp claim_sentence(%{state: "accepted", closed: true}),
    do: "An accepted outcome closed this issue."

  defp claim_sentence(%{state: "accepted"}),
    do:
      "An outcome was accepted. This repository has not opted in to verified closing, " <>
        "so the issue stays open for a person to decide."

  defp claim_sentence(%{state: "not_applicable"}),
    do: "Graded as outside the gate: human work, or a repository with agents disabled."

  defp claim_sentence(%{state: state}), do: "The last claim graded #{state}."

  defp short_sha(sha) when is_binary(sha), do: binary_part(sha, 0, min(byte_size(sha), 7))
  defp short_sha(_sha), do: ""

  defp section_word(:acceptance_criteria), do: "acceptance criteria"
  defp section_word(:success_metrics), do: "success metrics"
  defp section_word(section), do: to_string(section)

  # The clock is armed only while an attempt is live, so an issue nobody is
  # working on costs no timer at all, and only on the transition into a live
  # attempt, so a re-read cannot stack timers.
  defp arm_tick(socket) do
    live? = not is_nil(socket.assigns[:live_attempt])
    ticking? = socket.assigns[:ticking?] == true

    cond do
      live? and not ticking? and connected?(socket) ->
        socket |> assign(:ticking?, true) |> schedule_tick()

      not live? ->
        assign(socket, :ticking?, false)

      true ->
        socket
    end
  end

  defp subscribed?(_issue, nil), do: false
  defp subscribed?(issue, user), do: Notifications.subscribed?(issue, user)

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      wide
    >
      <Circle.issue_detail>
        <:heading>
          <.form
            :if={@editing}
            for={@form}
            id="issue-edit-form"
            phx-submit="save"
            class="issue-editor"
          >
            <.input field={@form[:title]} label="Title" />
            <.input field={@form[:body]} type="textarea" label="Body" />
            <footer class="issue-editor__foot">
              <button type="button" class="btn" data-variant="ghost" phx-click="toggle_edit">
                Cancel
              </button>
              <.button type="submit" variant={:primary}>Save</.button>
            </footer>
          </.form>

          <div :if={!@editing} class="issue-heading">
            <h1 class="issue-heading__title">
              {@issue.title}
              <span class="issue-heading__number">#{@issue.number}</span>
            </h1>
            <p class="issue-heading__meta">
              <%!-- A pull request is one of these rows, so this page can be
              reached for a number that proposes a change to code. It says so
              with the pull request's own glyph and state, and links to the
              surface that can review it, rather than drawing the issue circle
              over it (#120). --%>
              <Circle.pull_request_state
                :if={@pull_request_state}
                id="issue-pull-request-state"
                state={@pull_request_state}
                show_label
              />
              <Circle.issue_state
                :if={!@pull_request_state}
                state={@issue.state}
                reason={@issue.state_reason}
                show_label
              />
              <span :if={@pull_request_state} class="issue-heading__dot" aria-hidden="true">·</span>
              <.link
                :if={@pull_request_state}
                id="issue-pull-request-link"
                navigate={~p"/#{@owner}/#{@repo}/pulls/#{@issue.number}"}
                class="btn"
                data-variant="link"
              >
                Pull request
              </.link>
              <span class="issue-heading__dot" aria-hidden="true">·</span>
              <span>
                {author(@issue)} opened this {relative(@issue.inserted_at)} ago
              </span>
              <span :if={@issue.comments > 0} class="issue-heading__dot" aria-hidden="true">·</span>
              <span :if={@issue.comments > 0}>
                {@issue.comments} {ngettext_comments(@issue.comments)}
              </span>
            </p>
            <div class="issue-heading__actions">
              <button
                :if={@can_edit and @issue.state == "open"}
                class="btn"
                data-variant="primary"
                data-size="sm"
                phx-click="close"
              >
                Close issue
              </button>
              <button
                :if={@can_edit and @issue.state == "closed"}
                class="btn"
                data-variant="ghost"
                data-size="sm"
                phx-click="reopen"
              >
                Reopen issue
              </button>
              <button
                :if={@can_edit}
                class="btn"
                data-variant="ghost"
                data-size="sm"
                phx-click="toggle_edit"
              >
                <.icon name="edit" /> Edit
              </button>
            </div>
          </div>
        </:heading>

        <:rail>
          <Circle.properties_panel :if={@can_write}>
            <:group heading="State">
              <Circle.field_menu id="issue-state-menu" label="Change the state of this issue">
                <:trigger>
                  <Circle.issue_state
                    state={@issue.state}
                    reason={@issue.state_reason}
                    show_label
                  />
                </:trigger>
                <Circle.field_menu_item
                  :for={{label, state, reason} <- state_options()}
                  label={label}
                  mode={:choice}
                  selected={@issue.state == state and close_reason(@issue) == reason}
                  closes="issue-state-menu"
                  on_select={JS.push("set_state", value: %{state: state, reason: reason})}
                >
                  <:glyph><Circle.issue_state state={state} reason={reason} /></:glyph>
                </Circle.field_menu_item>
              </Circle.field_menu>
            </:group>

            <:group heading="Assignees">
              <Circle.assignee
                :for={assignee <- @issue.assignees}
                name={assignee["login"]}
                show_name
                size={:sm}
              />
              <span :if={@issue.assignees == []} class="properties-panel__none">No one assigned</span>
              <Circle.field_menu id="issue-assignee-menu" label="Assign this issue" align={:end}>
                <:trigger><.icon name="user-add" class="properties-panel__add" /></:trigger>
                <Circle.field_menu_item
                  :for={user <- @assignable}
                  label={user.github_login}
                  selected={assigned?(@issue, user.github_login)}
                  on_select={JS.push("toggle_assignee", value: %{login: user.github_login})}
                >
                  <:glyph><Circle.assignee name={user.github_login} size={:sm} /></:glyph>
                </Circle.field_menu_item>
                <p :if={@assignable == []} class="properties-panel__none">
                  Nobody in this repository can be assigned yet.
                </p>
              </Circle.field_menu>
            </:group>

            <:group heading="Labels">
              <Circle.issue_label
                :for={label <- @issue.labels}
                name={label["name"]}
                tone={label_tone(label["color"])}
              />
              <span :if={@issue.labels == []} class="properties-panel__none">None yet</span>
              <Circle.field_menu
                id="issue-label-menu"
                label="Change the labels on this issue"
                align={:end}
              >
                <:trigger><.icon name="tag" class="properties-panel__add" /></:trigger>
                <Circle.field_menu_item
                  :for={label <- @repo_labels}
                  label={label.name}
                  selected={labelled?(@issue, label.name)}
                  on_select={JS.push("toggle_label", value: %{name: label.name})}
                >
                  <:glyph>
                    <span class="issue-label__dot" data-tone={label_tone(label.color)} />
                  </:glyph>
                </Circle.field_menu_item>
                <p :if={@repo_labels == []} class="properties-panel__none">
                  This repository has no labels yet.
                </p>
              </Circle.field_menu>
            </:group>

            <:group heading="Milestone">
              <.link
                :if={@issue.milestone}
                navigate={~p"/#{@owner}/#{@repo}/milestones"}
                class="properties-panel__link"
              >
                <.icon name="flag" /> {@issue.milestone["title"]}
              </.link>
              <span :if={!@issue.milestone} class="properties-panel__none">No milestone</span>
              <Circle.field_menu id="issue-milestone-menu" label="Set the milestone" align={:end}>
                <:trigger><.icon name="edit-pencil" class="properties-panel__add" /></:trigger>
                <Circle.field_menu_item
                  label="No milestone"
                  mode={:choice}
                  selected={is_nil(@issue.milestone)}
                  closes="issue-milestone-menu"
                  on_select={JS.push("set_milestone", value: %{number: ""})}
                />
                <Circle.field_menu_item
                  :for={milestone <- @repo_milestones}
                  label={milestone.title}
                  icon="flag"
                  mode={:choice}
                  selected={milestoned?(@issue, milestone.number)}
                  closes="issue-milestone-menu"
                  on_select={JS.push("set_milestone", value: %{number: milestone.number})}
                />
              </Circle.field_menu>
            </:group>
          </Circle.properties_panel>

          <%!-- Outside the properties panel too, and for the opposite
          reason: everything in that panel edits the issue record, and this
          starts an execution somewhere else. Starting and cancelling need
          write authority, which `Assignments.create/1` and
          `Assignments.cancel/2` re-check server-side, so each control is
          hidden and its event is refused rather than only hidden. That a live
          attempt exists, and how long it has run, is not a write and is shown
          to any reader who reached the issue. --%>
          <section :if={@can_write or @live_attempt} id="issue-work" class="properties-panel__group">
            <h3 class="properties-panel__heading">Agent work</h3>

            <%!-- The bound an attempt inherits, said before anyone starts one.
            An unscoped issue is worth naming here rather than at grading time:
            `OUTCOME-001` refuses a claim against it, so the sentence is what
            turns a later `incomplete` into something a person could have
            fixed first. See `OpenAgents.Issues.WorkScope`. --%>
            <p :if={@can_write} class="properties-panel__none" id="issue-work-bound">
              <%= if @scope.scoped? do %>
                This issue states every section an accepted outcome needs, so an attempt on it
                runs for up to an hour.
              <% else %>
                This issue does not state its {Enum.map_join(
                  @scope.missing_sections,
                  ", ",
                  &section_word/1
                )}, so no claim against it can be accepted and an attempt runs for {div(
                  @scope.wall_clock_ms,
                  60_000
                )} minutes.
              <% end %>
            </p>

            <div :if={@live_attempt} id="issue-work-live">
              <p class="properties-panel__none">
                Work is <span id="issue-work-state">{@live_attempt.state}</span>{live_attempt_branch(
                  @live_attempt
                )}, for <span id="issue-work-elapsed">{elapsed(@live_attempt, @now)}</span>.
                One attempt may be live on an issue at a time.
              </p>
              <.button
                :if={@can_write}
                id="issue-work-cancel"
                variant={:ghost}
                size={:sm}
                data-tone="danger"
                phx-click="cancel_work"
              >
                Cancel work
              </.button>
            </div>

            <p
              :if={@can_write and is_nil(@live_attempt) and @work_computers == []}
              class="properties-panel__none"
              id="issue-work-unavailable"
            >
              No connected computer can take this work. Pair one, keep it online, and give it a
              working directory and an agent.
            </p>

            <.form
              :if={@can_write and is_nil(@live_attempt) and @work_computers != []}
              for={@work_form}
              id="issue-work-form"
              phx-change="preview_work"
              phx-submit="start_work"
            >
              <.input
                field={@work_form[:computer_id]}
                type="select"
                label="Computer"
                options={Enum.map(@work_computers, &{&1.name, &1.id})}
              />
              <.input
                field={@work_form[:agent_id]}
                type="select"
                label="Agent"
                options={agent_options(@work_computers, @work_form[:computer_id].value)}
              />
              <.input
                field={@work_form[:cwd]}
                type="select"
                label="Working directory"
                options={root_options(@work_computers, @work_form[:computer_id].value)}
              />
              <.input field={@work_form[:branch]} type="text" label="Branch" />
              <p class="properties-panel__none">
                The objective is read from this issue. The agent may write only this branch, and
                its credential expires with the attempt.
              </p>
              <.button id="issue-work-start" variant={:primary} size={:sm} type="submit">
                Start work
              </.button>
            </.form>
          </section>

          <%!-- What happened to this issue, as records rather than as prose.
          Every line is reachable through
          `GET /api/v1/repos/:owner/:repo/issues/:n/activity` for the same
          reader, because both read `OpenAgents.Issues.Activity` with the same
          viewer. The section is absent when the issue has no evidence at all,
          rather than present and empty. --%>
          <section
            :if={
              released_in(@activity) || receipt_families(@activity) != [] ||
                visible_traces(@activity) != [] || latest_claim(@claims)
            }
            id="issue-evidence"
            class="properties-panel__group"
          >
            <h3 class="properties-panel__heading">Evidence</h3>

            <p :if={claim = latest_claim(@claims)} id="issue-evidence-claim">
              <.badge data-tone={claim_tone(claim)}>{claim.state}</.badge>
              <span class="properties-panel__none">{claim_sentence(claim)}</span>
            </p>

            <p :if={release = released_in(@activity)} id="issue-evidence-release">
              <span class="properties-panel__none">
                Shipped in the release at <code>{short_sha(release.sha)}</code>, promoted {Calendar.strftime(
                  release.promoted_at,
                  "%Y-%m-%d"
                )}.
              </span>
            </p>

            <p
              :for={{family, count} <- receipt_families(@activity)}
              id={"issue-evidence-receipt-#{family}"}
              class="properties-panel__none"
            >
              {count} {family} {if count == 1, do: "receipt", else: "receipts"}
            </p>

            <%!-- An issue says a trajectory exists. It never shows one: the
            steps carry prompts, tool arguments, and tool results, which the
            repository's own gate exists to withhold. See
            `OpenAgents.Issues.TraceDisclosure`. --%>
            <p
              :for={trace <- visible_traces(@activity)}
              id={"issue-evidence-trace-#{trace.id}"}
              class="properties-panel__none"
            >
              An agent trajectory of {trace.step_count} {if trace.step_count == 1,
                do: "step",
                else: "steps"} was recorded<%= if trace[:digest] do %>
                , as <code>{short_sha(String.replace_prefix(trace.digest, "sha256:", ""))}</code>
              <% end %>. Its
              contents are not published here.
            </p>
          </section>

          <%!-- Outside the properties panel on purpose. Everything in that
          panel changes the issue and needs a writable membership; following an
          issue changes only what reaches your own inbox, so it is offered to
          anyone who may take part in the conversation. --%>
          <section :if={@can_participate} class="properties-panel__group">
            <h3 class="properties-panel__heading">Notifications</h3>
            <p class="properties-panel__none">
              {if @subscribed?,
                do: "You get comments on this issue.",
                else: "You do not get comments on this issue."}
            </p>
            <.button
              id="issue-subscription-toggle"
              variant={:ghost}
              size={:sm}
              phx-click="toggle_subscription"
            >
              {if @subscribed?, do: "Unsubscribe", else: "Subscribe"}
            </.button>
          </section>
        </:rail>

        <section class="issue-body">
          <div :if={@issue.body} class="timeline-comment__body !p-0">
            {Markdown.to_html(@issue.body)}
          </div>
          <p :if={!@issue.body} class="properties-panel__none">No description provided.</p>
        </section>

        <Circle.timeline>
          <%= for event <- @events do %>
            <Circle.timeline_comment
              :if={event.kind == :comment}
              id={"comment-#{event.id}"}
              author={event.actor}
              at={event.at}
              badge={event.badge}
            >
              {Markdown.to_html(event.body)}
            </Circle.timeline_comment>
            <Circle.timeline_event
              :if={event.kind == :event}
              actor={event.actor}
              text={event.text}
              icon={event.icon}
              tone={event.tone}
              at={event.at}
            />
            <Circle.timeline_event
              :if={event.kind == :commit}
              id={"closing-reference-#{event.id}"}
              actor={event.actor}
              text={event.text}
              icon={event.icon}
              tone={event.tone}
              at={event.at}
            >
              <.text_button navigate={event.commit_path}>
                <code>{event.short_sha}</code>
              </.text_button>
            </Circle.timeline_event>
          <% end %>
        </Circle.timeline>

        <.alert :if={@issue.locked} variant={:warning} appearance={:notice}>
          This conversation is locked{if @issue.locked_reason, do: " as #{@issue.locked_reason}"}.
        </.alert>

        <.alert
          :if={!@can_participate and !@issue.locked}
          variant={:info}
          appearance={:notice}
          id="sign-in-to-comment"
        >
          <.link navigate={~p"/"} class="font-medium underline">
            Sign in with GitHub
          </.link>
          to comment on this issue.
        </.alert>

        <.form
          :if={@can_participate and !@issue.locked}
          for={@comment_form}
          id="comment-form"
          phx-submit="add_comment"
        >
          <Circle.comment_composer id="issue-composer" author={viewer(@current_user)}>
            <.input
              field={@comment_form[:body]}
              type="textarea"
              label="Comment"
              placeholder="Leave a comment"
            />
            <:hint>Markdown is supported.</:hint>
            <:actions>
              <.button type="submit" variant={:primary} size={:sm}>Comment</.button>
            </:actions>
          </Circle.comment_composer>
        </.form>
      </Circle.issue_detail>
    </Layouts.app>
    """
  end

  # ── the derived history ────────────────────────────────────────────────────

  # GitHub's timeline is an endpoint backed by an event log. This schema has no
  # such table, so the feed is assembled from the columns that do exist. The
  # result is honest but partial: a label added and removed leaves no trace,
  # and a close records when but not who.
  defp timeline(issue, comments, attempts, references, syncs, base) do
    opened = %{
      kind: :event,
      actor: author(issue),
      text: "opened this issue",
      icon: "plus-circle",
      tone: :neutral,
      at: stamp(issue.inserted_at),
      sort: issue.inserted_at
    }

    commented =
      Enum.map(comments, fn comment ->
        login = actor_label(comment.user)

        %{
          kind: :comment,
          id: comment.id,
          actor: login,
          badge: if(login == author(issue), do: "Author"),
          body: comment.body,
          at: stamp(comment.created_at),
          sort: comment.created_at
        }
      end)

    # A close that arrived from a commit names the commit, the pusher, and the
    # push. The derived close below cannot name any of those, so where a
    # commit did the closing that entry stands in for it rather than beside
    # it.
    referenced =
      Enum.map(references, fn reference ->
        %{
          kind: :commit,
          id: reference.id,
          actor: reference_actor(reference),
          text: reference_text(reference),
          icon: if(reference.closed, do: "octicon-issue-closed", else: "commit"),
          tone: if(reference.closed, do: :success, else: :neutral),
          at: stamp(reference.inserted_at),
          sort: reference.inserted_at,
          short_sha: String.slice(reference.commit_sha, 0, 7),
          commit_path: "#{base}/commit/#{reference.commit_sha}"
        }
      end)

    closed_by_commit? = Enum.any?(references, & &1.closed)

    closed =
      if issue.state == "closed" and not closed_by_commit? and issue.closed_at do
        [
          %{
            kind: :event,
            actor: nil,
            text: close_text(issue),
            icon: close_icon(issue),
            tone: close_tone(issue),
            at: stamp(issue.closed_at),
            sort: issue.closed_at
          }
        ]
      else
        []
      end

    Enum.sort_by(
      [opened | commented] ++
        referenced ++
        closed ++
        attempt_events(attempts) ++
        sync_events(syncs),
      & &1.sort,
      DateTime
    )
  end

  # An edit the forge made on its own. It carries the system as its actor
  # rather than the person whose close triggered it: they closed an issue,
  # they did not touch this one.
  defp sync_events(syncs) do
    Enum.map(syncs, fn sync ->
      %{
        kind: :event,
        id: sync.id,
        actor: "system",
        text: sync_text(sync),
        icon: if(sync.checked, do: "check-circle", else: "circle"),
        tone: :neutral,
        at: stamp(sync.inserted_at),
        sort: sync.inserted_at
      }
    end)
  end

  defp sync_text(%{checked: true, reference_number: number}),
    do: "checked the task for ##{number}"

  defp sync_text(%{reference_number: number}), do: "unchecked the task for ##{number}"

  # An attempt produces at most two events: it started, and it ended. Both
  # read from `forge_assignments`, so an attempt that never finished shows as
  # started and nothing more rather than as a silent gap.
  defp attempt_events(attempts) do
    Enum.flat_map(attempts, fn attempt ->
      started_at = attempt[:started_at] || attempt[:admitted_at]

      start =
        if started_at do
          [
            %{
              kind: :event,
              actor: nil,
              text: attempt_start_text(attempt),
              icon: "play",
              tone: :neutral,
              at: stamp(started_at),
              sort: started_at
            }
          ]
        else
          []
        end

      finish =
        if attempt[:finished_at] do
          [
            %{
              kind: :event,
              actor: nil,
              text: attempt_finish_text(attempt),
              icon: attempt_icon(attempt),
              tone: attempt_tone(attempt),
              at: stamp(attempt[:finished_at]),
              sort: attempt[:finished_at]
            }
          ]
        else
          []
        end

      start ++ finish
    end)
  end

  # A projection at `pulse` carries no branch and no revision, so every clause
  # that names one matches on a key that may be absent rather than on a struct
  # field that is always there. The sentence a reader gets is shorter, never
  # wrong: an anonymous reader of a public repository is told that work started
  # and how it ended, and is not told the ref it ran on.
  defp attempt_start_text(%{target_kind: "computer", branch: branch}),
    do: "started work on a computer, on branch #{branch}"

  defp attempt_start_text(%{branch: branch}), do: "started work on a box, on branch #{branch}"

  defp attempt_start_text(%{target_kind: "computer"}), do: "started work on a computer"

  defp attempt_start_text(_attempt), do: "started work on a box"

  defp attempt_finish_text(%{state: "completed", terminal_commit: commit})
       when is_binary(commit),
       do: "finished this work at #{String.slice(commit, 0, 7)}"

  defp attempt_finish_text(%{state: "completed"}), do: "finished this work"

  defp attempt_finish_text(%{state: "cancelled"}), do: "cancelled this work"

  defp attempt_finish_text(%{failure_reason: reason}) when is_binary(reason),
    do: "stopped this work: #{reason}"

  defp attempt_finish_text(_attempt), do: "stopped this work"

  defp live_attempt_branch(%{branch: branch}) when is_binary(branch),
    do:
      Phoenix.HTML.raw(
        " on branch <code>#{Phoenix.HTML.html_escape(branch) |> Phoenix.HTML.safe_to_string()}</code>"
      )

  defp live_attempt_branch(_attempt), do: ""

  defp attempt_icon(%{state: "completed"}), do: "check-circle"
  defp attempt_icon(_attempt), do: "x-circle-filled"

  defp attempt_tone(%{state: "completed"}), do: :success
  defp attempt_tone(%{state: "cancelled"}), do: :neutral
  defp attempt_tone(_attempt), do: :danger

  # The principal is recorded on the row; the login is what a reader
  # recognizes. Falling back to the principal is honest rather than blank.
  defp reference_actor(%{closed_by_user: %{github_login: login}}) when is_binary(login), do: login
  defp reference_actor(reference), do: reference.principal

  defp reference_text(%{closed: true}), do: "closed this as completed in"
  defp reference_text(_reference), do: "referenced this in"

  defp close_text(%{state_reason: "not_planned"}), do: "closed this as not planned"
  defp close_text(%{state_reason: "duplicate"}), do: "closed this as a duplicate"
  defp close_text(_issue), do: "closed this as completed"

  defp close_icon(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
    do: "x-circle-filled"

  defp close_icon(_issue), do: "octicon-issue-closed"

  defp close_tone(%{state_reason: reason}) when reason in ["not_planned", "duplicate"],
    do: :danger

  defp close_tone(_issue), do: :success

  # ── GitHub's fields, read the way this interface reads them ────────────────

  # The three states this application can express. `duplicate` is a valid
  # GitHub close reason and is rendered when it arrives from the API, but it is
  # not offered here: the menu that sets it has nowhere to record which issue
  # it duplicates, and a duplicate that does not say of what is worse than a
  # plain close.
  defp state_options do
    [
      {"Open", "open", nil},
      {"Closed as completed", "closed", "completed"},
      {"Closed as not planned", "closed", "not_planned"}
    ]
  end

  defp close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
  defp close_reason(_issue), do: nil

  # `JS.push` sends a number as a number and a form sends it as a string, so
  # the handler takes whichever arrives rather than making the call sites agree.
  defp number_or_nil(""), do: nil
  defp number_or_nil(number) when is_integer(number), do: number
  defp number_or_nil(number) when is_binary(number), do: String.to_integer(number)

  defp assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
  defp labelled?(issue, name), do: Enum.any?(issue.labels || [], &(&1["name"] == name))
  defp milestoned?(%{milestone: %{"number" => n}}, number), do: n == number
  defp milestoned?(_issue, _number), do: false

  defp author(issue), do: actor_label(issue.user)

  defp login(%{} = user), do: user["login"] || user[:login] || "anonymous"
  defp login(_user), do: "anonymous"

  defp actor_label(%{"agent" => true} = user), do: "#{login(user)} (agent)"
  defp actor_label(user), do: login(user)

  defp viewer(%{github_login: login}) when is_binary(login), do: login
  defp viewer(_user), do: nil

  defp ngettext_comments(1), do: "comment"
  defp ngettext_comments(_count), do: "comments"

  # A GitHub label carries a hex colour chosen by whoever made it. Rendering it
  # would put a second palette on the page, and dropping it would make every
  # label grey. So the hue is read and mapped to the nearest tone on our
  # ladder: the author's intent survives -- red means broken, green means new
  # -- while the values stay ours.
  defp label_tone(color) when is_binary(color) do
    case Integer.parse(String.trim_leading(color, "#"), 16) do
      {value, _rest} -> hue_tone(value)
      :error -> :neutral
    end
  end

  defp label_tone(_color), do: :neutral

  defp hue_tone(value) do
    r = div(value, 65_536)
    g = value |> div(256) |> rem(256)
    b = rem(value, 256)
    high = Enum.max([r, g, b])
    low = Enum.min([r, g, b])

    # A grey label is as deliberate a choice as a red one, so anything close to
    # the diagonal stays neutral rather than being forced into a hue.
    if high == 0 or (high - low) / high < 0.2 do
      :neutral
    else
      r |> hue(g, b, high, low) |> tone_for_hue()
    end
  end

  defp hue(r, g, b, high, low) do
    delta = high - low

    sextant =
      cond do
        high == r -> wrap((g - b) / delta)
        high == g -> (b - r) / delta + 2
        true -> (r - g) / delta + 4
      end

    sextant * 60
  end

  # The red sextant straddles zero: a value of -0.5 is 330 degrees, not -30.
  defp wrap(sextant) when sextant < 0, do: sextant + 6
  defp wrap(sextant), do: sextant

  defp tone_for_hue(h) when h < 20 or h >= 340, do: :danger
  defp tone_for_hue(h) when h < 70, do: :warning
  defp tone_for_hue(h) when h < 170, do: :success
  defp tone_for_hue(h) when h < 260, do: :info
  defp tone_for_hue(_h), do: :primary

  # `nil` for a plain issue, the pull request's state for a row a pull request
  # is built on. One query per page view, at mount, beside the issue it marks.
  defp pull_request_state(issue) do
    [issue]
    |> PullRequests.markers_by_issue_id()
    |> Map.get(issue.id)
    |> then(&(&1 && &1.state))
  end

  defp stamp(at), do: RelativeTime.ago(at)

  defp relative(at), do: RelativeTime.since(at)
end