6.5 KiB · text History ea1f5be

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