Mobile REST API
/api/mobile/v1 — the surface the Android client speaks.
Versioned separately from /api/v1, which is the Gitea/GitHub-compatible
shape the Forgejo Actions runner needs and can’t be reshaped for a phone
without breaking docker/metadata-action.
Authentication
Every request carries a Personal Access Token:
Authorization: Bearer ggp_…
Authorization: token ggp_… # equivalent
GitGudWeb.Api.Mobile.Auth verifies the token in the :mobile_api
pipeline and assigns the owner; each action then narrows to the one
scope it needs. The two checks are separate because the pipeline can’t
know whether a request is a read or a write — a token scoped to
issues:read must reach GET /issues while being turned away from
POST /comments.
A scope says what the token may ask for. What its owner is allowed
to do is a separate question, answered by
GitGud.Organizations.can?/3 against the repository. Both must pass.
Scopes
| Scope | |
|---|---|
repo |
Create, import and delete repositories (predates the split; used by /api/v1) |
repo:read |
List and read repository metadata |
ci:read / ci:write |
Read runs, jobs and logs / re-run and cancel |
issues:read / issues:write |
Read issues and comments / comment, open, close |
pulls:read / pulls:write |
Read PRs, reviews and comments / comment, review, merge |
wiki:read / wiki:write |
Read wiki pages / create and edit them |
packages:read |
Read published packages and how to install them |
PersonalAccessToken.mobile_scopes/0 is the bundle the Android client
needs. It is not a scope — the “Mobile app” checkbox on the tokens
page ticks the individual scopes, so revoking one surface later doesn’t
mean re-pairing the device.
PersonalAccessToken.desktop_scopes/0 (repo:read, ci:read,
ci:write) is the same idea for the Plasma CI widget, behind the
“Desktop widget” checkbox. The mobile bundle is a superset of it.
Errors
Every 4xx is {"message": "..."} with a reason worth showing a user
(“token is missing the ‘ci:write’ scope”, “2 of 3 required approvals”).
A repository the caller can’t read returns 404, not 403 — a 403 would confirm that a private repo by that name exists. A repo they can read but not write to returns 403, since they already know it’s there.
Endpoints
Identity
GET /me
Returns the token owner, its scopes, whether they cover the mobile bundle, and the instance URL. The client calls this after a QR scan to confirm pairing before persisting credentials, and on every cold start so a revoked token reads as “signed out”.
Repositories
GET /repos
GET /repos/:owner/:name
/repos returns {namespaces, repositories} — everything reachable via
Repositories.relevant_repo_ids_for_user/1 (owned, org-member,
team-granted), each row carrying the status of its latest CI run
(batch-loaded in one query, not N+1). Deliberately not every public repo
on the instance: the phone’s repo list is a work surface, not a
directory.
/repos/:owner/:name adds open issue / PR / wiki-page counts, the
latest run, and a permissions map the client uses to decide which
buttons to render at all.
CI
GET /repos/:owner/:name/runs ?limit=
GET /repos/:owner/:name/runs/:number
GET /repos/:owner/:name/jobs/:job_id/logs ?limit=
POST /repos/:owner/:name/runs/:number/rerun → 201, a new run
POST /repos/:owner/:name/runs/:number/jobs/:job/rerun → the same run, in place
POST /repos/:owner/:name/runs/:number/cancel
:job in the re-run-job route is the workflow-level job id (the key in
the YAML), not a row id — a matrix’s legs share it, and “re-run build”
means every leg of build. :job_id in the logs route is a row id, and
is checked against the repo so a token can’t read another repo’s logs by
guessing an integer.
Logs come back grouped by step and capped (2000 lines by default, 10000
max) with a truncated flag. The runner streams one flat log per job
and each step owns a slice of it; the grouping is what lets a client
collapse the passing steps and open straight to the one that failed.
Write actions need ci:write and :write on the repository.
Issues
GET /repos/:owner/:name/issues ?state=&search=&limit=&offset=
GET /repos/:owner/:name/issues/:number
POST /repos/:owner/:name/issues/:number/comments {"body": "..."}
POST /repos/:owner/:name/issues/:number/state {"state": "open"|"closed"}
Closing needs :triage on the repository or authorship of the issue —
the same rule the web UI applies, restated in the controller because the
context doesn’t enforce it.
Pull requests
GET /repos/:owner/:name/pulls ?state=&search=&limit=&offset=
GET /repos/:owner/:name/pulls/:number
POST /repos/:owner/:name/pulls/:number/comments {"body": "..."}
POST /repos/:owner/:name/pulls/:number/reviews {"state": "approved"|"changes_requested"|"commented", "body": "..."}
POST /repos/:owner/:name/pulls/:number/merge {"message": "...", "override": true}
show splits comments (discussion) from line_comments (those with a
file_path). They share a table, but they’re different things to a
client: the discussion is a thread you read top to bottom, and a line
comment only makes sense next to the file it’s pinned to.
merge_blockers arrives as flat, already-worded strings, so a client
can show them verbatim next to a disabled Merge button rather than
reassembling them from tuples.
override is honoured only for repo admins, and refused loudly
otherwise rather than silently downgraded — a caller who believed they
were bypassing a rule shouldn’t be left thinking they had.
Wiki
GET /repos/:owner/:name/wiki
GET /repos/:owner/:name/wiki/:slug
Read-only today (wiki:write exists for whenever editing is worth
doing). A repo whose wiki was never initialized returns an empty list
rather than 404 — “no pages yet” is a state to render, not an error.
Packages
GET /repos/:owner/:name/packages
GET /repos/:owner/:name/packages/:format/:package
GET /repos/:owner/:name/package-usage/:format
Read-only, and there is no packages:write. Publishing is what a CI
job does with a token scoped to one repository; a phone has no build to
publish and no business holding a credential that could.
The index returns every format with something published, each with its
packages — one call fills the tab, because a list that needs a request
per format renders in stages. It also returns available_formats,
every format the forge can serve, so a repository that has published
nothing can still offer “here is how you would publish to it” without
the client carrying a format list that goes stale.
Instructions are a separate call, fetched when the reader opens them: they are several hundred lines of shell across all formats and most readers open one. They come from the server rather than being built on the phone because they are full of things only the server knows — the registry’s own URL, the namespace a repository belongs to, whether a Hex repo is named after the org or the repository. A client that rebuilt those strings would be wrong the first time a deployment moved.
package-usage/:format rather than packages/:format/usage: a package
may legitimately be named usage, and the two would collide the day
one is.
Every package and every version carries an install_line — the
one-liner you’d paste. Versions carry their own rather than inheriting
the latest, because the reason to open a version list is usually to
install something that isn’t the newest thing. It is null for a
format with no meaningful one-liner, which the client renders as
absence rather than guessing.
Wire conventions
Shapes live in one module, GitGudWeb.Api.Mobile.JSON, because the
client pins every field name in its Kotlin DTOs — one place makes it
obvious when a rename would break a shipped app.
- timestamps are ISO-8601 UTC strings, or
null - SHAs are lowercase hex, never the raw 20-byte binary
- user references collapse to
{handle, display_name, email} - markdown bodies ship twice:
body(source, for quote-reply) andbody_html(rendered server-side, for display). Rendering on the server means cross-references, alerts, footnotes and syntax highlighting work on the phone without a second markdown dialect that disagrees with the web about the same text - unloaded Ecto associations serialize as
nullrather than raising, so a forgotten preload degrades to a missing field instead of a 500