diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md
index 38d37c2..0fc2541 100644
--- a/docs/ENVIRONMENT_VARIABLES.md
+++ b/docs/ENVIRONMENT_VARIABLES.md
@@ -100,6 +100,7 @@ Standard GitHub Enterprise Cloud on `github.com` works with the default — no o
| `PRIVATE_REPOSITORIES` | Include private repositories | `false` | `true`, `false` |
| `PUBLIC_REPOSITORIES` | Include public repositories | `true` | `true`, `false` |
| `INCLUDE_ARCHIVED` | Include archived repositories | `false` | `true`, `false` |
+| `INCLUDE_COLLABORATOR_REPOS` | Include repositories where you are a collaborator (not just owned). Set to `false` to limit imports to repos you own. | `true` | `true`, `false` |
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `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) |
diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx
index 64bf005..0137410 100644
--- a/src/components/config/GitHubMirrorSettings.tsx
+++ b/src/components/config/GitHubMirrorSettings.tsx
@@ -35,6 +35,7 @@ import {
HardDrive,
FileCode2,
Plus,
+ Users,
X
} from "lucide-react";
import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config";
@@ -244,6 +245,26 @@ export function GitHubMirrorSettings({
+
+
handleGitHubChange('includeCollaboratorRepos', !!checked)}
+ />
+
+
+
+ Also mirror repos where you're a collaborator but not the owner. Turn off to limit imports to repos you own.
+
+
+
+
{
includeArchived: envConfig.github.includeArchived ?? existingConfig?.[0]?.githubConfig?.includeArchived ?? false,
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 ?? []),
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
starredReposMode: envConfig.github.starredReposMode || existingConfig?.[0]?.githubConfig?.starredReposMode || 'dedicated-org',
diff --git a/src/lib/github-affiliation.test.ts b/src/lib/github-affiliation.test.ts
new file mode 100644
index 0000000..dca5f44
--- /dev/null
+++ b/src/lib/github-affiliation.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, test, mock } from "bun:test";
+import { getGithubRepositories } from "@/lib/github";
+
+function makeRepo() {
+ 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" },
+ private: false,
+ fork: false,
+ has_issues: true,
+ archived: false,
+ size: 1,
+ language: "TypeScript",
+ description: "",
+ default_branch: "main",
+ visibility: "public",
+ disabled: false,
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-02T00:00:00Z",
+ };
+}
+
+function makeOctokit() {
+ let captured: Record | null = null;
+ const paginate = mock(async (_method: unknown, options?: Record) => {
+ captured = options ?? null;
+ return [makeRepo()];
+ });
+ return {
+ octokit: {
+ paginate,
+ repos: { listForAuthenticatedUser: () => {} },
+ } as any,
+ getCaptured: () => captured,
+ };
+}
+
+describe("getGithubRepositories - affiliation", () => {
+ test("defaults to owner+collaborator when field is unset (backward compat)", async () => {
+ const { octokit, getCaptured } = makeOctokit();
+ await getGithubRepositories({ octokit, config: { githubConfig: { owner: "octo" } as any } });
+ expect(getCaptured()?.affiliation).toBe("owner,collaborator");
+ });
+
+ test("uses owner only when includeCollaboratorRepos is false", async () => {
+ const { octokit, getCaptured } = makeOctokit();
+ await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
+ });
+ expect(getCaptured()?.affiliation).toBe("owner");
+ });
+
+ test("uses owner+collaborator when includeCollaboratorRepos is true", async () => {
+ const { octokit, getCaptured } = makeOctokit();
+ await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", includeCollaboratorRepos: true } as any },
+ });
+ expect(getCaptured()?.affiliation).toBe("owner,collaborator");
+ });
+
+ test("override forces owner+collaborator regardless of config (used by cleanup)", async () => {
+ const { octokit, getCaptured } = makeOctokit();
+ await getGithubRepositories({
+ octokit,
+ config: { githubConfig: { owner: "octo", includeCollaboratorRepos: false } as any },
+ includeCollaboratorReposOverride: true,
+ });
+ expect(getCaptured()?.affiliation).toBe("owner,collaborator");
+ });
+});
diff --git a/src/lib/github.ts b/src/lib/github.ts
index dc3db64..680a54e 100644
--- a/src/lib/github.ts
+++ b/src/lib/github.ts
@@ -235,14 +235,25 @@ export async function getGithubRepoCloneUrl({
export async function getGithubRepositories({
octokit,
config,
+ includeCollaboratorReposOverride,
}: {
octokit: Octokit;
config: Partial;
+ // Force-include collaborator repos regardless of user setting. Used by the
+ // cleanup service so we never mark a collab repo as orphaned just because
+ // the import filter is currently off.
+ includeCollaboratorReposOverride?: boolean;
}): Promise {
try {
+ const includeCollab =
+ includeCollaboratorReposOverride ??
+ config.githubConfig?.includeCollaboratorRepos ??
+ true;
+ const affiliation = includeCollab ? "owner,collaborator" : "owner";
+
const repos = await octokit.paginate(
octokit.repos.listForAuthenticatedUser,
- { per_page: 100 },
+ { per_page: 100, affiliation },
);
const skipForks = config.githubConfig?.skipForks ?? false;
diff --git a/src/lib/repository-cleanup-service.ts b/src/lib/repository-cleanup-service.ts
index df7e6a4..ea5ef92 100644
--- a/src/lib/repository-cleanup-service.ts
+++ b/src/lib/repository-cleanup-service.ts
@@ -33,9 +33,12 @@ async function identifyOrphanedRepositories(config: any): Promise {
let githubApiAccessible = true;
try {
- // Fetch GitHub data
+ // 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.
const [basicAndForkedRepos, starredRepos] = await Promise.all([
- getGithubRepositories({ octokit, config }),
+ getGithubRepositories({ octokit, config, includeCollaboratorReposOverride: true }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
diff --git a/src/lib/utils/config-mapper.test.ts b/src/lib/utils/config-mapper.test.ts
index 666785d..cb7de4e 100644
--- a/src/lib/utils/config-mapper.test.ts
+++ b/src/lib/utils/config-mapper.test.ts
@@ -1,6 +1,53 @@
import { expect, test } from "bun:test";
-import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
-import { scheduleConfigSchema } from "@/lib/db/schema";
+import {
+ mapDbScheduleToUi,
+ mapDbToUiConfig,
+ mapUiScheduleToDb,
+ mapUiToDbConfig,
+} from "./config-mapper";
+import { githubConfigSchema, scheduleConfigSchema } from "@/lib/db/schema";
+import type {
+ AdvancedOptions,
+ GitHubConfig,
+ GiteaConfig,
+ MirrorOptions,
+} from "@/types/config";
+
+function buildMinimalUiConfigs(overrides: { includeCollaboratorRepos?: boolean } = {}) {
+ const githubConfig: GitHubConfig = {
+ username: "octo",
+ token: "ghp_x",
+ privateRepositories: false,
+ mirrorStarred: false,
+ ...overrides,
+ };
+ const giteaConfig: GiteaConfig = {
+ url: "https://gitea.example",
+ username: "octo",
+ token: "g_x",
+ organization: "github-mirrors",
+ visibility: "public",
+ starredReposOrg: "starred",
+ preserveOrgStructure: false,
+ };
+ const mirrorOptions: MirrorOptions = {
+ mirrorReleases: false,
+ mirrorLFS: false,
+ mirrorMetadata: false,
+ metadataComponents: {
+ issues: false,
+ pullRequests: false,
+ labels: false,
+ milestones: false,
+ wiki: false,
+ },
+ };
+ const advancedOptions: AdvancedOptions = {
+ skipForks: false,
+ starredCodeOnly: false,
+ };
+ return { githubConfig, giteaConfig, mirrorOptions, advancedOptions };
+}
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
const existing = scheduleConfigSchema.parse({});
@@ -34,3 +81,46 @@ test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
expect(mapped.startTime).toBe("22:15");
expect(mapped.timezone).toBe("Asia/Kolkata");
});
+
+test("includeCollaboratorRepos round-trips through UI -> DB -> UI when true", () => {
+ const ui = buildMinimalUiConfigs({ includeCollaboratorRepos: true });
+ const db = mapUiToDbConfig(
+ ui.githubConfig,
+ ui.giteaConfig,
+ ui.mirrorOptions,
+ ui.advancedOptions,
+ );
+ expect(db.githubConfig.includeCollaboratorRepos).toBe(true);
+
+ const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
+ expect(roundTripped.githubConfig.includeCollaboratorRepos).toBe(true);
+});
+
+test("includeCollaboratorRepos round-trips through UI -> DB -> UI when false", () => {
+ const ui = buildMinimalUiConfigs({ includeCollaboratorRepos: false });
+ const db = mapUiToDbConfig(
+ ui.githubConfig,
+ ui.giteaConfig,
+ ui.mirrorOptions,
+ ui.advancedOptions,
+ );
+ expect(db.githubConfig.includeCollaboratorRepos).toBe(false);
+
+ const roundTripped = mapDbToUiConfig({ githubConfig: db.githubConfig, giteaConfig: db.giteaConfig });
+ expect(roundTripped.githubConfig.includeCollaboratorRepos).toBe(false);
+});
+
+test("DB row missing includeCollaboratorRepos defaults to true on read", () => {
+ // Existing rows from before this field existed have no value stored.
+ const ui = mapDbToUiConfig({ githubConfig: { owner: "octo", token: "" } });
+ expect(ui.githubConfig.includeCollaboratorRepos).toBe(true);
+});
+
+test("githubConfigSchema parses includeCollaboratorRepos with true default", () => {
+ const parsed = githubConfigSchema.parse({
+ owner: "octo",
+ type: "personal",
+ token: "",
+ });
+ expect(parsed.includeCollaboratorRepos).toBe(true);
+});
diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts
index 00b0c56..af8bb53 100644
--- a/src/lib/utils/config-mapper.ts
+++ b/src/lib/utils/config-mapper.ts
@@ -50,6 +50,7 @@ export function mapUiToDbConfig(
// Map checkbox fields with proper names
includeStarred: githubConfig.mirrorStarred,
includePrivate: githubConfig.privateRepositories,
+ includeCollaboratorRepos: githubConfig.includeCollaboratorRepos ?? true,
includeForks: !advancedOptions.skipForks, // Note: UI has skipForks, DB has includeForks
skipForks: advancedOptions.skipForks, // Add skipForks field
includeArchived: false, // Not in UI yet, default to false
@@ -142,6 +143,7 @@ export function mapDbToUiConfig(dbConfig: any): {
username: dbConfig.githubConfig?.owner || "", // Map owner to username
token: dbConfig.githubConfig?.token || "",
privateRepositories: dbConfig.githubConfig?.includePrivate || false, // Map includePrivate to privateRepositories
+ includeCollaboratorRepos: dbConfig.githubConfig?.includeCollaboratorRepos ?? true,
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 b9e969b..e552564 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -61,6 +61,7 @@ export interface GitHubConfig {
username: string;
token: string;
privateRepositories: boolean;
+ includeCollaboratorRepos?: boolean;
mirrorStarred: boolean;
starredLists?: string[];
starredDuplicateStrategy?: DuplicateNameStrategy;