mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-23 09:49:37 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 079df29f44 | |||
| dff3cafb5e | |||
| 6ca7c0eec0 | |||
| 6ebf1916e8 |
@@ -107,6 +107,7 @@ Standard GitHub Enterprise Cloud on `github.com` works with the default — no o
|
||||
| `MIRROR_STARRED_LISTS` | Optional comma-separated GitHub Star List names to mirror (only used when `MIRROR_STARRED=true`) | - | Comma-separated list names (empty = all starred repos) |
|
||||
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
|
||||
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
|
||||
| `STARRED_DUPLICATE_STRATEGY` | Name collision strategy when two starred repos share a name from different owners (`suffix` = `repo-owner`, `prefix` = `owner-repo`) | `suffix` | `suffix`, `prefix`, `owner-org` |
|
||||
|
||||
### Organization Settings
|
||||
|
||||
@@ -115,6 +116,7 @@ Standard GitHub Enterprise Cloud on `github.com` works with the default — no o
|
||||
| `MIRROR_ORGANIZATIONS` | Mirror organization repositories | `false` | `true`, `false` |
|
||||
| `PRESERVE_ORG_STRUCTURE` | Preserve GitHub organization structure in Gitea | `false` | `true`, `false` |
|
||||
| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` |
|
||||
| `INCLUDE_ORGANIZATIONS` | Opt-in allowlist: only mirror repos from these organizations (empty = all orgs you belong to). Sets `includeOrganizations` in GitHub config | - | Comma-separated org names |
|
||||
| `MIRROR_STRATEGY` | Repository organization strategy | `preserve` | `preserve`, `single-org`, `flat-user`, `mixed` |
|
||||
|
||||
### Advanced Settings
|
||||
|
||||
+43
-43
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.19.1",
|
||||
"version": "3.20.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -54,39 +54,39 @@
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.7",
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@astrojs/node": "10.1.4",
|
||||
"@astrojs/react": "^5.0.7",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.13",
|
||||
"@radix-ui/react-avatar": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.13",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||
"@radix-ui/react-hover-card": "^1.1.16",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-popover": "^1.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.9",
|
||||
"@radix-ui/react-radio-group": "^1.4.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||
"@radix-ui/react-select": "^2.3.0",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"astro": "^6.4.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
@@ -94,39 +94,39 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.4.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-icons": "^5.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^13.0.2",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^28.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"vitest": "^4.1.0"
|
||||
"tsx": "^4.22.4",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"packageManager": "bun@1.3.10"
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function GitHubMirrorSettings({
|
||||
const [starListsOpen, setStarListsOpen] = React.useState(false);
|
||||
const [starListSearch, setStarListSearch] = React.useState("");
|
||||
const [customStarListName, setCustomStarListName] = React.useState("");
|
||||
const [customOrgName, setCustomOrgName] = React.useState("");
|
||||
const [availableStarLists, setAvailableStarLists] = React.useState<string[]>([]);
|
||||
const [loadingStarLists, setLoadingStarLists] = React.useState(false);
|
||||
const [loadedStarLists, setLoadedStarLists] = React.useState(false);
|
||||
@@ -138,6 +139,38 @@ export function GitHubMirrorSettings({
|
||||
});
|
||||
}, [githubConfig, normalizeStarListNames, onGitHubConfigChange]);
|
||||
|
||||
const includedOrgs = React.useMemo(
|
||||
() => githubConfig.includeOrganizations ?? [],
|
||||
[githubConfig.includeOrganizations],
|
||||
);
|
||||
|
||||
const addIncludedOrg = React.useCallback(() => {
|
||||
const trimmed = customOrgName.trim();
|
||||
if (!trimmed) return;
|
||||
const exists = includedOrgs.some(
|
||||
(org) => org.toLowerCase() === trimmed.toLowerCase(),
|
||||
);
|
||||
if (!exists) {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
includeOrganizations: [...includedOrgs, trimmed],
|
||||
});
|
||||
}
|
||||
setCustomOrgName("");
|
||||
}, [customOrgName, includedOrgs, githubConfig, onGitHubConfigChange]);
|
||||
|
||||
const removeIncludedOrg = React.useCallback(
|
||||
(name: string) => {
|
||||
onGitHubConfigChange({
|
||||
...githubConfig,
|
||||
includeOrganizations: includedOrgs.filter(
|
||||
(org) => org.toLowerCase() !== name.toLowerCase(),
|
||||
),
|
||||
});
|
||||
},
|
||||
[includedOrgs, githubConfig, onGitHubConfigChange],
|
||||
);
|
||||
|
||||
const loadStarLists = React.useCallback(async () => {
|
||||
if (
|
||||
loadingStarLists ||
|
||||
@@ -947,6 +980,64 @@ export function GitHubMirrorSettings({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Users className="h-4 w-4 mt-0.5 text-muted-foreground" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Limit to specific organizations
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to mirror repos from every organization you belong to.
|
||||
Add one or more organizations to mirror only their repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{includedOrgs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{includedOrgs.map((org) => (
|
||||
<Badge key={org} variant="secondary" className="gap-1">
|
||||
<span>{org}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeIncludedOrg(org)}
|
||||
className="rounded-sm hover:text-foreground/80"
|
||||
aria-label={`Remove ${org} organization`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={customOrgName}
|
||||
onChange={(event) => setCustomOrgName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addIncludedOrg();
|
||||
}
|
||||
}}
|
||||
placeholder="Add organization name"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addIncludedOrg}
|
||||
disabled={!customOrgName.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,14 @@ interface EnvConfig {
|
||||
skipForks?: boolean;
|
||||
includeArchived?: boolean;
|
||||
mirrorOrganizations?: boolean;
|
||||
includeOrganizations?: string[];
|
||||
preserveOrgStructure?: boolean;
|
||||
onlyMirrorOrgs?: boolean;
|
||||
starredCodeOnly?: boolean;
|
||||
autoMirrorStarred?: boolean;
|
||||
starredReposOrg?: string;
|
||||
starredReposMode?: 'dedicated-org' | 'preserve-owner';
|
||||
starredDuplicateStrategy?: 'suffix' | 'prefix' | 'owner-org';
|
||||
starredLists?: string[];
|
||||
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
|
||||
};
|
||||
@@ -101,6 +103,9 @@ function parseEnvConfig(): EnvConfig {
|
||||
const protectedRepos = process.env.CLEANUP_PROTECTED_REPOS
|
||||
? process.env.CLEANUP_PROTECTED_REPOS.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const includeOrganizations = process.env.INCLUDE_ORGANIZATIONS
|
||||
? process.env.INCLUDE_ORGANIZATIONS.split(',').map((org) => org.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const starredLists = process.env.MIRROR_STARRED_LISTS
|
||||
? process.env.MIRROR_STARRED_LISTS.split(',').map((list) => list.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
@@ -121,12 +126,14 @@ function parseEnvConfig(): EnvConfig {
|
||||
skipForks: process.env.SKIP_FORKS === 'true',
|
||||
includeArchived: process.env.INCLUDE_ARCHIVED === 'true',
|
||||
mirrorOrganizations: process.env.MIRROR_ORGANIZATIONS === 'true',
|
||||
includeOrganizations,
|
||||
preserveOrgStructure: process.env.PRESERVE_ORG_STRUCTURE === 'true',
|
||||
onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true',
|
||||
starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true',
|
||||
autoMirrorStarred: process.env.AUTO_MIRROR_STARRED === 'true',
|
||||
starredReposOrg: process.env.STARRED_REPOS_ORG,
|
||||
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
|
||||
starredDuplicateStrategy: process.env.STARRED_DUPLICATE_STRATEGY as 'suffix' | 'prefix' | 'owner-org',
|
||||
starredLists,
|
||||
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
|
||||
},
|
||||
@@ -277,9 +284,12 @@ export async function initializeConfigFromEnv(): Promise<void> {
|
||||
includePrivate: envConfig.github.privateRepositories ?? existingConfig?.[0]?.githubConfig?.includePrivate ?? false,
|
||||
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
|
||||
includeCollaboratorRepos: envConfig.github.includeCollaboratorRepos ?? existingConfig?.[0]?.githubConfig?.includeCollaboratorRepos ?? true,
|
||||
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
|
||||
// Opt-in org allowlist from INCLUDE_ORGANIZATIONS (comma-separated). Falls
|
||||
// back to existing config so the UI-managed list isn't clobbered on restart.
|
||||
includeOrganizations: envConfig.github.includeOrganizations ?? existingConfig?.[0]?.githubConfig?.includeOrganizations ?? [],
|
||||
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
|
||||
starredReposMode: envConfig.github.starredReposMode || existingConfig?.[0]?.githubConfig?.starredReposMode || 'dedicated-org',
|
||||
starredDuplicateStrategy: envConfig.github.starredDuplicateStrategy || existingConfig?.[0]?.githubConfig?.starredDuplicateStrategy || 'suffix',
|
||||
mirrorStrategy,
|
||||
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
|
||||
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,
|
||||
|
||||
@@ -164,3 +164,92 @@ describe("getGithubRepositories - skipPersonalRepos", () => {
|
||||
expect(repos.map((r) => r.name)).toContain("org-lib");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGithubRepositories - includeOrganizations allowlist", () => {
|
||||
const personalRepo = makeRepo({ name: "my-lib", ownerLogin: "octo", ownerType: "User" });
|
||||
const wantedOrgRepo = makeRepo({ name: "wanted", ownerLogin: "wanted-org", ownerType: "Organization" });
|
||||
const otherOrgRepo = makeRepo({ name: "noise", ownerLogin: "noise-org", ownerType: "Organization" });
|
||||
const collabRepo = makeRepo({ name: "collab", ownerLogin: "other-user", ownerType: "User" });
|
||||
|
||||
test("empty allowlist — keeps repos from all orgs (backward compat)", async () => {
|
||||
const { octokit } = makeOctokit([wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: [] } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).toContain("noise");
|
||||
});
|
||||
|
||||
test("non-empty allowlist — keeps only listed orgs, drops other orgs", async () => {
|
||||
const { octokit } = makeOctokit([wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: ["wanted-org"] } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).not.toContain("noise");
|
||||
});
|
||||
|
||||
test("allowlist match is case-insensitive", async () => {
|
||||
const { octokit } = makeOctokit([wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: ["Wanted-Org"] } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).not.toContain("noise");
|
||||
});
|
||||
|
||||
test("allowlist never restricts personal or collaborator repos", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, collabRepo, wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: ["wanted-org"] } as any },
|
||||
});
|
||||
// User-owned and collaborator repos pass through regardless of the allowlist
|
||||
expect(repos.map((r) => r.name)).toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("collab");
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).not.toContain("noise");
|
||||
});
|
||||
|
||||
test("composes with skipPersonalRepos — drops personal, keeps only listed org", async () => {
|
||||
const { octokit } = makeOctokit([personalRepo, wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: {
|
||||
githubConfig: {
|
||||
owner: "octo",
|
||||
skipPersonalRepos: true,
|
||||
includeOrganizations: ["wanted-org"],
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
expect(repos.map((r) => r.name)).not.toContain("my-lib");
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).not.toContain("noise");
|
||||
});
|
||||
|
||||
test("blank/whitespace entries are ignored (treated as empty allowlist)", async () => {
|
||||
const { octokit } = makeOctokit([wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: [" ", ""] } as any },
|
||||
});
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).toContain("noise");
|
||||
});
|
||||
|
||||
test("includeAllOrgsOverride bypasses allowlist (cleanup safety)", async () => {
|
||||
const { octokit } = makeOctokit([wantedOrgRepo, otherOrgRepo]);
|
||||
const repos = await getGithubRepositories({
|
||||
octokit,
|
||||
config: { githubConfig: { owner: "octo", includeOrganizations: ["wanted-org"] } as any },
|
||||
includeAllOrgsOverride: true,
|
||||
});
|
||||
// Override returns all org repos so cleanup never false-orphans excluded orgs
|
||||
expect(repos.map((r) => r.name)).toContain("wanted");
|
||||
expect(repos.map((r) => r.name)).toContain("noise");
|
||||
});
|
||||
});
|
||||
|
||||
+22
-1
@@ -236,6 +236,7 @@ export async function getGithubRepositories({
|
||||
octokit,
|
||||
config,
|
||||
includeCollaboratorReposOverride,
|
||||
includeAllOrgsOverride,
|
||||
}: {
|
||||
octokit: Octokit;
|
||||
config: Partial<Config>;
|
||||
@@ -243,6 +244,10 @@ export async function getGithubRepositories({
|
||||
// cleanup service so we never mark a collab repo as orphaned just because
|
||||
// the import filter is currently off.
|
||||
includeCollaboratorReposOverride?: boolean;
|
||||
// Bypass the includeOrganizations allowlist so all org repos are returned.
|
||||
// Used by the cleanup service so a previously-mirrored org repo isn't flagged
|
||||
// as orphaned just because the user narrowed the allowlist.
|
||||
includeAllOrgsOverride?: boolean;
|
||||
}): Promise<GitRepo[]> {
|
||||
try {
|
||||
const includeCollab =
|
||||
@@ -266,6 +271,16 @@ export async function getGithubRepositories({
|
||||
const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false;
|
||||
// The authenticated user's login — used to identify personally-owned repos
|
||||
const authenticatedUserLogin = config.githubConfig?.owner ?? "";
|
||||
// Opt-in organization allowlist. When non-empty, only repos owned by the
|
||||
// listed organizations are imported; org repos from any other org the user
|
||||
// happens to be a member of are dropped. Empty = import all org repos
|
||||
// (backward-compatible default). Owned/collaborator repos are unaffected.
|
||||
const includeOrgs = includeAllOrgsOverride
|
||||
? []
|
||||
: config.githubConfig?.includeOrganizations ?? [];
|
||||
const allowedOrgs = new Set(
|
||||
includeOrgs.map((org) => org.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
|
||||
const filteredRepos = repos.filter((repo) => {
|
||||
const isForkAllowed = !skipForks || !repo.fork;
|
||||
@@ -277,7 +292,13 @@ export async function getGithubRepositories({
|
||||
authenticatedUserLogin.length > 0 &&
|
||||
repo.owner.login === authenticatedUserLogin &&
|
||||
repo.owner.type === "User";
|
||||
return isForkAllowed && !isPersonalRepo;
|
||||
// When an allowlist is configured, only keep org repos whose owning org
|
||||
// is listed. Non-org repos (owned/collaborator) are never restricted here.
|
||||
const isOrgAllowed =
|
||||
allowedOrgs.size === 0 ||
|
||||
repo.owner.type !== "Organization" ||
|
||||
allowedOrgs.has(repo.owner.login.toLowerCase());
|
||||
return isForkAllowed && !isPersonalRepo && isOrgAllowed;
|
||||
});
|
||||
|
||||
return filteredRepos.map((repo) => ({
|
||||
|
||||
@@ -33,12 +33,18 @@ async function identifyOrphanedRepositories(config: any): Promise<any[]> {
|
||||
let githubApiAccessible = true;
|
||||
|
||||
try {
|
||||
// Fetch GitHub data. Always include collaborator repos here regardless
|
||||
// of the user's import filter, otherwise repos previously mirrored as a
|
||||
// collaborator would be flagged as orphaned and archived/deleted as soon
|
||||
// as the user disables the filter.
|
||||
// Fetch GitHub data. Always include collaborator repos and bypass the
|
||||
// organization allowlist here regardless of the user's import filters,
|
||||
// otherwise repos previously mirrored as a collaborator or from an org the
|
||||
// user later removed from the allowlist would be flagged as orphaned and
|
||||
// archived/deleted as soon as the user narrows those filters.
|
||||
const [basicAndForkedRepos, starredRepos] = await Promise.all([
|
||||
getGithubRepositories({ octokit, config, includeCollaboratorReposOverride: true }),
|
||||
getGithubRepositories({
|
||||
octokit,
|
||||
config,
|
||||
includeCollaboratorReposOverride: true,
|
||||
includeAllOrgsOverride: true,
|
||||
}),
|
||||
config.githubConfig?.includeStarred
|
||||
? getGithubStarredRepositories({ octokit, config })
|
||||
: Promise.resolve([]),
|
||||
|
||||
@@ -158,3 +158,36 @@ test("DB row missing skipPersonalRepos defaults to false on read", () => {
|
||||
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
|
||||
expect(ui.advancedOptions.skipPersonalRepos).toBe(false);
|
||||
});
|
||||
|
||||
// Regression for #326: the Name Collision Strategy dropdown didn't persist
|
||||
// because starredDuplicateStrategy was missing from both mapper directions.
|
||||
test("starredDuplicateStrategy round-trips UI -> DB -> UI when set to prefix", () => {
|
||||
const ui = buildMinimalUiConfigs();
|
||||
ui.githubConfig.starredDuplicateStrategy = "prefix";
|
||||
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
|
||||
expect(db.githubConfig.starredDuplicateStrategy).toBe("prefix");
|
||||
|
||||
const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
|
||||
expect(roundTripped.githubConfig.starredDuplicateStrategy).toBe("prefix");
|
||||
});
|
||||
|
||||
test("starredDuplicateStrategy round-trips UI -> DB -> UI when set to suffix", () => {
|
||||
const ui = buildMinimalUiConfigs();
|
||||
ui.githubConfig.starredDuplicateStrategy = "suffix";
|
||||
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
|
||||
expect(db.githubConfig.starredDuplicateStrategy).toBe("suffix");
|
||||
|
||||
const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
|
||||
expect(roundTripped.githubConfig.starredDuplicateStrategy).toBe("suffix");
|
||||
});
|
||||
|
||||
test("starredDuplicateStrategy defaults to suffix on save when unset", () => {
|
||||
const ui = buildMinimalUiConfigs();
|
||||
const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, ui.advancedOptions);
|
||||
expect(db.githubConfig.starredDuplicateStrategy).toBe("suffix");
|
||||
});
|
||||
|
||||
test("DB row missing starredDuplicateStrategy defaults to suffix on read", () => {
|
||||
const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
|
||||
expect(ui.githubConfig.starredDuplicateStrategy).toBe("suffix");
|
||||
});
|
||||
|
||||
@@ -31,6 +31,24 @@ function normalizeStarredLists(lists: string[] | undefined): string[] {
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
// Trim, drop blanks, and de-duplicate case-insensitively while preserving the
|
||||
// first-seen casing of each organization name.
|
||||
function normalizeOrgList(orgs: string[] | undefined): string[] {
|
||||
if (!Array.isArray(orgs)) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const org of orgs) {
|
||||
if (typeof org !== "string") continue;
|
||||
const trimmed = org.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(trimmed);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps UI config structure to database schema structure
|
||||
*/
|
||||
@@ -56,13 +74,14 @@ export function mapUiToDbConfig(
|
||||
includeArchived: false, // Not in UI yet, default to false
|
||||
includePublic: true, // Not in UI yet, default to true
|
||||
|
||||
// Organization related fields
|
||||
includeOrganizations: [], // Not in UI yet
|
||||
// Organization related fields — opt-in allowlist (empty = all org repos)
|
||||
includeOrganizations: normalizeOrgList(githubConfig.includeOrganizations),
|
||||
|
||||
// Starred repos organization
|
||||
starredReposOrg: giteaConfig.starredReposOrg,
|
||||
starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
|
||||
starredLists: normalizeStarredLists(githubConfig.starredLists),
|
||||
starredDuplicateStrategy: githubConfig.starredDuplicateStrategy ?? "suffix",
|
||||
|
||||
// Mirror strategy
|
||||
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
|
||||
@@ -145,8 +164,10 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
token: dbConfig.githubConfig?.token || "",
|
||||
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
|
||||
includeCollaboratorRepos: dbConfig.githubConfig?.includeCollaboratorRepos ?? true,
|
||||
includeOrganizations: normalizeOrgList(dbConfig.githubConfig?.includeOrganizations),
|
||||
mirrorStarred: dbConfig.githubConfig?.includeStarred || false, // Map includeStarred to mirrorStarred
|
||||
starredLists: normalizeStarredLists(dbConfig.githubConfig?.starredLists),
|
||||
starredDuplicateStrategy: dbConfig.githubConfig?.starredDuplicateStrategy ?? "suffix",
|
||||
};
|
||||
|
||||
// Map from database Gitea config to UI fields
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface GitHubConfig {
|
||||
token: string;
|
||||
privateRepositories: boolean;
|
||||
includeCollaboratorRepos?: boolean;
|
||||
includeOrganizations?: string[];
|
||||
mirrorStarred: boolean;
|
||||
starredLists?: string[];
|
||||
starredDuplicateStrategy?: DuplicateNameStrategy;
|
||||
|
||||
+11
-11
@@ -9,24 +9,24 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.0",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@astrojs/mdx": "^5.0.6",
|
||||
"@astrojs/react": "^5.0.7",
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@splinetool/react-spline": "^4.1.0",
|
||||
"@splinetool/runtime": "^1.12.69",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@splinetool/runtime": "^1.12.97",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.1.6",
|
||||
"astro": "^6.4.6",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
Generated
+741
-745
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user