From 0b6b6b76bf4b4b70e08c5075e48b3d346595cc3d Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Sat, 13 Jun 2026 08:00:50 +0530 Subject: [PATCH] feat(github): add skipPersonalRepos toggle to mirror only org repos (#304) (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `skipPersonalRepos: z.boolean().default(false)` to githubConfigSchema - Filter out user-owned repos in getGithubRepositories when flag is true - Wire ONLY_MIRROR_ORGS env var to skipPersonalRepos in env-config-loader - Add checkbox UI in GitHubMirrorSettings Filtering & Behavior section - Round-trip skipPersonalRepos through config-mapper (UI ↔ DB) - Add skipPersonalRepos to AdvancedOptions TypeScript type - Mark include/exclude arrays in configSchema as unused/reserved - Update ENVIRONMENT_VARIABLES.md to document ONLY_MIRROR_ORGS effect --- docs/ENVIRONMENT_VARIABLES.md | 2 +- .../config/GitHubMirrorSettings.tsx | 20 +++++ src/lib/db/schema.ts | 3 + src/lib/env-config-loader.ts | 2 + src/lib/github-affiliation.test.ts | 84 +++++++++++++++++-- src/lib/github.ts | 13 ++- src/lib/utils/config-mapper.test.ts | 34 ++++++++ src/lib/utils/config-mapper.ts | 2 + src/types/config.ts | 1 + 9 files changed, 150 insertions(+), 11 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 29e0767..40458fc 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -114,7 +114,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) | `false` | `true`, `false` | +| `ONLY_MIRROR_ORGS` | Only mirror organization repos (skip personal); sets `skipPersonalRepos: true` in GitHub config | `false` | `true`, `false` | | `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 0137410..a594723 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -927,6 +927,26 @@ export function GitHubMirrorSettings({

+ +
+ handleAdvancedChange('skipPersonalRepos', !!checked)} + /> +
+ +

+ Exclude repositories owned by your personal GitHub account; only mirror repos belonging to organizations +

+
+
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 8eae4b3..c965803 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -34,6 +34,7 @@ export const githubConfigSchema = z.object({ autoMirrorStarred: z.boolean().default(false), skipStarredIssues: z.boolean().optional(), // Deprecated: kept for backward compatibility, use starredCodeOnly instead starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(), + skipPersonalRepos: z.boolean().default(false), }); export const backupStrategyEnum = z.enum([ @@ -156,7 +157,9 @@ export const configSchema = z.object({ isActive: z.boolean().default(true), githubConfig: githubConfigSchema, giteaConfig: giteaConfigSchema, + // Unused/reserved — stored for future glob support but not currently read include: z.array(z.string()).default(["*"]), + // Unused/reserved — stored for future glob support but not currently read exclude: z.array(z.string()).default([]), scheduleConfig: scheduleConfigSchema, cleanupConfig: cleanupConfigSchema, diff --git a/src/lib/env-config-loader.ts b/src/lib/env-config-loader.ts index 44921c5..686afa1 100644 --- a/src/lib/env-config-loader.ts +++ b/src/lib/env-config-loader.ts @@ -285,6 +285,8 @@ export async function initializeConfigFromEnv(): Promise { starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false, autoMirrorStarred: envConfig.github.autoMirrorStarred ?? existingConfig?.[0]?.githubConfig?.autoMirrorStarred ?? false, starredLists: envConfig.github.starredLists ?? existingConfig?.[0]?.githubConfig?.starredLists ?? [], + // ONLY_MIRROR_ORGS=true maps to skipPersonalRepos: true + skipPersonalRepos: envConfig.github.onlyMirrorOrgs ?? existingConfig?.[0]?.githubConfig?.skipPersonalRepos ?? false, }; // Build Gitea config diff --git a/src/lib/github-affiliation.test.ts b/src/lib/github-affiliation.test.ts index c977f5d..eefab9e 100644 --- a/src/lib/github-affiliation.test.ts +++ b/src/lib/github-affiliation.test.ts @@ -1,15 +1,23 @@ import { describe, expect, test, mock } from "bun:test"; import { getGithubRepositories } from "@/lib/github"; -function makeRepo() { +function makeRepo(overrides: Partial<{ + name: string; + full_name: string; + ownerLogin: string; + ownerType: string; + fork: boolean; +}> = {}) { + const ownerLogin = overrides.ownerLogin ?? "octo"; + const ownerType = overrides.ownerType ?? "User"; return { - name: "demo", - full_name: "octo/demo", - html_url: "https://github.com/octo/demo", - clone_url: "https://github.com/octo/demo.git", - owner: { login: "octo", type: "User" }, + name: overrides.name ?? "demo", + full_name: overrides.full_name ?? `${ownerLogin}/${overrides.name ?? "demo"}`, + html_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}`, + clone_url: `https://github.com/${ownerLogin}/${overrides.name ?? "demo"}.git`, + owner: { login: ownerLogin, type: ownerType }, private: false, - fork: false, + fork: overrides.fork ?? false, has_issues: true, archived: false, size: 1, @@ -23,11 +31,11 @@ function makeRepo() { }; } -function makeOctokit() { +function makeOctokit(reposToReturn?: ReturnType[]) { let captured: Record | null = null; const paginate = mock(async (_method: unknown, options?: Record) => { captured = options ?? null; - return [makeRepo()]; + return reposToReturn ?? [makeRepo()]; }); return { octokit: { @@ -98,3 +106,61 @@ describe("getGithubRepositories - affiliation", () => { } }); }); + +describe("getGithubRepositories - skipPersonalRepos", () => { + const personalRepo = makeRepo({ name: "my-lib", ownerLogin: "octo", ownerType: "User" }); + const orgRepo = makeRepo({ name: "org-lib", ownerLogin: "my-org", ownerType: "Organization" }); + const otherUserRepo = makeRepo({ name: "collab-lib", ownerLogin: "other-user", ownerType: "User" }); + + test("default false — keeps all repos including personal", async () => { + const { octokit } = makeOctokit([personalRepo, orgRepo]); + const repos = await getGithubRepositories({ + octokit, + config: { githubConfig: { owner: "octo", skipPersonalRepos: false } as any }, + }); + expect(repos.map((r) => r.name)).toContain("my-lib"); + expect(repos.map((r) => r.name)).toContain("org-lib"); + }); + + test("skipPersonalRepos=true — drops repos owned by authenticated user", async () => { + const { octokit } = makeOctokit([personalRepo, orgRepo]); + const repos = await getGithubRepositories({ + octokit, + config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any }, + }); + expect(repos.map((r) => r.name)).not.toContain("my-lib"); + expect(repos.map((r) => r.name)).toContain("org-lib"); + }); + + test("skipPersonalRepos=true — keeps repos owned by other users (collaborator repos)", async () => { + const { octokit } = makeOctokit([personalRepo, orgRepo, otherUserRepo]); + const repos = await getGithubRepositories({ + octokit, + config: { githubConfig: { owner: "octo", skipPersonalRepos: true } as any }, + }); + expect(repos.map((r) => r.name)).not.toContain("my-lib"); + expect(repos.map((r) => r.name)).toContain("org-lib"); + expect(repos.map((r) => r.name)).toContain("collab-lib"); + }); + + test("skipPersonalRepos=true with no owner configured — keeps all repos (safe fallback)", async () => { + const { octokit } = makeOctokit([personalRepo, orgRepo]); + const repos = await getGithubRepositories({ + octokit, + config: { githubConfig: { owner: "", skipPersonalRepos: true } as any }, + }); + // Empty owner means we can't identify the user, so nothing should be dropped + expect(repos.map((r) => r.name)).toContain("my-lib"); + expect(repos.map((r) => r.name)).toContain("org-lib"); + }); + + test("skipPersonalRepos=true — unset (undefined) behaves like false", async () => { + const { octokit } = makeOctokit([personalRepo, orgRepo]); + const repos = await getGithubRepositories({ + octokit, + config: { githubConfig: { owner: "octo" } as any }, + }); + expect(repos.map((r) => r.name)).toContain("my-lib"); + expect(repos.map((r) => r.name)).toContain("org-lib"); + }); +}); diff --git a/src/lib/github.ts b/src/lib/github.ts index a1507e3..1375ab3 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -263,10 +263,21 @@ export async function getGithubRepositories({ ); const skipForks = config.githubConfig?.skipForks ?? false; + const skipPersonalRepos = config.githubConfig?.skipPersonalRepos ?? false; + // The authenticated user's login — used to identify personally-owned repos + const authenticatedUserLogin = config.githubConfig?.owner ?? ""; const filteredRepos = repos.filter((repo) => { const isForkAllowed = !skipForks || !repo.fork; - return isForkAllowed; + // When skipPersonalRepos is true, drop repos owned by the authenticated user + // (owner.type === "User" and owner.login matches the configured GitHub username). + // Org repos have owner.type === "Organization" so they are always kept. + const isPersonalRepo = + skipPersonalRepos && + authenticatedUserLogin.length > 0 && + repo.owner.login === authenticatedUserLogin && + repo.owner.type === "User"; + return isForkAllowed && !isPersonalRepo; }); return filteredRepos.map((repo) => ({ diff --git a/src/lib/utils/config-mapper.test.ts b/src/lib/utils/config-mapper.test.ts index cb7de4e..8799550 100644 --- a/src/lib/utils/config-mapper.test.ts +++ b/src/lib/utils/config-mapper.test.ts @@ -124,3 +124,37 @@ test("githubConfigSchema parses includeCollaboratorRepos with true default", () }); expect(parsed.includeCollaboratorRepos).toBe(true); }); + +test("skipPersonalRepos defaults to false in githubConfigSchema", () => { + const parsed = githubConfigSchema.parse({ + owner: "octo", + type: "personal", + token: "", + }); + expect(parsed.skipPersonalRepos).toBe(false); +}); + +test("skipPersonalRepos round-trips UI -> DB -> UI when true", () => { + const ui = buildMinimalUiConfigs(); + const advancedWithSkip: AdvancedOptions = { ...ui.advancedOptions, skipPersonalRepos: true }; + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, advancedWithSkip); + expect(db.githubConfig.skipPersonalRepos).toBe(true); + + const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig }); + expect(roundTripped.advancedOptions.skipPersonalRepos).toBe(true); +}); + +test("skipPersonalRepos round-trips UI -> DB -> UI when false", () => { + const ui = buildMinimalUiConfigs(); + const advancedWithSkip: AdvancedOptions = { ...ui.advancedOptions, skipPersonalRepos: false }; + const db = mapUiToDbConfig(ui.githubConfig, ui.giteaConfig, ui.mirrorOptions, advancedWithSkip); + expect(db.githubConfig.skipPersonalRepos).toBe(false); + + const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig }); + expect(roundTripped.advancedOptions.skipPersonalRepos).toBe(false); +}); + +test("DB row missing skipPersonalRepos defaults to false on read", () => { + const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } }); + expect(ui.advancedOptions.skipPersonalRepos).toBe(false); +}); diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index af8bb53..d2c9921 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -71,6 +71,7 @@ export function mapUiToDbConfig( // Advanced options starredCodeOnly: advancedOptions.starredCodeOnly, autoMirrorStarred: advancedOptions.autoMirrorStarred ?? false, + skipPersonalRepos: advancedOptions.skipPersonalRepos ?? false, }; // Map Gitea config to match database schema @@ -194,6 +195,7 @@ export function mapDbToUiConfig(dbConfig: any): { // Support both old (skipStarredIssues) and new (starredCodeOnly) field names for backward compatibility starredCodeOnly: dbConfig.githubConfig?.starredCodeOnly ?? (dbConfig.githubConfig as any)?.skipStarredIssues ?? false, autoMirrorStarred: dbConfig.githubConfig?.autoMirrorStarred ?? false, + skipPersonalRepos: dbConfig.githubConfig?.skipPersonalRepos ?? false, }; return { diff --git a/src/types/config.ts b/src/types/config.ts index e552564..c57828d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -86,6 +86,7 @@ export interface AdvancedOptions { skipForks: boolean; starredCodeOnly: boolean; autoMirrorStarred?: boolean; + skipPersonalRepos?: boolean; } export interface SaveConfigApiRequest {