Org webhooks, CI dashboards, podman runner config, LV crash fix
f87fc1d · gmorell · 2026-06-24 14:49
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 stranger → public + internal repos | |
| 86 | + * anonymous → public 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