Mirroring every GitHub repository into Gitea with one API call each

Gitea pull mirrors created through the migrate API: the request body, why private repos store a GitHub token in Gitea, what breaks when that token rotates, and how to prove each mirror is complete.

I wanted a local copy of everything on my GitHub account in the Gitea instance that runs in my cluster. I didn’t want a one-off clone. I wanted mirrors that keep pulling, so that if GitHub disappeared tomorrow I’d lose at most one sync interval. Gitea calls these pull mirrors, and the migrate API creates them in one request per repository.

The request

curl -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
  https://<gitea>/api/v1/repos/migrate -d '{
    "clone_addr": "<clone-url>",
    "repo_name":  "<repo>",
    "repo_owner": "<org>",
    "service":    "github",
    "mirror":     true,
    "private":    true,
    "auth_token": "<github-token>"
  }'

clone_addr is the repository’s ordinary HTTPS clone URL on GitHub. I built the body from gh repo list --json name,isPrivate,description, set private to whatever GitHub said, and sent auth_token only for the private repositories. Public ones don’t need it.

The call is synchronous. The 201 comes back after the first clone has finished, which was true even for a 22 MB fork, so you can check the result straight away. New mirrors pull every 8 hours by default (mirror_interval).

The token you hand over stays in Gitea

For a private source repository Gitea needs credentials on every sync, not just the first one. So the auth_token you pass is stored in the mirror’s remote configuration and reused each time. That has two consequences.

First, think about which token that is. My gh CLI token has broad scopes, and it is now stored in Gitea’s database. A fine-grained GitHub token with read-only access to repository contents would be the better choice.

Second, rotating that token breaks every private mirror. They can’t pull any more until each one gets the new credentials. Put that on the checklist for whenever you rotate.

Gitea’s scope errors are more helpful than they look

My first attempt used an automation account’s token, and asking whether the target organization existed returned 403. It’s tempting to read that as “the org is missing” or “no permission”. The body said exactly what was wrong:

token does not have at least one of required scope(s), required=[read:organization]

Gitea enforces token scopes per endpoint, and the error names the scope it wanted. Creating an organization needs write:organization.

While you’re at it, ask Gitea whose token you’re holding. GET /api/v1/user returns the login. The token I thought was my own user token turned out to belong to the site admin account, which is useful to know before creating sixteen repositories under that name.

Proving each mirror is complete

A 201 means the clone ran, not that it got everything. For each repository I compared the head commit of the default branch on both sides:

gh api repos/<user>/<repo>/commits/<branch> --jq .sha
curl -s -H "Authorization: token $GITEA_TOKEN" \
  https://<gitea>/api/v1/repos/<org>/<repo>/branches/<branch> | jq -r .commit.id

Same SHA, not empty, mirror: true, visibility as intended. All sixteen matched. Forks mirror from your fork, not from the upstream project. If you want upstream’s history, mirror that URL instead.