Files
gitea-migration/contracts/gitea-api.md
2026-03-01 11:08:33 -05:00

12 KiB

API Contracts — Gitea Migration Toolkit

All API calls used by the migration scripts. Every gitea_api, gitea_backup_api, and github_api call in the codebase MUST have a corresponding entry here.


Gitea REST API v1

Base URL: ${GITEA_INTERNAL_URL}/api/v1 (primary) or ${GITEA_BACKUP_INTERNAL_URL}/api/v1 (backup) Auth: Authorization: token ${GITEA_ADMIN_TOKEN} (or GITEA_BACKUP_ADMIN_TOKEN)


POST /users/{username}/tokens

Used in: Phase 1 (primary), Phase 2 (backup) Purpose: Generate API token for admin user Auth: Basic auth (-u username:password) — token doesn't exist yet

Request:

{
  "name": "string",
  "scopes": ["all"]
}

Response (201):

{
  "id": 1,
  "name": "migration-token",
  "sha1": "abc123...",
  "token_last_eight": "abc12345"
}

Notes: sha1 field contains the full token value — save this immediately, it's only returned once.


GET /user

Used in: Preflight (validate token), Phase 1 post-check Purpose: Verify the current authenticated user

Request: No body

Response (200):

{
  "id": 1,
  "login": "string",
  "email": "string",
  "is_admin": true
}

POST /orgs

Used in: Phase 1 Purpose: Create organization

Request:

{
  "username": "string",
  "full_name": "string",
  "description": "string",
  "visibility": "public"
}

Response (201):

{
  "id": 1,
  "name": "string",
  "full_name": "string",
  "visibility": "public"
}

GET /orgs/{org}

Used in: Phase 1 (idempotency check), Phase 1 post-check Purpose: Check if organization exists

Request: No body

Response (200):

{
  "id": 1,
  "name": "string",
  "full_name": "string"
}

404: Organization does not exist


POST /repos/migrate

Used in: Phase 4 (import from GitHub), Phase 4 (Fedora pull mirrors) Purpose: Migrate/mirror a repository from an external source

Request (GitHub import — primary):

{
  "clone_addr": "https://github.com/{owner}/{repo}.git",
  "auth_token": "string (GitHub PAT)",
  "repo_owner": "string (Gitea org name)",
  "repo_name": "string",
  "service": "github",
  "mirror": false,
  "issues": true,
  "labels": true,
  "milestones": false,
  "wiki": false
}

Request (Fedora pull mirror):

{
  "clone_addr": "http://{UNRAID_IP}:{PORT}/{org}/{repo}.git",
  "auth_username": "string",
  "auth_password": "string",
  "repo_owner": "string (admin username on Fedora)",
  "repo_name": "string",
  "mirror": true,
  "mirror_interval": "8h"
}

Response (201):

{
  "id": 1,
  "name": "string",
  "full_name": "org/repo",
  "empty": false,
  "mirror": false,
  "default_branch": "main"
}

Notes: Migration is async — poll GET /repos/{owner}/{repo} until empty is false.


GET /repos/{owner}/{repo}

Used in: Phase 4 (idempotency + polling), Phase 4 post-check, Phase 5 post-check Purpose: Get repository details

Request: No body

Response (200):

{
  "id": 1,
  "name": "string",
  "full_name": "org/repo",
  "empty": false,
  "mirror": false,
  "default_branch": "main",
  "archived": false,
  "description": "string"
}

404: Repository does not exist


DELETE /repos/{owner}/{repo}

Used in: Phase 4 teardown Purpose: Delete a repository

Request: No body Response: 204 No Content


GET /repos/{owner}/{repo}/commits

Used in: Phase 4 post-check Purpose: Verify repo has commits

Query params: ?limit=1

Response (200): Array of commit objects (at least 1 if repo has content)


GET /repos/{owner}/{repo}/contents/{filepath}

Used in: Phase 5 post-check (check .gitea/workflows/ exists), Phase 9 (check security workflow exists) Purpose: Get file or directory contents

Response (200): File/directory metadata 404: Path does not exist


GET /admin/runners/registration-token

Used in: Phase 3 Purpose: Get registration token for new runners

Request: No body

Response (200):

{
  "token": "string"
}

Notes: Requires admin-level API token.


GET /admin/runners

Used in: Phase 3 post-check Purpose: List all registered runners

Request: No body

Response (200):

[
  {
    "id": 1,
    "name": "string",
    "status": "online",
    "labels": ["linux"]
  }
]

POST /repos/{owner}/{repo}/push_mirrors

Used in: Phase 6 Purpose: Create a push mirror to GitHub

Request:

{
  "remote_address": "https://github.com/{owner}/{repo}.git",
  "remote_username": "string",
  "remote_password": "string (GitHub PAT)",
  "interval": "8h",
  "sync_on_commit": true
}

Response (201):

{
  "id": 1,
  "remote_name": "string",
  "remote_address": "string",
  "interval": "8h",
  "sync_on_commit": true
}

GET /repos/{owner}/{repo}/push_mirrors

Used in: Phase 6 (idempotency check), Phase 6 post-check Purpose: List push mirrors for a repo

Response (200): Array of push mirror objects


DELETE /repos/{owner}/{repo}/push_mirrors/{id}

Used in: Phase 6 teardown Purpose: Remove a push mirror

Response: 204 No Content


POST /repos/{owner}/{repo}/push_mirrors-sync

Used in: Phase 6 post-check Purpose: Trigger immediate push mirror sync

Request: No body Response: 200 OK


POST /repos/{owner}/{repo}/branch_protections

Used in: Phase 7, Phase 9 (update with status checks) Purpose: Create branch protection rule

Request:

{
  "branch_name": "main",
  "enable_push": false,
  "enable_push_whitelist": false,
  "require_signed_commits": false,
  "enable_status_check": true,
  "status_check_contexts": [],
  "enable_approvals_whitelist": false,
  "required_approvals": 1
}

Response (201): Branch protection object


GET /repos/{owner}/{repo}/branch_protections

Used in: Phase 7 (idempotency), Phase 7 post-check Purpose: List all branch protection rules

Response (200): Array of branch protection objects


GET /repos/{owner}/{repo}/branch_protections/{name}

Used in: Phase 7 (check specific branch) Purpose: Get a specific branch protection rule

Response (200): Branch protection object 404: Rule does not exist


PATCH /repos/{owner}/{repo}/branch_protections/{name}

Used in: Phase 9 (add status check contexts) Purpose: Update branch protection rule

Request (partial update):

{
  "enable_status_check": true,
  "status_check_contexts": ["semgrep", "trivy", "gitleaks"]
}

Response (200): Updated branch protection object


DELETE /repos/{owner}/{repo}/branch_protections/{name}

Used in: Phase 7 teardown, Phase 9 teardown Purpose: Delete branch protection rule

Response: 204 No Content


GET /repos/{owner}/{repo}/actions/workflows

Used in: Phase 5 post-check Purpose: List workflows in repo

Response (200):

{
  "total_count": 1,
  "workflows": [
    {
      "id": "string",
      "name": "string",
      "path": ".gitea/workflows/ci.yml",
      "state": "active"
    }
  ]
}

GET /settings/api

Used in: Phase 1 post-check (verify Actions enabled) Purpose: Get instance API settings

Response (200): Settings object with has_actions field


GitHub REST API

Base URL: https://api.github.com Auth: Authorization: token ${GITHUB_TOKEN}


GET /user

Used in: Preflight Purpose: Validate GitHub personal access token

Request: No body

Response (200):

{
  "login": "string",
  "id": 1
}

401: Token invalid or expired


GET /repos/{owner}/{repo}

Used in: Preflight (verify repos exist), Phase 8 (check archive status) Purpose: Get repository details

Response (200):

{
  "name": "string",
  "full_name": "owner/repo",
  "archived": false,
  "description": "string"
}

404: Repository does not exist


PATCH /repos/{owner}/{repo}

Used in: Phase 8 (mark as mirror + update description), Phase 8 teardown (restore settings) Purpose: Update repository settings

Request (mark as mirror):

{
  "description": "[MIRROR] Offsite backup — primary at https://git.domain.com/org/repo — was: original description",
  "homepage": "https://git.domain.com/org/repo",
  "has_wiki": false,
  "has_projects": false
}

Request (restore):

{
  "description": "original description",
  "homepage": "original homepage",
  "has_wiki": true,
  "has_projects": true
}

Response (200): Updated repository object

PUT /repos/{owner}/{repo}/actions/permissions

Used in: Phase 6 (disable Actions), Phase 6 teardown (re-enable) Purpose: Enable or disable GitHub Actions on a repo

Request:

{
  "enabled": false
}

Response (204): No Content

GET /repos/{owner}/{repo}/pages

Used in: Phase 8 (snapshot Pages state for teardown) Purpose: Get GitHub Pages configuration

Response (200):

{
  "cname": "string",
  "source": {
    "branch": "main",
    "path": "/"
  }
}

404: Pages not enabled for this repo

DELETE /repos/{owner}/{repo}/pages

Used in: Phase 8 (disable Pages on mirror) Purpose: Disable GitHub Pages

Response (204): No Content


Error Responses

All Gitea and GitHub API endpoints return standard HTTP error codes. The migration scripts check http_code >= 400 in _api_call() (lib/common.sh) and treat any 4xx/5xx as a failure.

Common Error Codes

Code Meaning Typical Cause
400 Bad Request Malformed JSON, missing required fields, invalid field values
401 Unauthorized Token expired, revoked, or missing
403 Forbidden Token lacks required scope (e.g., admin endpoint with non-admin token)
404 Not Found Resource doesn't exist — used for idempotency checks (GET before POST)
409 Conflict Resource already exists (e.g., duplicate token name, duplicate org name)
422 Unprocessable Entity Validation failed (e.g., password too short, invalid repo name)
500 Internal Server Error Server-side failure — retry may work for transient issues

Error Response Body

Gitea errors return JSON with a message field:

{
  "message": "string describing the error",
  "url": "https://gitea.docs/api/endpoint"
}

GitHub errors return JSON with message and optionally errors:

{
  "message": "Validation Failed",
  "errors": [
    {
      "resource": "Repository",
      "field": "name",
      "code": "already_exists"
    }
  ]
}

How Scripts Handle Errors

  • _api_call() logs the full response body on any 4xx/5xx and returns exit code 1
  • Callers either exit 1 (fatal) or continue with || true (non-fatal, e.g., sync triggers)
  • Idempotency checks use 404 as a signal to proceed with creation — this is expected, not an error

Pagination

Gitea list endpoints return paginated results. The migration scripts do NOT paginate because the data volumes are typically small (a handful of repos, runners, and mirrors).

Gitea Pagination Headers

x-total-count: 42
link: <url?page=2&limit=50>; rel="next", <url?page=5&limit=50>; rel="last"

Default Limits

Endpoint Default per-page Max per-page
GET /admin/runners 50 50
GET /repos/{owner}/{repo}/push_mirrors 50 50
GET /repos/{owner}/{repo}/commits 50 50
GET /repos/{owner}/{repo}/branch_protections all (no pagination)

When Pagination Would Matter

If any of these grow beyond 50 items, scripts that use these endpoints would silently miss results. For typical migrations (under 50 repos/runners), this is not a concern. If migrating more than 50 repos, add ?limit=N or implement Link header following.