neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Mobile API: the live CI wall, for the app
2b51ebb · gmorell · 2026-11-03 19:26
Message
{commit_body(@commit)}
Files changed
added
lib/git_gud/workflows/log_line.ex
+23
−0
@@ -0,0 +1,23 @@
| 1 | +defmodule GitGud.Workflows.LogLine do | |
| 2 | + @moduledoc """ | |
| 3 | + A log line as one plain screen line: no colour codes, no | |
| 4 | + carriage-return progress bars (the last frame wins), nothing a CI wall | |
| 5 | + card can't show. Shared by the web wall and the mobile API's, so a TV | |
| 6 | + and a browser show a job the same way. | |
| 7 | + """ | |
| 8 | + | |
| 9 | + @max 400 | |
| 10 | + @ansi ~r/\e\[[0-9;?]*[ -\/]*[@-~]|\e\][^\a]*\a/ | |
| 11 | + | |
| 12 | + @spec plain(term()) :: String.t() | |
| 13 | + def plain(body) when is_binary(body) do | |
| 14 | + body | |
| 15 | + |> String.replace(@ansi, "") | |
| 16 | + |> String.split("\r") | |
| 17 | + |> List.last() | |
| 18 | + |> String.trim_trailing() | |
| 19 | + |> String.slice(0, @max) | |
| 20 | + end | |
| 21 | + | |
| 22 | + def plain(_), do: "" | |
| 23 | +end |
added
lib/git_gud_web/controllers/api/mobile/ci_wall_controller.ex
+105
−0
@@ -0,0 +1,105 @@
| 1 | +defmodule GitGudWeb.Api.Mobile.CiWallController do | |
| 2 | + @moduledoc """ | |
| 3 | + The live CI wall for the app: every job running right now, each with | |
| 4 | + the tail of its log, for a tablet or TV to tile and poll. The same | |
| 5 | + jobs the web's `/users/ci/live` and `/orgs/:handle/actions/live` show. | |
| 6 | + | |
| 7 | + * `GET /ci/live` — the token owner's: jobs in repositories they own, | |
| 8 | + belong to through an org, or reach through a team, and runs they | |
| 9 | + triggered anywhere. | |
| 10 | + * `GET /orgs/:org/ci/live` — one organization's, as far as the | |
| 11 | + token owner may see its repositories. An org they can't see is a | |
| 12 | + 404, as anywhere in this API. | |
| 13 | + | |
| 14 | + Both take `ci:read`, and `lines` (default 80, at most 400): how | |
| 15 | + much of each job's log to send, oldest first, as plain text. Jobs come | |
| 16 | + oldest-started first; the app reverses them itself. | |
| 17 | + | |
| 18 | + A job's token has no wall: it reaches its own repository and no other. | |
| 19 | + """ | |
| 20 | + use GitGudWeb, :controller | |
| 21 | + | |
| 22 | + import GitGudWeb.Api.Mobile.Helpers | |
| 23 | + | |
| 24 | + alias GitGud.Organizations | |
| 25 | + alias GitGud.Repositories | |
| 26 | + alias GitGud.Workflows | |
| 27 | + alias GitGud.Workflows.LogLine | |
| 28 | + alias GitGudWeb.Api.Mobile.Auth | |
| 29 | + alias GitGudWeb.Api.Mobile.JSON | |
| 30 | + alias GitGudWeb.OrgLive.Actions | |
| 31 | + | |
| 32 | + @default_lines 80 | |
| 33 | + @max_lines 400 | |
| 34 | + | |
| 35 | + def user(conn, params) do | |
| 36 | + with {:ok, user} <- Auth.require_scope(conn, "ci:read"), | |
| 37 | + :ok <- person(conn) do | |
| 38 | + jobs = | |
| 39 | + Workflows.list_running_jobs_for_user( | |
| 40 | + user.id, | |
| 41 | + Repositories.relevant_repo_ids_for_user(user) | |
| 42 | + ) | |
| 43 | + | |
| 44 | + wall(conn, jobs, params) | |
| 45 | + else | |
| 46 | + {:error, conn} -> conn | |
| 47 | + end | |
| 48 | + end | |
| 49 | + | |
| 50 | + def org(conn, %{"org" => handle} = params) do | |
| 51 | + with {:ok, user} <- Auth.require_scope(conn, "ci:read"), | |
| 52 | + :ok <- person(conn), | |
| 53 | + {:ok, org} <- fetch_org(conn, user, handle) do | |
| 54 | + jobs = | |
| 55 | + org | |
| 56 | + |> Repositories.visible_repo_ids_for_org(user) | |
| 57 | + |> Workflows.list_running_jobs_for_repos() | |
| 58 | + | |
| 59 | + wall(conn, jobs, params) | |
| 60 | + else | |
| 61 | + {:error, conn} -> conn | |
| 62 | + end | |
| 63 | + end | |
| 64 | + | |
| 65 | + defp wall(conn, jobs, params) do | |
| 66 | + lines = int_param(params, "lines", @default_lines, @max_lines) | |
| 67 | + | |
| 68 | + json(conn, %{ | |
| 69 | + "jobs" => Enum.map(jobs, &card(&1, lines)), | |
| 70 | + "polled_at" => JSON.timestamp(DateTime.utc_now(:second)) | |
| 71 | + }) | |
| 72 | + end | |
| 73 | + | |
| 74 | + defp card(job, lines) do | |
| 75 | + run = job.workflow_run | |
| 76 | + repo = JSON.repository(run.repository) | |
| 77 | + | |
| 78 | + %{ | |
| 79 | + "job" => JSON.job(job), | |
| 80 | + "run" => JSON.run(run), | |
| 81 | + "repository" => Map.take(repo, ["owner", "name", "full_name"]), | |
| 82 | + "runner" => | |
| 83 | + job.runner && %{"name" => job.runner.name, "architecture" => job.runner.architecture}, | |
| 84 | + "log" => job |> Workflows.job_log_tail(lines) |> Enum.map(&LogLine.plain(&1.body)) | |
| 85 | + } | |
| 86 | + end | |
| 87 | + | |
| 88 | + defp person(conn) do | |
| 89 | + if Map.has_key?(conn.assigns, :api_job), | |
| 90 | + do: {:error, send_error(conn, 403, "a job's token has no CI wall")}, | |
| 91 | + else: :ok | |
| 92 | + end | |
| 93 | + | |
| 94 | + defp fetch_org(conn, user, handle) do | |
| 95 | + case Organizations.get_organization_by_handle(handle) do | |
| 96 | + nil -> | |
| 97 | + {:error, send_error(conn, 404, "organization not found")} | |
| 98 | + | |
| 99 | + org -> | |
| 100 | + if Actions.can_view?(org, user), | |
| 101 | + do: {:ok, org}, | |
| 102 | + else: {:error, send_error(conn, 404, "organization not found")} | |
| 103 | + end | |
| 104 | + end | |
| 105 | +end |
modified
lib/git_gud_web/live/ci_wall_live.ex
+1
−13
@@ -248,19 +248,7 @@ defmodule GitGudWeb.CiWallLive do
| 248 | 248 | |
| 249 | 249 | defp append_line(lines, body), do: Enum.take(lines ++ [line(body)], -@tail_lines) |
| 250 | 250 | |
| 251 | − # Plain text, one screen line: no colour codes, no carriage-return | |
| 252 | − # progress bars (the last frame wins), nothing a card can't show. | |
| 253 | − @ansi ~r/\e\[[0-9;?]*[ -\/]*[@-~]|\e\][^\a]*\a/ | |
| 254 | − defp line(body) when is_binary(body) do | |
| 255 | − body | |
| 256 | − |> String.replace(@ansi, "") | |
| 257 | − |> String.split("\r") | |
| 258 | − |> List.last() | |
| 259 | − |> String.trim_trailing() | |
| 260 | − |> String.slice(0, 400) | |
| 261 | − end | |
| 262 | − | |
| 263 | − defp line(_), do: "" | |
| 251 | + defp line(body), do: GitGud.Workflows.LogLine.plain(body) | |
| 264 | 252 | |
| 265 | 253 | defp running_jobs({:user, user_id, repo_ids}), |
| 266 | 254 | do: Workflows.list_running_jobs_for_user(user_id, repo_ids) |
modified
lib/git_gud_web/router.ex
+4
−0
@@ -378,6 +378,10 @@ defmodule GitGudWeb.Router do
| 378 | 378 | get "/repos", RepositoryController, :index |
| 379 | 379 | get "/repos/:owner/:name", RepositoryController, :show |
| 380 | 380 | |
| 381 | + # The live CI wall, for a tablet or TV (`CiWallController`). | |
| 382 | + get "/ci/live", CiWallController, :user | |
| 383 | + get "/orgs/:org/ci/live", CiWallController, :org | |
| 384 | + | |
| 381 | 385 | get "/repos/:owner/:name/runs", CiController, :index |
| 382 | 386 | get "/repos/:owner/:name/runs/:number", CiController, :show |
| 383 | 387 | post "/repos/:owner/:name/runs/:number/rerun", CiController, :rerun |
added
test/git_gud_web/controllers/api/mobile/mobile_ci_wall_test.exs
+161
−0
@@ -0,0 +1,161 @@
| 1 | +defmodule GitGudWeb.Api.Mobile.MobileCiWallTest do | |
| 2 | + @moduledoc """ | |
| 3 | + `/api/mobile/v1/ci/live` and `/orgs/:org/ci/live` — the app's CI wall. | |
| 4 | + The same jobs as the web's walls; what matters is what leaks, and that | |
| 5 | + each card carries enough to draw it without another request. | |
| 6 | + """ | |
| 7 | + use GitGudWeb.ConnCase, async: false | |
| 8 | + | |
| 9 | + import GitGud.AccountsFixtures | |
| 10 | + import GitGud.ForgeFixtures | |
| 11 | + | |
| 12 | + alias GitGud.Accounts | |
| 13 | + alias GitGud.Accounts.PersonalAccessToken | |
| 14 | + alias GitGud.Organizations | |
| 15 | + alias GitGud.Repo | |
| 16 | + alias GitGud.Repositories | |
| 17 | + alias GitGud.Workflows | |
| 18 | + alias GitGud.Workflows.WorkflowJob | |
| 19 | + alias GitGud.Workflows.WorkflowRun | |
| 20 | + | |
| 21 | + @base "/api/mobile/v1" | |
| 22 | + | |
| 23 | + defp authed(conn, user, scopes \\ PersonalAccessToken.mobile_scopes()) do | |
| 24 | + {:ok, _t, token} = Accounts.create_personal_access_token(user, "tv", scopes) | |
| 25 | + | |
| 26 | + conn | |
| 27 | + |> put_req_header("authorization", "Bearer #{token}") | |
| 28 | + |> put_req_header("accept", "application/json") | |
| 29 | + end | |
| 30 | + | |
| 31 | + defp running_job(repo, status \\ "running") do | |
| 32 | + run = | |
| 33 | + %WorkflowRun{repository_id: repo.id} | |
| 34 | + |> WorkflowRun.create_changeset(%{ | |
| 35 | + number: System.unique_integer([:positive]), | |
| 36 | + workflow_path: ".forgejo/workflows/ci.yml", | |
| 37 | + workflow_name: "ci", | |
| 38 | + trigger: "push", | |
| 39 | + head_ref: "refs/heads/main", | |
| 40 | + head_sha: :crypto.strong_rand_bytes(20) | |
| 41 | + }) | |
| 42 | + |> Ecto.Changeset.change(status: "running") | |
| 43 | + |> Repo.insert!() | |
| 44 | + | |
| 45 | + %WorkflowJob{workflow_run_id: run.id} | |
| 46 | + |> WorkflowJob.create_changeset(%{job_id: "build", name: "build", definition: %{}}) | |
| 47 | + |> Ecto.Changeset.change(status: status, started_at: DateTime.utc_now(:second)) | |
| 48 | + |> Repo.insert!() | |
| 49 | + end | |
| 50 | + | |
| 51 | + defp ids(body), do: body["jobs"] |> Enum.map(& &1["job"]["id"]) |> Enum.sort() | |
| 52 | + | |
| 53 | + describe "a user's wall" do | |
| 54 | + test "running jobs only, each with its repository, run and plain log tail", %{conn: conn} do | |
| 55 | + {user, repo} = repository_fixture() | |
| 56 | + job = running_job(repo) | |
| 57 | + running_job(repo, "queued") | |
| 58 | + step = Workflows.ensure_log_step(job) | |
| 59 | + | |
| 60 | + Workflows.append_log(step, 1, [ | |
| 61 | + %{content: "\e[32mcompiling\e[0m"}, | |
| 62 | + %{content: "10%\r50%\r100%"}, | |
| 63 | + %{content: "done"} | |
| 64 | + ]) | |
| 65 | + | |
| 66 | + body = conn |> authed(user) |> get("#{@base}/ci/live?lines=2") |> json_response(200) | |
| 67 | + | |
| 68 | + assert [card] = body["jobs"] | |
| 69 | + assert card["job"]["id"] == job.id | |
| 70 | + assert card["job"]["status"] == "running" | |
| 71 | + assert card["run"]["workflow_name"] == "ci" | |
| 72 | + assert card["repository"]["full_name"] == "#{user.handle}/#{repo.name}" | |
| 73 | + assert card["log"] == ["100%", "done"] | |
| 74 | + assert body["polled_at"] | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "nobody else's jobs", %{conn: conn} do | |
| 78 | + {user, repo} = repository_fixture() | |
| 79 | + mine = running_job(repo) | |
| 80 | + {_other, theirs} = repository_fixture() | |
| 81 | + running_job(theirs) | |
| 82 | + | |
| 83 | + body = conn |> authed(user) |> get("#{@base}/ci/live") |> json_response(200) | |
| 84 | + assert ids(body) == [mine.id] | |
| 85 | + end | |
| 86 | + | |
| 87 | + test "needs ci:read", %{conn: conn} do | |
| 88 | + user = user_fixture() | |
| 89 | + | |
| 90 | + assert conn | |
| 91 | + |> authed(user, ["repo:read"]) | |
| 92 | + |> get("#{@base}/ci/live") | |
| 93 | + |> json_response(403) | |
| 94 | + end | |
| 95 | + end | |
| 96 | + | |
| 97 | + describe "an organization's wall" do | |
| 98 | + setup do | |
| 99 | + owner = user_fixture() | |
| 100 | + | |
| 101 | + {:ok, org} = | |
| 102 | + Organizations.create_organization(owner, %{ | |
| 103 | + "handle" => "org#{System.unique_integer([:positive])}", | |
| 104 | + "name" => "Org" | |
| 105 | + }) | |
| 106 | + | |
| 107 | + repo = fn visibility -> | |
| 108 | + {:ok, r} = | |
| 109 | + Repositories.create_repository_for_org(org, owner, %{ | |
| 110 | + "name" => unique_repo_name(), | |
| 111 | + "visibility" => visibility | |
| 112 | + }) | |
| 113 | + | |
| 114 | + r | |
| 115 | + end | |
| 116 | + | |
| 117 | + %{owner: owner, org: org, public: repo.("public"), private: repo.("private")} | |
| 118 | + end | |
| 119 | + | |
| 120 | + test "a member sees every repository's jobs, and nothing outside the org", ctx do | |
| 121 | + a = running_job(ctx.public) | |
| 122 | + b = running_job(ctx.private) | |
| 123 | + {_, personal} = repository_fixture(%{owner: ctx.owner}) | |
| 124 | + running_job(personal) | |
| 125 | + | |
| 126 | + body = | |
| 127 | + ctx.conn | |
| 128 | + |> authed(ctx.owner) | |
| 129 | + |> get("#{@base}/orgs/#{ctx.org.handle}/ci/live") | |
| 130 | + |> json_response(200) | |
| 131 | + | |
| 132 | + assert ids(body) == Enum.sort([a.id, b.id]) | |
| 133 | + end | |
| 134 | + | |
| 135 | + test "an outsider sees the public repositories' jobs only", ctx do | |
| 136 | + a = running_job(ctx.public) | |
| 137 | + running_job(ctx.private) | |
| 138 | + | |
| 139 | + body = | |
| 140 | + ctx.conn | |
| 141 | + |> authed(user_fixture()) | |
| 142 | + |> get("#{@base}/orgs/#{ctx.org.handle}/ci/live") | |
| 143 | + |> json_response(200) | |
| 144 | + | |
| 145 | + assert ids(body) == [a.id] | |
| 146 | + end | |
| 147 | + | |
| 148 | + test "a private org is a 404 to an outsider, as is one that doesn't exist", ctx do | |
| 149 | + {:ok, secret} = | |
| 150 | + Organizations.create_organization(ctx.owner, %{ | |
| 151 | + "handle" => "secret#{System.unique_integer([:positive])}", | |
| 152 | + "name" => "Secret", | |
| 153 | + "visibility" => "private" | |
| 154 | + }) | |
| 155 | + | |
| 156 | + conn = authed(ctx.conn, user_fixture()) | |
| 157 | + assert get(conn, "#{@base}/orgs/#{secret.handle}/ci/live") |> json_response(404) | |
| 158 | + assert get(conn, "#{@base}/orgs/nope-nope/ci/live") |> json_response(404) | |
| 159 | + end | |
| 160 | + end | |
| 161 | +end |
Parents: 3fd642d