Gitea-compatible REST API
git-shark's /api/v1 is being migrated from a bespoke JSON shape to the
Gitea REST contract, so Gitea-ecosystem tooling — Renovate
first, then tea, act_runner, and assorted bots — can drive a git-shark
instance with its stock gitea/forgejo platform driver and no git-shark-specific
code. Tracked in issue #20.
Why migrate rather than run a shim
The REST API has no external consumers yet, so reshaping it in place is a rename,
not a breaking change — the cheapest possible moment to do it. A parallel
Gitea shim next to the bespoke API would fight the existing routes for the same
paths (GET /api/v1/repos/{owner}/{name} wants one JSON body, not two) and leave
two schemas to maintain forever. One Gitea-native schema is less code and broader
compatibility.
The domain layer keeps its own vocabulary: internally these are merge requests
(MergeRequest*), and only the wire speaks Gitea's "pull request". The mapping is
confined to the DTO/resource layer in de.workaround.api.
Component map
| Concern | Type | Notes |
|---|---|---|
| Auth (REST) | ApiTokenAuthFilter | Accepts both Authorization: Bearer <PAT> and the Gitea-style Authorization: token <PAT>; same personal access tokens as git-over-HTTP |
| Auth (git) | GitBasicAuthFilter | Basic auth for git-over-HTTP: the PAT is accepted as the password or the username (Renovate clones with the token in the username, empty password — like a GitHub PAT) |
| DTOs | ApiModels | All response records are Gitea-shaped; snake_case fields via @JsonProperty. Shared with the MCP tools — reshaping the REST body reshapes MCP tool output too (accepted; see decisions) |
| Surrogate ids | GiteaIds | Folds a UUID PK into a stable non-negative long for Gitea's int64 id |
| Version probe | VersionApiResource | GET /api/v1/version; string from gitshark.gitea-api.version |
| Repositories | RepositoryApiResource | Gitea repository object incl. owner, full_name, default_branch, clone_url, html_url, permissions, merge flags |
| Pulls | PullApiResource | Merge requests as Gitea pull requests (list/create/get/PATCH/merge + line-review comments); domain stays MergeRequest* |
| Releases | ReleaseApiResource | Gitea release object addressed by tag; draft constant false, zipball_url/tarball_url point at git-shark's own `…/archive/{ref}.zip |
| Labels | LabelApiResource | Always [] — no label model yet |
| Statuses | CommitStatusApiResource | All-clear combined status + echoing POST /statuses/{sha}; no status store yet |
| User | UserApiResource | Self identity in Gitea user shape |
| Search | SearchApiResource | git-shark-specific (not a Gitea endpoint); returns the email-free PersonView and a shallow repository projection |
Key decisions
- Reported version is deliberately low (
gitshark.gitea-api.version, default1.13.0). Gitea clients gate feature calls on it:>= 1.14.0makes Renovate callrequested_reviewers,>= 1.24.0unlocksdelete_branch_after_merge— neither is implemented, so the version is kept below them until they are. Raise it as capabilities land. - Surrogate
idis one-way.GiteaIds.of(uuid)is a display value only; git-shark never looks an entity up by it — owner/name and per-reponumber(Gitea'sindex) remain the real keys. The fold is lossy; do not reverse it. - MCP shares the DTOs. MCP tools return
ApiModelsrecords directly, so they now emit Gitea-shaped JSON. Accepted (option (a) in the spike): the shape is richer, not worse, for AI-agent consumers. MCP has no request base URL, so the repositoryclone_url/html_urlare null there while the REST resource fills them from the request's external base URI. - Fields git-shark has no feature for are hard-coded:
archivedandmirrorare always false (no archive feature; push-mirrors are outbound, not incoming mirrors), and theallow_*merge flags advertise merge commits only, matching the one merge strategy the merge service implements.default_merge_styleis reported asmergeand is load-bearing, not cosmetic: Renovate picks a merge method by runningdefault_merge_stylefirst through a.find, and itsisAllowedthrows on an unrecognized style — so omitting the field (leaving it undefined on the wire) makes Renovate block the whole repository with "unknown merge style" before it ever checksallow_merge_commits. mergeableis a placeholder = "the pull is open", not a real conflict check. Computing true mergeability needs a live trial-merge per pull; Renovate only needs a hint, and the actualPOST {number}/mergestill rejects a real conflict, so the cheap approximation is safe.- PII: the self-scoped
UserViewcarriesemail; the searchPersonViewomits it, because search is anonymous and would otherwise disclose every matched user's address.
What works today
Authorization: token <PAT>scheme alongsideBearer.GET /api/v1/version— Gitea version probe.GET /api/v1/user— self identity in Gitea user shape (id,login,username,full_name,email).GET /api/v1/reposandGET /api/v1/repos/{owner}/{name}— Gitea repository objects, includingdefault_branch(live git read),clone_url/html_url(from the request base URL),permissions,fork/parent, and merge flags.GET /api/v1/repos/{owner}/{name}/branches/{branch}— branch object (name,commit.id,protected); the branch segment is matched greedily so slash-bearing names resolve, and only real branch refs count (tag/SHA → 404).pullsresource (/api/v1/repos/{owner}/{name}/pulls) — merge requests projected as Gitea pull requests (number/index,id,title,body,stateopen/closed +merged,head/baserefs,mergeable, email-freeuser/assignee, emptylabels). Create takes{title, body, head, base};PATCH {number}editstitle/bodyand closes (state:"closed") or reopens (state:"open");POST {number}/mergemerges (strategy body ignored — only merge commits). List supports?state=open|closed|alland pagination (page size capped at 50 so Renovate's paging terminates). The line-review comments are git-shark's own feature, kept underpulls/{number}/comments.releasesresource (/api/v1/repos/{owner}/{name}/releases) — list, create ({tag_name, target_commitish, name, body, prerelease}; a missing tag is cut fromtarget_commitish),GET latest(newest non-prerelease, else404), andGET/PATCH/DELETE tags/{tag}with the tag matched greedily for slashes.draftis always false andpublished_atmirrorscreated_at; release assets are not implemented, so only the source-archive URLs are exposed. See Releases architecture.GET labels→[](no label model yet; Renovate skips labels when empty).- Commit-status stubs:
GET commits/{ref}/statusreports an all-clear combined status,GET commits/{ref}/statusesis empty, andPOST statuses/{sha}echoes the posted status without persisting (no status store yet). Enough for Renovate to treat a branch as passing and proceed;refis matched greedily for slashes.
What still needs to be implemented
- Find-by-branch
GET pulls/{base}/{head}— deliberately skipped: Renovate finds a branch's pull by listing and filtering client-side, and the two-segment route would collide withpulls/{index}/pulls/{index}/comments. - A real commit-status store wired to the CI runners (replace the stubs above).
GET /repos/{owner}/{name}/contents/{path}(Renovate mostly clones, so low priority).- Issue open/closed mapping + issue-comment REST endpoints (dependency dashboard);
deferred — run Renovate with
dependencyDashboard: false. - Release assets (
/releases/{id}/assets) and draft releases; see the gap list in Releases architecture.
Validation
A real Renovate LOG_LEVEL=debug run (platform: gitea, endpoint: http://localhost:8080/api/v1, git-url unset so it clones via clone_url)
against a seeded repo with an outdated npm dependency opened dependency PRs
end to end, and merging one via POST /pulls/{number}/merge advanced main.
Two field-fidelity bugs surfaced and were fixed as part of this:
default_merge_stylemust be present (see decisions) — Renovate blocked the whole repo without it.- git auth must accept the token as the Basic username — Renovate clones with the PAT in the username and an empty password.
The npm registry lookup for the dependency needs outbound network; Release-notes
retrieval warns without a github.com token but does not block PR creation.