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 |
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.
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.
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