run view: parallel-stage job layout + live update on run completion

d41fffb · gmorell · 2026-06-26 14:53

2 files +65 -5
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows.ex
+11 −0
@@ -959,6 +959,7 @@ defmodule GitGud.Workflows do
959 959 |> Repo.update!()
960 960
961 961 update_run_commit_status(run)
962 + broadcast_run_status(run)
962 963
963 964 true ->
964 965 run =
@@ -968,10 +969,20 @@ defmodule GitGud.Workflows do
968 969
969 970 update_run_commit_status(run)
970 971 notify_run_webhook(run)
972 + broadcast_run_status(run)
971 973 run
972 974 end
973 975 end
974 976
977 + # Notify the run's LiveView that the run reached a terminal state so it
978 + # can flip the status badge + show the re-run button without a reload.
979 + # (The per-job broadcast races ahead of `maybe_complete_run`, so the
980 + # run isn't terminal yet when that fires.)
981 + defp broadcast_run_status(%WorkflowRun{id: rid} = run) do
982 + Phoenix.PubSub.broadcast(GitGud.PubSub, topic_for_run(rid), {:workflow_run_status, run})
983 + run
984 + end
985 +
975 986 # Fire the `workflow_run` webhook for a run that just reached a
976 987 # terminal state. Loads the repo so the fan-out can reach org hooks.
977 988 defp notify_run_webhook(%WorkflowRun{} = run) do
modified lib/git_gud_web/live/workflow_live/show.ex
+54 −5
@@ -55,6 +55,34 @@ defmodule GitGudWeb.WorkflowLive.Show do
55 55 Enum.find_value(run.jobs, fn job -> Enum.find(job.steps, &(&1.id == id)) end)
56 56 end
57 57
58 + # Group jobs into dependency stages from their `needs` DAG: jobs in the
59 + # same stage have no dependency between them, so they run in parallel.
60 + # Matrix legs share a `job_id` with no `needs`, so they all land in
61 + # stage 0 together.
62 + defp job_stages(jobs) do
63 + needs_by_id = Map.new(jobs, fn j -> {j.job_id, j.needs || []} end)
64 +
65 + jobs
66 + |> Enum.group_by(&job_depth(&1.job_id, needs_by_id, MapSet.new()))
67 + |> Enum.sort_by(fn {stage, _} -> stage end)
68 + |> Enum.map(fn {_stage, stage_jobs} -> stage_jobs end)
69 + end
70 +
71 + defp job_depth(id, needs_by_id, seen) do
72 + if MapSet.member?(seen, id) do
73 + 0
74 + else
75 + case Map.get(needs_by_id, id, []) do
76 + [] ->
77 + 0
78 +
79 + needs ->
80 + seen = MapSet.put(seen, id)
81 + 1 + (needs |> Enum.map(&job_depth(&1, needs_by_id, seen)) |> Enum.max(fn -> -1 end))
82 + end
83 + end
84 + end
85 +
58 86 @impl true
59 87 def handle_info({:workflow_log, step_id, seq, body}, socket) do
60 88 if has_step?(socket, step_id) do
@@ -83,6 +111,17 @@ defmodule GitGudWeb.WorkflowLive.Show do
83 111 {:noreply, assign(socket, :run, run)}
84 112 end
85 113
114 + # The run reached a terminal state — re-read so the status badge flips
115 + # and the Re-run button appears without a manual reload.
116 + def handle_info({:workflow_run_status, _run}, socket) do
117 + run = Workflows.get_run!(socket.assigns.repo, socket.assigns.run.number)
118 +
119 + {:noreply,
120 + socket
121 + |> assign(:run, run)
122 + |> assign(:artifacts, Workflows.list_artifacts(run))}
123 + end
124 +
86 125 # Defensive catch-all: ignore any other broadcast on the run topic
87 126 # rather than letting an unhandled message take the whole view down.
88 127 def handle_info(_msg, socket), do: {:noreply, socket}
@@ -219,10 +258,18 @@ defmodule GitGudWeb.WorkflowLive.Show do
219 258 </div>
220 259 </header>
221 260
222 <section class="space-y-4">
223 <article :for={job <- @run.jobs} class="border border-base-300 rounded p-4">
224 <header class="flex items-baseline justify-between gap-3">
225 <h2 class="font-semibold font-mono">{job.job_id}</h2>
261 + <section class="space-y-6">
262 + <div :for={stage <- job_stages(@run.jobs)} class="space-y-2">
263 + <div
264 + :if={length(stage) > 1}
265 + class="text-xs uppercase tracking-wide opacity-50 flex items-center gap-1.5"
266 + >
267 + <.icon name="hero-bolt" class="size-3" /> {length(stage)} jobs run in parallel
268 + </div>
269 + <div class={["grid gap-4", length(stage) > 1 && "lg:grid-cols-2"]}>
270 + <article :for={job <- stage} class="border border-base-300 rounded p-4">
271 + <header class="flex items-baseline justify-between gap-3">
272 + <h2 class="font-semibold font-mono">{job.name || job.job_id}</h2>
226 273 <div class="flex items-baseline gap-2 text-xs opacity-60">
227 274 <span :if={job.started_at}>{duration(job.started_at, job.completed_at)}</span>
228 275 <span class={["badge badge-sm", status_class(job.status)]}>{job.status}</span>
@@ -260,7 +307,9 @@ defmodule GitGudWeb.WorkflowLive.Show do
260 307 ><div :for={{dom_id, chunk} <- @streams[stream_name(step.id)]} id={dom_id}><%= chunk.body %></div></pre>
261 308 </li>
262 309 </ol>
263 </article>
310 + </article>
311 + </div>
312 + </div>
264 313 </section>
265 314
266 315 <section :if={@artifacts != []} class="space-y-2">

Parents: bc2e7a7