Compare commits

...

9 Commits

Author SHA1 Message Date
Arunavo Ray 079df29f44 chore: bump version to 3.20.0 2026-06-19 08:44:03 +05:30
ARUNAVO RAY dff3cafb5e fix(config): persist Name Collision Strategy (starredDuplicateStrategy) (#326) (#328)
The "Name collision strategy" dropdown (starredDuplicateStrategy) never
persisted: the field was absent from both directions of the UI<->DB config
mapper. On save, mapUiToDbConfig dropped it before the DB write; on load,
mapDbToUiConfig never read it, so the UI reset to the "suffix" (repo-owner)
default. Mirror logic in gitea.ts then read undefined and also defaulted to
suffix — so repos really were created with that pattern regardless of the
user's choice. It has been broken since the field was introduced.

- Map starredDuplicateStrategy in mapUiToDbConfig and mapDbToUiConfig
- Add STARRED_DUPLICATE_STRATEGY env var for parity (reporter could not
  work around it via compose because no env var existed) + docs
- Round-trip tests covering save, load, and the missing-field default
2026-06-19 08:43:27 +05:30
ARUNAVO RAY 6ca7c0eec0 feat(github): add organization allowlist to mirror only selected orgs (#327)
Repository discovery requested the `organization_member` affiliation
unconditionally, so repos from every org a user belongs to were imported —
even orgs they never explicitly added. `skipPersonalRepos` only dropped
user-owned repos and left org repos unfiltered, which surprised users who
expected "only mirror org repos" to mean "only the orgs I chose" (reported
on #304).

Wire up the previously-dormant `includeOrganizations` config field as an
opt-in allowlist: when non-empty, only repos owned by the listed
organizations are imported. Empty = all org repos (backward-compatible).
Owned and collaborator repos are never restricted, so it composes cleanly
with `skipPersonalRepos`.

- Filter org repos by the allowlist in getGithubRepositories
- Add includeAllOrgsOverride so the cleanup service bypasses the allowlist
  and never false-orphans a previously-mirrored repo from an org the user
  later removes from the list
- UI control under Filtering & Behavior; INCLUDE_ORGANIZATIONS env var
- Case-insensitive dedup/trim in the UI<->DB mapper round-trip
- 7 unit tests covering the filter, composition, and the cleanup override
2026-06-19 08:42:17 +05:30
ARUNAVO RAY 6ebf1916e8 chore(deps): update dependencies across app and website (#325)
* chore(deps): update app dependencies to latest in-range versions

* chore(deps): update website (www) dependencies to latest in-range versions
2026-06-14 12:25:45 +05:30
Arunavo Ray 91de0d1030 chore: bump version to 3.19.1 2026-06-14 12:07:48 +05:30
Brendan Davidson 85bd1f4042 Repository table bulk actions (#322)
* Handle indexing when shift + clicking in the repository table

* Move the buttons when selecting rows

* Add in a bulk delete func in the repositories table

* Add bulk delete handler

* Make the single action use the bulk delete

* Delete the single repository id handler
2026-06-14 10:14:51 +05:30
Brendan Davidson 4a28015685 Skip the user defined orgs to ignore (#323) 2026-06-14 10:14:48 +05:30
Arunavo Ray da23941369 chore: bump version to 3.19.0 2026-06-13 09:14:33 +05:30
Brendan Davidson 906ce57e8c Handle indexing when shift + clicking in the repository table (#316) 2026-06-13 09:14:02 +05:30
18 changed files with 1711 additions and 1297 deletions
+318 -316
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
View File
@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.18.0",
"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>
+189 -106
View File
@@ -98,6 +98,8 @@ export default function Repository() {
const [repoToDelete, setRepoToDelete] = useState<Repository | null>(null);
const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false);
const [isDeletingRepo, setIsDeletingRepo] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isDeletingBulk, setIsDeletingBulk] = useState(false);
// Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -919,11 +921,9 @@ export default function Repository() {
setIsDeletingRepo(true);
try {
const response = await apiRequest<{ success: boolean; error?: string }>(
`/repositories/${repoToDelete.id}`,
{
method: "DELETE",
}
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
"/repositories",
{ method: "DELETE", body: JSON.stringify({ ids: [repoToDelete.id] }) }
);
if (response.success) {
@@ -941,6 +941,30 @@ export default function Repository() {
}
};
const handleBulkDelete = async () => {
if (!user || !user.id) return;
setIsDeletingBulk(true);
try {
const response = await apiRequest<{ success: boolean; deleted?: number; error?: string }>(
"/repositories",
{ method: "DELETE", body: JSON.stringify({ ids: [...selectedRepoIds] }) }
);
if (response.success) {
const count = response.deleted ?? selectedRepoIds.size;
toast.success(`Removed ${count} ${count === 1 ? "repository" : "repositories"} from Gitea Mirror.`);
setSelectedRepoIds(new Set());
await fetchRepositories(false);
} else {
showErrorToast(response.error || "Failed to delete repositories", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDeletingBulk(false);
setIsBulkDeleteDialogOpen(false);
}
};
// Determine what actions are available for selected repositories
const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return [];
@@ -977,7 +1001,9 @@ export default function Repository() {
if (selectedRepos.some(repo => repo.status === "ignored")) {
actions.push('include');
}
actions.push('delete');
return actions;
};
@@ -994,6 +1020,7 @@ export default function Repository() {
retry: selectedRepos.filter(repo => repo.status === "failed").length,
ignore: selectedRepos.filter(repo => repo.status !== "ignored").length,
include: selectedRepos.filter(repo => repo.status === "ignored").length,
delete: selectedRepos.length,
};
};
@@ -1312,111 +1339,122 @@ export default function Repository() {
</Button>
</div>
{/* Bulk actions on desktop - integrated into the same line */}
{/* Mirror All action */}
<div className="flex items-center gap-2 border-l pl-4">
{selectedRepoIds.size === 0 ? (
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
) : (
<>
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({actionCounts.mirror})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({actionCounts.sync})
</Button>
)}
{availableActions.includes('rerun-metadata') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRerunMetadata}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Re-run Metadata ({actionCounts.rerunMetadata})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="default"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="default"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
</>
)}
<Button
variant="default"
onClick={handleMirrorAllRepos}
disabled={isInitialLoading || loadingRepoIds.size > 0}
className="whitespace-nowrap"
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror All
</Button>
</div>
</div>
</div>
{/* Desktop: Bulk actions row - shown when repos are selected */}
{selectedRepoIds.size > 0 && (
<div className="hidden sm:flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
<span className="text-sm font-medium">
{selectedRepoIds.size} selected
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => setSelectedRepoIds(new Set())}
>
<X className="h-3 w-3" />
</Button>
</div>
{availableActions.includes('mirror') && (
<Button
variant="default"
size="default"
onClick={handleBulkMirror}
disabled={loadingRepoIds.size > 0}
>
<FlipHorizontal className="h-4 w-4 mr-2" />
Mirror ({actionCounts.mirror})
</Button>
)}
{availableActions.includes('sync') && (
<Button
variant="outline"
size="default"
onClick={handleBulkSync}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Sync ({actionCounts.sync})
</Button>
)}
{availableActions.includes('rerun-metadata') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRerunMetadata}
disabled={loadingRepoIds.size > 0}
>
<RefreshCw className="h-4 w-4 mr-2" />
Re-run Metadata ({actionCounts.rerunMetadata})
</Button>
)}
{availableActions.includes('retry') && (
<Button
variant="outline"
size="default"
onClick={handleBulkRetry}
disabled={loadingRepoIds.size > 0}
>
<RotateCcw className="h-4 w-4 mr-2" />
Retry
</Button>
)}
{availableActions.includes('ignore') && (
<Button
variant="ghost"
size="default"
onClick={() => handleBulkSkip(true)}
disabled={loadingRepoIds.size > 0}
>
<Ban className="h-4 w-4 mr-2" />
Ignore
</Button>
)}
{availableActions.includes('include') && (
<Button
variant="outline"
size="default"
onClick={() => handleBulkSkip(false)}
disabled={loadingRepoIds.size > 0}
>
<Check className="h-4 w-4 mr-2" />
Include
</Button>
)}
<Button
variant="destructive"
size="default"
onClick={() => setIsBulkDeleteDialogOpen(true)}
disabled={loadingRepoIds.size > 0}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete ({actionCounts.delete})
</Button>
</div>
)}
{/* Action buttons for mobile - only show when items are selected */}
{selectedRepoIds.size > 0 && (
<div className="flex items-center gap-2 flex-wrap sm:hidden">
@@ -1506,6 +1544,16 @@ export default function Repository() {
Include
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
disabled={loadingRepoIds.size > 0}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete ({actionCounts.delete})
</Button>
</div>
</div>
)}
@@ -1586,6 +1634,41 @@ export default function Repository() {
</DialogContent>
</Dialog>
<Dialog
open={isBulkDeleteDialogOpen}
onOpenChange={(open) => {
if (!open && !isDeletingBulk) setIsBulkDeleteDialogOpen(false);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"} from Gitea Mirror?</DialogTitle>
<DialogDescription>
These repositories will be deleted from Gitea Mirror only. Any mirrors on Gitea will remain untouched; remove them manually in Gitea if needed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsBulkDeleteDialogOpen(false)}
disabled={isDeletingBulk}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleBulkDelete} disabled={isDeletingBulk}>
{isDeletingBulk ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
Delete {selectedRepoIds.size} {selectedRepoIds.size === 1 ? "repository" : "repositories"}
</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteRepoDialogOpen}
onOpenChange={(open) => {
+57 -12
View File
@@ -99,6 +99,7 @@ export default function RepositoryTable({
onDismissSync,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
const { giteaConfig } = useGiteaConfig();
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
@@ -235,6 +236,7 @@ export default function RepositoryTable({
// Selection handlers
const handleSelectAll = (checked: boolean) => {
lastSelectedIndexRef.current = null;
if (checked) {
const allIds = new Set(
visibleRepositories
@@ -247,7 +249,7 @@ export default function RepositoryTable({
}
};
const handleSelectRepo = (repoId: string, checked: boolean) => {
const handleSelectRepo = (repoId: string, checked: boolean, index?: number) => {
const newSelection = new Set(selectedRepoIds);
if (checked) {
newSelection.add(repoId);
@@ -255,6 +257,32 @@ export default function RepositoryTable({
newSelection.delete(repoId);
}
onSelectionChange(newSelection);
if (index !== undefined) {
lastSelectedIndexRef.current = index;
}
};
const handleShiftRangeSelect = (currentIndex: number) => {
const lastIndex = lastSelectedIndexRef.current;
if (lastIndex === null) {
const repo = visibleRepositories[currentIndex];
if (repo?.id) {
const newSelection = new Set(selectedRepoIds);
newSelection.add(repo.id);
onSelectionChange(newSelection);
lastSelectedIndexRef.current = currentIndex;
}
return;
}
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
const newSelection = new Set(selectedRepoIds);
for (let i = start; i <= end; i++) {
const id = visibleRepositories[i]?.id;
if (id) newSelection.add(id);
}
onSelectionChange(newSelection);
lastSelectedIndexRef.current = currentIndex;
};
const isAllSelected =
@@ -263,7 +291,7 @@ export default function RepositoryTable({
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
// Mobile card layout for repository
const RepositoryCard = ({ repo }: { repo: Repository }) => {
const RepositoryCard = ({ repo, index }: { repo: Repository; index: number }) => {
const isLoading = repo.id ? loadingRepoIds.has(repo.id) : false;
const isSelected = repo.id ? selectedRepoIds.has(repo.id) : false;
const giteaUrl = getGiteaRepoUrl(repo);
@@ -274,12 +302,21 @@ export default function RepositoryTable({
<div className="flex flex-col gap-3">
{/* Header with checkbox and repo name */}
<div className="flex items-start gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean)}
className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/>
<div
onClickCapture={(e) => {
if (e.shiftKey && repo.id) {
e.stopPropagation();
handleShiftRangeSelect(index);
}
}}
>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, checked as boolean, index)}
className="mt-1 h-5 w-5"
aria-label={`Select ${repo.name}`}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-base truncate">{repo.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
@@ -635,8 +672,8 @@ export default function RepositoryTable({
</div>
{/* Repository cards */}
{visibleRepositories.map((repo) => (
<RepositoryCard key={repo.id} repo={repo} />
{visibleRepositories.map((repo, index) => (
<RepositoryCard key={repo.id} repo={repo} index={index} />
))}
</div>
@@ -701,10 +738,18 @@ export default function RepositoryTable({
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50"
>
{/* Checkbox */}
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
<div
className="h-full p-3 flex items-center justify-center flex-[0.3]"
onClickCapture={(e) => {
if (e.shiftKey && repo.id) {
e.stopPropagation();
handleShiftRangeSelect(virtualRow.index);
}
}}
>
<Checkbox
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked, virtualRow.index)}
aria-label={`Select ${repo.name}`}
/>
</div>
+11 -1
View File
@@ -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,
+89
View File
@@ -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");
});
});
+29 -2
View File
@@ -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) => ({
@@ -678,9 +699,11 @@ export async function getGithubStarredListNames({
export async function getGithubOrganizations({
octokit,
config,
skipOrgNames,
}: {
octokit: Octokit;
config: Partial<Config>;
skipOrgNames?: Set<string>;
}): Promise<{ organizations: GitOrg[]; failedOrgs: { name: string; avatarUrl: string; reason: string }[] }> {
try {
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
@@ -693,7 +716,7 @@ export async function getGithubOrganizations({
? excludedOrgsEnv.split(",").map((org) => org.trim().toLowerCase())
: [];
// Filter out excluded organizations
// Filter out excluded and user-ignored organizations
const filteredOrgs = orgs.filter((org) => {
if (excludedOrgs.includes(org.login.toLowerCase())) {
console.log(
@@ -701,6 +724,10 @@ export async function getGithubOrganizations({
);
return false;
}
if (skipOrgNames?.has(org.login.toLowerCase())) {
console.log(`Skipping organization ${org.login} - ignored by user`);
return false;
}
return true;
});
+11 -5
View File
@@ -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([]),
+33
View File
@@ -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");
});
+23 -2
View File
@@ -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
+1 -51
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { db, repositories, mirrorJobs } from "@/lib/db";
import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
@@ -62,53 +62,3 @@ export const PATCH: APIRoute = async (context) => {
}
};
export const DELETE: APIRoute = async (context) => {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const userId = user!.id;
const repoId = context.params.id;
if (!repoId) {
return new Response(JSON.stringify({ error: "Repository ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const [existingRepo] = await db
.select()
.from(repositories)
.where(and(eq(repositories.id, repoId), eq(repositories.userId, userId)))
.limit(1);
if (!existingRepo) {
return new Response(
JSON.stringify({ error: "Repository not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
await db
.delete(repositories)
.where(and(eq(repositories.id, repoId), eq(repositories.userId, userId)));
await db
.delete(mirrorJobs)
.where(and(eq(mirrorJobs.repositoryId, repoId), eq(mirrorJobs.userId, userId)));
return new Response(
JSON.stringify({ success: true }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return createSecureErrorResponse(error, "Delete repository", 500);
}
};
+49
View File
@@ -0,0 +1,49 @@
import type { APIRoute } from "astro";
import { db, repositories, mirrorJobs } from "@/lib/db";
import { eq, and, inArray } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
export const DELETE: APIRoute = async (context) => {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const userId = user!.id;
const body = await context.request.json();
const { ids } = body;
if (!Array.isArray(ids) || ids.length === 0) {
return new Response(JSON.stringify({ error: "ids must be a non-empty array" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Verify all repos belong to this user before deleting
const owned = await db
.select({ id: repositories.id })
.from(repositories)
.where(and(inArray(repositories.id, ids), eq(repositories.userId, userId)));
const ownedIds = owned.map((r) => r.id);
if (ownedIds.length === 0) {
return new Response(JSON.stringify({ error: "No matching repositories found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
await db.transaction(async (tx) => {
await tx.delete(mirrorJobs).where(and(inArray(mirrorJobs.repositoryId, ownedIds), eq(mirrorJobs.userId, userId)));
await tx.delete(repositories).where(and(inArray(repositories.id, ownedIds), eq(repositories.userId, userId)));
});
return new Response(
JSON.stringify({ success: true, deleted: ownedIds.length }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
return createSecureErrorResponse(error, "Bulk delete repositories", 500);
}
};
+12 -3
View File
@@ -49,13 +49,20 @@ export const POST: APIRoute = async ({ request, locals }) => {
const githubUsername = config.githubConfig?.owner || undefined;
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
// Load ignored orgs from the DB so we can skip them during import
const ignoredOrgRows = await db
.select({ normalizedName: organizations.normalizedName })
.from(organizations)
.where(and(eq(organizations.userId, userId), eq(organizations.status, "ignored")));
const ignoredOrgNames = new Set(ignoredOrgRows.map((o) => o.normalizedName));
// Fetch GitHub data in parallel
const [basicAndForkedRepos, starredRepos, orgResult] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.includeStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
getGithubOrganizations({ octokit, config }),
getGithubOrganizations({ octokit, config, skipOrgNames: ignoredOrgNames }),
]);
const { organizations: gitOrgs, failedOrgs } = orgResult;
@@ -152,7 +159,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
const existingOrgMap = new Map(existingOrgs.map((o) => [o.normalizedName, o.status]));
insertedRepos = newRepos.filter(
(r) => !existingRepoNames.has(r.normalizedFullName)
(r) =>
!existingRepoNames.has(r.normalizedFullName) &&
(!r.organization || !ignoredOrgNames.has(r.organization.toLowerCase()))
);
insertedOrgs = newOrgs.filter((o) => !existingOrgMap.has(o.normalizedName));
@@ -258,7 +267,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
newRepositories: insertedRepos.length,
newOrganizations: insertedOrgs.length,
skippedDisabledRepositories: allGithubRepos.length - mirrorableGithubRepos.length,
failedOrgs: failedOrgs.map((o) => o.name),
failedOrgs: failedOrgs.filter((o) => !ignoredOrgNames.has(o.name.toLowerCase())).map((o) => o.name),
recoveredOrgs: recoveredOrgCount,
},
});
+1
View File
@@ -62,6 +62,7 @@ export interface GitHubConfig {
token: string;
privateRepositories: boolean;
includeCollaboratorRepos?: boolean;
includeOrganizations?: string[];
mirrorStarred: boolean;
starredLists?: string[];
starredDuplicateStrategy?: DuplicateNameStrategy;
+11 -11
View File
@@ -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"
+741 -745
View File
File diff suppressed because it is too large Load Diff