12.4 KiB · text 5af55d9
defmodule GitGud.Federation.ActivityBuilders do
@moduledoc """
Builds ActivityStreams/ForgeFed JSON-LD documents for the events the
forge emits.
Conventions:
* Repo-scoped events (push, ticket open/close) use the repository
actor as `actor`. Stops us leaking which *user* triggered the
event for repos owned by orgs.
* Personal events (comments on issues, reviews) use the human
actor as `actor` and reference the repo as `context`.
* Audience is the repository actor's followers; we also @-mention
via `to`/`cc` so individual subscribers' inboxes get delivery
even when followership is at the repo level.
"""
alias GitGud.Federation.Actor
alias GitGud.Git
@as_context "https://www.w3.org/ns/activitystreams"
@forgefed_context "https://forgefed.org/ns"
@public "https://www.w3.org/ns/activitystreams#Public"
# ── id minting ───────────────────────────────────────────────────────
@doc "Mint a stable activity URL under an actor."
def activity_url(%Actor{actor_url: u}, type) do
u <> "/activities/" <> String.downcase(type) <> "/" <> rand()
end
defp rand, do: :crypto.strong_rand_bytes(12) |> Base.url_encode64(padding: false)
defp object_url(%Actor{actor_url: u}, kind, id) do
u <> "/" <> kind <> "/" <> Integer.to_string(id)
end
defp followers_collection(%Actor{followers_url: u}) when is_binary(u), do: u
defp followers_collection(_), do: nil
# ── push ─────────────────────────────────────────────────────────────
@doc """
Emit a ForgeFed `Push` activity describing one ref update.
`commits` is a list of `%{sha, message, author}` maps in the order
they should appear (oldest first).
"""
def push(%Actor{} = repo_actor, params) do
%{
ref: ref,
before: before_sha,
after: after_sha,
commits: commits
} = params
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(repo_actor, "Push"),
"type" => "Push",
"actor" => repo_actor.actor_url,
"to" => addressing(repo_actor),
"published" => now(),
"target" => repo_actor.actor_url,
"result" => after_sha,
"context" => repo_actor.actor_url,
"object" => %{
"type" => "OrderedCollection",
"totalItems" => length(commits),
"orderedItems" => Enum.map(commits, &commit_object(repo_actor, &1))
},
"summary" => "pushed #{length(commits)} commit(s) to #{ref}",
"ref" => ref,
"before" => before_sha,
"after" => after_sha
}
end
defp commit_object(repo_actor, %{sha: sha} = c) when is_binary(sha) do
hex = if byte_size(sha) == 20, do: Git.to_hex(sha), else: sha
%{
"type" => "Commit",
"id" => repo_actor.actor_url <> "/commit/" <> hex,
"attributedTo" => Map.get(c, :author) || repo_actor.actor_url,
"context" => repo_actor.actor_url,
"hash" => hex,
"summary" => Map.get(c, :message),
"published" => Map.get(c, :committed_at) || now()
}
|> drop_nils()
end
# ── tickets (issues + PRs use the same shape) ────────────────────────
@doc """
Create-Ticket activity. `kind` is `"issue"` or `"pull_request"`. The
acting actor is the human author; `repo_actor` is the ticket's
context.
"""
def create_ticket(%Actor{} = author_actor, %Actor{} = repo_actor, kind, ticket) do
object_id = object_url(repo_actor, kind <> "s", ticket.number)
# `actor` is the repo (so the repo's followers receive this); the
# human author is attached as `attributedTo` on the object. This
# matches ForgeFed convention and lets fan-out resolve to the
# right collection without us having to expand recipients
# manually.
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(repo_actor, "Create"),
"type" => "Create",
"actor" => repo_actor.actor_url,
"to" => addressing(repo_actor),
"attributedTo" => author_actor.actor_url,
"published" => now(),
"context" => repo_actor.actor_url,
"object" => %{
"type" => "Ticket",
"id" => object_id,
"attributedTo" => author_actor.actor_url,
"context" => repo_actor.actor_url,
"name" => "##{ticket.number} #{ticket.title}",
"content" => ticket.body || "",
"summary" => ticket.title,
"isResolved" => false,
"published" => published_at(ticket)
}
}
end
@doc """
Build an `Offer` wrapping a `Ticket` (a federated PR proposal) for a
remote target repository. Mirror of the inbound shape we accept.
`target_actor` is the remote repo actor on the receiving instance;
`source_clone_url` is the git URL the remote will fetch from to pull
in the proposed commits.
"""
def offer_pull_request(%Actor{} = author_actor, %Actor{} = target_actor, params) do
%{
title: title,
body: body,
source_branch: source_branch,
target_branch: target_branch,
head_hex: head_hex,
source_clone_url: source_clone_url
} = params
object_id = activity_url(author_actor, "Ticket")
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(author_actor, "Offer"),
"type" => "Offer",
"actor" => author_actor.actor_url,
"to" => [target_actor.actor_url],
"target" => target_actor.actor_url,
"published" => now(),
"object" => %{
"type" => "Ticket",
"id" => object_id,
"attributedTo" => author_actor.actor_url,
"context" => target_actor.actor_url,
"name" => title,
"content" => body || "",
"summary" => title,
"isMerge" => true,
"sourceBranch" => source_branch,
"targetBranch" => target_branch,
"head" => head_hex,
"source" => %{"type" => "Repository", "cloneUri" => source_clone_url}
}
}
end
@doc "PR-specific: includes source/target branch + head SHA."
def create_pull_request(author_actor, repo_actor, pr) do
base = create_ticket(author_actor, repo_actor, "pull_request", pr)
object =
base["object"]
|> Map.merge(%{
"type" => "Ticket",
"isMerge" => true,
"sourceBranch" => pr.source_ref,
"targetBranch" => pr.target_ref,
"head" => Git.to_hex(pr.head_sha),
"base" => Git.to_hex(pr.base_sha)
})
Map.put(base, "object", object)
end
# ── comments / notes ─────────────────────────────────────────────────
@doc """
Create-Note for a comment. `parent_object_url` is the URL of the
thing being replied to (issue, PR, or another comment).
"""
def create_comment(author_actor, repo_actor, parent_object_url, comment, parent_kind) do
note_id =
repo_actor.actor_url <> "/" <> parent_kind <> "_comment/" <> Integer.to_string(comment.id)
# Same pattern as create_ticket — fan out via the repo actor so
# repo followers (anyone watching the project) get the comment.
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(repo_actor, "Create"),
"type" => "Create",
"actor" => repo_actor.actor_url,
"attributedTo" => author_actor.actor_url,
"to" => addressing(repo_actor),
"published" => now(),
"context" => repo_actor.actor_url,
"object" => %{
"type" => "Note",
"id" => note_id,
"attributedTo" => author_actor.actor_url,
"context" => repo_actor.actor_url,
"inReplyTo" => parent_object_url,
"content" => comment.body,
"published" => published_at(comment)
}
}
end
# ── PR merged / closed ───────────────────────────────────────────────
def pr_merged(repo_actor, pr) do
object_id = object_url(repo_actor, "pull_requests", pr.number)
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(repo_actor, "Merge"),
"type" => "Merge",
"actor" => repo_actor.actor_url,
"to" => addressing(repo_actor),
"published" => now(),
"context" => repo_actor.actor_url,
"object" => object_id,
"result" => Git.to_hex(pr.merge_commit_sha)
}
end
# ── org-level activities ─────────────────────────────────────────────
@doc """
Group's `Create` of a Repository — announces a new repo to the org's
followers. The repo actor's URL is the activity object so remote
clients can `Follow` it for finer-grained notifications.
"""
def create_repository(%Actor{} = org_actor, %Actor{} = repo_actor) do
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(org_actor, "Create"),
"type" => "Create",
"actor" => org_actor.actor_url,
"to" => addressing(org_actor),
"published" => now(),
"object" => %{
"type" => "Repository",
"id" => repo_actor.actor_url,
"name" => repo_actor.name,
"attributedTo" => org_actor.actor_url
}
}
end
@doc """
Group's `Add` of a member. Announces (publicly) that someone joined
the org. For private orgs, callers should skip this entirely.
"""
def org_add_member(%Actor{} = org_actor, %Actor{} = member_actor, role) do
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(org_actor, "Add"),
"type" => "Add",
"actor" => org_actor.actor_url,
"to" => addressing(org_actor),
"published" => now(),
"object" => member_actor.actor_url,
"target" => org_actor.actor_url,
"role" => role
}
end
@doc """
Build a PR review verdict — `Like` for approve, `Dislike` for
changes-requested. `verb` is the AS2 activity type (`"Like"` /
`"Dislike"`). `object_url` is the federated PR's Ticket URL
(the `activity_url` we stored when we accepted the inbound Offer).
`body` is the optional review message.
"""
def pr_review_verdict(%Actor{} = reviewer, verb, object_url, body)
when verb in ["Like", "Dislike"] do
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(reviewer, verb),
"type" => verb,
"actor" => reviewer.actor_url,
"object" => object_url,
"content" => body || "",
"published" => now()
}
|> drop_nils()
end
@doc """
Build an ActivityPub `Flag` (abuse report) addressed at one or more
AP object URLs. `actor` is the local admin/instance actor doing the
flagging; `targets` is a list of object/actor URLs being reported.
`reason` is a short human string.
"""
def flag(%Actor{} = actor, targets, reason) when is_list(targets) do
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(actor, "Flag"),
"type" => "Flag",
"actor" => actor.actor_url,
"object" => targets,
"content" => reason,
"published" => now()
}
end
def ticket_closed(repo_actor, kind, ticket) do
object_id = object_url(repo_actor, kind <> "s", ticket.number)
%{
"@context" => [@as_context, @forgefed_context],
"id" => activity_url(repo_actor, "Update"),
"type" => "Update",
"actor" => repo_actor.actor_url,
"to" => addressing(repo_actor),
"published" => now(),
"context" => repo_actor.actor_url,
"object" => %{
"type" => "Ticket",
"id" => object_id,
"isResolved" => true
}
}
end
# ── helpers ──────────────────────────────────────────────────────────
defp addressing(actor) do
case followers_collection(actor) do
nil -> [@public]
url -> [@public, url]
end
end
defp now, do: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
defp published_at(%{inserted_at: %DateTime{} = dt}), do: DateTime.to_iso8601(dt)
defp published_at(_), do: now()
defp drop_nils(m), do: m |> Enum.reject(fn {_, v} -> is_nil(v) end) |> Map.new()
@doc "Sentinel for the public addressing tag."
def public_collection, do: @public
end