Files
gitea-migration/contracts/gitea-api.md
S 045283be50 docs: fix stale references across all documentation
README.md: add missing configure_runners.sh, fix check count 22→24
USAGE_GUIDE.md: fix check refs 23-24→21-22, add CAP column to
  manage_runner list example
PLAN.md: fix mirror-sync→push_mirrors-sync endpoint
contracts/gitea-api.md: add 5 missing endpoints (DELETE tokens,
  repo-scoped runner registration, PUT/POST GitHub Pages, GitHub
  commits), remove unused actions/workflows endpoint, fix
  GET /settings/api Used-in to include Phase 2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:55:23 -05:00

13 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.


DELETE /users/{username}/tokens/{token_name}

Used in: Phase 1, Phase 2 (idempotent re-run safety) Purpose: Delete a stale API token before regenerating Auth: Basic auth (-u username:password)

Request: No body Response: 204 No Content 404: Token does not exist (safe to ignore)

Notes: Gitea returns 409 Conflict if you try to POST a token with a name that already exists. Deleting first makes token generation idempotent.


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"]
  }
]

GET /repos/{owner}/{repo}/actions/runners/registration-token

Used in: manage_runner.sh (repo-scoped runner registration) Purpose: Get registration token scoped to a specific repo (vs admin-level global token)

Request: No body

Response (200):

{
  "token": "string"
}

Notes: Used when repos field in runners.conf is set to a specific repo name instead of all. Requires write access to the repo.


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 /settings/api

Used in: Phase 1 post-check, Phase 2 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

PUT /repos/{owner}/{repo}/pages

Used in: Phase 8 teardown (restore Pages configuration) Purpose: Update GitHub Pages configuration

Request:

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

Response (204): No Content

POST /repos/{owner}/{repo}/pages

Used in: Phase 8 teardown (re-enable Pages if it was deleted) Purpose: Create/enable GitHub Pages configuration

Request:

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

Response (201): Pages configuration object

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

Used in: Phase 6 post-check (compare Gitea and GitHub HEAD SHAs after mirror sync) Purpose: Get recent commits (GitHub side)

Query params: ?per_page=1

Response (200): Array of commit objects

Notes: The Gitea version of this endpoint is documented above. This GitHub-side usage verifies that push mirror sync propagated the latest commit.


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.