test/openagents_web/live/issue_show_live_test.exs

main at 58e6347eeb72 · 19 KB

defmodule OpenAgentsWeb.IssueShowLiveTest do
  use OpenAgentsWeb.ConnCase, async: false

  import Phoenix.LiveViewTest
  import OpenAgents.AccountsFixtures
  import OpenAgents.LabelsFixtures
  import OpenAgents.MilestonesFixtures

  alias OpenAgents.Accounts
  alias OpenAgents.Issues
  alias OpenAgents.Issues.ClosingReferences
  alias OpenAgents.Repositories

  setup %{conn: conn} do
    {:ok, conn: log_in_repository_user(conn, "issue-show", repository())}
  end

  defp issue!(attrs) do
    {:ok, issue} = Issues.create_issue(repository(), attrs)
    issue
  end

  defp path(issue), do: ~p"/OpenAgentsInc/openagents.com/issues/#{issue.number}"

  test "mounts and renders the title, number, state, and body", %{conn: conn} do
    issue =
      issue!(%{
        "title" => "Streaming stalls",
        "body" => "It hangs",
        "user" => %{"login" => "ada"}
      })

    {:ok, view, html} = live(conn, path(issue))

    assert html =~ "Streaming stalls"
    assert html =~ "##{issue.number}"
    assert html =~ "It hangs"
    assert html =~ "ada"
    assert has_element?(view, ~s{button[phx-click="close"]}, "Close issue")
    refute has_element?(view, ~s{button[phx-click="reopen"]})
  end

  test "an issue with no body says so rather than rendering blank", %{conn: conn} do
    issue = issue!(%{"title" => "Bare"})

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "No description provided."
    assert html =~ "anonymous"
  end

  # This used to assert that an empty section was hidden, which was right while
  # the rail was read-only. The rail is editable now, and a field you cannot
  # see is a field you cannot set: hiding "Labels" until an issue has one means
  # an issue can never get its first label. So the group is always present and
  # states its own emptiness instead.
  test "an empty property group says so rather than disappearing", %{conn: conn} do
    grace = repository_user_fixture("grace-show")
    {:ok, _membership} = Repositories.add_member(repository(), grace, "contributor")
    bare = issue!(%{"title" => "Bare"})
    {:ok, view, html} = live(conn, path(bare))

    assert html =~ "Labels"
    assert html =~ "Assignees"
    assert html =~ "Milestone"
    assert html =~ "None yet"
    assert html =~ "No one assigned"
    assert html =~ "No milestone"

    # And each one offers the control that fills it.
    for id <- ~w(issue-label-menu issue-assignee-menu issue-milestone-menu issue-state-menu) do
      assert has_element?(view, ~s{button[popovertarget="#{id}"]}),
             "the #{id} property is stated but cannot be changed"
    end

    label_fixture(repository(), %{name: "bug", color: "d73a4a"})
    milestone = milestone_fixture(repository(), %{title: "v1.0", due_on: nil})

    rich = issue!(%{"title" => "Rich", "labels" => ["bug"], "assignees" => ["grace-show"]})
    {:ok, rich} = Issues.set_milestone(rich, milestone.number)

    {:ok, view, html} = live(conn, path(rich))

    assert html =~ "bug"
    refute html =~ "None yet"
    assert has_element?(view, ~s{[title="grace-show"]})
    assert has_element?(view, ~s{a[href="/OpenAgentsInc/openagents.com/milestones"]}, "v1.0")
  end

  test "the rail changes state, labels, assignees, and the milestone", %{conn: conn} do
    hopper = repository_user_fixture("hopper-show")
    {:ok, _membership} = Repositories.add_member(repository(), hopper, "contributor")
    label_fixture(repository(), %{name: "bug", color: "d73a4a"})
    milestone = milestone_fixture(repository(), %{title: "v2.0", due_on: nil})
    issue = issue!(%{"title" => "Editable"})

    {:ok, view, _html} = live(conn, path(issue))

    view
    |> element(~s{#issue-label-menu button}, "bug")
    |> render_click()

    assert Issues.get_issue!(repository(), issue.id).labels |> Enum.map(& &1["name"]) == ["bug"]

    view
    |> element(~s{#issue-assignee-menu button}, "hopper-show")
    |> render_click()

    assert Issues.get_issue!(repository(), issue.id).assignees |> Enum.map(& &1["login"]) == [
             "hopper-show"
           ]

    view
    |> element(~s{#issue-milestone-menu button}, "v2.0")
    |> render_click()

    assert Issues.get_issue!(repository(), issue.id).milestone["number"] == milestone.number

    # The rail can pick a close reason the header's two buttons cannot.
    view
    |> element(~s{#issue-state-menu button}, "Closed as not planned")
    |> render_click()

    closed = Issues.get_issue!(repository(), issue.id)
    assert closed.state == "closed"
    assert closed.state_reason == "not_planned"

    # Clicking the same option again unsets it, because a set is a toggle.
    view
    |> element(~s{#issue-label-menu button}, "bug")
    |> render_click()

    assert Issues.get_issue!(repository(), issue.id).labels == []
  end

  test "a member who loses write access cannot triage through an open page", %{conn: conn} do
    user = Accounts.get_user(Plug.Conn.get_session(conn, "user_id"))
    issue = issue!(%{"title" => "Authority changed"})
    repository = Repositories.get_by_path!("OpenAgentsInc", "openagents.com")

    {:ok, view, _html} = live(conn, path(issue))
    {:ok, _} = Repositories.add_member(repository, user, "viewer")

    html = render_click(view, "toggle_label", %{"name" => "bug"})

    assert html =~ "Only repository members can change issue labels"
    assert Issues.get_issue!(repository(), issue.id).labels == []
  end

  test "the history is one feed of comments and state changes", %{conn: conn} do
    issue = issue!(%{"title" => "Threaded"})
    {:ok, view, html} = live(conn, path(issue))

    assert html =~ "opened this issue"

    view
    |> form("#comment-form", comment: %{body: "First"})
    |> render_submit()

    html = view |> element(~s{button[phx-click="close"]}) |> render_click()

    assert html =~ "First"
    assert html =~ "closed this as completed"

    # The close has no actor. This schema records when an issue was closed but
    # not by whom, and a sentence with an invented subject would be worse than
    # one without a subject at all.
    refute html =~ "issue-show closed this"
  end

  test "a comment from the issue's own author is marked as such", %{conn: conn} do
    # GitHub derives the Author badge by comparing the commenter to the issue's
    # author rather than storing it, so this asserts the comparison, not a field.
    issue = issue!(%{"title" => "Self-answered", "user" => %{"login" => "ada"}})

    {:ok, _} =
      Issues.create_comment(%{
        "issue_id" => issue.id,
        "body" => "Answering my own question",
        "user" => %{"login" => "ada"}
      })

    {:ok, _} =
      Issues.create_comment(%{
        "issue_id" => issue.id,
        "body" => "So am I",
        "user" => %{"login" => "grace"}
      })

    {:ok, view, _html} = live(conn, path(issue))

    assert has_element?(view, ".timeline-comment__badge", "Author")

    assert length(
             LazyHTML.query(document(view), ".timeline-comment__badge")
             |> LazyHTML.to_tree()
           ) ==
             1
  end

  defp document(view), do: view |> render() |> LazyHTML.from_fragment()

  test "a missing issue number raises rather than rendering an empty page", %{conn: conn} do
    assert_raise Ecto.NoResultsError, fn ->
      live(conn, ~p"/OpenAgentsInc/openagents.com/issues/9999")
    end
  end

  test "closing then reopening an issue swaps the action buttons", %{conn: conn} do
    issue = issue!(%{"title" => "Toggle me"})
    {:ok, view, _html} = live(conn, path(issue))

    html = view |> element(~s{button[phx-click="close"]}) |> render_click()

    assert html =~ "Issue closed"
    assert has_element?(view, ~s{button[phx-click="reopen"]}, "Reopen issue")
    refute has_element?(view, ~s{button[phx-click="close"]})

    closed = Issues.get_issue!(repository(), issue.id)
    assert closed.state == "closed"
    assert closed.state_reason == "completed"
    assert closed.closed_at

    html = view |> element(~s{button[phx-click="reopen"]}) |> render_click()

    assert html =~ "Issue reopened"
    assert has_element?(view, ~s{button[phx-click="close"]}, "Close issue")

    reopened = Issues.get_issue!(repository(), issue.id)
    assert reopened.state == "open"
    assert reopened.closed_at == nil
  end

  test "the edit toggle swaps the header for the edit form and back", %{conn: conn} do
    issue = issue!(%{"title" => "Editable", "body" => "Before"})
    {:ok, view, _html} = live(conn, path(issue))

    refute has_element?(view, "#issue-edit-form")

    view |> element(~s{button[phx-click="toggle_edit"]}) |> render_click()
    assert has_element?(view, "#issue-edit-form")
    refute has_element?(view, ~s{button[phx-click="close"]})

    view |> element(~s{#issue-edit-form button[phx-click="toggle_edit"]}) |> render_click()
    refute has_element?(view, "#issue-edit-form")
    assert has_element?(view, ~s{button[phx-click="close"]})
  end

  test "saving the edit form updates the issue and leaves edit mode", %{conn: conn} do
    issue = issue!(%{"title" => "Editable", "body" => "Before"})
    {:ok, view, _html} = live(conn, path(issue))

    view |> element(~s{button[phx-click="toggle_edit"]}) |> render_click()

    html =
      view
      |> form("#issue-edit-form", issue: %{title: "Edited", body: "After"})
      |> render_submit()

    assert html =~ "Issue updated"
    assert html =~ "Edited"
    assert html =~ "After"
    refute has_element?(view, "#issue-edit-form")

    updated = Issues.get_issue!(repository(), issue.id)
    assert updated.title == "Edited"
    assert updated.body == "After"
  end

  test "clearing the title in the edit form keeps the form and shows the error", %{conn: conn} do
    issue = issue!(%{"title" => "Editable"})
    {:ok, view, _html} = live(conn, path(issue))

    view |> element(~s{button[phx-click="toggle_edit"]}) |> render_click()

    html =
      view
      |> form("#issue-edit-form", issue: %{title: "", body: "still here"})
      |> render_submit()

    assert html =~ "can&#39;t be blank"
    assert has_element?(view, "#issue-edit-form")
    assert Issues.get_issue!(repository(), issue.id).title == "Editable"
  end

  test "adding a comment appends it to the thread and bumps the count", %{conn: conn} do
    issue = issue!(%{"title" => "Discuss"})
    {:ok, view, html} = live(conn, path(issue))

    assert has_element?(view, "#comment-form")
    refute html =~ "Looks good to me"

    html =
      view
      |> form("#comment-form", comment: %{body: "Looks good to me"})
      |> render_submit()

    assert html =~ "Comment added"
    assert html =~ "Looks good to me"
    assert html =~ "anonymous"

    assert [comment] = Issues.list_comments(issue)
    assert comment.body == "Looks good to me"
    assert Issues.get_issue!(repository(), issue.id).comments == 1
  end

  test "an empty comment body is rejected and nothing is stored", %{conn: conn} do
    issue = issue!(%{"title" => "Discuss"})
    {:ok, view, _html} = live(conn, path(issue))

    html =
      view
      |> form("#comment-form", comment: %{body: ""})
      |> render_submit()

    assert html =~ "can&#39;t be blank"
    assert Issues.list_comments(issue) == []
    assert Issues.get_issue!(repository(), issue.id).comments == 0
  end

  test "existing comments render with their author on mount", %{conn: conn} do
    issue = issue!(%{"title" => "Discuss"})

    {:ok, _} =
      Issues.create_comment(%{
        issue_id: issue.id,
        body: "Earlier note",
        user: %{"login" => "ada"}
      })

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "Earlier note"
    assert html =~ "ada"
  end

  # Reading is public on a public repository; the page shows the conversation
  # and an invitation to sign in, and no control that writes.
  test "an anonymous visitor reads an issue page without write controls" do
    issue = issue!(%{"title" => "Public reading"})

    {:ok, view, html} = live(build_conn(), path(issue))

    assert html =~ "Public reading"
    assert has_element?(view, "#sign-in-to-comment")
    refute has_element?(view, "#comment-form")
    refute has_element?(view, ~s{button[phx-click="close"]})
    refute has_element?(view, ~s{button[phx-click="toggle_edit"]})
  end

  test "the timeline shows every agent attempt, started and finished", %{conn: conn} do
    issue = issue!(%{"title" => "Worked by an agent"})
    sha = String.duplicate("ef", 20)

    record_attempt(issue, "agent/one", -600, %{state: "failed", failure_reason: "timeout"})
    record_attempt(issue, "agent/two", -60, %{state: "completed", terminal_commit: sha})

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "started work on a computer, on branch agent/one"
    assert html =~ "stopped this work: timeout"
    assert html =~ "started work on a computer, on branch agent/two"
    assert html =~ "finished this work at #{String.slice(sha, 0, 7)}"
  end

  test "an issue nobody has worked renders its timeline unchanged", %{conn: conn} do
    issue = issue!(%{"title" => "Never worked"})

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "opened this issue"
    refute html =~ "started work on"
  end

  # The attempt record is `forge_assignments`. Writing one directly keeps this
  # test about what the page renders rather than about the admission path.
  defp record_attempt(issue, branch, offset_seconds, overrides) do
    user = repository_user_fixture("attempt-#{System.unique_integer([:positive])}")

    {:ok, %{code: code}} =
      OpenAgents.Machines.start_pairing(%{
        "name" => branch,
        "tier" => "curated",
        "platform" => "linux-x64",
        "agent_version" => "0.1.0",
        "roots" => []
      })

    {:ok, machine} = OpenAgents.Machines.approve_pairing(user, code)

    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
    admitted_at = DateTime.add(now, offset_seconds, :second)
    state = Map.get(overrides, :state, "running")

    %OpenAgents.Forge.Assignment{}
    |> OpenAgents.Forge.Assignment.changeset(
      Map.merge(
        %{
          target_kind: "computer",
          machine_id: machine.id,
          repository_id: repository().id,
          issue_id: issue.id,
          requesting_principal: %{"type" => "user", "id" => user.id},
          branch: branch,
          state: state,
          admitted_at: admitted_at,
          started_at: admitted_at,
          finished_at:
            if(state in OpenAgents.Forge.Assignment.terminal_states(), do: admitted_at),
          deadline_at: DateTime.add(admitted_at, 3600, :second)
        },
        overrides
      )
    )
    |> OpenAgents.Repo.insert!()
  end

  # #130: a close that arrived from a commit names the commit, and the entry
  # links back to it. The derived close, which can name neither the actor nor
  # the commit, steps aside where a commit did the closing.
  test "the timeline links back to the commit that closed the issue", %{conn: conn} do
    issue = issue!(%{"title" => "Closed by a push"})
    sha = "9606cbc0e0f1a2b3c4d5e6f708192a3b4c5d6e7f"
    actor = github_user("issue-show")

    assert [_reference] =
             ClosingReferences.apply_commit(
               repository(),
               actor,
               sha,
               "Ship it\n\nCloses ##{issue.number}"
             )

    {:ok, view, html} = live(conn, path(issue))

    assert html =~ "closed this as completed in"
    assert html =~ String.slice(sha, 0, 7)

    assert has_element?(
             view,
             ~s{a[href="/OpenAgentsInc/openagents.com/commit/#{sha}"]}
           )
  end

  # #12: an edit nobody made by hand is still an edit somebody can see, and
  # the history says the system made it rather than naming a person who only
  # closed a different issue.
  test "the timeline shows the automatic task-list edit as a system entry", %{conn: conn} do
    child = issue!(%{"title" => "Read the legacy schema"})

    parent =
      issue!(%{
        "title" => "Port the forum",
        "body" => "- [ ] ##{child.number} Read the legacy schema"
      })

    {:ok, _closed} =
      Issues.update_issue(child, %{"state" => "closed", "state_reason" => "completed"}, nil)

    {:ok, _view, html} = live(conn, path(parent))

    assert html =~ "checked the task for ##{child.number}"
    assert html =~ "system"
  end

  # ── #10: the issue page shows the evidence, not only the work ───────────
  #
  # `OUTCOME-001` says an accepted outcome "explains which receipt satisfied
  # each acceptance criterion, so the issue page can show the mapping". The
  # verdict was durable and graded before this, and nowhere on the page. These
  # cover the rail that renders it, the release link, and the ATIF trace line
  # — which says a trajectory exists and never shows one.

  test "an issue with no evidence has no evidence section at all", %{conn: conn} do
    issue = issue!(%{"title" => "Nothing has happened"})

    {:ok, view, _html} = live(conn, path(issue))

    refute has_element?(view, "#issue-evidence")
  end

  test "the work form says what bound the attempt, and why", %{conn: conn} do
    issue = issue!(%{"title" => "Unscoped", "body" => "Make it better."})

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "does not state its"
    assert html =~ "acceptance criteria"
    assert html =~ "no claim against it can be accepted"
  end

  test "a scoped issue says it buys the full hour", %{conn: conn} do
    issue =
      issue!(%{
        "title" => "Scoped",
        "body" => """
        ## Problem

        A bound that comes from the caller is not a bound.

        ## Scope

        One admission point.

        ## Acceptance criteria

        - The wall clock comes from the issue.

        ## Success metrics

        An unscoped issue cannot buy an hour.
        """
      })

    {:ok, _view, html} = live(conn, path(issue))

    assert html =~ "states every section an accepted outcome needs"
  end

  test "the evidence rail says a trajectory exists and never shows one", %{conn: conn} do
    issue = issue!(%{"title" => "Recorded"})
    attempt = record_attempt(issue, "agent/trace", -60, %{state: "completed"})

    owner = OpenAgents.Repo.get!(Accounts.User, attempt.requesting_principal["id"])

    {:ok, _trace, :created} =
      OpenAgents.Traces.store(
        owner,
        %{
          "schema_version" => "ATIF-v1.7",
          "steps" => [%{"step_id" => 1, "message" => "SECRET-TRAJECTORY-CONTENT"}]
        },
        assignment_id: attempt.id,
        visibility: "ledger"
      )

    {:ok, view, html} = live(conn, path(issue))

    assert has_element?(view, "#issue-evidence")
    assert html =~ "An agent trajectory of 1 step was recorded"
    assert html =~ "contents are not published here"

    refute html =~ "SECRET-TRAJECTORY-CONTENT"
  end

  test "a trace the uploader did not consent to publishing stays off the page", %{conn: conn} do
    issue = issue!(%{"title" => "Withheld"})
    attempt = record_attempt(issue, "agent/dark", -60, %{state: "completed"})
    owner = OpenAgents.Repo.get!(Accounts.User, attempt.requesting_principal["id"])

    {:ok, _trace, :created} =
      OpenAgents.Traces.store(
        owner,
        %{"schema_version" => "ATIF-v1.7", "steps" => []},
        assignment_id: attempt.id
      )

    {:ok, view, _html} = live(conn, path(issue))

    refute has_element?(view, "#issue-evidence")
  end

  defp repository do
    OpenAgents.Repositories.get_by_path!("OpenAgentsInc", "openagents.com")
  end
end