12.9 KiB · text 5af55d9
defmodule GitGud.PullRequestsFederatedTest do
@moduledoc """
End-to-end test of the federated-PR inbound path: a remote actor
submits an Offer-wrapped Ticket; we fetch the source branch from a
local "remote" bare repo (its disk path stands in as the git URL) and
create the PR with `source_actor_id` set.
"""
use GitGud.DataCase, async: false
alias GitGud.Federation
alias GitGud.Federation.Actor
alias GitGud.Federation.InboxDelivery
alias GitGud.Federation.Workers.ProcessInbox
alias GitGud.Git
alias GitGud.PullRequests
alias GitGud.PullRequests.PullRequest
alias GitGud.Repo
import GitGud.ForgeFixtures
@moduletag :git_required
setup do
if System.find_executable("git") == nil do
{:skip, "git not on PATH"}
else
{_user, target_repo} = repository_fixture()
{_other, source_repo} = repository_fixture(%{name: "remote-src"})
# Build commits in the "remote" first so its object database has
# everything reachable from the feature branch. Then mirror main
# into the target repo via a fetch, so the merge_base computation
# finds a common ancestor.
main_sha = commit_file(source_repo.disk_path, "README.md", "main\n", "initial", nil)
:ok = Git.update_ref(source_repo.disk_path, "refs/heads/main", main_sha, nil)
feature_sha =
commit_file(source_repo.disk_path, "feature.txt", "from remote\n", "remote work", main_sha)
:ok = Git.update_ref(source_repo.disk_path, "refs/heads/feature", feature_sha, nil)
{_, 0} =
System.cmd(
"git",
[
"-C",
target_repo.disk_path,
"fetch",
"--no-tags",
source_repo.disk_path,
"+refs/heads/main:refs/heads/main"
],
stderr_to_stdout: true
)
{:ok, _} = Federation.ensure_local_actor(target_repo)
remote_actor = insert_remote_actor("https://remote.example/@alice")
# ProcessInbox's RemoteActors.fetch consults the allowlist when the
# instance is in default `:allowlist` mode. Add the host so the
# second test path (full inbox dispatch) doesn't drop the delivery.
{:ok, _} = Federation.allowlist_instance("remote.example")
{:ok,
target_repo: target_repo,
remote_clone_url: source_repo.disk_path,
remote_actor: remote_actor,
feature_sha: feature_sha}
end
end
test "create_federated_pull_request builds a PR linked to the remote actor", %{
target_repo: target_repo,
remote_clone_url: clone_url,
remote_actor: remote_actor,
feature_sha: feature_sha
} do
params = %{
title: "Add feature.txt",
body: "Please merge this",
source_ref: "feature",
target_ref: "main",
head_hex: Git.to_hex(feature_sha),
source_clone_url: clone_url,
activity_url: "https://remote.example/activities/offer/abc123"
}
assert {:ok, pr} =
PullRequests.create_federated_pull_request(target_repo, remote_actor, params)
assert pr.source_actor_id == remote_actor.id
assert pr.activity_url == params.activity_url
assert pr.source_clone_url == clone_url
assert is_nil(pr.author_id)
assert is_nil(pr.source_repository_id)
assert pr.head_sha == feature_sha
assert PullRequest.federated?(pr)
# The head was pinned into refs/pull/<N>/head on the target.
{:ok, pinned} = Git.resolve(target_repo.disk_path, "refs/pull/#{pr.number}/head")
assert pinned == feature_sha
end
test "Offer through ProcessInbox creates the PR; re-delivery is idempotent", %{
target_repo: target_repo,
remote_clone_url: clone_url,
remote_actor: remote_actor,
feature_sha: feature_sha
} do
target_actor = Repo.get_by!(Actor, local_repository_id: target_repo.id)
doc =
offer_body(
target_actor.actor_url,
clone_url,
Git.to_hex(feature_sha),
remote_actor.actor_url,
"https://remote.example/activities/offer/once"
)
{:ok, delivery1} = insert_delivery(doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery1.id}})
[pr] = Repo.all(PullRequest)
assert pr.source_actor_id == remote_actor.id
assert pr.activity_url == doc["id"]
{:ok, delivery2} = insert_delivery(doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery2.id}})
assert length(Repo.all(PullRequest)) == 1
end
test "Update of a federated PR refreshes head_sha + base_sha", %{
target_repo: target_repo,
remote_clone_url: clone_url,
remote_actor: remote_actor,
feature_sha: feature_sha
} do
{:ok, pr} =
PullRequests.create_federated_pull_request(target_repo, remote_actor, %{
title: "Initial",
body: "",
source_ref: "feature",
target_ref: "main",
head_hex: Git.to_hex(feature_sha),
source_clone_url: clone_url,
activity_url: "https://remote.example/activities/offer/update-me"
})
# Add another commit on the remote source branch.
new_head = commit_file(clone_url, "feature.txt", "edited\n", "second", feature_sha)
:ok = Git.update_ref(clone_url, "refs/heads/feature", new_head, feature_sha)
target_actor = Repo.get_by!(Actor, local_repository_id: target_repo.id)
update_doc = %{
"@context" => ["https://www.w3.org/ns/activitystreams", "https://forgefed.org/ns"],
"id" => "https://remote.example/activities/update/1",
"type" => "Update",
"actor" => remote_actor.actor_url,
"object" => %{
"type" => "Ticket",
"id" => "https://remote.example/activities/offer/update-me",
"isMerge" => true,
"sourceBranch" => "feature",
"targetBranch" => "main",
"head" => Git.to_hex(new_head),
"source" => %{"cloneUri" => clone_url}
}
}
{:ok, delivery} = insert_delivery(update_doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery.id}})
refreshed = Repo.get!(PullRequest, pr.id)
assert refreshed.head_sha == new_head
refute refreshed.head_sha == feature_sha
assert refreshed.mergeable == nil
assert is_binary(refreshed.base_sha)
end
test "Reject of a federated PR closes it", %{
target_repo: target_repo,
remote_clone_url: clone_url,
remote_actor: remote_actor,
feature_sha: feature_sha
} do
{:ok, pr} =
PullRequests.create_federated_pull_request(target_repo, remote_actor, %{
title: "Doomed",
body: "",
source_ref: "feature",
target_ref: "main",
head_hex: Git.to_hex(feature_sha),
source_clone_url: clone_url,
activity_url: "https://remote.example/activities/offer/to-reject"
})
target_actor = Repo.get_by!(Actor, local_repository_id: target_repo.id)
reject_doc = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.example/activities/reject/1",
"type" => "Reject",
"actor" => remote_actor.actor_url,
"object" => pr.activity_url
}
{:ok, delivery} = insert_delivery(reject_doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery.id}})
assert Repo.get!(PullRequest, pr.id).state == "closed"
end
test "Create(Note) inReplyTo a federated PR creates a federated comment", %{
target_repo: target_repo,
remote_clone_url: clone_url,
remote_actor: remote_actor,
feature_sha: feature_sha
} do
{:ok, pr} =
PullRequests.create_federated_pull_request(target_repo, remote_actor, %{
title: "Discuss",
body: "",
source_ref: "feature",
target_ref: "main",
head_hex: Git.to_hex(feature_sha),
source_clone_url: clone_url,
activity_url: "https://remote.example/activities/offer/discuss"
})
target_actor = Repo.get_by!(Actor, local_repository_id: target_repo.id)
parent_url = target_actor.actor_url <> "/pull_requests/#{pr.number}"
note_doc = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.example/activities/create/note-1",
"type" => "Create",
"actor" => remote_actor.actor_url,
"object" => %{
"type" => "Note",
"id" => "https://remote.example/notes/1",
"attributedTo" => remote_actor.actor_url,
"inReplyTo" => parent_url,
"content" => "looks good!"
}
}
{:ok, delivery} = insert_delivery(note_doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery.id}})
[comment] = Repo.all(GitGud.PullRequests.PrComment)
assert comment.source_actor_id == remote_actor.id
assert comment.body == "looks good!"
assert is_nil(comment.author_id)
end
test "Offer of a non-merge Ticket is ignored", %{
target_repo: target_repo,
remote_actor: remote_actor
} do
target_actor = Repo.get_by!(Actor, local_repository_id: target_repo.id)
doc = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => "https://remote.example/activities/offer/issue-shaped",
"type" => "Offer",
"actor" => remote_actor.actor_url,
"target" => target_actor.actor_url,
"object" => %{
"type" => "Ticket",
"name" => "An issue, not a PR",
"content" => "no merge intent"
}
}
{:ok, delivery} = insert_delivery(doc, target_actor.id, remote_actor.actor_url)
:ok = ProcessInbox.perform(%Oban.Job{args: %{"delivery_id" => delivery.id}})
assert Repo.all(PullRequest) == []
end
# ── helpers ──────────────────────────────────────────────────────────
defp insert_remote_actor(url) do
{:ok, actor} =
%Actor{kind: "remote"}
|> Ecto.Changeset.cast(
%{
actor_type: "Person",
actor_url: url,
inbox_url: url <> "/inbox",
outbox_url: url <> "/outbox",
preferred_username: "alice",
name: "alice",
public_key_pem:
"-----BEGIN PUBLIC KEY-----\nMIIB-stub-not-used-in-this-test\n-----END PUBLIC KEY-----\n",
public_key_id: url <> "#main-key",
remote_doc: %{"endpoints" => %{}},
last_fetched_at: DateTime.utc_now(:second)
},
[
:actor_type,
:actor_url,
:inbox_url,
:outbox_url,
:preferred_username,
:name,
:public_key_pem,
:public_key_id,
:remote_doc,
:last_fetched_at
]
)
|> Repo.insert()
actor
end
defp insert_delivery(doc, target_actor_id, source_actor_url) do
body = Jason.encode!(doc)
%InboxDelivery{}
|> InboxDelivery.changeset(%{
target_actor_id: target_actor_id,
source_actor_url: source_actor_url,
raw_body: body,
status: "accepted"
})
|> Repo.insert()
end
defp offer_body(target_actor_url, clone_url, head_hex, source_actor_url, activity_id) do
%{
"@context" => ["https://www.w3.org/ns/activitystreams", "https://forgefed.org/ns"],
"id" => activity_id,
"type" => "Offer",
"actor" => source_actor_url,
"target" => target_actor_url,
"object" => %{
"type" => "Ticket",
"name" => "From remote",
"content" => "",
"isMerge" => true,
"sourceBranch" => "feature",
"targetBranch" => "main",
"head" => head_hex,
"source" => %{"cloneUri" => clone_url}
}
}
end
defp commit_file(path, name, contents, message, parent) do
blob_tmp = Path.join(System.tmp_dir!(), "pr-fed-blob-#{System.unique_integer([:positive])}")
File.write!(blob_tmp, contents)
{hex, 0} = System.cmd("git", ["-C", path, "hash-object", "-w", blob_tmp])
File.rm(blob_tmp)
{:ok, blob_sha} = Git.from_hex(String.trim(hex))
index = Path.join(System.tmp_dir!(), "pr-fed-index-#{System.unique_integer([:positive])}")
env = [{"GIT_INDEX_FILE", index}]
try do
if parent do
{:ok, parent_commit} = Git.commit(path, parent)
{_, 0} =
System.cmd("git", ["-C", path, "read-tree", Git.to_hex(parent_commit.tree_sha)],
env: env
)
end
{_, 0} =
System.cmd(
"git",
[
"-C",
path,
"update-index",
"--add",
"--cacheinfo",
"100644," <> Git.to_hex(blob_sha) <> "," <> name
],
env: env
)
{tree_hex, 0} = System.cmd("git", ["-C", path, "write-tree"], env: env)
{:ok, tree_sha} = Git.from_hex(String.trim(tree_hex))
parents = if parent, do: [parent], else: []
{:ok, commit_sha} =
Git.commit_tree(path, tree_sha, parents, message,
author: {"tester", "tester@example", DateTime.utc_now(:second)}
)
commit_sha
after
_ = File.rm(index)
end
end
end