5.9 KiB · text History 6280797
defmodule GitGud.Events do
@moduledoc """
Append-only history for issues and pull requests.
Recording happens at the call sites that know who acted — the
LiveViews for anything a person clicks, `CommitBackfill` for pushes,
the federation inbox for anything that arrives from a remote actor.
The contexts themselves stay actor-free rather than growing an
`actor` argument on every mutation.
Recording is best-effort: `record/4` never raises and never takes
part in the caller's transaction. A dropped history row must not fail
the action it was describing.
## Payload shapes
Each `kind` carries a `data` map:
* `title_changed` — `%{"from" => old, "to" => new}`
* `pushed` — `%{"from" => hex | nil, "to" => hex, "commits" => n}`
* `labeled` / `unlabeled` — `%{"name" => label_name, "color" => hex}`
* `moderated` — `%{"note" => moderation_note}`
* `opened` / `closed` / `reopened` / `merged` / `unmoderated` — `%{}`
"""
import Ecto.Query, warn: false
require Logger
alias GitGud.Events.Event
alias GitGud.Issues.Issue
alias GitGud.PullRequests.PullRequest
alias GitGud.Repo
@doc """
Record one event against an issue or pull request.
`actor` may be a `%User{}` or nil — pushes have no user, and
federated actions have a remote actor rather than a local one.
Returns `:ok` regardless; failures are logged, not raised.
"""
def record(target, kind, actor \\ nil, data \\ %{}) do
{type, id} = target_ref(target)
do_record(type, id, kind, actor, data)
end
# Dispatched here rather than in the function head: the context
# getters `Map.put` a `:labels` key onto these structs, which still
# matches `%Issue{}` at runtime but reads as an incompatible type to
# the checker.
defp target_ref(%{__struct__: Issue, id: id}), do: {"issue", id}
defp target_ref(%{__struct__: PullRequest, id: id}), do: {"pull_request", id}
defp do_record(target_type, target_id, kind, actor, data) do
attrs = %{
target_type: target_type,
target_id: target_id,
kind: to_string(kind),
data: stringify(data),
actor_id: actor && Map.get(actor, :id)
}
case %Event{} |> Event.changeset(attrs) |> Repo.insert() do
{:ok, _event} ->
:ok
{:error, changeset} ->
Logger.warning(
"could not record #{kind} on #{target_type} #{target_id}: #{inspect(changeset.errors)}"
)
:ok
end
catch
kind_thrown, reason ->
Logger.warning("event recording crashed: #{inspect({kind_thrown, reason})}")
:ok
end
# JSONB keys come back as strings; write them that way so a row reads
# the same whether it came from the DB or from the struct just built.
defp stringify(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_string(k), v} end)
end
@doc """
Record a backlink on everything newly cross-referenced by a body.
`old_body` is what the text said before the edit — refs already
present then aren't new relationships, so re-saving a description
doesn't re-announce the same link. Pass nil when the body is being
created.
A body referring to its own issue records nothing, and a target
already backlinked from this source is left alone, so an edit that
moves a reference around doesn't stack duplicates.
"""
def record_cross_references(source, repo, old_body, new_body, actor) do
previous = ref_keys(old_body, repo)
for {kind, item, _target_repo} <- GitGud.Markdown.extract_refs(new_body || "", repo),
{kind, item.id} not in previous,
not same_target?(source, kind, item) do
:ok = record_backlink(kind, item, source, repo, actor)
end
:ok
end
defp ref_keys(nil, _repo), do: []
defp ref_keys(old_body, repo) do
old_body
|> GitGud.Markdown.extract_refs(repo)
|> Enum.map(fn {kind, item, _} -> {kind, item.id} end)
end
# `lookup_referent/2` hands back a bare map, not the struct, so the
# comparison is on type + id rather than struct identity.
defp same_target?(%Issue{id: id}, :issue, %{id: id}), do: true
defp same_target?(%PullRequest{id: id}, :pull_request, %{id: id}), do: true
defp same_target?(_source, _kind, _item), do: false
defp record_backlink(kind, item, source, source_repo, actor) do
target_type = if kind == :issue, do: "issue", else: "pull_request"
if already_backlinked?(target_type, item.id, source) do
:ok
else
do_record(target_type, item.id, "cross_referenced", actor, %{
from_type: source_kind(source),
from_number: source.number,
from_title: source.title,
from_repo: GitGud.Repositories.Storage.repo_handle(source_repo) <> "/" <> source_repo.name
})
end
end
defp source_kind(%Issue{}), do: "issue"
defp source_kind(%PullRequest{}), do: "pull_request"
defp already_backlinked?(target_type, target_id, source) do
from(e in Event,
where:
e.target_type == ^target_type and e.target_id == ^target_id and
e.kind == "cross_referenced" and
fragment("?->>'from_type' = ?", e.data, ^source_kind(source)) and
fragment("?->>'from_number' = ?", e.data, ^to_string(source.number))
)
|> Repo.exists?()
end
@doc "One target's history, oldest first, with `:actor` preloaded."
def list_for(target, opts \\ []) do
{type, id} = target_ref(target)
do_list(type, id, opts)
end
defp do_list(target_type, target_id, opts) do
limit = Keyword.get(opts, :limit, 200)
from(e in Event,
where: e.target_type == ^target_type and e.target_id == ^target_id,
order_by: [asc: e.inserted_at, asc: e.id],
limit: ^limit,
preload: [:actor]
)
|> Repo.all()
end
@doc "How many events a target has — for the tab badge."
def count_for(target) do
{type, id} = target_ref(target)
do_count(type, id)
end
defp do_count(target_type, target_id) do
from(e in Event,
where: e.target_type == ^target_type and e.target_id == ^target_id
)
|> Repo.aggregate(:count)
end
end