ADR 0017 — Target the standard Gitea API v1; surface Forgejo gaps transparently
- Status: Accepted
- Date: 2026-07-01
- Tracking: legacy tracker 1011 (Forgejo actions probe), docs/contributing/forgejo-15-actions-probe.md
Context
madtea is described in the project CLAUDE.md as follows: “Forgejo
compatibility: Uses standard API v1 endpoints that Forgejo maintains for Gitea
compatibility. Works with both platforms without modification.” In practice, the
two platforms are close but not identical. Forgejo is a soft fork of Gitea that
maintains compatibility with the Gitea API v1 for the vast majority of endpoints
but has not implemented some Actions-family endpoints that Gitea ships (verified
empirically in docs/contributing/forgejo-15-actions-probe.md against Forgejo 15).
Without any handling, a Forgejo user calling an unimplemented endpoint receives a
bare 404 page not found from Forgejo’s router — an opaque response that gives
no signal about whether the endpoint exists but was not found (e.g. a
wrong-ID issue) or whether the endpoint family is simply not implemented on this
platform.
Two architectural responses are possible:
- Per-platform branching — detect the platform at every call site and route to different logic or different endpoint paths.
- Single standard target with transparent gap surfacing — code to the shared API v1; detect the platform lazily and only to produce a better error message when a known-missing endpoint is hit.
Decision
madtea targets one API surface — the standard Gitea API v1 — behind the
gitea.Client interface. There is no per-platform branching in the core request
logic. Forgejo-specific endpoint gaps are surfaced transparently as
ErrUnsupportedOnForgejo errors, not silently swallowed or routed around.
One Client interface, one HTTP client
internal/gitea presents a single Client interface (implemented by
HTTPClient). All API domains — issues, PRs, repos, releases, actions, etc. —
call the same requestWithMeta/doRequest path. The caller never has a
ForgejoClient vs. GiteaClient code path.
Lazy platform detection, narrowly scoped
HTTPClient caches a ServerFlavour (Gitea or Forgejo) detected from
/api/v1/version — Forgejo’s version string includes a +gitea-X.Y.Z suffix
that distinguishes it from upstream Gitea. NodeInfo is a fallback for builds
that predate the suffix. Detection is guarded by sync.Once so it runs at most
once per client and only when needed.
Detection is used in exactly one place: maybeWrapForgejoUnimplemented
(client_flavour.go), called from the error path. If a request returns a bare
404 page not found body and the path matches a pattern in the
forgejoMissingEndpoints list, the error is wrapped as ErrUnsupportedOnForgejo
with:
- the endpoint and endpoint family that was called,
- the server version string for diagnostic clarity,
- an alternative endpoint or UI path, when one is known.
This information is surfaced in the tool result or CLI error output, giving the user an actionable explanation rather than a generic 404.
The gap list is empirically grounded and version-stamped
forgejoMissingEndpoints is a table of regexp.Regexp patterns covering
the endpoint families Forgejo 15 does not implement, matching the findings in
docs/contributing/forgejo-15-actions-probe.md. The gap list is intentionally
conservative and kept in sync with the probe document. New gaps are added only
after empirical verification against a real Forgejo instance.
No Forgejo-specific alternate code paths
For the endpoints Forgejo does implement (the large majority of API v1), madtea issues the same request it would to Gitea. There is no parallel implementation for either platform. If Forgejo adds an endpoint that was previously missing, it will work automatically once the platform detection recognises the new version; no code change is needed.
Consequences
- Works on both platforms unmodified for the common case. The vast majority of madtea operations (issue and PR management, repo CRUD, branch operations, labels, milestones, releases) use endpoints that Forgejo fully implements. Those operations are platform-transparent.
- Actions gaps on Forgejo are explicit, not silent. A user running
madtea actions workflows liston a Forgejo server gets a clear message naming the unimplemented family and a hint (alternative endpoint or “see docs/COMPAT.md”), not a confusing 404.errors.Is(err, &ErrUnsupportedOnForgejo{})is available to callers that need to handle the gap programmatically. - Forgejo is an ally, not a second-class citizen. The approach respects Forgejo’s maintained API compatibility while being honest about the gaps. The wording in error messages and docs is factual, not disparaging.
- Detection is a narrow, private implementation detail.
ServerFlavourandErrUnsupportedOnForgejoare ininternal/gitea; service and cmd layers never branch on platform. If that ever changes, it will require a new ADR and a layering-guard update. - Forgejo versions beyond 15 may close the gaps. The
forgejoMissingEndpointslist must be re-probed against each major Forgejo release.docs/contributing/forgejo-15-actions-probe.mdis the probe record; update both the doc and the list when gaps close. - Relates to ADR 0014. The
internal/giteaclient is the infrastructure layer; it never importsservice,cmd, ormcp. The flavour-detection and error-wrapping logic stays inside that layer per the layering rules.
Alternatives considered
- Platform-detect at startup; branch all callers. Rejected: it doubles the code paths for every endpoint family, requires every new endpoint to carry two implementations, and is prone to the two implementations drifting. It also conflates “not implemented yet” with “different API shape” — Forgejo’s gap is absence, not a different endpoint design.
- Gitea-only; document Forgejo as unsupported. Rejected: Forgejo is the dominant self-hosted FOSS option and the platform madtea’s own development runs against. Excluding it would exclude the primary development target.
- Silent fallback on 404 (no error wrapping). Rejected: bare 404 on an unimplemented endpoint is indistinguishable from a wrong ID or a permissions error. Wrapping the gap as a typed, named error is the minimum viable honesty — an agent or user cannot act on a bare 404.
Canonical source: docs/adr/0017-standard-forge-api-target.md in the madtea repo.