Chart how long a repository's runs have been taking

b55f15e · Gabriel Morell · 2026-09-26 03:46

17 files +637 -3
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/repositories.ex
+17 −0
@@ -51,6 +51,23 @@ defmodule GitGud.Repositories do
51 51 |> Repo.all()
52 52 end
53 53
54 + @doc """
55 + Repositories by id, with the namespace preloaded, as `%{id => repo}`.
56 +
57 + For a page that already knows which repositories a viewer may see —
58 + the org CI dashboard computes that set once — and needs to put names
59 + to them without asking per row.
60 + """
61 + def list_repositories_by_ids([]), do: %{}
62 +
63 + def list_repositories_by_ids(ids) when is_list(ids) do
64 + Repository
65 + |> where([r], r.id in ^ids)
66 + |> Repo.all()
67 + |> Repo.preload([:owner, :organization])
68 + |> Map.new(&{&1.id, &1})
69 + end
70 +
54 71 # A repo is the same-name profile repo when its `name` matches its
55 72 # namespace's handle. For org-owned repos that's the org's handle;
56 73 # for user-owned (no org) that's the user's handle. The COALESCE
modified lib/git_gud/workflows.ex
+92 −0
@@ -290,6 +290,98 @@ defmodule GitGud.Workflows do
290 290 |> Repo.all()
291 291 end
292 292
293 + @doc """
294 + How long a repository's runs have been taking, oldest first.
295 +
296 + Only runs that both started and finished: a queued run has no
297 + duration, and a running one's is still changing. `:limit` caps how
298 + far back it goes (default 40).
299 +
300 + Oldest first because the chart reads left to right in time, and a
301 + caller wanting "the last N" already got them — reversing a 40-element
302 + list in the view would be the same work in a worse place.
303 + """
304 + def run_durations(%Repository{id: rid}, opts \\ []) do
305 + limit = Keyword.get(opts, :limit, 40)
306 +
307 + duration_fields()
308 + |> where([r], r.repository_id == ^rid)
309 + |> order_by([r], desc: r.started_at)
310 + |> limit(^limit)
311 + |> Repo.all()
312 + |> Enum.reverse()
313 + end
314 +
315 + @doc """
316 + The same, for several repositories at once, as `%{repository_id => [point]}`.
317 +
318 + One query with a window rather than one per repository: an org
319 + dashboard draws a chart per repo, and a page that issues a query per
320 + row is a page that gets slower every time someone creates a
321 + repository.
322 +
323 + `:per_repo` caps each repository's series (default 20).
324 + """
325 + def run_durations_for_repos(repo_ids, opts \\ []) when is_list(repo_ids) do
326 + if repo_ids == [] do
327 + %{}
328 + else
329 + per_repo = Keyword.get(opts, :per_repo, 20)
330 +
331 + ranked =
332 + duration_fields()
333 + |> where([r], r.repository_id in ^repo_ids)
334 + |> windows([r], w: [partition_by: r.repository_id, order_by: [desc: r.started_at]])
335 + |> select_merge([r], %{rank: over(row_number(), :w)})
336 +
337 + from(p in subquery(ranked), where: p.rank <= ^per_repo)
338 + |> Repo.all()
339 + |> Enum.group_by(& &1.repository_id)
340 + |> Map.new(fn {rid, points} -> {rid, Enum.reverse(points)} end)
341 + end
342 + end
343 +
344 + # `seconds` is computed in the database rather than from the two
345 + # timestamps in Elixir, so a caller can sort or filter on it later
346 + # without re-deriving it, and so the numbers a chart draws are the
347 + # numbers the database has.
348 + defp duration_fields do
349 + from(r in WorkflowRun,
350 + where: not is_nil(r.started_at) and not is_nil(r.completed_at),
351 + select: %{
352 + repository_id: r.repository_id,
353 + number: r.number,
354 + workflow_name: r.workflow_name,
355 + status: r.status,
356 + conclusion: r.conclusion,
357 + started_at: r.started_at,
358 + seconds:
359 + fragment("greatest(0, extract(epoch from (? - ?))::int)", r.completed_at, r.started_at)
360 + }
361 + )
362 + end
363 +
364 + @doc """
365 + Middle duration of a series, in seconds, or nil for an empty one.
366 +
367 + The median rather than the mean: one pathological ten-minute run
368 + shouldn't move the number a person reads as "how long this usually
369 + takes".
370 + """
371 + def median_duration([]), do: nil
372 +
373 + def median_duration(points) do
374 + sorted = points |> Enum.map(& &1.seconds) |> Enum.sort()
375 + count = length(sorted)
376 + middle = div(count, 2)
377 +
378 + if rem(count, 2) == 1 do
379 + Enum.at(sorted, middle)
380 + else
381 + div(Enum.at(sorted, middle - 1) + Enum.at(sorted, middle), 2)
382 + end
383 + end
384 +
293 385 @active_statuses ~w(queued running)
294 386
295 387 @doc "Statuses considered 'in flight' for the dashboards."
modified lib/git_gud_web/live/ci_components.ex
+163 −0
@@ -10,6 +10,19 @@ defmodule GitGudWeb.CiComponents do
10 10 alias GitGud.Git
11 11 alias GitGud.Repositories.Storage
12 12
13 + # Drawn on a fixed grid and stretched to fit, rather than a grid sized
14 + # to the number of runs: a viewBox only as wide as its bars gets
15 + # stretched several times over by `preserveAspectRatio="none"`, which
16 + # turns 4px marks into fat ones and rounded ends into ellipses. At a
17 + # constant 400 the stretch is under 2x at any realistic width, so a
18 + # bar looks like a bar.
19 + @viewbox_width 400
20 +
21 + # Capped, so a repository with four runs draws four thin bars at the
22 + # left rather than four columns spread across the page. An empty
23 + # right-hand side is the truth: there is no more history yet.
24 + @max_pitch 24
25 +
13 26 @doc """
14 27 A status-filter tab strip. `current` is `:active | :all` and posts a
15 28 `"filter"` event with `phx-value-scope`.
@@ -105,6 +118,156 @@ defmodule GitGudWeb.CiComponents do
105 118 defp jobs_loaded?(%{jobs: jobs}) when is_list(jobs), do: true
106 119 defp jobs_loaded?(_), do: false
107 120
121 + @doc """
122 + How long a repository's runs have been taking, oldest left.
123 +
124 + One bar per finished run, height by duration, with a dashed line at
125 + the median so "slower lately" is something you can see rather than
126 + infer. Bars rather than a line: runs are discrete events at irregular
127 + times, and joining them would draw a trend through hours when nothing
128 + ran.
129 +
130 + Colour is status, not identity — the two chromatic states are the
131 + theme's own `success` and `error`, which is why this reads correctly
132 + in all four themes and why dark mode is each theme's chosen step
133 + rather than a flip of the light one. The pair is ΔE 11.6 (dark) and
134 + 14.6 (light) apart under deuteranopia, and neither carries meaning
135 + alone: every bar names its outcome in a tooltip, the legend spells
136 + the states out, and the run list below is the same data as text.
137 +
138 + `compact` drops the axis, the median line and the legend, for the
139 + sparkline an org dashboard draws per repository.
140 + """
141 + attr :points, :list, required: true, doc: "from `Workflows.run_durations/2`, oldest first"
142 + attr :median, :integer, default: nil
143 + attr :compact, :boolean, default: false
144 + attr :height, :integer, default: 96
145 + attr :class, :string, default: nil
146 +
147 + def duration_chart(assigns) do
148 + count = length(assigns.points)
149 + pitch = pitch(count)
150 +
151 + assigns =
152 + assigns
153 + |> assign(:count, count)
154 + |> assign(:pitch, pitch)
155 + |> assign(:bar_width, bar_width(pitch))
156 + |> assign(:peak, peak_seconds(assigns.points))
157 + |> assign(:viewbox_width, @viewbox_width)
158 +
159 + ~H"""
160 + <div class={@class}>
161 + <svg
162 + :if={@count > 0}
163 + viewBox={"0 0 #{@viewbox_width} #{@height}"}
164 + preserveAspectRatio="none"
165 + class="w-full"
166 + style={"height: #{@height}px"}
167 + role="img"
168 + aria-label={"Run durations, #{@count} finished runs, median #{humanize_secs(@median)}"}
169 + >
170 + <line
171 + :if={!@compact && @median}
172 + x1="0"
173 + x2={@count * @pitch}
174 + y1={baseline(@median, @peak, @height)}
175 + y2={baseline(@median, @peak, @height)}
176 + stroke="currentColor"
177 + stroke-width="1"
178 + stroke-dasharray="3 3"
179 + class="opacity-30"
180 + />
181 + <g :for={{point, index} <- Enum.with_index(@points)}>
182 + <rect
183 + x={index * @pitch}
184 + y={@height - bar_height(point.seconds, @peak, @height)}
185 + width={@bar_width}
186 + height={bar_height(point.seconds, @peak, @height)}
187 + rx="2"
188 + fill={outcome_fill(point)}
189 + >
190 + <title>
191 + #{point.number} {point.workflow_name} · {humanize_secs(point.seconds)} · {outcome(point)}
192 + </title>
193 + </rect>
194 + </g>
195 + </svg>
196 +
197 + <p :if={@count == 0} class="text-xs opacity-60">No finished runs yet.</p>
198 +
199 + <div :if={!@compact && @count > 0} class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
200 + <span class="text-xs opacity-70">
201 + median {humanize_secs(@median)} · peak {humanize_secs(@peak)} over {@count} run{if @count ==
202 + 1,
203 + do: "",
204 + else: "s"}
205 + </span>
206 + <span class="text-xs opacity-70 flex items-center gap-3">
207 + <span :for={{label, fill} <- legend()} class="flex items-center gap-1">
208 + <span class="inline-block size-2 rounded-sm" style={"background: #{fill}"} />{label}
209 + </span>
210 + </span>
211 + </div>
212 + </div>
213 + """
214 + end
215 +
216 + defp pitch(0), do: @max_pitch
217 + defp pitch(count), do: min(@viewbox_width / count, @max_pitch)
218 +
219 + defp bar_width(pitch), do: pitch |> Kernel.*(0.55) |> min(6.0) |> max(2.0)
220 +
221 + defp legend do
222 + [
223 + {"succeeded", "var(--color-success)"},
224 + {"failed", "var(--color-error)"},
225 + {"other", "var(--color-base-content)"}
226 + ]
227 + end
228 +
229 + # `conclusion` once a run has finished, else its raw status — the two
230 + # columns say different things and a finished run's verdict is in the
231 + # second one.
232 + defp outcome(%{status: status, conclusion: conclusion}) do
233 + if status in ~w(completed finished) and is_binary(conclusion), do: conclusion, else: status
234 + end
235 +
236 + defp outcome_fill(point) do
237 + case outcome(point) do
238 + "success" -> "var(--color-success)"
239 + "failure" -> "var(--color-error)"
240 + "timed_out" -> "var(--color-error)"
241 + # Cancelled, skipped, and anything a newer server invents: a
242 + # state, but not one anybody needs to pick out of the row.
243 + _ -> "color-mix(in oklab, var(--color-base-content) 35%, transparent)"
244 + end
245 + end
246 +
247 + # Named in the summary as well as drawn: without a y-axis, the peak is
248 + # what tells a reader how tall the tallest bar is.
249 + #
250 + # The tallest bar is the longest run, so a repository whose runs take
251 + # nine seconds gets a readable chart and so does one whose runs take
252 + # an hour. A floor of one second keeps a series of instant runs from
253 + # dividing by zero.
254 + defp peak_seconds([]), do: 1
255 + defp peak_seconds(points), do: points |> Enum.map(& &1.seconds) |> Enum.max() |> max(1)
256 +
257 + # A finished run always draws something: a zero-height bar reads as
258 + # "no run", which is a different fact from "a run that took no time".
259 + defp bar_height(seconds, peak, height), do: max(2, round(seconds / peak * (height - 4)))
260 +
261 + defp baseline(seconds, peak, height), do: height - bar_height(seconds, peak, height)
262 +
263 + defp humanize_secs(nil), do: "—"
264 + defp humanize_secs(s) when s < 60, do: "#{s}s"
265 + defp humanize_secs(s) when s < 3600, do: "#{div(s, 60)}m#{pad(rem(s, 60))}s"
266 + defp humanize_secs(s), do: "#{div(s, 3600)}h#{pad(div(rem(s, 3600), 60))}m"
267 +
268 + defp pad(n) when n < 10, do: "0#{n}"
269 + defp pad(n), do: "#{n}"
270 +
108 271 def status_class("success"), do: "badge-success"
109 272 def status_class("running"), do: "badge-info"
110 273 def status_class("queued"), do: "badge-warning"
modified lib/git_gud_web/live/org_live/actions.ex
+83 −2
@@ -34,7 +34,8 @@ defmodule GitGudWeb.OrgLive.Actions do
34 34 |> assign(:repo_ids, repo_ids)
35 35 |> assign(:filter, :active)
36 36 |> assign(:page_title, "CI — @#{org.handle}")
37 − |> load_runs()}
37 + |> load_runs()
38 + |> load_durations()}
38 39 else
39 40 {:ok,
40 41 socket
@@ -43,6 +44,19 @@ defmodule GitGudWeb.OrgLive.Actions do
43 44 end
44 45 end
45 46
47 + defp repo_handle(repo), do: GitGud.Repositories.Storage.repo_handle(repo)
48 +
49 + # Minutes *and* seconds under an hour: three repositories whose runs
50 + # take 70, 95 and 110 seconds all read as "1m" otherwise, which is
51 + # the column saying nothing.
52 + defp humanize(nil), do: "—"
53 + defp humanize(s) when s < 60, do: "#{s}s"
54 + defp humanize(s) when s < 3600, do: "#{div(s, 60)}m#{pad(rem(s, 60))}s"
55 + defp humanize(s), do: "#{div(s, 3600)}h#{pad(div(rem(s, 3600), 60))}m"
56 +
57 + defp pad(n) when n < 10, do: "0#{n}"
58 + defp pad(n), do: "#{n}"
59 +
46 60 defp can_view?(%{visibility: "public"}, _user), do: true
47 61 defp can_view?(%{visibility: "internal"}, %{} = _user), do: true
48 62 defp can_view?(%{visibility: "internal"}, nil), do: false
@@ -72,7 +86,39 @@ defmodule GitGudWeb.OrgLive.Actions do
72 86
73 87 @impl true
74 88 def handle_info({:ci_run_changed, _run_id}, socket) do
75 − {:noreply, load_runs(socket)}
89 + {:noreply, socket |> load_runs() |> load_durations()}
90 + end
91 +
92 + # Small multiples: one series per repository, all from one query, so
93 + # the page costs the same whether the org has three repositories with
94 + # CI or thirty.
95 + #
96 + # Ordered by the repository whose last run is newest — an org
97 + # dashboard is read to see what has been happening — and capped,
98 + # because past a screenful of sparklines nobody is reading them.
99 + @chart_repos 12
100 + @per_repo 20
101 +
102 + defp load_durations(socket) do
103 + histories = Workflows.run_durations_for_repos(socket.assigns.repo_ids, per_repo: @per_repo)
104 + repos = Repositories.list_repositories_by_ids(Map.keys(histories))
105 +
106 + series =
107 + histories
108 + |> Enum.flat_map(fn {rid, points} ->
109 + case Map.fetch(repos, rid) do
110 + {:ok, repo} ->
111 + [%{repo: repo, points: points, median: Workflows.median_duration(points)}]
112 +
113 + :error ->
114 + []
115 + end
116 + end)
117 + |> Enum.sort_by(&(&1.points |> List.last() |> Map.fetch!(:started_at)), {:desc, DateTime})
118 +
119 + socket
120 + |> assign(:duration_total, length(series))
121 + |> assign(:duration_series, Enum.take(series, @chart_repos))
76 122 end
77 123
78 124 @impl true
@@ -90,6 +136,41 @@ defmodule GitGudWeb.OrgLive.Actions do
90 136 <.filter_tabs current={@filter} active_count={@active_count} />
91 137 </header>
92 138
139 + <section :if={@duration_series != []} class="rounded-box border border-base-300 p-3">
140 + <header class="flex items-baseline justify-between mb-2">
141 + <h2 class="text-sm font-medium">Run times</h2>
142 + <span :if={@duration_total > length(@duration_series)} class="text-xs opacity-60">
143 + {length(@duration_series)} of {@duration_total} repositories
144 + </span>
145 + </header>
146 +
147 + <ul class="space-y-2">
148 + <li
149 + :for={series <- @duration_series}
150 + class="grid grid-cols-[minmax(6rem,12rem)_1fr_auto] items-center gap-3"
151 + >
152 + <.link
153 + navigate={~p"/r/#{repo_handle(series.repo)}/#{series.repo.name}/actions"}
154 + class="link link-hover text-xs font-mono truncate"
155 + >
156 + {series.repo.name}
157 + </.link>
158 + <GitGudWeb.CiComponents.duration_chart
159 + points={series.points}
160 + median={series.median}
161 + compact={true}
162 + height={28}
163 + />
164 + <span class="text-xs opacity-70 tabular-nums">{humanize(series.median)}</span>
165 + </li>
166 + </ul>
167 +
168 + <p class="text-xs opacity-60 mt-2">
169 + Each bar is one finished run, oldest left, scaled to that repository's own
170 + longest run; the number is its median.
171 + </p>
172 + </section>
173 +
93 174 <ul class="divide-y divide-base-300">
94 175 <li :if={@runs == []} class="py-6 opacity-60 text-sm">
95 176 {if @filter == :active, do: "No runs in flight.", else: "No workflow runs yet."}
modified lib/git_gud_web/live/workflow_live/index.ex
+21 −1
@@ -25,6 +25,7 @@ defmodule GitGudWeb.WorkflowLive.Index do
25 25 |> assign(:handle, Storage.repo_handle(repo))
26 26 |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
27 27 |> stream(:runs, runs)
28 + |> assign_durations()
28 29 |> assign(:page_title, "Actions — #{owner}/#{name}")}
29 30 end
30 31
@@ -35,11 +36,22 @@ defmodule GitGudWeb.WorkflowLive.Index do
35 36 {:noreply,
36 37 socket
37 38 |> stream(:runs, Workflows.list_runs(repo), reset: true)
38 − |> assign(:latest_run, Workflows.latest_run(repo))}
39 + |> assign(:latest_run, Workflows.latest_run(repo))
40 + |> assign_durations()}
39 41 end
40 42
41 43 def handle_info(_msg, socket), do: {:noreply, socket}
42 44
45 + # A run finishing is what adds a bar, so the chart is recomputed on
46 + # the same event that redraws the list.
47 + defp assign_durations(socket) do
48 + durations = Workflows.run_durations(socket.assigns.repo)
49 +
50 + socket
51 + |> assign(:durations, durations)
52 + |> assign(:median_duration, Workflows.median_duration(durations))
53 + end
54 +
43 55 @impl true
44 56 def render(assigns) do
45 57 ~H"""
@@ -56,6 +68,14 @@ defmodule GitGudWeb.WorkflowLive.Index do
56 68 />
57 69 <h1 class="text-xl font-semibold">Actions</h1>
58 70
71 + <section :if={@durations != []} class="rounded-box border border-base-300 p-3">
72 + <h2 class="text-sm font-medium mb-2">Run times</h2>
73 + <GitGudWeb.CiComponents.duration_chart
74 + points={@durations}
75 + median={@median_duration}
76 + />
77 + </section>
78 +
59 79 <ul id="runs" phx-update="stream" class="divide-y divide-base-300">
60 80 <li id="runs-empty" class="hidden only:block py-6 opacity-60 text-sm">
61 81 No workflow runs yet.
added test/git_gud/workflows_durations_test.exs
+141 −0
@@ -0,0 +1,141 @@
1 +defmodule GitGud.WorkflowsDurationsTest do
2 + @moduledoc """
3 + The run-duration history behind the CI charts.
4 +
5 + What the charts need and the run list doesn't: only finished runs,
6 + oldest first, with the duration computed once in the database rather
7 + than re-derived per view.
8 + """
9 + use GitGud.DataCase, async: false
10 +
11 + import GitGud.ForgeFixtures
12 +
13 + alias GitGud.Workflows
14 + alias GitGud.Workflows.WorkflowRun
15 +
16 + setup do
17 + {user, repo} = repository_fixture()
18 + %{user: user, repo: repo}
19 + end
20 +
21 + defp run(repo, number, opts) do
22 + # `:utc_datetime` columns reject microseconds, and `utc_now/0` has
23 + # them.
24 + default_start = DateTime.add(DateTime.utc_now(:second), -number * 60, :second)
25 + started = Keyword.get(opts, :started, default_start)
26 + seconds = Keyword.get(opts, :seconds)
27 +
28 + %WorkflowRun{repository_id: repo.id}
29 + |> WorkflowRun.create_changeset(%{
30 + number: number,
31 + workflow_path: ".forgejo/workflows/build.yml",
32 + trigger: "push",
33 + head_sha: :crypto.strong_rand_bytes(20)
34 + })
35 + |> Ecto.Changeset.change(
36 + status: Keyword.get(opts, :status, "success"),
37 + conclusion: Keyword.get(opts, :conclusion, "success"),
38 + started_at: started,
39 + completed_at: seconds && DateTime.add(started, seconds, :second)
40 + )
41 + |> GitGud.Repo.insert!()
42 + end
43 +
44 + describe "run_durations/2" do
45 + test "returns finished runs oldest first, with their duration", %{repo: repo} do
46 + run(repo, 1, seconds: 30, started: ~U[2026-09-01 10:00:00Z])
47 + run(repo, 2, seconds: 90, started: ~U[2026-09-02 10:00:00Z])
48 +
49 + assert [first, second] = Workflows.run_durations(repo)
50 +
51 + assert first.number == 1
52 + assert first.seconds == 30
53 + assert second.number == 2
54 + assert second.seconds == 90
55 + end
56 +
57 + test "skips runs that haven't finished", %{repo: repo} do
58 + run(repo, 1, seconds: 30)
59 + run(repo, 2, seconds: nil, status: "running", conclusion: nil)
60 +
61 + assert [only] = Workflows.run_durations(repo)
62 + assert only.number == 1
63 + end
64 +
65 + test "keeps failures — a slow failing run is the interesting one", %{repo: repo} do
66 + run(repo, 1, seconds: 10, status: "failure", conclusion: "failure")
67 +
68 + assert [point] = Workflows.run_durations(repo)
69 + assert point.conclusion == "failure"
70 + end
71 +
72 + test "honours the limit, keeping the most recent", %{repo: repo} do
73 + for n <- 1..5,
74 + do:
75 + run(repo, n,
76 + seconds: n,
77 + started: DateTime.add(~U[2026-09-01 00:00:00Z], n * 3600, :second)
78 + )
79 +
80 + points = Workflows.run_durations(repo, limit: 2)
81 +
82 + assert Enum.map(points, & &1.number) == [4, 5]
83 + end
84 +
85 + test "a clock that went backwards doesn't make a negative bar", %{repo: repo} do
86 + run(repo, 1, seconds: -30)
87 +
88 + assert [point] = Workflows.run_durations(repo)
89 + assert point.seconds == 0
90 + end
91 + end
92 +
93 + describe "run_durations_for_repos/2" do
94 + test "groups by repository in one query", %{repo: repo, user: user} do
95 + {_user, other} = repository_fixture(%{owner: user})
96 + run(repo, 1, seconds: 10)
97 + run(other, 1, seconds: 20)
98 +
99 + histories = Workflows.run_durations_for_repos([repo.id, other.id])
100 +
101 + assert [%{seconds: 10}] = histories[repo.id]
102 + assert [%{seconds: 20}] = histories[other.id]
103 + end
104 +
105 + test "caps each repository's series independently", %{repo: repo} do
106 + for n <- 1..5,
107 + do:
108 + run(repo, n,
109 + seconds: n,
110 + started: DateTime.add(~U[2026-09-01 00:00:00Z], n * 3600, :second)
111 + )
112 +
113 + assert [%{number: 4}, %{number: 5}] =
114 + Workflows.run_durations_for_repos([repo.id], per_repo: 2)[repo.id]
115 + end
116 +
117 + test "is empty for no repositories, rather than querying for none" do
118 + assert Workflows.run_durations_for_repos([]) == %{}
119 + end
120 + end
121 +
122 + describe "median_duration/1" do
123 + test "is the middle of an odd series" do
124 + assert Workflows.median_duration([%{seconds: 10}, %{seconds: 90}, %{seconds: 20}]) == 20
125 + end
126 +
127 + test "averages the two middles of an even one" do
128 + assert Workflows.median_duration([%{seconds: 10}, %{seconds: 20}]) == 15
129 + end
130 +
131 + test "resists one pathological run, which a mean would not" do
132 + points = Enum.map([10, 10, 10, 10, 6000], &%{seconds: &1})
133 +
134 + assert Workflows.median_duration(points) == 10
135 + end
136 +
137 + test "is nil for nothing" do
138 + assert Workflows.median_duration([]) == nil
139 + end
140 + end
141 +end
added test/git_gud_web/live/ci_duration_chart_test.exs
+120 −0
@@ -0,0 +1,120 @@
1 +defmodule GitGudWeb.CiDurationChartTest do
2 + @moduledoc """
3 + The run-time history on both CI dashboards.
4 +
5 + Colour is status here, so the things that keep it readable without
6 + colour are asserted too: every bar names its outcome in a tooltip,
7 + and the legend spells the states out.
8 + """
9 + use GitGudWeb.ConnCase, async: false
10 +
11 + import Phoenix.LiveViewTest
12 + import GitGud.ForgeFixtures
13 +
14 + alias GitGud.Workflows.WorkflowRun
15 +
16 + setup %{conn: conn} do
17 + {user, repo} = repository_fixture(%{visibility: "public"})
18 + %{conn: log_in_user(conn, user), user: user, repo: repo, handle: user.handle}
19 + end
20 +
21 + defp finished_run(repo, number, seconds, conclusion \\ "success") do
22 + started = DateTime.add(~U[2026-09-01 00:00:00Z], number * 3600, :second)
23 +
24 + %WorkflowRun{repository_id: repo.id}
25 + |> WorkflowRun.create_changeset(%{
26 + number: number,
27 + workflow_path: ".forgejo/workflows/build.yml",
28 + workflow_name: "build",
29 + trigger: "push",
30 + head_sha: :crypto.strong_rand_bytes(20)
31 + })
32 + |> Ecto.Changeset.change(
33 + status: conclusion,
34 + conclusion: conclusion,
35 + started_at: started,
36 + completed_at: DateTime.add(started, seconds, :second)
37 + )
38 + |> GitGud.Repo.insert!()
39 + end
40 +
41 + describe "the repository dashboard" do
42 + test "charts finished runs with a median", %{conn: conn, repo: repo, handle: handle} do
43 + finished_run(repo, 1, 30)
44 + finished_run(repo, 2, 90)
45 + finished_run(repo, 3, 600, "failure")
46 +
47 + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions")
48 +
49 + assert html =~ "Run times"
50 + # The peak bounds the scale for a chart with no y-axis.
51 + assert html =~ "median 1m30s"
52 + assert html =~ "peak 10m00s"
53 + assert html =~ "over 3 runs"
54 +
55 + # Status is never colour alone: each bar says what it was.
56 + assert html =~ "1m30s"
57 + assert html =~ "10m00s"
58 + assert html =~ "succeeded"
59 + assert html =~ "failed"
60 + end
61 +
62 + test "says nothing at all when no run has finished", %{conn: conn, repo: repo, handle: handle} do
63 + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions")
64 +
65 + refute html =~ "Run times"
66 + end
67 +
68 + test "a running run isn't charted — its duration is still changing", %{
69 + conn: conn,
70 + repo: repo,
71 + handle: handle
72 + } do
73 + finished_run(repo, 1, 45)
74 +
75 + %WorkflowRun{repository_id: repo.id}
76 + |> WorkflowRun.create_changeset(%{
77 + number: 2,
78 + workflow_path: ".forgejo/workflows/build.yml",
79 + trigger: "push",
80 + head_sha: :crypto.strong_rand_bytes(20)
81 + })
82 + |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second))
83 + |> GitGud.Repo.insert!()
84 +
85 + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions")
86 +
87 + assert html =~ "over 1 run"
88 + end
89 + end
90 +
91 + describe "the organization dashboard" do
92 + setup %{user: user} do
93 + {:ok, org} =
94 + GitGud.Organizations.create_organization(user, %{
95 + "handle" => "org#{System.unique_integer([:positive])}",
96 + "name" => "Test Org"
97 + })
98 +
99 + {:ok, repo} =
100 + GitGud.Repositories.create_repository_for_org(org, user, %{
101 + "name" => "widgets",
102 + "visibility" => "public"
103 + })
104 +
105 + %{org: org, org_repo: repo}
106 + end
107 +
108 + test "draws one sparkline per repository", %{conn: conn, org: org, org_repo: repo} do
109 + finished_run(repo, 1, 120)
110 + finished_run(repo, 2, 240)
111 +
112 + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.handle}/actions")
113 +
114 + assert html =~ "Run times"
115 + assert html =~ "widgets"
116 + # The median, in the compact spelling the row uses.
117 + assert html =~ "3m"
118 + end
119 + end
120 +end

Parents: 1003c50