Actions: one container per event, for the runs a push started

a119a0e · Gabriel Morell · 2026-10-24 15:16

17 files +263 -25
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows.ex
+32 −1
@@ -512,6 +512,8 @@ defmodule GitGud.Workflows do
512 512
513 513 matching ->
514 514 event_payload = payload_fun.()
515 + # One event, however many workflows it starts.
516 + event_id = Ecto.UUID.generate()
515 517
516 518 for {path, doc} <- matching do
517 519 {:ok, run} =
@@ -523,6 +525,7 @@ defmodule GitGud.Workflows do
523 525 trigger: trigger,
524 526 workflow_doc: doc.raw,
525 527 event_payload: event_payload,
528 + event_id: event_id,
526 529 jobs: doc.jobs
527 530 })
528 531 )
@@ -885,10 +888,13 @@ defmodule GitGud.Workflows do
885 888 Repo.transaction(fn ->
886 889 number = next_run_number(rid)
887 890
891 + # A caller starting several runs for one event passes its id; any
892 + # other run is an event of its own.
888 893 run_attrs =
889 894 attrs
890 895 |> Map.delete(:jobs)
891 896 |> Map.put(:number, number)
897 + |> Map.put_new_lazy(:event_id, &Ecto.UUID.generate/0)
892 898
893 899 run =
894 900 %WorkflowRun{repository_id: rid}
@@ -1204,11 +1210,36 @@ defmodule GitGud.Workflows do
1204 1210
1205 1211 WorkflowRun
1206 1212 |> where([r], r.repository_id == ^rid)
1207 − |> order_by([r], desc: r.inserted_at)
1213 + |> order_by([r], desc: r.inserted_at, desc: r.id)
1208 1214 |> limit(^limit)
1209 1215 |> Repo.all()
1210 1216 end
1211 1217
1218 + @doc """
1219 + A repository's recent runs grouped by the event that started them
1220 + (`event_id`): one push's `ci`, `audit` and `image` together. Newest
1221 + event first, each `%{id: event_id, runs: [run]}` with its runs in the
1222 + order they were created and `triggered_by` preloaded.
1223 +
1224 + `:limit` caps the runs looked at (default 50), so the oldest event
1225 + shown may be missing some of its runs.
1226 + """
1227 + def list_run_events(%Repository{} = repo, opts \\ []) do
1228 + runs = repo |> list_runs(opts) |> Repo.preload(:triggered_by)
1229 +
1230 + {order, by_event} =
1231 + Enum.reduce(runs, {[], %{}}, fn run, {order, acc} ->
1232 + order = if Map.has_key?(acc, run.event_id), do: order, else: [run.event_id | order]
1233 + {order, Map.update(acc, run.event_id, [run], &[run | &1])}
1234 + end)
1235 +
1236 + order
1237 + |> Enum.reverse()
1238 + |> Enum.map(fn event_id ->
1239 + %{id: event_id, runs: Enum.sort_by(Map.fetch!(by_event, event_id), & &1.number)}
1240 + end)
1241 + end
1242 +
1212 1243 @doc """
1213 1244 How long a repository's runs have been taking, oldest first.
1214 1245
modified lib/git_gud/workflows/workflow_run.ex
+8 −1
@@ -41,6 +41,12 @@ defmodule GitGud.Workflows.WorkflowRun do
41 41 # it (`GitGud.Workflows.Workers.WorkflowRunDispatch`).
42 42 field :trigger_depth, :integer, default: 0
43 43
44 + # The event the run came from: every run one push, pull request
45 + # activity, ref create/delete or `workflow_run` completion started
46 + # shares it; a manual dispatch, a schedule tick or a re-run has one of
47 + # its own. The Actions page groups by it.
48 + field :event_id, Ecto.UUID, read_after_writes: true
49 +
44 50 # A `pull_request` run for a fork or federated pull request, whose
45 51 # workflow and code come from someone who may not have write access:
46 52 # its jobs get no secrets or variables and a read-only token, and
@@ -78,7 +84,8 @@ defmodule GitGud.Workflows.WorkflowRun do
78 84 :trigger_depth,
79 85 :fork,
80 86 :approved_by_id,
81 − :approved_at
87 + :approved_at,
88 + :event_id
82 89 ])
83 90 |> validate_required([:number, :workflow_path, :trigger, :head_sha])
84 91 end
modified lib/git_gud_web/live/workflow_live/index.ex
+84 −23
@@ -19,14 +19,12 @@ defmodule GitGudWeb.WorkflowLive.Index do
19 19 if connected?(socket),
20 20 do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
21 21
22 − runs = Workflows.list_runs(repo)
23 −
24 22 {:ok,
25 23 socket
26 24 |> assign(:repo, repo)
27 25 |> assign(:handle, Storage.repo_handle(repo))
28 26 |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
29 − |> stream(:runs, runs)
27 + |> stream(:events, run_events(repo), dom_id: &event_dom_id/1)
30 28 |> assign_durations()
31 29 |> assign_dispatch()
32 30 |> assign(:page_title, "Actions — #{owner}/#{name}")}
@@ -166,7 +164,7 @@ defmodule GitGudWeb.WorkflowLive.Index do
166 164
167 165 {:noreply,
168 166 socket
169 − |> stream(:runs, Workflows.list_runs(repo), reset: true)
167 + |> stream(:events, run_events(repo), reset: true, dom_id: &event_dom_id/1)
170 168 |> assign(:latest_run, Workflows.latest_run(repo))
171 169 |> assign_durations()}
172 170 end
@@ -301,29 +299,57 @@ defmodule GitGudWeb.WorkflowLive.Index do
301 299 />
302 300 </section>
303 301
304 − <ul id="runs" phx-update="stream" class="divide-y divide-base-300">
302 + <%!-- One container per event: everything a push (or a pull
303 + request's activity, a tag, a schedule tick) started. --%>
304 + <ul id="run-events" phx-update="stream" class="space-y-3">
305 305 <li id="runs-empty" class="hidden only:block py-6 opacity-60 text-sm">
306 306 No workflow runs yet.
307 307 </li>
308 − <li :for={{id, run} <- @streams.runs} id={id} class="py-3">
309 − <div class="flex items-baseline justify-between gap-3">
310 − <.link
311 − navigate={~p"/r/#{@handle}/#{@repo.name}/actions/#{run.number}"}
312 − class="font-medium link link-hover"
308 + <li
309 + :for={{id, event} <- @streams.events}
310 + id={id}
311 + class="rounded-box border border-base-300 transition-colors hover:border-base-content/20"
312 + >
313 + <div class="flex items-start justify-between gap-3 px-3 pt-3 pb-2">
314 + <div class="min-w-0">
315 + <p class="font-medium truncate">{event_title(event.head)}</p>
316 + <p class="text-xs opacity-60 mt-1 font-mono truncate">
317 + {event.head.trigger} · {short_ref(event.head.head_ref)} · {Git.short(
318 + event.head.head_sha
319 + )}
320 + <span :if={event.head.triggered_by}>· {event.head.triggered_by.handle}</span>
321 + <span>· {format_dt(event.head.inserted_at)}</span>
322 + </p>
323 + </div>
324 + <span
325 + :if={length(event.runs) > 1}
326 + class={["badge badge-sm shrink-0", status_class(event.status)]}
313 327 >
314 − #{run.number} — {run.workflow_name || run.workflow_path}
315 − </.link>
316 − <span class={["badge badge-sm", status_class(run.status)]}>{run.status}</span>
317 − </div>
318 − <p class="text-xs opacity-60 mt-1 font-mono">
319 − {run.trigger} · {run.head_ref} · {Git.short(run.head_sha)}
320 − </p>
321 − <p class="text-xs opacity-50 mt-0.5">
322 − <span :if={run.started_at}>started {format_dt(run.started_at)}</span>
323 − <span :if={run.started_at} class="ml-2">
324 − · {duration(run.started_at, run.completed_at)}
328 + {event.status}
325 329 </span>
326 − </p>
330 + </div>
331 + <ul class="divide-y divide-base-300 border-t border-base-300">
332 + <li
333 + :for={run <- event.runs}
334 + id={"run-#{run.id}"}
335 + class="flex items-baseline justify-between gap-3 px-3 py-2 transition-colors hover:bg-base-200/60"
336 + >
337 + <div class="min-w-0">
338 + <.link
339 + navigate={~p"/r/#{@handle}/#{@repo.name}/actions/#{run.number}"}
340 + class="font-medium link link-hover"
341 + >
342 + #{run.number} — {run.workflow_name || run.workflow_path}
343 + </.link>
344 + <span :if={run.started_at} class="ml-2 text-xs opacity-50">
345 + {duration(run.started_at, run.completed_at)}
346 + </span>
347 + </div>
348 + <span class={["badge badge-sm shrink-0", status_class(run.status)]}>
349 + {run.status}
350 + </span>
351 + </li>
352 + </ul>
327 353 </li>
328 354 </ul>
329 355 </div>
@@ -331,6 +357,42 @@ defmodule GitGudWeb.WorkflowLive.Index do
331 357 """
332 358 end
333 359
360 + defp run_events(repo) do
361 + for %{runs: [head | _] = runs} = event <- Workflows.list_run_events(repo),
362 + do: Map.merge(event, %{head: head, status: event_status(runs)})
363 + end
364 +
365 + defp event_dom_id(%{id: event_id}), do: "event-#{event_id}"
366 +
367 + # What the event was, in a line: the pushed commit's subject, the pull
368 + # request's title, the cron, or the workflow someone ran.
369 + defp event_title(%{event_payload: %{"head_commit" => %{"message" => msg}}})
370 + when is_binary(msg) and msg != "",
371 + do: msg |> String.split("\n", parts: 2) |> hd()
372 +
373 + defp event_title(%{event_payload: %{"pull_request" => %{"number" => n, "title" => t}}}),
374 + do: "##{n} #{t}"
375 +
376 + defp event_title(%{trigger: "schedule", event_payload: %{"schedule" => cron}}),
377 + do: "Scheduled (#{cron})"
378 +
379 + defp event_title(%{trigger: "workflow_dispatch"} = run),
380 + do: "Run by hand: #{run.workflow_name || run.workflow_path}"
381 +
382 + defp event_title(run), do: run.workflow_name || run.workflow_path
383 +
384 + defp short_ref("refs/heads/" <> name), do: name
385 + defp short_ref("refs/tags/" <> name), do: name
386 + defp short_ref(ref), do: ref
387 +
388 + # The event as a whole: still going while any run is, else its worst
389 + # outcome.
390 + defp event_status(runs) do
391 + statuses = Enum.map(runs, & &1.status)
392 +
393 + Enum.find(~w(running queued failure cancelled), "success", &(&1 in statuses))
394 + end
395 +
334 396 defp status_class("success"), do: "badge-success"
335 397 defp status_class("running"), do: "badge-primary"
336 398 defp status_class("queued"), do: "badge-warning"
@@ -345,7 +407,6 @@ defmodule GitGudWeb.WorkflowLive.Index do
345 407 defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
346 408 defp format_dt(_), do: ""
347 409
348 − defp duration(nil, _), do: ""
349 410 defp duration(start, nil), do: "for #{secs(DateTime.diff(DateTime.utc_now(), start))}"
350 411 defp duration(start, %DateTime{} = finish), do: "in #{secs(DateTime.diff(finish, start))}"
351 412
added priv/repo/migrations/20261024150738_add_workflow_run_event_id.exs
+59 −0
@@ -0,0 +1,59 @@
1 +defmodule GitGud.Repo.Migrations.AddWorkflowRunEventId do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + `workflow_runs.event_id` — the event a run came from, shared by every
6 + run that one push, pull request activity, ref create/delete or
7 + `workflow_run` completion started. The Actions page groups by it.
8 +
9 + Runs from before it was recorded are grouped after the fact: the
10 + dispatcher gives every run of one event the same trigger, commit, ref,
11 + pull request and payload, inserted moments apart, so those (to the
12 + minute) are taken as one event. A manual dispatch, a schedule tick or a
13 + re-run was always an event of its own.
14 + """
15 +
16 + def up do
17 + alter table(:workflow_runs) do
18 + add :event_id, :uuid
19 + end
20 +
21 + execute """
22 + WITH keyed AS (
23 + SELECT id,
24 + CASE
25 + WHEN trigger IN ('push', 'pull_request', 'pull_request_target',
26 + 'create', 'delete', 'workflow_run')
27 + THEN concat_ws('|', repository_id, trigger, encode(head_sha, 'hex'),
28 + head_ref, pull_request_number,
29 + md5(coalesce(event_payload::text, '')),
30 + date_trunc('minute', inserted_at))
31 + ELSE 'run:' || id::text
32 + END AS k
33 + FROM workflow_runs
34 + ),
35 + events AS (
36 + SELECT k, gen_random_uuid() AS event_id FROM keyed GROUP BY k
37 + )
38 + UPDATE workflow_runs r
39 + SET event_id = events.event_id
40 + FROM keyed JOIN events USING (k)
41 + WHERE r.id = keyed.id
42 + """
43 +
44 + alter table(:workflow_runs) do
45 + # A run inserted without one is an event of its own.
46 + modify :event_id, :uuid, null: false, default: fragment("gen_random_uuid()")
47 + end
48 +
49 + create index(:workflow_runs, [:repository_id, :event_id])
50 + end
51 +
52 + def down do
53 + drop index(:workflow_runs, [:repository_id, :event_id])
54 +
55 + alter table(:workflow_runs) do
56 + remove :event_id
57 + end
58 + end
59 +end
added test/git_gud_web/live/workflow_run_events_test.exs
+80 −0
@@ -0,0 +1,80 @@
1 +defmodule GitGudWeb.WorkflowRunEventsTest do
2 + @moduledoc """
3 + The Actions page groups runs by the event that started them: one push's
4 + workflows share a container, and a run started by hand, or a re-run,
5 + gets one of its own.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.ForgeFixtures
11 + import GitGud.WorkflowGitHelpers
12 +
13 + alias GitGud.Repo
14 + alias GitGud.Repositories.Storage
15 + alias GitGud.Workflows
16 +
17 + @moduletag :capture_log
18 +
19 + defp workflow(name) do
20 + """
21 + name: #{name}
22 + on: [push, workflow_dispatch]
23 + jobs:
24 + build:
25 + runs-on: self-hosted
26 + steps:
27 + - run: echo hi
28 + """
29 + end
30 +
31 + setup %{conn: conn} do
32 + {owner, repo} = repository_fixture(%{visibility: "public"})
33 + repo = Repo.preload(repo, [:owner, :organization])
34 +
35 + head =
36 + commit!(
37 + repo,
38 + %{
39 + ".forgejo/workflows/ci.yml" => workflow("ci"),
40 + ".forgejo/workflows/audit.yml" => workflow("audit"),
41 + ".forgejo/workflows/image.yml" => workflow("image")
42 + },
43 + [],
44 + "Add CI\n\nThree workflows."
45 + )
46 +
47 + push_ref!(repo, "refs/heads/main", nil, head, owner.id)
48 + path = "/r/#{Storage.repo_handle(repo)}/#{repo.name}/actions"
49 + {:ok, conn: conn, repo: repo, path: path}
50 + end
51 +
52 + test "one push's runs share an event, and its container", %{conn: conn, repo: repo, path: path} do
53 + assert [%{id: event_id, runs: [_, _, _] = runs}] = Workflows.list_run_events(repo)
54 +
55 + {:ok, view, _html} = live(conn, path)
56 +
57 + for run <- runs, do: assert(has_element?(view, "#event-#{event_id} #run-#{run.id}"))
58 + assert has_element?(view, "#event-#{event_id}", "Add CI")
59 + end
60 +
61 + test "a run by hand and a re-run are events of their own", %{
62 + conn: conn,
63 + repo: repo,
64 + path: path
65 + } do
66 + [%{runs: [first | _]} = push] = Workflows.list_run_events(repo)
67 + {:ok, manual} = Workflows.dispatch_manual(repo, "audit.yml", "main")
68 + {:ok, rerun} = Workflows.rerun(first)
69 +
70 + events = Workflows.list_run_events(repo)
71 + assert length(events) == 3
72 + assert Enum.map(events, &length(&1.runs)) |> Enum.sort() == [1, 1, 3]
73 + refute manual.event_id in [push.id, rerun.event_id]
74 +
75 + {:ok, view, _html} = live(conn, path)
76 + assert has_element?(view, "#event-#{manual.event_id} #run-#{manual.id}")
77 + assert has_element?(view, "#event-#{rerun.event_id} #run-#{rerun.id}")
78 + refute has_element?(view, "#event-#{push.id} #run-#{manual.id}")
79 + end
80 +end

Parents: ffc715c