defmodule OpenAgentsWeb.PullRequestLiveTest do
use OpenAgentsWeb.ConnCase
import Ecto.Query
import Phoenix.LiveViewTest
import OpenAgents.AccountsFixtures
import OpenAgents.IssuesFixtures
alias OpenAgents.Forge.Repos
alias OpenAgents.PullRequests.PullRequest
alias OpenAgents.Repo
alias OpenAgents.Stacks
alias OpenAgents.Stacks.Stack
alias OpenAgents.Stacks.StackEntry
test "the pull request list links to a valid browser detail page", %{conn: conn} do
target = repository_fixture()
source = repository_fixture()
issue = issue_fixture(target, %{title: "Add pull requests"})
%PullRequest{}
|> PullRequest.changeset(%{
repository_id: target.id,
issue_id: issue.id,
head_repository_id: source.id,
head_ref: "feature",
head_sha: String.duplicate("a", 40),
base_ref: "main",
base_sha: String.duplicate("b", 40)
})
|> Repo.insert!()
{:ok, index, _html} = live(conn, "/#{target.owner}/#{target.name}/pulls")
assert has_element?(index, "#pull-request-index")
assert has_element?(
index,
"a[href='/#{target.owner}/#{target.name}/pulls/#{issue.number}']"
)
{:ok, show, _html} =
live(conn, "/#{target.owner}/#{target.name}/pulls/#{issue.number}")
assert has_element?(show, "#pull-request-show")
end
describe "telling pull requests apart from issues (#120)" do
setup do
target = repository_fixture()
source = repository_fixture()
plain = issue_fixture(target, %{title: "A plain issue"})
proposal = issue_fixture(target, %{title: "A proposed change"})
pull_request =
%PullRequest{}
|> PullRequest.changeset(%{
repository_id: target.id,
issue_id: proposal.id,
head_repository_id: source.id,
head_ref: "feature",
head_sha: String.duplicate("a", 40),
base_ref: "main",
base_sha: String.duplicate("b", 40),
draft: false
})
|> Repo.insert!()
%{target: target, plain: plain, proposal: proposal, pull_request: pull_request}
end
test "the issue list no longer mixes pull requests in", %{
conn: conn,
target: target,
plain: plain,
proposal: proposal
} do
{:ok, index, _html} = live(conn, "/#{target.owner}/#{target.name}/issues")
assert has_element?(
index,
"a[href='/#{target.owner}/#{target.name}/issues/#{plain.number}']"
)
refute has_element?(
index,
"a[href='/#{target.owner}/#{target.name}/issues/#{proposal.number}']"
)
end
test "the pull request list draws the pull-request glyph, not an issue circle", %{
conn: conn,
target: target
} do
{:ok, index, _html} = live(conn, "/#{target.owner}/#{target.name}/pulls")
assert has_element?(index, ".issue-status [data-icon='pull-request-open']")
refute has_element?(index, ".issue-status [data-icon='octicon-issue-opened']")
end
test "the repository nav counts issues and pull requests apart", %{
conn: conn,
target: target
} do
{:ok, index, _html} = live(conn, "/#{target.owner}/#{target.name}/pulls")
issues_count =
index
|> element("a[href='/#{target.owner}/#{target.name}/issues'] .repo-tabs__count")
|> render()
pulls_count =
index
|> element("a[href='/#{target.owner}/#{target.name}/pulls'] .repo-tabs__count")
|> render()
assert issues_count =~ ">1<"
assert pulls_count =~ ">1<"
end
test "the pull request page states its own state as a glyph", %{
conn: conn,
target: target,
proposal: proposal
} do
{:ok, show, _html} = live(conn, "/#{target.owner}/#{target.name}/pulls/#{proposal.number}")
assert has_element?(show, "#pull-request-glyph[data-category='open']")
assert has_element?(show, "#pull-request-state")
end
test "an issue page reached for a pull-request number says so and links to it", %{
conn: conn,
target: target,
proposal: proposal
} do
{:ok, show, _html} = live(conn, "/#{target.owner}/#{target.name}/issues/#{proposal.number}")
assert has_element?(show, "#issue-pull-request-state[data-category='open']")
assert has_element?(
show,
"#issue-pull-request-link[href='/#{target.owner}/#{target.name}/pulls/#{proposal.number}']"
)
end
test "a plain issue page keeps the issue glyph and offers no pull request", %{
conn: conn,
target: target,
plain: plain
} do
{:ok, show, _html} = live(conn, "/#{target.owner}/#{target.name}/issues/#{plain.number}")
refute has_element?(show, "#issue-pull-request-state")
refute has_element?(show, "#issue-pull-request-link")
assert has_element?(show, ".issue-heading__meta [data-icon='octicon-issue-opened']")
end
end
describe "stacked pull request review" do
setup do
base =
Path.join(
System.tmp_dir!(),
"pull-request-show-#{System.unique_integer([:positive])}"
)
previous_data = Application.get_env(:openagents, :forge_data_dir)
previous_wal = Application.get_env(:openagents, :forge_wal_dir)
Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
on_exit(fn ->
restore_env(:forge_data_dir, previous_data)
restore_env(:forge_wal_dir, previous_wal)
File.rm_rf(base)
end)
repository = repository_fixture()
oids = seed_chain(repository, ["layer-1", "layer-2"])
pull_requests = pull_request_chain(repository, oids, ["layer-1", "layer-2"])
actor = repository_user_fixture("stack-reviewer")
{:ok, _stack} = Stacks.create(repository, pull_requests, actor)
%{repository: repository, oids: oids, pull_requests: pull_requests}
end
test "the layer diff runs from the boundary OID to the observed head OID", %{
conn: conn,
repository: repository,
oids: oids,
pull_requests: pull_requests
} do
top = Enum.at(pull_requests, 1)
{:ok, show, html} = live(conn, pull_path(repository, top))
assert has_element?(show, "#stack-review")
assert element(show, "#stack-layer-range") |> render() =~
"#{short(oids["layer-1"])} → #{short(oids["layer-2"])}"
assert html =~ "layer-2.md"
refute html =~ "layer-1.md"
refute has_element?(show, "#stack-stale-boundary")
end
test "the cumulative preview runs from the trunk tip to the observed head OID", %{
conn: conn,
repository: repository,
oids: oids,
pull_requests: pull_requests
} do
top = Enum.at(pull_requests, 1)
{:ok, show, html} = live(conn, pull_path(repository, top) <> "?view=cumulative")
assert element(show, "#stack-cumulative-range") |> render() =~
"Everything through position 2"
assert element(show, "#stack-cumulative-range") |> render() =~
"#{short(oids["main"])} → #{short(oids["layer-2"])}"
assert html =~ "layer-1.md"
assert html =~ "layer-2.md"
end
test "a parent rewrite shows the stale boundary state instead of lower layers", %{
conn: conn,
repository: repository,
oids: oids,
pull_requests: pull_requests
} do
path = Repos.bare_path(repository.storage_key)
rewritten =
commit(path, oids["main"], "Rewritten layer", ["README.md", "rewritten.md"])
{_, 0} = Repos.git(path, ["update-ref", "refs/heads/layer-1", rewritten])
top = Enum.at(pull_requests, 1)
{:ok, show, html} = live(conn, pull_path(repository, top))
assert has_element?(show, "#stack-stale-boundary")
assert render(show) =~ "based on an outdated parent commit"
refute html =~ "layer-1.md"
refute html =~ "layer-2.md"
end
test "a reader sees the readiness summary and trunk link but no actions", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
top = Enum.at(pull_requests, 1)
{:ok, show, _html} = live(conn, pull_path(repository, top))
assert element(show, "#stack-readiness") |> render() =~
"0 of 2 layers ready · 2 still in draft"
assert has_element?(
show,
"#stack-map a[href='/#{repository.owner}/#{repository.name}/tree/main']"
)
refute has_element?(show, "#stack-rebase")
refute has_element?(show, "#stack-unstack")
end
test "a writer starts a stack rebase from the page", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
conn = log_in_repository_user(conn, "stack-writer", repository)
top = Enum.at(pull_requests, 1)
{:ok, show, _html} = live(conn, pull_path(repository, top))
show |> element("#stack-rebase") |> render_click()
assert element(show, "#stack-operation-status") |> render() =~ "Stack rebase queued"
assert Repo.exists?(
from operation in OpenAgents.Stacks.Operation,
where: operation.kind == "rebase" and operation.state == "pending"
)
show |> element("#stack-rebase") |> render_click()
assert element(show, "#stack-operation-status") |> render() =~ "Stack rebase queued"
show |> element("#stack-operation-refresh") |> render_click()
assert has_element?(show, "#stack-operation-status")
end
test "a writer removes the top layer from the stack", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
conn = log_in_repository_user(conn, "stack-writer", repository)
[bottom, top] = pull_requests
{:ok, bottom_show, _html} = live(conn, pull_path(repository, bottom))
refute has_element?(bottom_show, "#stack-unstack")
assert has_element?(bottom_show, "#stack-rebase")
{:ok, show, _html} = live(conn, pull_path(repository, top))
assert has_element?(show, "#stack-unstack")
show |> element("#stack-unstack") |> render_click()
assert render(show) =~ "The pull request left the stack."
refute has_element?(show, "#stack-review")
end
test "a merged stack member keeps its stack map and shows the merged state", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
merge_stack!(pull_requests)
[bottom, top] = pull_requests
{:ok, show, _html} = live(conn, pull_path(repository, bottom))
assert element(show, "#pull-request-state") |> render() =~ "merged"
refute has_element?(show, "#stack-review")
assert element(show, "#stack-history") |> render() =~ "layer 1 of 2"
assert element(show, "#stack-history-note") |> render() =~ "merged into main"
layers = element(show, "#stack-history-map") |> render()
assert layers =~ ~s(data-state="merged")
refute layers =~ ~s(data-state="open")
assert has_element?(
show,
"#stack-history-map a[href='#{pull_path(repository, top)}']"
)
refute has_element?(show, "#stack-rebase")
refute has_element?(show, "#stack-unstack")
end
test "a pull request closed without merging shows closed and no stack history", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
top = Enum.at(pull_requests, 1)
{1, _rows} =
Repo.update_all(
from(entry in StackEntry, where: entry.pull_request_id == ^top.id),
set: [removed_at: DateTime.utc_now()]
)
{1, _rows} =
Repo.update_all(
from(pr in PullRequest, where: pr.id == ^top.id),
set: [state: "closed"]
)
{:ok, show, _html} = live(conn, pull_path(repository, top))
assert element(show, "#pull-request-state") |> render() =~ "closed"
refute has_element?(show, "#stack-review")
refute has_element?(show, "#stack-history")
end
test "the pull request list shows the merged state", %{
conn: conn,
repository: repository,
pull_requests: pull_requests
} do
merge_stack!(pull_requests)
{:ok, index, _html} = live(conn, "/#{repository.owner}/#{repository.name}/pulls")
assert has_element?(index, "span[data-variant='done']", "merged")
end
end
# Marks every layer merged the way `Merge.execute/2` records it: the entry
# removal and the pull request merge share one timestamp, and the stack
# completes.
defp merge_stack!(pull_requests) do
now = DateTime.utc_now()
ids = Enum.map(pull_requests, & &1.id)
{_count, _rows} =
Repo.update_all(
from(entry in StackEntry, where: entry.pull_request_id in ^ids),
set: [removed_at: now]
)
{_count, _rows} =
Repo.update_all(
from(pr in PullRequest, where: pr.id in ^ids),
set: [state: "closed", merged_at: now]
)
{_count, _rows} = Repo.update_all(Stack, set: [state: "completed"])
:ok
end
defp pull_path(repository, pull_request) do
"/#{repository.owner}/#{repository.name}/pulls/#{pull_request.issue.number}"
end
defp short(oid), do: String.slice(oid, 0, 12)
defp seed_chain(repository, branches) do
path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
main = commit(path, nil, "Seed repository", ["README.md"])
{_, 0} = Repos.git(path, ["update-ref", "refs/heads/main", main])
{oids, _files} =
Enum.reduce(branches, {%{"main" => main}, ["README.md"]}, fn branch, {oids, files} ->
parent = Map.fetch!(oids, previous_branch(branches, branch))
files = files ++ ["#{branch}.md"]
oid = commit(path, parent, "Layer #{branch}", files)
{_, 0} = Repos.git(path, ["update-ref", "refs/heads/#{branch}", oid])
{Map.put(oids, branch, oid), files}
end)
oids
end
defp previous_branch(branches, branch) do
index = Enum.find_index(branches, &(&1 == branch))
Enum.at(["main" | branches], index)
end
defp commit(path, parent, message, files) do
tree_input =
Enum.map_join(files, fn file ->
blob = git!(path, ["hash-object", "-w", "--stdin"], "#{file}\n")
"100644 blob #{blob}\t#{file}\n"
end)
tree = git!(path, ["mktree"], tree_input)
parent_args = if parent, do: ["-p", parent], else: []
git!(path, ["commit-tree", tree] ++ parent_args ++ ["-m", message], "",
env: [
{"GIT_AUTHOR_NAME", "Test Author"},
{"GIT_AUTHOR_EMAIL", "author@example.test"},
{"GIT_COMMITTER_NAME", "Test Author"},
{"GIT_COMMITTER_EMAIL", "author@example.test"}
]
)
end
defp pull_request_chain(repository, oids, branches) do
branches
|> Enum.with_index()
|> Enum.map(fn {branch, index} ->
base = Enum.at(["main" | branches], index)
pull_request(repository, branch, base, oids[base], oids[branch])
end)
end
defp pull_request(repository, head_ref, base_ref, base_sha, head_sha) do
issue = issue_fixture(repository, %{title: "PR #{head_ref}"})
{:ok, pull_request} =
%PullRequest{}
|> PullRequest.changeset(%{
repository_id: repository.id,
issue_id: issue.id,
head_repository_id: repository.id,
head_ref: head_ref,
head_sha: head_sha,
base_ref: base_ref,
base_sha: base_sha,
state: "open"
})
|> Repo.insert()
Repo.preload(pull_request, :issue)
end
defp git!(git_dir, args, input, options \\ []) do
input_path =
Path.join(
System.tmp_dir!(),
"pull-request-show-input-#{System.unique_integer([:positive])}"
)
File.write!(input_path, input)
try do
{output, 0} =
System.cmd(
"sh",
["-c", ~s(exec git --git-dir "$GIT_DIR" "$@" < "$INPUT"), "sh"] ++ args,
env: [{"GIT_DIR", git_dir}, {"INPUT", input_path}] ++ Keyword.get(options, :env, [])
)
String.trim(output)
after
File.rm(input_path)
end
end
defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
end