Add editable titles and a history subpage for issues and pull requests

ce54fcf · Gabriel Morell · 2026-09-09 22:00

15 files +1311 -96
Message
{commit_body(@commit)}

Files changed

added lib/git_gud/events.ex
+116 −0
@@ -0,0 +1,116 @@
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 actedthe
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
@@ -0,0 +1,38 @@
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,14 +462,56 @@ defmodule GitGud.PullRequests do
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
@@ -0,0 +1,171 @@
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 happensa 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
@@ -0,0 +1,70 @@
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 + recordedissues 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