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 small (3 repos, ~3 runners, ~3 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 this migration (3 repos, handful of runners), this
is not a concern. If extending to N repos where N > 50, add ?limit=N or
implement Link header following.