neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: one container per event, for the runs a push started
a119a0e · Gabriel Morell · 2026-10-24 15:16
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