mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-15 21:09:40 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74606f0a5f | |||
| 187ecc5d60 | |||
| 632bbd0d4a | |||
| 0b65e40784 | |||
| 4a8b4f6ff3 | |||
| 1d9dfdeb70 | |||
| 079df29f44 | |||
| dff3cafb5e | |||
| 6ca7c0eec0 | |||
| 6ebf1916e8 | |||
| 91de0d1030 | |||
| 85bd1f4042 | |||
| 4a28015685 | |||
| da23941369 | |||
| 906ce57e8c |
@@ -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.18.0",
|
||||
"version": "3.20.3",
|
||||
"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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Regression test for the #334 sibling bug — labels silently dropped on issue update.
|
||||
*
|
||||
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only `CreateIssueOption`
|
||||
* does), so a `labels` key in a `PATCH .../issues/{index}` body is silently ignored.
|
||||
* The old code put `labels` in the update PATCH, so label changes never propagated
|
||||
* onto already-mirrored issues. The fix builds the edit body WITHOUT labels
|
||||
* (`buildGiteaIssueEditPayload`) and reconciles labels separately through the
|
||||
* sub-resource `PUT .../issues/{index}/labels` (`buildGiteaIssueLabelsPayload`).
|
||||
*
|
||||
* Verified live against Gitea 1.24.7: PATCH with `labels` leaves the issue's labels
|
||||
* unchanged; PUT to the labels sub-resource replaces them. Confirmed end-to-end that
|
||||
* a drifted (label-less) mirrored issue reconciles back to its GitHub label set.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { buildGiteaIssueEditPayload, buildGiteaIssueLabelsPayload } from "@/lib/gitea";
|
||||
|
||||
describe("buildGiteaIssueEditPayload (#334 sibling)", () => {
|
||||
test("edit body never carries `labels` (Gitea's EditIssueOption ignores it)", () => {
|
||||
const payload = buildGiteaIssueEditPayload({
|
||||
title: "[GH-ISSUE #1] Fix the thing",
|
||||
body: "desc",
|
||||
closed: false,
|
||||
});
|
||||
expect(payload).not.toHaveProperty("labels");
|
||||
expect(payload).toEqual({
|
||||
title: "[GH-ISSUE #1] Fix the thing",
|
||||
body: "desc",
|
||||
state: "open",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps the closed flag to Gitea's `state`", () => {
|
||||
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: true }).state).toBe("closed");
|
||||
expect(buildGiteaIssueEditPayload({ title: "t", body: "b", closed: false }).state).toBe("open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGiteaIssueLabelsPayload (#334 sibling)", () => {
|
||||
test("replaces the full label set with the resolved Gitea label ids", () => {
|
||||
expect(buildGiteaIssueLabelsPayload([7, 9])).toEqual({ labels: [7, 9] });
|
||||
});
|
||||
|
||||
test("sends an empty set so upstream label removals propagate", () => {
|
||||
expect(buildGiteaIssueLabelsPayload([])).toEqual({ labels: [] });
|
||||
});
|
||||
|
||||
test("treats a missing id list as an empty set (defensive)", () => {
|
||||
expect(buildGiteaIssueLabelsPayload(undefined as any)).toEqual({ labels: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Regression test for #334 — "Release titles not being mirrored properly".
|
||||
*
|
||||
* Root cause: the release create/update payloads sent the release title under the
|
||||
* JSON key `title`, but Gitea/Forgejo's release API expects `name` (the API Go
|
||||
* struct is `Title string \`json:"name"\``). `title` is silently dropped, so every
|
||||
* mirrored release landed with a blank name.
|
||||
*
|
||||
* `buildGiteaReleasePayload` is the single source of truth for both the create
|
||||
* (POST) and update (PATCH) bodies. Verified live against Gitea 1.24.7: a payload
|
||||
* with `title` yields `name: ""`; a payload with `name` sets the title correctly.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { buildGiteaReleasePayload } from "@/lib/gitea";
|
||||
|
||||
describe("buildGiteaReleasePayload (#334)", () => {
|
||||
test("carries the release title under `name`, never `title`", () => {
|
||||
const payload = buildGiteaReleasePayload(
|
||||
{ tag_name: "v0.19.0", name: "v0.19.0", draft: false, prerelease: false },
|
||||
"## Features\n- something"
|
||||
);
|
||||
|
||||
expect(payload.name).toBe("v0.19.0");
|
||||
expect(payload).not.toHaveProperty("title");
|
||||
expect(payload).toEqual({
|
||||
tag_name: "v0.19.0",
|
||||
name: "v0.19.0",
|
||||
body: "## Features\n- something",
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to tag_name when the GitHub release name is empty or null", () => {
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: null }, "x").name).toBe("v1.2.3");
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3", name: "" }, "x").name).toBe("v1.2.3");
|
||||
expect(buildGiteaReleasePayload({ tag_name: "v1.2.3" }, "x").name).toBe("v1.2.3");
|
||||
});
|
||||
|
||||
test("passes draft/prerelease/body through unchanged", () => {
|
||||
const payload = buildGiteaReleasePayload(
|
||||
{ tag_name: "v2.0.0", name: "Two", draft: true, prerelease: true },
|
||||
"notes body"
|
||||
);
|
||||
expect(payload.body).toBe("notes body");
|
||||
expect(payload.draft).toBe(true);
|
||||
expect(payload.prerelease).toBe(true);
|
||||
expect(payload.tag_name).toBe("v2.0.0");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,10 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { classifyReleasesForReconciliation } from "@/lib/gitea";
|
||||
import {
|
||||
classifyReleasesForReconciliation,
|
||||
classifyAssetsForReconciliation,
|
||||
} from "@/lib/gitea";
|
||||
|
||||
describe("classifyReleasesForReconciliation", () => {
|
||||
describe("normal repo — published_at order matches tag-commit order", () => {
|
||||
@@ -148,3 +151,82 @@ describe("classifyReleasesForReconciliation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Asset reconciliation — regression for #331.
|
||||
*
|
||||
* Root cause: assets were uploaded only on the create path. When a Gitea release
|
||||
* already existed (every re-sync, or after an interrupted first upload), the update
|
||||
* path PATCHed the body and `continue`d without ever touching assets — so a release
|
||||
* that existed without its full asset set stayed permanently asset-less and re-syncing
|
||||
* could never heal it. Reproduced on a real Forgejo pull-mirror: GitHub release with
|
||||
* two 35-40MB binaries → Gitea release with 0 assets → re-sync logged "Updating
|
||||
* existing release" and left it at 0.
|
||||
*
|
||||
* Fix: reconcile assets idempotently on both paths via classifyAssetsForReconciliation.
|
||||
*/
|
||||
describe("classifyAssetsForReconciliation", () => {
|
||||
it("uploads all assets when the Gitea release has none (the #331 broken state)", () => {
|
||||
const github = [
|
||||
{ name: "base.zip", size: 40_264_954 },
|
||||
{ name: "extras.zip", size: 37_098_528 },
|
||||
];
|
||||
const gitea: Array<{ id: number; name: string; size: number }> = [];
|
||||
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
|
||||
|
||||
expect(toSkip).toEqual([]);
|
||||
expect(toUpload).toEqual([
|
||||
{ name: "base.zip", replaceAssetId: null },
|
||||
{ name: "extras.zip", replaceAssetId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("backfills only the missing asset when one already exists", () => {
|
||||
const github = [
|
||||
{ name: "base.zip", size: 40_264_954 },
|
||||
{ name: "extras.zip", size: 37_098_528 },
|
||||
];
|
||||
const gitea = [{ id: 9, name: "base.zip", size: 40_264_954 }];
|
||||
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
|
||||
|
||||
expect(toSkip).toEqual(["base.zip"]);
|
||||
expect(toUpload).toEqual([{ name: "extras.zip", replaceAssetId: null }]);
|
||||
});
|
||||
|
||||
it("is idempotent — skips everything when all assets already match by name+size", () => {
|
||||
const github = [
|
||||
{ name: "base.zip", size: 40_264_954 },
|
||||
{ name: "extras.zip", size: 37_098_528 },
|
||||
];
|
||||
const gitea = [
|
||||
{ id: 9, name: "base.zip", size: 40_264_954 },
|
||||
{ id: 10, name: "extras.zip", size: 37_098_528 },
|
||||
];
|
||||
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
|
||||
|
||||
expect(toUpload).toEqual([]);
|
||||
expect(toSkip).toEqual(["base.zip", "extras.zip"]);
|
||||
});
|
||||
|
||||
it("replaces an asset whose size changed upstream (re-upload over the stale copy)", () => {
|
||||
const github = [{ name: "firmware.bin", size: 2048 }];
|
||||
const gitea = [{ id: 42, name: "firmware.bin", size: 1024 }]; // truncated/stale
|
||||
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(github, gitea);
|
||||
|
||||
expect(toSkip).toEqual([]);
|
||||
expect(toUpload).toEqual([{ name: "firmware.bin", replaceAssetId: 42 }]);
|
||||
});
|
||||
|
||||
it("handles a release with no GitHub assets", () => {
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(
|
||||
[],
|
||||
[{ id: 1, name: "leftover.zip", size: 10 }]
|
||||
);
|
||||
expect(toUpload).toEqual([]);
|
||||
expect(toSkip).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+385
-80
@@ -2186,6 +2186,98 @@ export const syncGiteaRepo = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the JSON body for creating/updating a Gitea release.
|
||||
*
|
||||
* Gitea/Forgejo expose the release title through the JSON field `name`, not
|
||||
* `title` (the API Go struct is `Title string \`json:"name"\``); sending `title`
|
||||
* is silently ignored and leaves the release name blank (#334). Create and update
|
||||
* send the same fields; `target` is intentionally omitted (see #331/#333) so Gitea
|
||||
* attaches the release to the already-synced tag instead of 404-ing on the target.
|
||||
*/
|
||||
export function buildGiteaReleasePayload(
|
||||
release: { tag_name: string; name?: string | null; draft?: boolean; prerelease?: boolean },
|
||||
releaseNote: string
|
||||
): { tag_name: string; name: string; body: string; draft?: boolean; prerelease?: boolean } {
|
||||
return {
|
||||
tag_name: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for a Gitea issue / PR-as-issue edit (PATCH .../issues/{index}).
|
||||
*
|
||||
* Deliberately excludes `labels`: Gitea's `EditIssueOption` has no `labels` field
|
||||
* (only `CreateIssueOption` does), so any `labels` key here is silently dropped —
|
||||
* the same class of bug as the release `title` mix-up (#334 sibling). Labels are
|
||||
* applied separately via the labels sub-resource (see buildGiteaIssueLabelsPayload).
|
||||
*/
|
||||
export function buildGiteaIssueEditPayload(opts: {
|
||||
title: string;
|
||||
body: string;
|
||||
closed: boolean;
|
||||
}): { title: string; body: string; state: "open" | "closed" } {
|
||||
return { title: opts.title, body: opts.body, state: opts.closed ? "closed" : "open" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for the Gitea issue labels sub-resource
|
||||
* (PUT .../issues/{index}/labels), which replaces the full label set idempotently.
|
||||
*/
|
||||
export function buildGiteaIssueLabelsPayload(labelIds: number[]): { labels: number[] } {
|
||||
return { labels: labelIds ?? [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the label set on an existing Gitea issue (or PR-as-issue) via the
|
||||
* dedicated labels sub-resource.
|
||||
*
|
||||
* Gitea/Forgejo's `EditIssueOption` has no `labels` field (only
|
||||
* `CreateIssueOption` does), so a `labels` key in a `PATCH .../issues/{index}`
|
||||
* body is silently dropped by the JSON decoder — the same class of bug as the
|
||||
* release `title` vs `name` mix-up (#334). Label changes on an already-mirrored
|
||||
* issue therefore have to go through `PUT .../issues/{index}/labels`, which
|
||||
* replaces the whole set idempotently: it both applies newly added labels and
|
||||
* removes ones deleted upstream.
|
||||
*
|
||||
* Best-effort: labels are secondary metadata, so a transient failure here is
|
||||
* logged and left to self-heal on the next sync rather than failing (and
|
||||
* retrying) the entire issue + comment mirror.
|
||||
*/
|
||||
async function reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber,
|
||||
labelIds,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
decryptedConfig: Config;
|
||||
giteaOwner: string;
|
||||
repoName: string;
|
||||
issueNumber: number;
|
||||
labelIds: number[];
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await httpPut(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${issueNumber}/labels`,
|
||||
buildGiteaIssueLabelsPayload(labelIds),
|
||||
{ Authorization: `token ${decryptedConfig.giteaConfig!.token}` }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Labels] Failed to reconcile labels on issue #${issueNumber}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
} (will retry on next sync)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mirrorGitRepoIssuesToGitea = async ({
|
||||
config,
|
||||
octokit,
|
||||
@@ -2437,12 +2529,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
targetIssueNumber = existingIssue.number;
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issuePayload.title,
|
||||
body: issuePayload.body,
|
||||
state: issue.state === "closed" ? "closed" : "open",
|
||||
labels: issuePayload.labels,
|
||||
},
|
||||
closed: issue.state === "closed",
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -2487,12 +2578,11 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
);
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${targetIssueNumber}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issuePayload.title,
|
||||
body: issuePayload.body,
|
||||
state: issue.state === "closed" ? "closed" : "open",
|
||||
labels: issuePayload.labels,
|
||||
},
|
||||
closed: issue.state === "closed",
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -2531,6 +2621,21 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea's EditIssueOption ignores `labels`, so the PATCH above can't change
|
||||
// them on an already-mirrored issue — reconcile via the labels sub-resource.
|
||||
// Only needed on the update paths; a freshly POSTed issue already got its
|
||||
// labels from CreateIssueOption. (#334 sibling)
|
||||
if (existingIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: targetIssueNumber,
|
||||
labelIds: giteaLabelIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Clone comments
|
||||
const comments = await octokit.paginate(
|
||||
octokit.rest.issues.listComments,
|
||||
@@ -2691,6 +2796,168 @@ export function classifyReleasesForReconciliation(
|
||||
return { toCreate, toSkip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which of a GitHub release's assets need (re)uploading to Gitea.
|
||||
*
|
||||
* Compared by name:
|
||||
* - present in Gitea with a matching size -> skip (already mirrored)
|
||||
* - present with a different size -> upload, replacing the stale copy
|
||||
* - absent -> upload (fresh)
|
||||
*
|
||||
* Pure function so the create/update reconciliation decision is unit-testable
|
||||
* without hitting the network (regression guard for #331).
|
||||
*/
|
||||
export function classifyAssetsForReconciliation(
|
||||
githubAssets: Array<{ name: string; size: number }>,
|
||||
giteaAssets: Array<{ id: number; name: string; size: number }>
|
||||
): {
|
||||
toUpload: Array<{ name: string; replaceAssetId: number | null }>;
|
||||
toSkip: string[];
|
||||
} {
|
||||
const existingByName = new Map(giteaAssets.map((a) => [a.name, a]));
|
||||
const toUpload: Array<{ name: string; replaceAssetId: number | null }> = [];
|
||||
const toSkip: string[] = [];
|
||||
|
||||
for (const asset of githubAssets) {
|
||||
const existing = existingByName.get(asset.name);
|
||||
if (existing && existing.size === asset.size) {
|
||||
toSkip.push(asset.name);
|
||||
} else {
|
||||
toUpload.push({ name: asset.name, replaceAssetId: existing ? existing.id : null });
|
||||
}
|
||||
}
|
||||
|
||||
return { toUpload, toSkip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently mirror a GitHub release's assets onto the matching Gitea release.
|
||||
*
|
||||
* Runs on BOTH the create and update paths so assets are reconciled on every sync,
|
||||
* not only on the single sync where the Gitea release is first created. Previously
|
||||
* assets were uploaded inline in the create path only; the update path PATCHed the
|
||||
* body and `continue`d without ever looking at assets. Any release whose assets
|
||||
* failed or were interrupted on first creation therefore stayed permanently
|
||||
* asset-less, and re-syncing could never heal it (#331).
|
||||
*
|
||||
* Strategy: compare by asset name. Skip assets already present with a matching size;
|
||||
* (re)upload anything missing, and replace an existing asset whose size differs
|
||||
* (truncated/changed upstream). Returns per-release counts so the caller can report.
|
||||
*/
|
||||
async function reconcileReleaseAssets({
|
||||
config,
|
||||
decryptedConfig,
|
||||
repoOwner,
|
||||
repoName,
|
||||
giteaReleaseId,
|
||||
githubAssets,
|
||||
tagName,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
decryptedConfig: Config;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
giteaReleaseId: number;
|
||||
githubAssets: Array<{ name: string; size: number; browser_download_url: string }>;
|
||||
tagName: string;
|
||||
}): Promise<{ uploaded: number; failed: number; skipped: number }> {
|
||||
let uploaded = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
if (!githubAssets || githubAssets.length === 0) {
|
||||
return { uploaded, failed, skipped };
|
||||
}
|
||||
|
||||
const giteaBaseUrl = config.giteaConfig!.url;
|
||||
const giteaAuth = { Authorization: `token ${decryptedConfig.giteaConfig!.token}` };
|
||||
|
||||
// Fetch existing attachments so we only transfer what's missing or changed.
|
||||
const existingAssets: Array<{ id: number; name: string; size: number }> = await httpGet(
|
||||
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets`,
|
||||
giteaAuth
|
||||
)
|
||||
.then((r) => (Array.isArray(r?.data) ? r.data : []))
|
||||
.catch(() => []);
|
||||
|
||||
const { toUpload, toSkip } = classifyAssetsForReconciliation(
|
||||
githubAssets,
|
||||
existingAssets
|
||||
);
|
||||
skipped = toSkip.length;
|
||||
|
||||
const githubByName = new Map(githubAssets.map((a) => [a.name, a]));
|
||||
|
||||
for (const { name, replaceAssetId } of toUpload) {
|
||||
const asset = githubByName.get(name)!;
|
||||
try {
|
||||
// Download from GitHub. fetch strips the Authorization header on the
|
||||
// cross-host redirect to GitHub's object storage, so this works for both
|
||||
// public and private release assets.
|
||||
console.log(
|
||||
`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes) for ${tagName}`
|
||||
);
|
||||
const assetResponse = await fetch(asset.browser_download_url, {
|
||||
headers: {
|
||||
Accept: "application/octet-stream",
|
||||
Authorization: `token ${decryptedConfig.githubConfig!.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assetResponse.ok) {
|
||||
console.error(
|
||||
`[Releases] Failed to download asset ${asset.name}: ${assetResponse.status} ${assetResponse.statusText}`
|
||||
);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetData = await assetResponse.arrayBuffer();
|
||||
|
||||
// Gitea rejects a duplicate attachment name, so drop a stale/mismatched
|
||||
// copy before re-uploading.
|
||||
if (replaceAssetId !== null) {
|
||||
await httpDelete(
|
||||
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets/${replaceAssetId}`,
|
||||
giteaAuth
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("attachment", new Blob([assetData]), asset.name);
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${giteaBaseUrl}/api/v1/repos/${repoOwner}/${repoName}/releases/${giteaReleaseId}/assets?name=${encodeURIComponent(asset.name)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `token ${decryptedConfig.giteaConfig!.token}` },
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
console.log(`[Releases] Successfully uploaded asset: ${asset.name}`);
|
||||
uploaded++;
|
||||
} else {
|
||||
const errorText = await uploadResponse.text();
|
||||
console.error(
|
||||
`[Releases] Failed to upload asset ${asset.name}: ${uploadResponse.status} ${errorText}`
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
} catch (assetError) {
|
||||
console.error(
|
||||
`[Releases] Error processing asset ${asset.name}: ${
|
||||
assetError instanceof Error ? assetError.message : String(assetError)
|
||||
}`
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { uploaded, failed, skipped };
|
||||
}
|
||||
|
||||
export async function mirrorGitHubReleasesToGitea({
|
||||
octokit,
|
||||
repository,
|
||||
@@ -2776,6 +3043,9 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
let mirroredCount = 0;
|
||||
let skippedCount = 0;
|
||||
let skippedMissingTagCount = 0;
|
||||
let totalAssetsUploaded = 0;
|
||||
let totalAssetsFailed = 0;
|
||||
|
||||
// Process releases in their GitHub API order (newest first by default)
|
||||
const releasesToProcess = limitedReleases.slice();
|
||||
@@ -2830,14 +3100,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
|
||||
await httpPatch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${existingRelease.id}`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
target: release.target_commitish,
|
||||
title: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
buildGiteaReleasePayload(release, releaseNote),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
@@ -2853,6 +3116,49 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
console.log(`[Releases] Release ${release.tag_name} already up-to-date, skipping`);
|
||||
skippedCount++;
|
||||
}
|
||||
|
||||
// Reconcile assets on every sync — backfill any that are missing or changed.
|
||||
// The update path used to `continue` here without touching assets, so a
|
||||
// release that existed without its full asset set stayed broken forever (#331).
|
||||
const assetResult = await reconcileReleaseAssets({
|
||||
config,
|
||||
decryptedConfig,
|
||||
repoOwner,
|
||||
repoName,
|
||||
giteaReleaseId: existingRelease.id,
|
||||
githubAssets: release.assets || [],
|
||||
tagName: release.tag_name,
|
||||
});
|
||||
if (assetResult.uploaded > 0) {
|
||||
console.log(
|
||||
`[Releases] Backfilled ${assetResult.uploaded} missing/changed asset(s) for existing release ${release.tag_name}`
|
||||
);
|
||||
}
|
||||
totalAssetsUploaded += assetResult.uploaded;
|
||||
totalAssetsFailed += assetResult.failed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The git tag must already exist in Gitea before we create a release for it.
|
||||
// For a mirror, tags are synced from upstream by Gitea's own git mirror, which
|
||||
// can lag behind this metadata sync (e.g. a large/slow initial clone). If the
|
||||
// tag isn't present yet, skip and let a later sync pick it up — do NOT ask Gitea
|
||||
// to create the release against a `target` branch:
|
||||
// - if the target can't be resolved Gitea returns 404 "The target couldn't be
|
||||
// found" and the release is lost (#331),
|
||||
// - if it can, Gitea would create a brand-new tag at the wrong commit.
|
||||
const tagExists = await httpGet(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/tags/${encodeURIComponent(release.tag_name)}`,
|
||||
{ Authorization: `token ${decryptedConfig.giteaConfig.token}` }
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!tagExists) {
|
||||
console.warn(
|
||||
`[Releases] Tag ${release.tag_name} is not present in Gitea yet — skipping release for now (the git mirror may still be syncing; it will be retried on the next sync)`
|
||||
);
|
||||
skippedMissingTagCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2862,71 +3168,31 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
} else {
|
||||
console.log(`[Releases] Creating release ${release.tag_name} with GitHub date header (no changelog)`);
|
||||
}
|
||||
|
||||
|
||||
const createReleaseResponse = await httpPost(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases`,
|
||||
{
|
||||
tag_name: release.tag_name,
|
||||
target: release.target_commitish,
|
||||
title: release.name || release.tag_name,
|
||||
body: releaseNote,
|
||||
draft: release.draft,
|
||||
prerelease: release.prerelease,
|
||||
},
|
||||
buildGiteaReleasePayload(release, releaseNote),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||
}
|
||||
);
|
||||
|
||||
// Mirror release assets if they exist
|
||||
// Mirror release assets if they exist (idempotent — see reconcileReleaseAssets)
|
||||
if (release.assets && release.assets.length > 0) {
|
||||
console.log(`[Releases] Mirroring ${release.assets.length} assets for release ${release.tag_name}`);
|
||||
|
||||
for (const asset of release.assets) {
|
||||
try {
|
||||
// Download the asset from GitHub
|
||||
console.log(`[Releases] Downloading asset: ${asset.name} (${asset.size} bytes)`);
|
||||
const assetResponse = await fetch(asset.browser_download_url, {
|
||||
headers: {
|
||||
'Accept': 'application/octet-stream',
|
||||
'Authorization': `token ${decryptedConfig.githubConfig.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!assetResponse.ok) {
|
||||
console.error(`[Releases] Failed to download asset ${asset.name}: ${assetResponse.statusText}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const assetData = await assetResponse.arrayBuffer();
|
||||
|
||||
// Upload the asset to Gitea release
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', new Blob([assetData]), asset.name);
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/releases/${createReleaseResponse.data.id}/assets?name=${encodeURIComponent(asset.name)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `token ${decryptedConfig.giteaConfig.token}`,
|
||||
},
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
console.log(`[Releases] Successfully uploaded asset: ${asset.name}`);
|
||||
} else {
|
||||
const errorText = await uploadResponse.text();
|
||||
console.error(`[Releases] Failed to upload asset ${asset.name}: ${errorText}`);
|
||||
}
|
||||
} catch (assetError) {
|
||||
console.error(`[Releases] Error processing asset ${asset.name}: ${assetError instanceof Error ? assetError.message : String(assetError)}`);
|
||||
}
|
||||
}
|
||||
const assetResult = await reconcileReleaseAssets({
|
||||
config,
|
||||
decryptedConfig,
|
||||
repoOwner,
|
||||
repoName,
|
||||
giteaReleaseId: createReleaseResponse.data.id,
|
||||
githubAssets: release.assets,
|
||||
tagName: release.tag_name,
|
||||
});
|
||||
totalAssetsUploaded += assetResult.uploaded;
|
||||
totalAssetsFailed += assetResult.failed;
|
||||
}
|
||||
|
||||
|
||||
mirroredCount++;
|
||||
const noteInfo = originalReleaseNote ? ` with ${originalReleaseNote.length} character changelog` : " without changelog";
|
||||
console.log(`[Releases] Successfully mirrored release: ${release.tag_name}${noteInfo}`);
|
||||
@@ -2935,7 +3201,21 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date)`);
|
||||
console.log(
|
||||
`✅ Mirrored/Updated ${mirroredCount} releases to Gitea (${skippedCount} already up-to-date, ${skippedMissingTagCount} skipped: tag not synced yet); assets uploaded: ${totalAssetsUploaded}, failed: ${totalAssetsFailed}`
|
||||
);
|
||||
|
||||
if (skippedMissingTagCount > 0) {
|
||||
console.warn(
|
||||
`[Releases] ${skippedMissingTagCount} release(s) skipped because their git tag is not in Gitea yet for ${repository.fullName} — these will be created automatically once the git mirror finishes syncing the tags`
|
||||
);
|
||||
}
|
||||
|
||||
if (totalAssetsFailed > 0) {
|
||||
console.error(
|
||||
`[Releases] ⚠️ ${totalAssetsFailed} release asset(s) failed to mirror for ${repository.fullName} — they will be retried on the next sync`
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce release retention limit by removing the oldest excess releases from Gitea
|
||||
try {
|
||||
@@ -3279,12 +3559,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: issueData.title,
|
||||
body: issueData.body,
|
||||
state: issueData.closed ? "closed" : "open",
|
||||
labels: issueData.labels,
|
||||
},
|
||||
closed: issueData.closed,
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -3323,6 +3602,20 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea drops `labels` on issue edit, so the "pull-request" marker label
|
||||
// can't be set via the PATCH above — reconcile it on the update path.
|
||||
// (#334 sibling)
|
||||
if (existingPrIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: existingPrIssue.number,
|
||||
labelIds: issueData.labels,
|
||||
});
|
||||
}
|
||||
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Successfully created issue for PR #${pr.number}`);
|
||||
} catch (apiError) {
|
||||
@@ -3367,12 +3660,11 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
if (existingPrIssue) {
|
||||
await httpPatch(
|
||||
`${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues/${existingPrIssue.number}`,
|
||||
{
|
||||
buildGiteaIssueEditPayload({
|
||||
title: basicIssueData.title,
|
||||
body: basicIssueData.body,
|
||||
state: basicIssueData.closed ? "closed" : "open",
|
||||
labels: basicIssueData.labels,
|
||||
},
|
||||
closed: basicIssueData.closed,
|
||||
}),
|
||||
{
|
||||
Authorization: `token ${decryptedConfig.giteaConfig!.token}`,
|
||||
}
|
||||
@@ -3410,6 +3702,19 @@ export async function mirrorGitRepoPullRequestsToGitea({
|
||||
}
|
||||
}
|
||||
|
||||
// Same as the enriched path — reconcile the marker label via the labels
|
||||
// sub-resource since PATCH ignores it. (#334 sibling)
|
||||
if (existingPrIssue) {
|
||||
await reconcileGiteaIssueLabels({
|
||||
config,
|
||||
decryptedConfig,
|
||||
giteaOwner,
|
||||
repoName,
|
||||
issueNumber: existingPrIssue.number,
|
||||
labelIds: basicIssueData.labels,
|
||||
});
|
||||
}
|
||||
|
||||
successCount++;
|
||||
console.log(`[Pull Requests] ✅ Created basic issue for PR #${pr.number}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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