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