Hi, thanks for making this tool! I faced some issues while trying to mirror organization repos. The bulk Mirror Organization action does not follow the documented destination priority
Destination Organization overrides (org-level and per-repo) are silently ignored. The repos land in whatever target the mirror strategy would normally pick, as if no override had been set. With the default preserve strategy that means a Gitea org named after the GitHub org, and with single-org it means the globally configured destination organization.
With the mixed strategy, all of an org's repos land in the user's personal account instead of preserving org structure.
Starred repos owned by an org the user is also a member of (and thus reachable via that org's own "Mirror Organization" button) don't follow the starred-repos destination when caught up in that org's bulk mirror, since the query matches by org name only and does not exclude starred repos. Under preserve/single-org they are routed into the org's target, and under mixed they land in the personal account (symptom 2). Only the flat-user bulk path happened to resolve starred mode correctly.
Mirroring the same repos individually from the Repositories page works fine, and the Organizations UI already shows the correct destination when an override is applied.
Leave starredReposMode at its default, dedicated-org, with starredReposOrg unset.
Be a member of GitHub org A, and star a repo owned by A, e.g. A/tools, that you did not create yourself.
Select the Mixed Mode strategy.
Click Mirror Organization for org A.
Expected:A/tools mirrors into the dedicated Gitea org starred. Starred status takes priority over org membership and strategy. Actual:A/tools mirrors into user's personal account, indistinguishable from any other repo caught by Scenario 2.
Root cause
The canonical resolver getGiteaRepoOwnerAsync(), in src/lib/gitea.ts, follows the documented priority correctly, and every mirror path uses it, except two that have their own logic.
mirrorGitHubOrgToGitea() picked the target from the strategy alone and never read organization.destinationOrg or per-repo destinationOrg (Scenario 1)
}elseif(mirrorStrategy==="preserve"){// For preserve strategy, create/use an org with the same name as GitHub
targetOrgName=organization.name;// <-- ignores organization.destinationOrg
giteaOrgId=awaitgetOrCreateGiteaOrg({...});}else{// For flat-user strategy, we shouldn't create organizations at all
targetOrgName=config.giteaConfig?.defaultOwner||"";// <-- `mixed` also lands here
}
Its per-repo dispatch only checked for flat-user vs. everything else, so mixed, which fell into the fallback above, sent every org repo to defaultOwner (Scenario 2).
On the non-flat-user path, per-repo overrides and starred-repo mode were never used (symptoms 1 and 3). Only the flat-user branch worked correctly, because mirrorGithubRepoToGitea() calls the canonical resolver itself
if(mirrorStrategy==="flat-user"){awaitmirrorGithubRepoToGitea({octokit,repository: repoData,config});}else{// For preserve and single-org strategies, use organization
awaitmirrorGitHubRepoToGiteaOrg({...,giteaOrgId: giteaOrgId!,// undefined for `mixed`. Never assigned by the fallback above
orgName: targetOrgName,// defaultOwner for `mixed` which ignores repoData.destinationOrg
});}
Why mixed ends up in user's personal account
giteaOrgId is declared let giteaOrgId: number; and the fallback branch never sets it, so for mixed it is undefined at runtime (hidden by the giteaOrgId! assertion)
The migrate payload is built with uid: giteaOrgId, giving uid: undefined, and JSON.stringify drops undefined values. The request body sent to POST /api/v1/repos/migrate has no uid at all
So every repo of the org ends up under the token's user account. The actual wrong destination comes from Gitea defaulting the missing uid, which is why the logs and the DB still looked consistent even though the location was wrong.
Crash recovery also had the same problem. It pre-routed org repos by GitHub org name using the legacy(?) preserveOrgStructure flag, skipping overrides and mirrorStrategy:
Hi, thanks for making this tool! I faced some issues while trying to mirror organization repos. The bulk **Mirror Organization** action does not follow the documented destination priority
<img width="546" height="237" alt="Image" src="https://github.com/user-attachments/assets/de1c8f3a-0639-4641-89b7-7d22b30b88c0" />
(_Repository override -> Organization override -> Strategy default, starred repos always follow starred-repo mode_)
<img width="378" height="377" alt="Image" src="https://github.com/user-attachments/assets/3271e00a-06ad-4c73-bd7b-6d15e78843e3" />
This results in
1. Destination Organization overrides (org-level and per-repo) are silently ignored. The repos land in whatever target the mirror strategy would normally pick, as if no override had been set. With the default `preserve` strategy that means a Gitea org named after the GitHub org, and with `single-org` it means the globally configured destination organization.
2. With the `mixed` strategy, all of an org's repos land in the user's personal account instead of preserving org structure.
3. Starred repos owned by an org the user is also a member of (and thus reachable via that org's own "Mirror Organization" button) don't follow the starred-repos destination when caught up in that org's bulk mirror, since the query matches by org name only and does not exclude starred repos. Under `preserve`/`single-org` they are routed into the org's target, and under `mixed` they land in the personal account (symptom 2). Only the `flat-user` bulk path happened to resolve starred mode correctly.
Mirroring the same repos individually from the Repositories page works fine, and the Organizations UI already shows the correct destination when an override is applied.
## Steps to reproduce
### Scenario 1 - overrides ignored (default `preserve` strategy)
1. Set a Destination Organization override on a GitHub org, e.g. `A` -> `B`.
2. Click **Mirror Organization** for org `A`.
**Expected:** repos mirror into Gitea org `B`.
**Actual:** a Gitea org `A` is created and repos mirror there.
### Scenario 2 - `mixed` strategy goes to user's personal account
1. Select the Mixed Mode strategy ("org repos preserve structure").
2. Click **Mirror Organization** for org `A`.
**Expected:** repos mirror into Gitea org `A`.
**Actual:** repos mirror into user's personal account.
### Scenario 3 - starred org repo ignores starred-repo mode
1. Leave `starredReposMode` at its default, `dedicated-org`, with `starredReposOrg` unset.
2. Be a member of GitHub org `A`, and star a repo owned by `A`, e.g. `A/tools`, that you did not create yourself.
3. Select the Mixed Mode strategy.
4. Click **Mirror Organization** for org `A`.
**Expected:** `A/tools` mirrors into the dedicated Gitea org `starred`. Starred status takes priority over org membership and strategy.
**Actual:** `A/tools` mirrors into user's personal account, indistinguishable from any other repo caught by Scenario 2.
## Root cause
The canonical resolver `getGiteaRepoOwnerAsync()`, in `src/lib/gitea.ts`, follows the documented priority correctly, and every mirror path uses it, except two that have their own logic.
**`mirrorGitHubOrgToGitea()`** picked the target from the strategy alone and never read `organization.destinationOrg` or per-repo `destinationOrg` (Scenario 1)
```ts
} else if (mirrorStrategy === "preserve") {
// For preserve strategy, create/use an org with the same name as GitHub
targetOrgName = organization.name; // <-- ignores organization.destinationOrg
giteaOrgId = await getOrCreateGiteaOrg({ ... });
} else {
// For flat-user strategy, we shouldn't create organizations at all
targetOrgName = config.giteaConfig?.defaultOwner || ""; // <-- `mixed` also lands here
}
```
Its per-repo dispatch only checked for `flat-user` vs. everything else, so `mixed`, which fell into the fallback above, sent every org repo to `defaultOwner` (Scenario 2).
On the non-`flat-user` path, per-repo overrides and starred-repo mode were never used (symptoms 1 and 3). Only the `flat-user` branch worked correctly, because `mirrorGithubRepoToGitea()` calls the canonical resolver itself
```ts
if (mirrorStrategy === "flat-user") {
await mirrorGithubRepoToGitea({ octokit, repository: repoData, config });
} else {
// For preserve and single-org strategies, use organization
await mirrorGitHubRepoToGiteaOrg({
...,
giteaOrgId: giteaOrgId!, // undefined for `mixed`. Never assigned by the fallback above
orgName: targetOrgName, // defaultOwner for `mixed` which ignores repoData.destinationOrg
});
}
```
### Why `mixed` ends up in user's personal account
1. `giteaOrgId` is declared `let giteaOrgId: number;` and the fallback branch never sets it, so for `mixed` it is `undefined` at runtime **(hidden by the `giteaOrgId!` assertion)**
2. The migrate payload is built with `uid: giteaOrgId`, giving `uid: undefined`, and `JSON.stringify` drops `undefined` values. The request body sent to `POST /api/v1/repos/migrate` has **no `uid` at all**
```json
{
"clone_addr": "https://github.com/a/b.git",
"repo_name": "b",
"mirror": true
}
```
3. [Gitea's migrate handler](https://github.com/go-gitea/gitea/blob/11363e2f0cd61ce79965ccded3dcfe2f0324270a/routers/api/v1/repo/migrate.go#L71) defaults a missing owner to the authenticated user
```go
} else {
repoOwner = ctx.Doer
}
```
So every repo of the org ends up under the token's user account. The actual wrong destination comes from Gitea defaulting the missing `uid`, which is why the logs and the DB still looked consistent even though the location was wrong.
**Crash recovery** also had the same problem. It pre-routed org repos by GitHub org name using the legacy(?) `preserveOrgStructure` flag, skipping overrides and `mirrorStrategy`:
```ts
if (repo.organization && config.giteaConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization, // <-- GitHub org name, ignores destinationOrg
repository: repoData,
});
}
```
<!-- github-sync-id:4896446409 -->
Also, the Repositories page doesn't show org-level destination overrides for the repo in the organizations column.
So if you set an org override but without repo override, the Repositories page still shows the old strategy-based destination, even though sync will use the override. Only the Organizations page shows it correctly (org-wide).
I left this out of the PR since I wasn't sure if it's intentional design choice or not. Happy to add it if needed.
Also, the Repositories page doesn't show org-level destination overrides for the repo in the organizations column.
So if you set an org override but without repo override, the Repositories page still shows the old strategy-based destination, even though sync will use the override. Only the Organizations page shows it correctly (org-wide).
I left this out of the PR since I wasn't sure if it's intentional design choice or not. Happy to add it if needed.
<!-- github-comment-id:4985821452 -->
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Hi, thanks for making this tool! I faced some issues while trying to mirror organization repos. The bulk Mirror Organization action does not follow the documented destination priority
(Repository override -> Organization override -> Strategy default, starred repos always follow starred-repo mode)
This results in
Destination Organization overrides (org-level and per-repo) are silently ignored. The repos land in whatever target the mirror strategy would normally pick, as if no override had been set. With the default
preservestrategy that means a Gitea org named after the GitHub org, and withsingle-orgit means the globally configured destination organization.With the
mixedstrategy, all of an org's repos land in the user's personal account instead of preserving org structure.Starred repos owned by an org the user is also a member of (and thus reachable via that org's own "Mirror Organization" button) don't follow the starred-repos destination when caught up in that org's bulk mirror, since the query matches by org name only and does not exclude starred repos. Under
preserve/single-orgthey are routed into the org's target, and undermixedthey land in the personal account (symptom 2). Only theflat-userbulk path happened to resolve starred mode correctly.Mirroring the same repos individually from the Repositories page works fine, and the Organizations UI already shows the correct destination when an override is applied.
Steps to reproduce
Scenario 1 - overrides ignored (default
preservestrategy)A->B.A.Expected: repos mirror into Gitea org
B.Actual: a Gitea org
Ais created and repos mirror there.Scenario 2 -
mixedstrategy goes to user's personal accountA.Expected: repos mirror into Gitea org
A.Actual: repos mirror into user's personal account.
Scenario 3 - starred org repo ignores starred-repo mode
starredReposModeat its default,dedicated-org, withstarredReposOrgunset.A, and star a repo owned byA, e.g.A/tools, that you did not create yourself.A.Expected:
A/toolsmirrors into the dedicated Gitea orgstarred. Starred status takes priority over org membership and strategy.Actual:
A/toolsmirrors into user's personal account, indistinguishable from any other repo caught by Scenario 2.Root cause
The canonical resolver
getGiteaRepoOwnerAsync(), insrc/lib/gitea.ts, follows the documented priority correctly, and every mirror path uses it, except two that have their own logic.mirrorGitHubOrgToGitea()picked the target from the strategy alone and never readorganization.destinationOrgor per-repodestinationOrg(Scenario 1)Its per-repo dispatch only checked for
flat-uservs. everything else, somixed, which fell into the fallback above, sent every org repo todefaultOwner(Scenario 2).On the non-
flat-userpath, per-repo overrides and starred-repo mode were never used (symptoms 1 and 3). Only theflat-userbranch worked correctly, becausemirrorGithubRepoToGitea()calls the canonical resolver itselfWhy
mixedends up in user's personal accountgiteaOrgIdis declaredlet giteaOrgId: number;and the fallback branch never sets it, so formixedit isundefinedat runtime (hidden by thegiteaOrgId!assertion)The migrate payload is built with
uid: giteaOrgId, givinguid: undefined, andJSON.stringifydropsundefinedvalues. The request body sent toPOST /api/v1/repos/migratehas nouidat allGitea's migrate handler defaults a missing owner to the authenticated user
So every repo of the org ends up under the token's user account. The actual wrong destination comes from Gitea defaulting the missing
uid, which is why the logs and the DB still looked consistent even though the location was wrong.Crash recovery also had the same problem. It pre-routed org repos by GitHub org name using the legacy(?)
preserveOrgStructureflag, skipping overrides andmirrorStrategy:Also, the Repositories page doesn't show org-level destination overrides for the repo in the organizations column.
So if you set an org override but without repo override, the Repositories page still shows the old strategy-based destination, even though sync will use the override. Only the Organizations page shows it correctly (org-wide).
I left this out of the PR since I wasn't sure if it's intentional design choice or not. Happy to add it if needed.