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:

  1. Per-platform branching — detect the platform at every call site and route to different logic or different endpoint paths.
  2. 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 list on 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. ServerFlavour and ErrUnsupportedOnForgejo are in internal/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 forgejoMissingEndpoints list must be re-probed against each major Forgejo release. docs/contributing/forgejo-15-actions-probe.md is the probe record; update both the doc and the list when gaps close.
  • Relates to ADR 0014. The internal/gitea client is the infrastructure layer; it never imports service, cmd, or mcp. 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.