neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
Log in to mark this repository.
Add editable titles and a history subpage for issues and pull requests
ce54fcf · Gabriel Morell · 2026-09-09 22:00
Message
{commit_body(@commit)}
Files changed
added
lib/git_gud/events.ex
+116
−0
| 1 | +defmodule GitGud.Events do | |
| 2 | + @moduledoc """ | |
| 3 | + Append-only history for issues and pull requests. | |
| 4 | + | |
| 5 | + Recording happens at the call sites that know who acted — the | |
| 6 | + LiveViews for anything a person clicks, `CommitBackfill` for pushes, | |
| 7 | + the federation inbox for anything that arrives from a remote actor. | |
| 8 | + The contexts themselves stay actor-free rather than growing an | |
| 9 | + `actor` argument on every mutation. | |
| 10 | + | |
| 11 | + Recording is best-effort: `record/4` never raises and never takes | |
| 12 | + part in the caller's transaction. A dropped history row must not fail | |
| 13 | + the action it was describing. | |
| 14 | + | |
| 15 | + ## Payload shapes | |
| 16 | + | |
| 17 | + Each `kind` carries a `data` map: | |
| 18 | + | |
| 19 | + * `title_changed` — `%{"from" => old, "to" => new}` | |
| 20 | + * `pushed` — `%{"from" => hex | nil, "to" => hex, "commits" => n}` | |
| 21 | + * `labeled` / `unlabeled` — `%{"name" => label_name, "color" => hex}` | |
| 22 | + * `moderated` — `%{"note" => moderation_note}` | |
| 23 | + * `opened` / `closed` / `reopened` / `merged` / `unmoderated` — `%{}` | |
| 24 | + """ | |
| 25 | + | |
| 26 | + import Ecto.Query, warn: false | |
| 27 | + | |
| 28 | + require Logger | |
| 29 | + | |
| 30 | + alias GitGud.Events.Event | |
| 31 | + alias GitGud.Issues.Issue | |
| 32 | + alias GitGud.PullRequests.PullRequest | |
| 33 | + alias GitGud.Repo | |
| 34 | + | |
| 35 | + @doc """ | |
| 36 | + Record one event against an issue or pull request. | |
| 37 | + | |
| 38 | + `actor` may be a `%User{}` or nil — pushes have no user, and | |
| 39 | + federated actions have a remote actor rather than a local one. | |
| 40 | + Returns `:ok` regardless; failures are logged, not raised. | |
| 41 | + """ | |
| 42 | + def record(target, kind, actor \\ nil, data \\ %{}) do | |
| 43 | + {type, id} = target_ref(target) | |
| 44 | + do_record(type, id, kind, actor, data) | |
| 45 | + end | |
| 46 | + | |
| 47 | + # Dispatched here rather than in the function head: the context | |
| 48 | + # getters `Map.put` a `:labels` key onto these structs, which still | |
| 49 | + # matches `%Issue{}` at runtime but reads as an incompatible type to | |
| 50 | + # the checker. | |
| 51 | + defp target_ref(%{__struct__: Issue, id: id}), do: {"issue", id} | |
| 52 | + defp target_ref(%{__struct__: PullRequest, id: id}), do: {"pull_request", id} | |
| 53 | + | |
| 54 | + defp do_record(target_type, target_id, kind, actor, data) do | |
| 55 | + attrs = %{ | |
| 56 | + target_type: target_type, | |
| 57 | + target_id: target_id, | |
| 58 | + kind: to_string(kind), | |
| 59 | + data: stringify(data), | |
| 60 | + actor_id: actor && Map.get(actor, :id) | |
| 61 | + } | |
| 62 | + | |
| 63 | + case %Event{} |> Event.changeset(attrs) |> Repo.insert() do | |
| 64 | + {:ok, _event} -> | |
| 65 | + :ok | |
| 66 | + | |
| 67 | + {:error, changeset} -> | |
| 68 | + Logger.warning( | |
| 69 | + "could not record #{kind} on #{target_type} #{target_id}: #{inspect(changeset.errors)}" | |
| 70 | + ) | |
| 71 | + | |
| 72 | + :ok | |
| 73 | + end | |
| 74 | + catch | |
| 75 | + kind_thrown, reason -> | |
| 76 | + Logger.warning("event recording crashed: #{inspect({kind_thrown, reason})}") | |
| 77 | + :ok | |
| 78 | + end | |
| 79 | + | |
| 80 | + # JSONB keys come back as strings; write them that way so a row reads | |
| 81 | + # the same whether it came from the DB or from the struct just built. | |
| 82 | + defp stringify(map) when is_map(map) do | |
| 83 | + Map.new(map, fn {k, v} -> {to_string(k), v} end) | |
| 84 | + end | |
| 85 | + | |
| 86 | + @doc "One target's history, oldest first, with `:actor` preloaded." | |
| 87 | + def list_for(target, opts \\ []) do | |
| 88 | + {type, id} = target_ref(target) | |
| 89 | + do_list(type, id, opts) | |
| 90 | + end | |
| 91 | + | |
| 92 | + defp do_list(target_type, target_id, opts) do | |
| 93 | + limit = Keyword.get(opts, :limit, 200) | |
| 94 | + | |
| 95 | + from(e in Event, | |
| 96 | + where: e.target_type == ^target_type and e.target_id == ^target_id, | |
| 97 | + order_by: [asc: e.inserted_at, asc: e.id], | |
| 98 | + limit: ^limit, | |
| 99 | + preload: [:actor] | |
| 100 | + ) | |
| 101 | + |> Repo.all() | |
| 102 | + end | |
| 103 | + | |
| 104 | + @doc "How many events a target has — for the tab badge." | |
| 105 | + def count_for(target) do | |
| 106 | + {type, id} = target_ref(target) | |
| 107 | + do_count(type, id) | |
| 108 | + end | |
| 109 | + | |
| 110 | + defp do_count(target_type, target_id) do | |
| 111 | + from(e in Event, | |
| 112 | + where: e.target_type == ^target_type and e.target_id == ^target_id | |
| 113 | + ) | |
| 114 | + |> Repo.aggregate(:count) | |
| 115 | + end | |
| 116 | +end |
added
lib/git_gud/events/event.ex
+38
−0
| 1 | +defmodule GitGud.Events.Event do | |
| 2 | + use Ecto.Schema | |
| 3 | + import Ecto.Changeset | |
| 4 | + | |
| 5 | + alias GitGud.Accounts.User | |
| 6 | + | |
| 7 | + @valid_target_types ~w(issue pull_request) | |
| 8 | + | |
| 9 | + @valid_kinds ~w( | |
| 10 | + opened closed reopened merged | |
| 11 | + title_changed | |
| 12 | + pushed | |
| 13 | + labeled unlabeled | |
| 14 | + moderated unmoderated | |
| 15 | + ) | |
| 16 | + | |
| 17 | + schema "events" do | |
| 18 | + field :target_type, :string | |
| 19 | + field :target_id, :integer | |
| 20 | + field :kind, :string | |
| 21 | + field :data, :map, default: %{} | |
| 22 | + | |
| 23 | + belongs_to :actor, User | |
| 24 | + | |
| 25 | + timestamps(type: :utc_datetime, updated_at: false) | |
| 26 | + end | |
| 27 | + | |
| 28 | + def changeset(event, attrs) do | |
| 29 | + event | |
| 30 | + |> cast(attrs, [:target_type, :target_id, :kind, :data, :actor_id]) | |
| 31 | + |> validate_required([:target_type, :target_id, :kind]) | |
| 32 | + |> validate_inclusion(:target_type, @valid_target_types) | |
| 33 | + |> validate_inclusion(:kind, @valid_kinds) | |
| 34 | + end | |
| 35 | + | |
| 36 | + def valid_kinds, do: @valid_kinds | |
| 37 | + def valid_target_types, do: @valid_target_types | |
| 38 | +end |
modified
lib/git_gud/pull_requests.ex
+45
−3
| 462 | 462 | stderr_to_stdout: true |
| 463 | 463 | ) |
| 464 | 464 | |
| 465 | − pr | |
| 466 | − |> Ecto.Changeset.change(head_sha: new_sha) | |
| 467 | − |> Repo.update!() | |
| 465 | + old_sha = pr.head_sha | |
| 466 | + | |
| 467 | + updated = | |
| 468 | + pr | |
| 469 | + |> Ecto.Changeset.change(head_sha: new_sha) | |
| 470 | + |> Repo.update!() | |
| 471 | + | |
| 472 | + # No user to attribute: this runs in the post-receive worker, | |
| 473 | + # which knows the ref that moved but not who pushed it. | |
| 474 | + if old_sha != new_sha do | |
| 475 | + _ = | |
| 476 | + GitGud.Events.record(updated, "pushed", nil, %{ | |
| 477 | + from: old_sha && Git.to_hex(old_sha), | |
| 478 | + to: Git.to_hex(new_sha), | |
| 479 | + commits: count_new_commits(source_repo, old_sha, new_sha) | |
| 480 | + }) | |
| 481 | + end | |
| 468 | 482 | end |
| 469 | 483 | |
| 470 | 484 | :ok |
| 471 | 485 | end |
| 472 | 486 | |
| 487 | + # How many commits the push added on top of the old head. Best-effort: | |
| 488 | + # a force-push that rewrites history has no meaningful count, and a | |
| 489 | + # missing old head (first push) counts as unknown. | |
| 490 | + defp count_new_commits(_repo, nil, _new_sha), do: nil | |
| 491 | + | |
| 492 | + defp count_new_commits(repo, old_sha, new_sha) do | |
| 493 | + case System.cmd( | |
| 494 | + "git", | |
| 495 | + [ | |
| 496 | + "-C", | |
| 497 | + repo.disk_path, | |
| 498 | + "rev-list", | |
| 499 | + "--count", | |
| 500 | + Git.to_hex(old_sha) <> ".." <> Git.to_hex(new_sha) | |
| 501 | + ], | |
| 502 | + stderr_to_stdout: true | |
| 503 | + ) do | |
| 504 | + {out, 0} -> | |
| 505 | + case Integer.parse(String.trim(out)) do | |
| 506 | + {n, _} -> n | |
| 507 | + _ -> nil | |
| 508 | + end | |
| 509 | + | |
| 510 | + _ -> | |
| 511 | + nil | |
| 512 | + end | |
| 513 | + end | |
| 514 | + | |
| 473 | 515 | defp pin_pull_head(target_repo, source_repo, source_ref, pr_number) do |
| 474 | 516 | # We've already brought the commit object in via the temp ref above. |
| 475 | 517 | # Pin it under a stable `refs/pull/<N>/head` and clean up the temp. |
added
lib/git_gud_web/components/event_components.ex
+171
−0
| 1 | +defmodule GitGudWeb.EventComponents do | |
| 2 | + @moduledoc """ | |
| 3 | + Shared pieces for issue and pull request history: the editable title | |
| 4 | + on the show pages, and the timeline the history subpages render. | |
| 5 | + | |
| 6 | + Both targets carry the same fields here, so the components take a | |
| 7 | + plain title/number rather than an `%Issue{}` or `%PullRequest{}`. | |
| 8 | + """ | |
| 9 | + use GitGudWeb, :html | |
| 10 | + | |
| 11 | + alias GitGud.Git | |
| 12 | + | |
| 13 | + @doc """ | |
| 14 | + The `#N — Title` heading, with an inline edit form when the viewer | |
| 15 | + may rename it. | |
| 16 | + | |
| 17 | + The parent LiveView owns the state: it handles `edit_title`, | |
| 18 | + `cancel_edit_title` and `save_title`, and passes `editing?`. | |
| 19 | + """ | |
| 20 | + attr :number, :integer, required: true | |
| 21 | + attr :title, :string, required: true | |
| 22 | + attr :editing?, :boolean, default: false | |
| 23 | + attr :can_edit?, :boolean, default: false | |
| 24 | + attr :error, :string, default: nil | |
| 25 | + | |
| 26 | + def editable_title(assigns) do | |
| 27 | + ~H""" | |
| 28 | + <%= if @editing? do %> | |
| 29 | + <form phx-submit="save_title" class="flex items-start gap-2"> | |
| 30 | + <div class="flex-1"> | |
| 31 | + <input | |
| 32 | + type="text" | |
| 33 | + name="title" | |
| 34 | + value={@title} | |
| 35 | + required | |
| 36 | + maxlength="255" | |
| 37 | + autofocus | |
| 38 | + aria-label="Title" | |
| 39 | + class="input input-bordered w-full text-xl font-semibold" | |
| 40 | + /> | |
| 41 | + <p :if={@error} class="text-xs text-error mt-1">{@error}</p> | |
| 42 | + </div> | |
| 43 | + <button type="submit" class="btn btn-sm btn-primary">Save</button> | |
| 44 | + <button type="button" phx-click="cancel_edit_title" class="btn btn-sm btn-ghost"> | |
| 45 | + Cancel | |
| 46 | + </button> | |
| 47 | + </form> | |
| 48 | + <% else %> | |
| 49 | + <div class="flex items-baseline gap-2"> | |
| 50 | + <h1 class="text-2xl font-semibold"> | |
| 51 | + #{@number} — {@title} | |
| 52 | + </h1> | |
| 53 | + <button | |
| 54 | + :if={@can_edit?} | |
| 55 | + type="button" | |
| 56 | + phx-click="edit_title" | |
| 57 | + class="btn btn-xs btn-ghost" | |
| 58 | + aria-label="Edit title" | |
| 59 | + > | |
| 60 | + <.icon name="hero-pencil-square" class="size-4" /> Edit | |
| 61 | + </button> | |
| 62 | + </div> | |
| 63 | + <% end %> | |
| 64 | + """ | |
| 65 | + end | |
| 66 | + | |
| 67 | + @doc "The history timeline — one row per recorded event, oldest first." | |
| 68 | + attr :events, :list, required: true | |
| 69 | + | |
| 70 | + def timeline(assigns) do | |
| 71 | + ~H""" | |
| 72 | + <p :if={@events == []} class="py-12 text-center text-sm opacity-60"> | |
| 73 | + Nothing recorded yet. History starts when something happens — a push, a | |
| 74 | + rename, a label, a state change. | |
| 75 | + </p> | |
| 76 | + | |
| 77 | + <ol :if={@events != []} class="relative border-l border-base-300 ml-2 space-y-4 py-2"> | |
| 78 | + <li :for={event <- @events} id={"event-#{event.id}"} class="ml-4"> | |
| 79 | + <span class={[ | |
| 80 | + "absolute -left-1.5 size-3 rounded-full border-2 border-base-100", | |
| 81 | + dot_class(event.kind) | |
| 82 | + ]} /> | |
| 83 | + <p class="text-sm"> | |
| 84 | + <span class="font-medium">{actor_name(event)}</span> | |
| 85 | + {describe(event)} | |
| 86 | + </p> | |
| 87 | + <p class="text-xs opacity-50 mt-0.5" title={exact_time(event.inserted_at)}> | |
| 88 | + {format_dt(event.inserted_at)} | |
| 89 | + </p> | |
| 90 | + </li> | |
| 91 | + </ol> | |
| 92 | + """ | |
| 93 | + end | |
| 94 | + | |
| 95 | + defp dot_class("merged"), do: "bg-secondary" | |
| 96 | + defp dot_class("closed"), do: "bg-error" | |
| 97 | + defp dot_class("reopened"), do: "bg-success" | |
| 98 | + defp dot_class("opened"), do: "bg-success" | |
| 99 | + defp dot_class("pushed"), do: "bg-primary" | |
| 100 | + defp dot_class(k) when k in ["moderated", "unmoderated"], do: "bg-warning" | |
| 101 | + defp dot_class(_), do: "bg-base-300" | |
| 102 | + | |
| 103 | + # A push has no user attached; a federated action has a remote actor | |
| 104 | + # rather than a local one. Both land here as nil. | |
| 105 | + defp actor_name(%{actor: %{handle: h}}) when is_binary(h), do: h | |
| 106 | + defp actor_name(%{kind: "pushed"}), do: "A push" | |
| 107 | + defp actor_name(_), do: "Someone" | |
| 108 | + | |
| 109 | + defp describe(%{kind: "opened"}), do: "opened this." | |
| 110 | + defp describe(%{kind: "closed"}), do: "closed this." | |
| 111 | + defp describe(%{kind: "reopened"}), do: "reopened this." | |
| 112 | + defp describe(%{kind: "merged"}), do: "merged this." | |
| 113 | + | |
| 114 | + defp describe(%{kind: "title_changed", data: %{"from" => from, "to" => to}}) do | |
| 115 | + assigns = %{from: from, to: to} | |
| 116 | + | |
| 117 | + ~H""" | |
| 118 | + changed the title from <span class="line-through opacity-60">{@from}</span> | |
| 119 | + to <span class="font-medium">{@to}</span>. | |
| 120 | + """ | |
| 121 | + end | |
| 122 | + | |
| 123 | + defp describe(%{kind: "title_changed"}), do: "changed the title." | |
| 124 | + | |
| 125 | + defp describe(%{kind: "pushed", data: data}) do | |
| 126 | + assigns = %{ | |
| 127 | + from: short(data["from"]), | |
| 128 | + to: short(data["to"]), | |
| 129 | + commits: data["commits"] | |
| 130 | + } | |
| 131 | + | |
| 132 | + ~H""" | |
| 133 | + updated the head <span :if={@from}>from <code class="font-mono text-xs">{@from}</code></span> | |
| 134 | + to | |
| 135 | + <code class="font-mono text-xs">{@to}</code><span :if={is_integer(@commits) and @commits > 0}>, {@commits} new commit(s)</span>. | |
| 136 | + """ | |
| 137 | + end | |
| 138 | + | |
| 139 | + defp describe(%{kind: kind, data: %{"name" => name}}) when kind in ["labeled", "unlabeled"] do | |
| 140 | + verb = if kind == "labeled", do: "added", else: "removed" | |
| 141 | + assigns = %{name: name, verb: verb} | |
| 142 | + | |
| 143 | + ~H""" | |
| 144 | + {@verb} the <span class="badge badge-xs">{@name}</span> label. | |
| 145 | + """ | |
| 146 | + end | |
| 147 | + | |
| 148 | + defp describe(%{kind: "labeled"}), do: "added a label." | |
| 149 | + defp describe(%{kind: "unlabeled"}), do: "removed a label." | |
| 150 | + | |
| 151 | + defp describe(%{kind: "moderated", data: %{"note" => note}}) | |
| 152 | + when is_binary(note) and note != "", | |
| 153 | + do: "moderated this: #{note}" | |
| 154 | + | |
| 155 | + defp describe(%{kind: "moderated"}), do: "moderated this." | |
| 156 | + defp describe(%{kind: "unmoderated"}), do: "restored this." | |
| 157 | + defp describe(%{kind: kind}), do: kind | |
| 158 | + | |
| 159 | + # `data` holds hex strings — the sha columns are binary, but an event | |
| 160 | + # payload is JSON, so callers encode before recording. | |
| 161 | + defp short(hex) when is_binary(hex) and byte_size(hex) >= 7, do: binary_part(hex, 0, 7) | |
| 162 | + defp short(hex) when is_binary(hex), do: hex | |
| 163 | + defp short(_), do: nil | |
| 164 | + | |
| 165 | + defp format_dt(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") | |
| 166 | + defp exact_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC") | |
| 167 | + | |
| 168 | + @doc "Hex-encode a sha for an event payload, tolerating nil." | |
| 169 | + def hex(nil), do: nil | |
| 170 | + def hex(sha) when is_binary(sha), do: Git.to_hex(sha) | |
| 171 | +end |
added
lib/git_gud_web/live/issue_live/history.ex
+70
−0
| 1 | +defmodule GitGudWeb.IssueLive.History do | |
| 2 | + @moduledoc """ | |
| 3 | + Everything recorded against one issue, oldest first. | |
| 4 | + | |
| 5 | + History only covers what happened after `events` started being | |
| 6 | + recorded — issues opened before that show an empty timeline rather | |
| 7 | + than a reconstructed one. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + use GitGudWeb, :live_view | |
| 11 | + | |
| 12 | + import GitGudWeb.EventComponents | |
| 13 | + | |
| 14 | + alias GitGud.Events | |
| 15 | + alias GitGud.Issues | |
| 16 | + alias GitGud.Repositories | |
| 17 | + alias GitGud.Repositories.Storage | |
| 18 | + | |
| 19 | + @impl true | |
| 20 | + def mount(%{"owner" => owner, "name" => name, "number" => number}, _session, socket) do | |
| 21 | + repo = | |
| 22 | + Repositories.get_repository_by_path!(owner, name) | |
| 23 | + |> GitGud.Repo.preload([:owner, :organization]) | |
| 24 | + | |
| 25 | + issue = Issues.get_issue!(repo, String.to_integer(number)) | |
| 26 | + | |
| 27 | + {:ok, | |
| 28 | + socket | |
| 29 | + |> assign(:repo, repo) | |
| 30 | + |> assign(:handle, Storage.repo_handle(repo)) | |
| 31 | + |> GitGudWeb.RepoLive.Header.assign_chrome(repo) | |
| 32 | + |> assign(:issue, issue) | |
| 33 | + |> assign(:events, Events.list_for(issue)) | |
| 34 | + |> assign(:page_title, "History · ##{issue.number} #{issue.title}")} | |
| 35 | + end | |
| 36 | + | |
| 37 | + @impl true | |
| 38 | + def render(assigns) do | |
| 39 | + ~H""" | |
| 40 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 41 | + <div class="space-y-6"> | |
| 42 | + <GitGudWeb.RepoLive.Header.header | |
| 43 | + repo={@repo} | |
| 44 | + handle={@handle} | |
| 45 | + current_scope={@current_scope} | |
| 46 | + has_packages?={@has_packages?} | |
| 47 | + can_admin?={@can_admin?} | |
| 48 | + latest_run={@latest_run} | |
| 49 | + open_pulls={@open_pulls} | |
| 50 | + /> | |
| 51 | + | |
| 52 | + <header> | |
| 53 | + <p class="text-xs opacity-60"> | |
| 54 | + <.link | |
| 55 | + navigate={~p"/r/#{@handle}/#{@repo.name}/issues/#{@issue.number}"} | |
| 56 | + class="link link-hover" | |
| 57 | + > | |
| 58 | + Back to #{@issue.number} | |
| 59 | + </.link> | |
| 60 | + </p> | |
| 61 | + <h1 class="text-2xl font-semibold">History</h1> | |
| 62 | + <p class="text-sm opacity-70 mt-1">#{@issue.number} — {@issue.title}</p> | |
| 63 | + </header> | |
| 64 | + | |
| 65 | + <.timeline events={@events} /> | |
| 66 | + </div> | |
| 67 | + </Layouts.app> | |
| 68 | + """ | |
| 69 | + end | |
| 70 | +end |
modified
lib/git_gud_web/live/issue_live/show.ex
+94
−7
Click to load diff…
added
lib/git_gud_web/live/pr_live/history.ex
+71
−0
Click to load diff…
modified
lib/git_gud_web/live/pr_live/show.ex
+100
−8
Click to load diff…
modified
lib/git_gud_web/router.ex
+2
−0
Click to load diff…
added
priv/repo/migrations/20260909040000_create_events.exs
+30
−0
Click to load diff…
added
test/git_gud/events_test.exs
+93
−0
Click to load diff…
added
test/git_gud_web/live/issue_live/title_history_test.exs
+231
−0
Click to load diff…
added
test/git_gud_web/live/pr_live/title_history_test.exs
+131
−0
Click to load diff…
modified
test/git_gud_web/live/repo_live/fork_banner_test.exs
+4
−78
Click to load diff…
modified
test/support/fixtures/forge_fixtures.ex
+115
−0
Click to load diff…
Parents: 5a2585d