[GH-ISSUE #343] [Bug] Gitea "Mirror Organization" ignores destination overrides and can mirror org repos to user's personal account #7071

Open
opened 2026-07-16 01:42:06 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @YuzuZensai on GitHub (Jul 15, 2026).
Original GitHub issue: https://github.com/RayLabsHQ/gitea-mirror/issues/343

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

Image

(Repository override -> Organization override -> Strategy default, starred repos always follow starred-repo mode)

Image

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)

} 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

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

    {
      "clone_addr": "https://github.com/a/b.git",
      "repo_name": "b",
      "mirror": true
    }
    
  3. Gitea's migrate handler defaults a missing owner to the authenticated user

    } 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:

if (repo.organization && config.giteaConfig.preserveOrgStructure) {
  await mirrorGitHubOrgRepoToGiteaOrg({
    config,
    octokit,
    orgName: repo.organization, // <-- GitHub org name, ignores destinationOrg
    repository: repoData,
  });
}
Originally created by @YuzuZensai on GitHub (Jul 15, 2026). Original GitHub issue: https://github.com/RayLabsHQ/gitea-mirror/issues/343 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/main/routers/api/v1/repo/migrate.go) 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, }); } ```
Author
Owner

@YuzuZensai commented on GitHub (Jul 15, 2026):

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.

<!-- gh-comment-id:4985821452 --> @YuzuZensai commented on GitHub (Jul 15, 2026): 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/gitea-mirror#7071