Files
gitea-migration/contracts/gitea-api.md

599 lines
12 KiB
Markdown

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