# 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**: ```json { "name": "string", "scopes": ["all"] } ``` **Response** (201): ```json { "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): ```json { "id": 1, "login": "string", "email": "string", "is_admin": true } ``` --- ### POST /orgs **Used in**: Phase 1 **Purpose**: Create organization **Request**: ```json { "username": "string", "full_name": "string", "description": "string", "visibility": "public" } ``` **Response** (201): ```json { "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): ```json { "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)**: ```json { "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)**: ```json { "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): ```json { "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): ```json { "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): ```json { "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): ```json [ { "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**: ```json { "remote_address": "https://github.com/{owner}/{repo}.git", "remote_username": "string", "remote_password": "string (GitHub PAT)", "interval": "8h", "sync_on_commit": true } ``` **Response** (201): ```json { "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**: ```json { "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): ```json { "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): ```json { "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): ```json { "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): ```json { "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)**: ```json { "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)**: ```json { "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**: ```json { "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): ```json { "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: ```json { "message": "string describing the error", "url": "https://gitea.docs/api/endpoint" } ``` GitHub errors return JSON with `message` and optionally `errors`: ```json { "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: ; rel="next", ; 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.