Org webhooks, CI dashboards, podman runner config, LV crash fix

f87fc1d · gmorell · 2026-06-24 14:49

27 files +1092 -78
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+25 −0
@@ -12,6 +12,31 @@ intermediate "I was here when I stopped" state) lives in the
12 12
13 13 ---
14 14
15 +## Slice 58 — Org webhooks + CI dashboards
16 +
17 +Extends webhooks to org scope and adds two CI "where do my builds stand" views: an org-wide single pane of glass and a per-user feed.
18 +
19 +### Org-scoped webhooks
20 +
21 +- **`webhooks` is now repo-XOR-org owned.** Migration `add_org_webhooks_and_run_actor` makes `repository_id` nullable, adds `organization_id`, and a `webhooks_one_owner` check constraint (exactly one owner). `Webhook` schema gains `belongs_to :organization`.
22 +- **`Webhooks.notify/3` fans out to both** the repo's hooks and (when org-owned) the owning org's hooks via a single `dynamic/2` OR-clause. Now takes a full `%Repository{}` (reads `organization_id`).
23 +- **`workflow_run` event is finally emitted** — it was a declared known-event with no caller. `Workflows.notify_run_webhook/1` fires on terminal run state (`maybe_complete_run` + `cancel/1`), best-effort/rescued.
24 +- **Org webhook UI** at `/orgs/:handle/settings/webhooks` (admin-only) with inline recent-deliveries log (`Webhooks.recent_deliveries_by_hook/2`). Nav links added to `org_settings_header` + org show page.
25 +
26 +### CI dashboards
27 +
28 +- **Org single pane of glass** `/orgs/:handle/actions` (`OrgLive.Actions`) — every run across the org's repos the viewer may see. Visibility respected via `Repositories.visible_repo_ids_for_org/2`.
29 +- **Per-user view** `/users/ci` (`UserLive.Ci`) — runs in repos the user owns / is an org member of / has team access to (`Repositories.relevant_repo_ids_for_user/1`), unioned with runs they triggered. Linked from the user dropdown.
30 +- **Live updates** via one instance-wide PubSub topic `Workflows.ci_topic()` (`"ci:runs"`); subscribers re-query their visibility-scoped list on `{:ci_run_changed, run_id}`. Broadcast from `update_run_commit_status/1` + the claim→running sites. Shared rendering in `GitGudWeb.CiComponents`.
31 +
32 +### Pusher attribution
33 +
34 +- **`workflow_runs.triggered_by_id`** threaded end-to-end so the user view can show "runs I triggered": SSH `Ssh.Channel` sets `GITGUD_PUSHER_ID` in the git Port env; smart-HTTP injects it into the CGI env; `gitgud-hook` forwards `x-pusher-id`; `HookController``HookReceiver.receive/3``CommitBackfill``Workflows.dispatch`. Installed per-repo hooks just `exec` the central script, so only `gitgud-hook` changed.
35 +
36 +### Pending
37 +
38 +- Migration not yet applied — dev Postgres was down at write time; run `mix ecto.migrate` once it's up.
39 +
15 40 ## Slice 57 — libcluster + L1/L2 distributed Nebulex
16 41
17 42 Lifts the cache from per-node ETS to a two-tier cluster cache so the syntax-highlight and rendered-markdown payloads survive request landings on any pod.
modified lib/git_gud/repositories.ex
+58 −0
@@ -77,6 +77,64 @@ defmodule GitGud.Repositories do
77 77 |> Repo.all()
78 78 end
79 79
80 + @doc """
81 + Ids of repos in `org` that `viewer` may see. Mirrors the org page's
82 + visibility policy:
83 +
84 + * org member → every repo the org owns
85 + * logged-in strangerpublic + internal repos
86 + * anonymouspublic repos only
87 +
88 + Used by the org CI dashboard to scope which runs are listed.
89 + """
90 + def visible_repo_ids_for_org(%Organization{id: oid} = org, viewer) do
91 + member? = viewer && GitGud.Organizations.member?(org, viewer)
92 +
93 + query =
94 + cond do
95 + member? ->
96 + from(r in Repository, where: r.organization_id == ^oid)
97 +
98 + not is_nil(viewer) ->
99 + from(r in Repository,
100 + where: r.organization_id == ^oid and r.visibility in ["public", "internal"]
101 + )
102 +
103 + true ->
104 + from(r in Repository,
105 + where: r.organization_id == ^oid and r.visibility == "public"
106 + )
107 + end
108 +
109 + query |> select([r], r.id) |> Repo.all()
110 + end
111 +
112 + @doc """
113 + Ids of every repo a user "cares about": repos they own, repos in any
114 + org they're a member of, and repos granted to them via a team. Used
115 + by the personal CI dashboard.
116 + """
117 + def relevant_repo_ids_for_user(%User{id: uid}) do
118 + owned_and_member =
119 + from(r in Repository,
120 + left_join: m in GitGud.Organizations.Membership,
121 + on: m.organization_id == r.organization_id and m.user_id == ^uid,
122 + where: r.owner_id == ^uid or not is_nil(m.id),
123 + select: r.id
124 + )
125 + |> Repo.all()
126 +
127 + team_granted =
128 + from(tr in GitGud.Organizations.TeamRepository,
129 + join: tm in GitGud.Organizations.TeamMembership,
130 + on: tm.team_id == tr.team_id and tm.user_id == ^uid,
131 + select: tr.repository_id
132 + )
133 + |> Repo.all()
134 +
135 + (owned_and_member ++ team_granted) |> Enum.uniq()
136 + end
137 +
80 138 @doc """
81 139 Repos a user can pin to their personal profile: their own + any repo
82 140 in an organization they're a member of. Returns rows with `:owner` +
modified lib/git_gud/repositories/hook_receiver.ex
+7 −2
@@ -18,8 +18,12 @@ defmodule GitGud.Repositories.HookReceiver do
18 18
19 19 @doc """
20 20 Process raw post-receive stdin. Returns the list of job results.
21 +
22 + `pusher_id` (optional) is the id of the user who pushed, threaded
23 + from the SSH/HTTP transport via the `gitgud-hook` bridge so workflow
24 + runs can be attributed to them. nil when unattributable.
21 25 """
22 def receive(repository_id, stdin) when is_binary(stdin) do
26 + def receive(repository_id, stdin, pusher_id \\ nil) when is_binary(stdin) do
23 27 triples =
24 28 stdin
25 29 |> String.split("\n", trim: true)
@@ -34,7 +38,8 @@ defmodule GitGud.Repositories.HookReceiver do
34 38 repository_id: repository_id,
35 39 ref: ref,
36 40 old_sha_hex: old_hex,
37 new_sha_hex: new_hex
41 + new_sha_hex: new_hex,
42 + pusher_id: pusher_id
38 43 }
39 44 |> CommitBackfill.new_job()
40 45 |> Oban.insert()
modified lib/git_gud/repositories/workers/commit_backfill.ex
+14 −6
@@ -24,9 +24,10 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
24 24 "ref" => ref,
25 25 "old_sha_hex" => old_hex,
26 26 "new_sha_hex" => new_hex
27 }
27 + } = args
28 28 }) do
29 29 repo = Repositories.get_repository!(rid)
30 + pusher_id = args["pusher_id"]
30 31
31 32 with {:ok, old_sha} <- maybe_hex(old_hex),
32 33 {:ok, new_sha} <- Git.from_hex(new_hex) do
@@ -38,7 +39,7 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
38 39 else
39 40 :ok = walk_and_cache(repo, old_sha, new_sha)
40 41 Webhooks.notify(repo, "push", push_payload(repo, ref, old_sha, new_sha, :update))
41 dispatch_workflows(repo, ref, new_sha)
42 + dispatch_workflows(repo, ref, new_sha, pusher_id)
42 43 publish_push(repo, ref, old_sha, new_sha)
43 44 refresh_cross_repo_prs(repo, ref, new_sha)
44 45 enqueue_language_stats(repo, ref, new_sha)
@@ -122,7 +123,7 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
122 123 defp sha_hex(<<0::size(160)>>), do: nil
123 124 defp sha_hex(sha) when is_binary(sha), do: Git.to_hex(sha)
124 125
125 defp dispatch_workflows(repo, ref, head_sha) do
126 + defp dispatch_workflows(repo, ref, head_sha, pusher_id) do
126 127 trigger =
127 128 cond do
128 129 String.starts_with?(ref, "refs/heads/") -> "push"
@@ -131,7 +132,13 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
131 132 end
132 133
133 134 if trigger do
134 _ = Workflows.dispatch(repo, %{trigger: trigger, head_sha: head_sha, ref: ref})
135 + _ =
136 + Workflows.dispatch(repo, %{
137 + trigger: trigger,
138 + head_sha: head_sha,
139 + ref: ref,
140 + triggered_by_id: pusher_id
141 + })
135 142 end
136 143 end
137 144
@@ -200,12 +207,13 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
200 207 ref: ref,
201 208 old_sha_hex: old_hex,
202 209 new_sha_hex: new_hex
203 }) do
210 + } = attrs) do
204 211 __MODULE__.new(%{
205 212 "repository_id" => rid,
206 213 "ref" => ref,
207 214 "old_sha_hex" => old_hex,
208 "new_sha_hex" => new_hex
215 + "new_sha_hex" => new_hex,
216 + "pusher_id" => Map.get(attrs, :pusher_id)
209 217 })
210 218 end
211 219 end
modified lib/git_gud/ssh/channel.ex
+12 −5
@@ -96,9 +96,9 @@ defmodule GitGud.Ssh.Channel do
96 96 case parse_command(cmd_str) do
97 97 {:ok, git_cmd, repo_arg} ->
98 98 case authorize(state.user, git_cmd, repo_arg) do
99 {:ok, disk_path} ->
99 + {:ok, disk_path, pusher_id} ->
100 100 Logger.info("ssh exec ok: #{git_cmd} #{disk_path}")
101 port = open_git_port(git_cmd, disk_path)
101 + port = open_git_port(git_cmd, disk_path, pusher_id)
102 102
103 103 if want_reply, do: :ssh_connection.reply_request(cm, want_reply, :success, ch)
104 104
@@ -172,7 +172,7 @@ defmodule GitGud.Ssh.Channel do
172 172 {:ok, repo} <- find_repo(owner, name),
173 173 {:ok, user} <- find_user(user_handle),
174 174 :ok <- check_access(user, repo, git_cmd) do
175 {:ok, repo.disk_path}
175 + {:ok, repo.disk_path, user.id}
176 176 else
177 177 err ->
178 178 {:deny, format_deny(err)}
@@ -251,7 +251,7 @@ defmodule GitGud.Ssh.Channel do
251 251
252 252 # ── git subprocess ──────────────────────────────────────────────────
253 253
254 defp open_git_port(git_cmd, disk_path) do
254 + defp open_git_port(git_cmd, disk_path, pusher_id) do
255 255 # `git-receive-pack` / `git-upload-pack` are exposed as standalone
256 256 # binaries (legacy dashed form), but invoking `git` with that string
257 257 # as the first arg fails: git treats it as a subcommand and reports
@@ -272,7 +272,14 @@ defmodule GitGud.Ssh.Channel do
272 272 :exit_status,
273 273 :use_stdio,
274 274 args: [subcommand, disk_path]
275 ]
275 + ] ++ port_env(pusher_id)
276 276 )
277 277 end
278 +
279 + # Pass the pusher's id into the git environment so the post-receive
280 + # hook (`gitgud-hook`) can attribute the runs this push triggers.
281 + defp port_env(nil), do: []
282 +
283 + defp port_env(pusher_id),
284 + do: [env: [{~c"GITGUD_PUSHER_ID", String.to_charlist(Integer.to_string(pusher_id))}]]
278 285 end
modified lib/git_gud/webhooks.ex
+58 −10

Click to load diff…

modified lib/git_gud/webhooks/webhook.ex
+5 −0

Click to load diff…

modified lib/git_gud/workflows.ex
+140 −9

Click to load diff…

modified lib/git_gud/workflows/runners.ex
+65 −0

Click to load diff…

modified lib/git_gud/workflows/workflow_run.ex
+6 −1

Click to load diff…

modified lib/git_gud_web/components/core_components.ex
+5 −0

Click to load diff…

modified lib/git_gud_web/components/layouts.ex
+5 −0

Click to load diff…

modified lib/git_gud_web/controllers/hook_controller.ex
+13 −1

Click to load diff…

added lib/git_gud_web/live/ci_components.ex
+135 −0

Click to load diff…

added lib/git_gud_web/live/org_live/actions.ex
+103 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/settings_runners.ex
+7 −17

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+11 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+7 −17

Click to load diff…

added lib/git_gud_web/live/user_live/ci.ex
+85 −0

Click to load diff…

modified lib/git_gud_web/live/webhook_live/index.ex
+41 −1

Click to load diff…

added lib/git_gud_web/live/webhook_live/org.ex
+187 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+14 −0

Click to load diff…

modified lib/git_gud_web/plugs/git_smart_http.ex
+14 −2

Click to load diff…

modified lib/git_gud_web/router.ex
+3 −0

Click to load diff…

modified lib/mix/tasks/gitgud.runner.create.ex
+14 −7

Click to load diff…

added priv/repo/migrations/20260623000000_add_org_webhooks_and_run_actor.exs
+48 −0

Click to load diff…

modified priv/scripts/gitgud-hook
+10 −0

Click to load diff…

Parents: 5bc80b5