8.5 KiB · text History b868907

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) and body_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 null rather than raising, so a forgotten preload degrades to a missing field instead of a 500