7.6 KiB · text 5af55d9
defmodule GitGud.Federation.Publishing do
@moduledoc """
Records a local Activity and fans it out to every accepted follower
of the publishing actor, collapsing to `sharedInbox` URLs when peers
advertise them.
Deduplicates so we send at most one POST per remote inbox endpoint
even when many followers live on the same instance.
"""
import Ecto.Query, warn: false
alias GitGud.Federation
alias GitGud.Federation.Activity
alias GitGud.Federation.Actor
alias GitGud.Federation.Follow
alias GitGud.Federation.Workers.DeliverOutbound
alias GitGud.Repo
@doc """
Persist `activity_doc` as belonging to `sending_actor` and enqueue
outbound deliveries.
`extra_recipients` is a list of additional `%Actor{}` records that
should receive the activity even if they don't follow — e.g. the
author of an issue receives comments on it.
"""
def publish(%Actor{} = sending_actor, %{"type" => type} = activity_doc, extra_recipients \\ []) do
activity_url = Map.fetch!(activity_doc, "id")
case persist(sending_actor, type, activity_url, activity_doc) do
{:ok, activity} ->
inbox_urls =
(follower_inboxes(sending_actor) ++ inboxes_of(extra_recipients))
|> Enum.uniq()
for inbox_url <- inbox_urls do
{:ok, _job} = DeliverOutbound.new_job(activity.id, inbox_url) |> Oban.insert()
end
{:ok, activity, inbox_urls}
{:error, _} = err ->
err
end
end
defp persist(actor, type, url, doc) do
%Activity{}
|> Activity.changeset(%{
activity_url: url,
type: type,
actor_id: actor.id,
raw: doc,
is_local: true,
published_at: DateTime.utc_now(:second)
})
|> Repo.insert(
on_conflict: {:replace, [:raw, :type]},
conflict_target: [:activity_url],
returning: true
)
end
@doc """
Inbox URLs for `actor`'s accepted followers. Collapses to
`sharedInbox` when the follower's home instance advertised one.
"""
def follower_inboxes(%Actor{id: aid}) do
Repo.all(
from f in Follow,
join: a in Actor,
on: a.id == f.follower_actor_id,
where: f.target_actor_id == ^aid and not is_nil(f.accepted_at),
select: %{inbox: a.inbox_url, shared: a.shared_inbox_url}
)
|> Enum.map(fn r -> r.shared || r.inbox end)
|> Enum.reject(&is_nil/1)
end
defp inboxes_of(actors) do
actors
|> Enum.flat_map(fn
%Actor{shared_inbox_url: u} when is_binary(u) -> [u]
%Actor{inbox_url: u} when is_binary(u) -> [u]
_ -> []
end)
end
# ── high-level convenience wrappers ──────────────────────────────────
alias GitGud.Federation.ActivityBuilders
alias GitGud.Issues.Issue
alias GitGud.Organizations.Organization
alias GitGud.PullRequests.PullRequest
alias GitGud.Repositories.Repository
def publish_org_repo_created(%Organization{} = org, %Repository{} = repo) do
with {:ok, org_actor} <- Federation.ensure_local_actor(org),
{:ok, repo_actor} <- Federation.ensure_local_actor(repo) do
doc = ActivityBuilders.create_repository(org_actor, repo_actor)
publish(org_actor, doc)
end
end
def publish_org_member_added(%Organization{} = org, %GitGud.Accounts.User{} = user, role) do
with {:ok, org_actor} <- Federation.ensure_local_actor(org),
{:ok, member_actor} <- Federation.ensure_local_actor(user) do
doc = ActivityBuilders.org_add_member(org_actor, member_actor, role)
publish(org_actor, doc)
end
end
def publish_push(%Repository{} = repo, params) do
{:ok, repo_actor} = Federation.ensure_local_actor(repo)
doc = ActivityBuilders.push(repo_actor, params)
publish(repo_actor, doc)
end
def publish_issue_opened(%Repository{} = repo, %Issue{} = issue, author) do
with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
{:ok, author_actor} <- maybe_actor_for(author) do
doc = ActivityBuilders.create_ticket(author_actor, repo_actor, "issue", issue)
_ = author_actor
publish(repo_actor, doc)
end
end
def publish_issue_comment(%Repository{} = repo, %Issue{} = issue, comment, author) do
with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
{:ok, author_actor} <- maybe_actor_for(author) do
parent_url = repo_actor.actor_url <> "/issues/" <> Integer.to_string(issue.number)
doc =
ActivityBuilders.create_comment(author_actor, repo_actor, parent_url, comment, "issue")
_ = author_actor
publish(repo_actor, doc)
end
end
def publish_pr_opened(%Repository{} = repo, %PullRequest{} = pr, author) do
with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
{:ok, author_actor} <- maybe_actor_for(author) do
doc = ActivityBuilders.create_pull_request(author_actor, repo_actor, pr)
_ = author_actor
publish(repo_actor, doc)
end
end
def publish_pr_comment(%Repository{} = repo, %PullRequest{} = pr, comment, author) do
with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
{:ok, author_actor} <- maybe_actor_for(author) do
parent_url = repo_actor.actor_url <> "/pull_requests/" <> Integer.to_string(pr.number)
doc =
ActivityBuilders.create_comment(
author_actor,
repo_actor,
parent_url,
comment,
"pull_request"
)
_ = author_actor
publish(repo_actor, doc)
end
end
def publish_pr_merged(%Repository{} = repo, %PullRequest{} = pr) do
{:ok, repo_actor} = Federation.ensure_local_actor(repo)
doc = ActivityBuilders.pr_merged(repo_actor, pr)
publish(repo_actor, doc)
end
@doc """
Publish a PR review to the originating remote actor. Only meaningful
for federated PRs (`pr.source_actor_id != nil`) — local PRs get the
review through the local DB. Maps:
approved → Like
changes_requested → Dislike
commented → no AP emit (no standard verb)
"""
def publish_pr_review(%PullRequest{source_actor_id: sid} = pr, reviewer, review)
when is_integer(sid) and review.state in ["approved", "changes_requested"] do
with {:ok, reviewer_actor} <- Federation.ensure_local_actor(reviewer) do
source_actor = GitGud.Repo.get(GitGud.Federation.Actor, sid)
object_url = pr.activity_url
verb =
case review.state do
"approved" -> "Like"
"changes_requested" -> "Dislike"
end
doc =
ActivityBuilders.pr_review_verdict(
reviewer_actor,
verb,
object_url,
review.body
)
activity_attrs = %{
activity_url: doc["id"],
type: verb,
actor_id: reviewer_actor.id,
target_actor_id: source_actor && source_actor.id,
raw: doc,
is_local: true,
published_at: DateTime.utc_now(:second)
}
with {:ok, activity} <-
%GitGud.Federation.Activity{}
|> GitGud.Federation.Activity.changeset(activity_attrs)
|> GitGud.Repo.insert() do
inbox = source_actor && (source_actor.shared_inbox_url || source_actor.inbox_url)
if is_binary(inbox) and inbox != "" do
{:ok, _job} =
GitGud.Federation.Workers.DeliverOutbound.new_job(activity.id, inbox)
|> Oban.insert()
end
{:ok, activity}
end
end
end
def publish_pr_review(_pr, _reviewer, _review), do: :ok
defp maybe_actor_for(nil), do: {:error, :no_author}
defp maybe_actor_for(%GitGud.Accounts.User{} = user), do: Federation.ensure_local_actor(user)
end