Mobile API: the live CI wall, for the app

2b51ebb · gmorell · 2026-11-03 19:26

18 files +294 -13
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