diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 40458fc..3cc8f96 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -115,6 +115,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 diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index a594723..0be4810 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -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([]); 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({

+ +
+ +
+
+ +

+ Leave empty to mirror repos from every organization you belong to. + Add one or more organizations to mirror only their repositories. +

+
+ + {includedOrgs.length > 0 && ( +
+ {includedOrgs.map((org) => ( + + {org} + + + ))} +
+ )} + +
+ setCustomOrgName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addIncludedOrg(); + } + }} + placeholder="Add organization name" + className="h-8 text-xs" + /> + +
+
+
diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts index 686afa1..0ce7e9d 100644 --- a/src/lib/env-config-loader.ts +++ b/src/lib/env-config-loader.ts @@ -20,6 +20,7 @@ interface EnvConfig { skipForks?: boolean; includeArchived?: boolean; mirrorOrganizations?: boolean; + includeOrganizations?: string[]; preserveOrgStructure?: boolean; onlyMirrorOrgs?: boolean; starredCodeOnly?: boolean; @@ -101,6 +102,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,6 +125,7 @@ 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', @@ -277,7 +282,9 @@ export async function initializeConfigFromEnv(): Promise { 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', mirrorStrategy, diff --git a/src/lib/github-affiliation.test.ts b/src/lib/github-affiliation.test.ts index eefab9e..d8f5288 100644 --- a/src/lib/github-affiliation.test.ts +++ b/src/lib/github-affiliation.test.ts @@ -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"); + }); +}); diff --git a/src/lib/github.ts b/src/lib/github.ts index 31e15a5..995b94e 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -236,6 +236,7 @@ export async function getGithubRepositories({ octokit, config, includeCollaboratorReposOverride, + includeAllOrgsOverride, }: { octokit: Octokit; config: Partial; @@ -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 { 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) => ({ diff --git a/src/lib/repository-cleanup-service.ts b/src/lib/repository-cleanup-service.ts index ea5ef92..716c4e1 100644 --- a/src/lib/repository-cleanup-service.ts +++ b/src/lib/repository-cleanup-service.ts @@ -33,12 +33,18 @@ async function identifyOrphanedRepositories(config: any): Promise { 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([]), diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index d2c9921..070ca75 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -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(); + 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,8 +74,8 @@ 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, @@ -145,6 +163,7 @@ 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), }; diff --git a/src/types/config.ts b/src/types/config.ts index c57828d..75760c4 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -62,6 +62,7 @@ export interface GitHubConfig { token: string; privateRepositories: boolean; includeCollaboratorRepos?: boolean; + includeOrganizations?: string[]; mirrorStarred: boolean; starredLists?: string[]; starredDuplicateStrategy?: DuplicateNameStrategy;