API Compatibility
What this answers: “will this madtea feature work against my server?” — madtea features × the Gitea and Forgejo REST APIs — plus the maintainer view: tracking drift between the two platforms. The client-vs-client question (“which CLI covers what”) is a different axis and lives in COMPARISON.md.
madtea targets Gitea API v1 as its single API surface (ADR 0017); Forgejo is supported through the Gitea-compatible API it maintains. Where Forgejo diverges, madtea detects the platform and surfaces the gap as a typed, explained error — see How madtea handles divergence.
This is a matrix, not a manual: Notes cells name endpoints and platform caveats in a clause or two. Semantics live in docs/reference/ and madt_help; a size test caps row length to keep it that way.
Legend: ✅ supported · ❌ not supported · ⚠️ partial — see notes · ⚙️ endpoint exists in spec but is feature-flag gated and off by default
Sources of truth. Gitea API: docs/api-specs/gitea-1.26.1.json. Forgejo API: docs/api-specs/forgejo-15.0.json plus the empirical 404 probe in forgejo-15-actions-probe.md. When the spec and the probe disagree on Forgejo (the spec lists endpoints that 404 at runtime), the probe wins.
API divergence last probed: 2026-06-18.
Command availability by platform
Any madtea command not listed here works identically on both platforms. The divergent set:
| Command | Gitea 1.26 | Forgejo 15.0 | Notes |
|---|---|---|---|
pr cancel-merge | ✅ | ❌ | Forgejo has no DELETE /pulls/{n}/merge route. |
file contents (bulk) / file get-ext | ✅ | ⚠️ | /file-contents and /contents-ext/{path} are Gitea-only (MCP madt_files action=contents); on Forgejo madtea degrades to plain /contents reads (per-file for bulk), so both still return content. |
actions run-delete | ✅ | ❌ | 405 on Forgejo. |
actions jobs / job / job-logs / logs | ✅ | ❌ | /actions/jobs/... family 404s on Forgejo. |
actions rerun / rerun-job | ✅ | ❌ | Forgejo: web UI only. |
actions artifacts | ✅ | ❌ | 404 on Forgejo. |
actions workflows (list/get/enable/disable) | ✅ | ❌ | 404 on Forgejo. actions dispatch works on both — Forgejo requires Content-Type: application/json. |
admin runners | ✅ | ⚠️ | Top-level /admin/runners absent on Forgejo (only /admin/actions/runners); madtea falls back gracefully. |
repo flags | ❌ | ⚙️ | /repos/{r}/flags config-gated by [repository] ENABLE_FLAGS=true; 404 on default installs. |
user quota | ❌ | ⚙️ | /user/quota config-gated by [quota] ENABLED=true; 404 on default installs. Gitea has no /quota family. |
activitypub | ❌ | ⚙️ | Config-gated by [federation] ENABLED=true; 404 on default installs. Gitea has no /activitypub paths. |
project | ⚠️ | ⚠️ | Project-board settings only (has_projects, projects_mode); board CRUD is web-UI-only on both. |
signing-key | ✅ | ✅ | Forgejo additionally serves /signing-key.ssh. |
setup --web | ✅ | ⚠️ | Browser OAuth login works on both; the final PAT mint (POST /users/{username}/tokens with the OAuth token as the basic-auth password) is rejected by Forgejo (auth method not allowed), so madtea falls back to token paste. Empirically 401 on Forgejo 15.0.1 stable (matches source-verified Forgejo main). |
How madtea handles divergence
Flavour is detected by probing /api/v1/version on the first request that needs it: Forgejo’s +gitea-X suffix (15.0.1+gitea-1.22.0) is unique, and a set X-Forgejo-Version response header is an unambiguous Forgejo marker even without the suffix; Gitea returns a bare 1.26.1 with no such header. Falls back to /nodeinfo (software.name) when /version is unavailable. The result is cached per forge host, process-wide (one probe per host across all clients), and exposed via gitea.ServerFlavourClient.
madtea handles a detected divergence two ways. Where a Gitea-only endpoint has a plain equivalent, the call degrades gracefully: file get-ext (/contents-ext) and file contents (/file-contents) fall back to plain /contents reads on Forgejo. Where no equivalent exists, a bare-router 404 page not found for a confirmed-unimplemented endpoint family is wrapped as *gitea.ErrUnsupportedOnForgejo (preserves the underlying *APIError, so gitea.IsNotFound still holds) instead of a generic API error 404. A JSON-body {"message": "..."} 404 is a resource-missing 404 (e.g. runner ID 9999), not endpoint-missing, and passes through untouched.
Example surfaced message:
endpoint not implemented on Forgejo (server=15.0.1+gitea-1.22.0):
actions/workflows (list) — request was /repos/o/r/actions/workflows
Re-verify the whole picture (fresh forgejo:15 container, admin token, smoke battery):
bash tests/integration/check-drift.sh smoke
Per-endpoint re-probing: forgejo-15-actions-probe.md.
Gitea vs Forgejo API differences
The per-endpoint picture behind the command table above:
| Feature | Gitea 1.26 | Forgejo 15.0 | Notes |
|---|---|---|---|
PR list base_branch | ✅ | ❌ | Param accepted but ignored on Forgejo. |
PR create reviewers | ✅ | ❌ | Field accepted but ignored on Forgejo. |
PR create team_reviewers | ✅ | ❌ | |
Issue list sort param | ❌ | ✅ | |
Issue list labels= filter — label names | ✅ | ✅ | Both platforms accept a comma-separated list of label NAMES in labels=; numeric IDs are treated as unknown names and silently discarded. madtea passes names (resolving any user-supplied IDs to their names). |
Issue list labels= filter — org label names | ❌ | ✅ | Gitea silently discards org-level label names in labels= (filter opens); Forgejo resolves them. madtea passes names through unchanged; org labels work on Forgejo, not on Gitea (platform limitation). |
Issue list assigned_by filter | ✅ | ✅ | The per-repo issue list uses assigned_by on both platforms (not assignee). madtea maps --assignee / assignee= to assigned_by. |
PR list labels= filter — label/org-label names | ✅ | ✅ | The /pulls labels= param takes numeric label IDs (array) on both platforms, not names — a name silently opens the filter (returns all PRs). madtea resolves label names (repo and org) to IDs, so name filtering works on both. |
Cross-repo issue search labels= filter — org label names | ✅ | ✅ | /repos/issues/search resolves label names globally (org labels included) on both platforms, so org-label names filter correctly with no ID resolution; madtea passes names through unchanged (a numeric ID would be read as a name and discarded). Nonexistent names are silently dropped by the server. |
Repo create object_format | ✅ | ✅ | |
Actions runs list/get | ✅ | ✅ | GET /actions/runs and GET /actions/runs/{id} work on both. |
Actions runs delete (DELETE /actions/runs/{id}) | ✅ | ❌ | Returns 405 on Forgejo (endpoint is GET-only). |
Actions runs/{id}/jobs | ✅ | ❌ | GET /actions/runs/{id}/jobs returns 404 page not found on Forgejo. |
Actions runs/{id}/cancel (POST) | ✅ | ❌ | Forgejo: web UI only (no REST endpoint). |
Actions runs/{id}/rerun (POST) | ✅ | ❌ | Forgejo: web UI only (no REST endpoint). |
Actions runs/{id}/artifacts | ✅ | ❌ | /actions/runs/{id}/artifacts is 404 on Forgejo. |
Actions runs/{id}/logs | ✅ | ❌ | /actions/runs/{id}/logs is 404 on Forgejo. |
Actions workflows (list / get / enable / disable) | ✅ | ❌ | /actions/workflows family absent on Forgejo. |
Actions workflows/{wf}/dispatches (POST) | ✅ | ✅ | Works on Forgejo with Content-Type: application/json. |
Actions jobs (/actions/jobs/{id}, /logs) | ✅ | ❌ | Repo-scoped jobs endpoints absent on Forgejo. |
Actions runners (repo / user / admin / org scope) | ✅ | ✅ | All work on Forgejo 15 (contrary to prior reports against Forgejo 11). |
Actions runners/registration-token (POST) | ✅ | ❌ | Forgejo returns 405 — endpoint is GET-only. madtea currently posts; tracked. |
Actions artifacts (list at /actions/artifacts) | ✅ | ❌ | Endpoint absent on Forgejo. |
Actions secrets, variables, tasks | ✅ | ✅ | |
/user/actions/runs, /user/actions/jobs | ✅ | ❌ | Cross-cutting “all my runs/jobs” only on Gitea. |
/admin/actions/runs, /admin/actions/jobs | ✅ | ❌ | Cluster-wide views only on Gitea — Forgejo has /admin/runners/jobs for active jobs. |
/admin/runners (top-level) | ✅ | ❌ | Forgejo exposes only /admin/actions/runners. |
/admin/runners/jobs | ✅ | ✅ | Returns active runner jobs on both. |
| Actions runs/jobs (non-owner) | ✅ | ✅ | Fixed in Gitea 1.26 (issue 36818). |
/file-contents (bulk file fetch) | ✅ | ⚠️ | Gitea-only route; on Forgejo madtea falls back to per-file /contents/{path} reads (same array shape, reads default branch). |
/contents-ext/{path} (extended-metadata file fetch) | ✅ | ⚠️ | Gitea-only route; on Forgejo madtea falls back to a plain /contents/{path} read. |
repo merge-upstream / sync-fork | ✅ (/merge-upstream) | ✅ (/sync_fork) | Same feature, two paths — madtea hits both depending on platform. |
| Quota management | ❌ | ⚙️ | /user/quota exists in Forgejo spec but is config-gated by [quota] ENABLED=true; 404 on default installs. Gitea has no /quota family. |
| ActivityPub federation | ❌ | ⚙️ | /activitypub/... exists in Forgejo spec but is config-gated by [federation] ENABLED=true; 404 on default installs. Gitea has no /activitypub paths. |
| Repository flags | ❌ | ⚙️ | /repos/{r}/flags exists in Forgejo spec but is config-gated by [repository] ENABLE_FLAGS=true; 404 on default installs. |
| Git notes API | ❌ | ❌ | /repos/{r}/git/notes/{sha} is 404 on Forgejo default install — endpoint expected per spec, not present at runtime. |
MCP tool annotations
Independent of the forge platform: every madt_* MCP tool declares all four MCP ToolAnnotations behaviour hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint), classified by each tool’s most dangerous action — see architecture/mcp-tool-annotations.md for the per-tool table. These are advisory hints: Claude Code currently surfaces no permission or UX effect from them, but other MCP clients may honour them (e.g. a confirmation prompt on destructive or open-world tools).
A second layer of annotations rides in each tool’s _meta map. Both keys below are Claude-Code-specific and ignored by every non-Anthropic client, so they carry no cross-platform risk. _meta["anthropic/maxResultSizeChars"] declares a per-tool output budget on the five large-output tools (madt_log, madt_prs, madt_files, madt_search, madt_issues); no Claude Code version floor is documented as of 2026-07, so treat it as current. _meta["anthropic/requiresUserInteraction"] forces a per-call permission prompt and is reserved for the one gated, wholly-dangerous tool — the instance-administration tool (madt_admin, whose surface is dominated by irreversible deletes); the flag is per-TOOL, so every action on it prompts (including madt_admin’s read-only listers) — it requires Claude Code v2.1.199+. (The irreversible forge-data deletes are omitted from every madtea surface entirely per ADR 0029, so there is no delete tool to flag.) Per-tool values and the reserve-list rationale live in internal/mcp/tool_meta_annotations.go.
Source specs: docs/api-specs/gitea-1.26.1.json, docs/api-specs/forgejo-15.0.json. We track only the latest stable of each platform. Older versions and pre-release dev branches are out of scope. Verified empirically against gitea:1.26.1 and codeberg.org/forgejo/forgejo:15.0.1+gitea-1.22.0 containers via tests/integration/forgejo-smoke.sh and the surface-diff in internal/gitea/api_surface_test.go.
Canonical source: docs/COMPAT.md in the madtea repo.