Merge pull request #138 from RayLabsHQ/issue-132-org-repo-duplicates

This commit is contained in:
ARUNAVO RAY
2025-10-30 07:11:34 +05:30
committed by GitHub
21 changed files with 2809 additions and 130 deletions

View File

@@ -101,26 +101,30 @@ jobs:
# Build and push Docker image # Build and push Docker image
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown
# Load image locally for security scanning (PRs only) # Load image locally for security scanning (PRs only)
- name: Load image for scanning - name: Load image for scanning
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64 platforms: linux/amd64
load: true load: true
tags: gitea-mirror:scan tags: gitea-mirror:scan
cache-from: type=gha cache-from: type=gha
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown
# Wait for image to be available in registry # Wait for image to be available in registry
- name: Wait for image availability - name: Wait for image availability
@@ -169,8 +173,8 @@ jobs:
- BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321 - BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:4321
\`\`\` \`\`\`
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and only built for \`linux/amd64\` to speed up CI. > 💡 **Note:** PR images are tagged as \`pr-<number>\` and built for both \`linux/amd64\` and \`linux/arm64\`.
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`). > Production images (\`latest\`, version tags) use the same multi-platform set.
--- ---
📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`; 📦 View in [GitHub Packages](https://github.com/${{ github.repository }}/pkgs/container/gitea-mirror)`;

View File

@@ -0,0 +1,18 @@
ALTER TABLE `organizations` ADD `normalized_name` text NOT NULL DEFAULT '';--> statement-breakpoint
UPDATE `organizations` SET `normalized_name` = lower(trim(`name`));--> statement-breakpoint
DELETE FROM `organizations`
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM `organizations`
GROUP BY `user_id`, `normalized_name`
);--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
ALTER TABLE `repositories` ADD `normalized_full_name` text NOT NULL DEFAULT '';--> statement-breakpoint
UPDATE `repositories` SET `normalized_full_name` = lower(trim(`full_name`));--> statement-breakpoint
DELETE FROM `repositories`
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM `repositories`
GROUP BY `user_id`, `normalized_full_name`
);--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_repositories_user_normalized_full_name` ON `repositories` (`user_id`,`normalized_full_name`);

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1761483928546, "when": 1761483928546,
"tag": "0006_military_la_nuit", "tag": "0006_military_la_nuit",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1761534391115,
"tag": "0007_whole_hellion",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,9 +20,11 @@ interface AddOrganizationDialogProps {
onAddOrganization: ({ onAddOrganization: ({
org, org,
role, role,
force,
}: { }: {
org: string; org: string;
role: MembershipRole; role: MembershipRole;
force?: boolean;
}) => Promise<void>; }) => Promise<void>;
} }
@@ -36,6 +38,14 @@ export default function AddOrganizationDialog({
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
useEffect(() => {
if (!isDialogOpen) {
setError("");
setOrg("");
setRole("member");
}
}, [isDialogOpen]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -54,7 +64,7 @@ export default function AddOrganizationDialog({
setRole("member"); setRole("member");
setIsDialogOpen(false); setIsDialogOpen(false);
} catch (err: any) { } catch (err: any) {
setError(err?.message || "Failed to add repository."); setError(err?.message || "Failed to add organization.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -139,7 +149,7 @@ export default function AddOrganizationDialog({
{isLoading ? ( {isLoading ? (
<LoaderCircle className="h-4 w-4 animate-spin" /> <LoaderCircle className="h-4 w-4 animate-spin" />
) : ( ) : (
"Add Repository" "Add Organization"
)} )}
</Button> </Button>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, Filter } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, Filter, LoaderCircle, Trash2 } from "lucide-react";
import type { MirrorJob, Organization } from "@/lib/db/schema"; import type { MirrorJob, Organization } from "@/lib/db/schema";
import { OrganizationList } from "./OrganizationsList"; import { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog"; import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -37,6 +37,14 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function Organization() { export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -52,6 +60,15 @@ export function Organization() {
status: "", status: "",
}); });
const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed const [loadingOrgIds, setLoadingOrgIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{
org: string;
role: MembershipRole;
} | null>(null);
const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false);
const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false);
const [orgToDelete, setOrgToDelete] = useState<Organization | null>(null);
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false);
const [isDeletingOrg, setIsDeletingOrg] = useState(false);
// Create a stable callback using useCallback // Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => { const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -256,19 +273,45 @@ export function Organization() {
const handleAddOrganization = async ({ const handleAddOrganization = async ({
org, org,
role, role,
force = false,
}: { }: {
org: string; org: string;
role: MembershipRole; role: MembershipRole;
force?: boolean;
}) => { }) => {
try { if (!user || !user.id) {
if (!user || !user.id) { return;
return; }
const trimmedOrg = org.trim();
const normalizedOrg = trimmedOrg.toLowerCase();
if (!trimmedOrg) {
toast.error("Please enter a valid organization name.");
throw new Error("Invalid organization name");
}
if (!force) {
const alreadyExists = organizations.some(
(existing) => existing.name?.trim().toLowerCase() === normalizedOrg
);
if (alreadyExists) {
toast.warning("Organization already exists.");
setDuplicateOrgCandidate({ org: trimmedOrg, role });
setIsDuplicateOrgDialogOpen(true);
throw new Error("Organization already exists");
} }
}
try {
setIsLoading(true);
const reqPayload: AddOrganizationApiRequest = { const reqPayload: AddOrganizationApiRequest = {
userId: user.id, userId: user.id,
org, org: trimmedOrg,
role, role,
force,
}; };
const response = await apiRequest<AddOrganizationApiResponse>( const response = await apiRequest<AddOrganizationApiResponse>(
@@ -280,25 +323,100 @@ export function Organization() {
); );
if (response.success) { if (response.success) {
toast.success(`Organization added successfully`); const message = force
setOrganizations((prev) => [...prev, response.organization]); ? "Organization already exists; using existing entry."
: "Organization added successfully";
toast.success(message);
await fetchOrganizations(); await fetchOrganizations(false);
setFilter((prev) => ({ setFilter((prev) => ({
...prev, ...prev,
searchTerm: org, searchTerm: trimmedOrg,
})); }));
if (force) {
setIsDuplicateOrgDialogOpen(false);
setDuplicateOrgCandidate(null);
}
} else { } else {
showErrorToast(response.error || "Error adding organization", toast); showErrorToast(response.error || "Error adding organization", toast);
} }
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
throw error;
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleConfirmDuplicateOrganization = async () => {
if (!duplicateOrgCandidate) {
return;
}
setIsProcessingDuplicateOrg(true);
try {
await handleAddOrganization({
org: duplicateOrgCandidate.org,
role: duplicateOrgCandidate.role,
force: true,
});
setIsDialogOpen(false);
setDuplicateOrgCandidate(null);
setIsDuplicateOrgDialogOpen(false);
} catch (error) {
// Error already surfaced via toast
} finally {
setIsProcessingDuplicateOrg(false);
}
};
const handleCancelDuplicateOrganization = () => {
setIsDuplicateOrgDialogOpen(false);
setDuplicateOrgCandidate(null);
};
const handleRequestDeleteOrganization = (orgId: string) => {
const org = organizations.find((item) => item.id === orgId);
if (!org) {
toast.error("Organization not found");
return;
}
setOrgToDelete(org);
setIsDeleteOrgDialogOpen(true);
};
const handleDeleteOrganization = async () => {
if (!user || !user.id || !orgToDelete) {
return;
}
setIsDeletingOrg(true);
try {
const response = await apiRequest<{ success: boolean; error?: string }>(
`/organizations/${orgToDelete.id}`,
{
method: "DELETE",
}
);
if (response.success) {
toast.success(`Removed ${orgToDelete.name} from Gitea Mirror.`);
await fetchOrganizations(false);
} else {
showErrorToast(response.error || "Failed to delete organization", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDeletingOrg(false);
setIsDeleteOrgDialogOpen(false);
setOrgToDelete(null);
}
};
const handleMirrorAllOrgs = async () => { const handleMirrorAllOrgs = async () => {
try { try {
if (!user || !user.id || organizations.length === 0) { if (!user || !user.id || organizations.length === 0) {
@@ -711,6 +829,7 @@ export function Organization() {
onMirror={handleMirrorOrg} onMirror={handleMirrorOrg}
onIgnore={handleIgnoreOrg} onIgnore={handleIgnoreOrg}
onAddOrganization={() => setIsDialogOpen(true)} onAddOrganization={() => setIsDialogOpen(true)}
onDelete={handleRequestDeleteOrganization}
onRefresh={async () => { onRefresh={async () => {
await fetchOrganizations(false); await fetchOrganizations(false);
}} }}
@@ -721,6 +840,68 @@ export function Organization() {
isDialogOpen={isDialogOpen} isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen} setIsDialogOpen={setIsDialogOpen}
/> />
<Dialog open={isDuplicateOrgDialogOpen} onOpenChange={(open) => {
if (!open) {
handleCancelDuplicateOrganization();
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Organization already exists</DialogTitle>
<DialogDescription>
{duplicateOrgCandidate?.org ?? "This organization"} is already synced in Gitea Mirror.
Continuing will reuse the existing entry without creating a duplicate. You can remove it later if needed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
Cancel
</Button>
<Button onClick={handleConfirmDuplicateOrganization} disabled={isProcessingDuplicateOrg}>
{isProcessingDuplicateOrg ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Continue"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isDeleteOrgDialogOpen} onOpenChange={(open) => {
if (!open) {
setIsDeleteOrgDialogOpen(false);
setOrgToDelete(null);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove organization from Gitea Mirror?</DialogTitle>
<DialogDescription>
{orgToDelete?.name ?? "This organization"} will be deleted from Gitea Mirror only. Nothing will be removed from Gitea; you will need to clean it up manually in Gitea if desired.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => {
setIsDeleteOrgDialogOpen(false);
setOrgToDelete(null);
}} disabled={isDeletingOrg}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteOrganization} disabled={isDeletingOrg}>
{isDeletingOrg ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
Delete
</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban } from "lucide-react"; import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si"; import { SiGithub, SiGitea } from "react-icons/si";
import type { Organization } from "@/lib/db/schema"; import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter"; import type { FilterParams } from "@/types/filter";
@@ -30,6 +30,7 @@ interface OrganizationListProps {
loadingOrgIds: Set<string>; loadingOrgIds: Set<string>;
onAddOrganization?: () => void; onAddOrganization?: () => void;
onRefresh?: () => Promise<void>; onRefresh?: () => Promise<void>;
onDelete?: (orgId: string) => void;
} }
// Helper function to get status badge variant and icon // Helper function to get status badge variant and icon
@@ -60,6 +61,7 @@ export function OrganizationList({
loadingOrgIds, loadingOrgIds,
onAddOrganization, onAddOrganization,
onRefresh, onRefresh,
onDelete,
}: OrganizationListProps) { }: OrganizationListProps) {
const { giteaConfig } = useGiteaConfig(); const { giteaConfig } = useGiteaConfig();
@@ -414,7 +416,7 @@ export function OrganizationList({
)} )}
{/* Dropdown menu for additional actions */} {/* Dropdown menu for additional actions */}
{org.status !== "ignored" && org.status !== "mirroring" && ( {org.status !== "mirroring" && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10"> <Button variant="ghost" size="icon" disabled={isLoading} className="h-10 w-10">
@@ -422,12 +424,26 @@ export function OrganizationList({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem {org.status !== "ignored" && (
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} <DropdownMenuItem
> onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
<Ban className="h-4 w-4 mr-2" /> >
Ignore Organization <Ban className="h-4 w-4 mr-2" />
</DropdownMenuItem> Ignore Organization
</DropdownMenuItem>
)}
{onDelete && (
<>
{org.status !== "ignored" && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => org.id && onDelete(org.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete from Mirror
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
@@ -561,7 +577,7 @@ export function OrganizationList({
)} )}
{/* Dropdown menu for additional actions */} {/* Dropdown menu for additional actions */}
{org.status !== "ignored" && org.status !== "mirroring" && ( {org.status !== "mirroring" && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" disabled={isLoading}> <Button variant="ghost" size="icon" disabled={isLoading}>
@@ -569,12 +585,26 @@ export function OrganizationList({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem {org.status !== "ignored" && (
onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} <DropdownMenuItem
> onClick={() => org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })}
<Ban className="h-4 w-4 mr-2" /> >
Ignore Organization <Ban className="h-4 w-4 mr-2" />
</DropdownMenuItem> Ignore Organization
</DropdownMenuItem>
)}
{onDelete && (
<>
{org.status !== "ignored" && <DropdownMenuSeparator />}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => org.id && onDelete(org.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete from Mirror
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -17,9 +17,11 @@ interface AddRepositoryDialogProps {
onAddRepository: ({ onAddRepository: ({
repo, repo,
owner, owner,
force,
}: { }: {
repo: string; repo: string;
owner: string; owner: string;
force?: boolean;
}) => Promise<void>; }) => Promise<void>;
} }
@@ -33,6 +35,14 @@ export default function AddRepositoryDialog({
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
useEffect(() => {
if (!isDialogOpen) {
setError("");
setRepo("");
setOwner("");
}
}, [isDialogOpen]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -18,7 +18,7 @@ import {
SelectValue, SelectValue,
} from "../ui/select"; } from "../ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check } from "lucide-react"; import { Search, RefreshCw, FlipHorizontal, RotateCcw, X, Filter, Ban, Check, LoaderCircle, Trash2 } from "lucide-react";
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror"; import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
import { import {
Drawer, Drawer,
@@ -30,6 +30,14 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useSSE } from "@/hooks/useSEE"; import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams"; import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -69,6 +77,15 @@ export default function Repository() {
}, [setFilter]); }, [setFilter]);
const [loadingRepoIds, setLoadingRepoIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed const [loadingRepoIds, setLoadingRepoIds] = useState<Set<string>>(new Set()); // this is used when the api actions are performed
const [duplicateRepoCandidate, setDuplicateRepoCandidate] = useState<{
owner: string;
repo: string;
} | null>(null);
const [isDuplicateRepoDialogOpen, setIsDuplicateRepoDialogOpen] = useState(false);
const [isProcessingDuplicateRepo, setIsProcessingDuplicateRepo] = useState(false);
const [repoToDelete, setRepoToDelete] = useState<Repository | null>(null);
const [isDeleteRepoDialogOpen, setIsDeleteRepoDialogOpen] = useState(false);
const [isDeletingRepo, setIsDeletingRepo] = useState(false);
// Create a stable callback using useCallback // Create a stable callback using useCallback
const handleNewMessage = useCallback((data: MirrorJob) => { const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -618,19 +635,45 @@ export default function Repository() {
const handleAddRepository = async ({ const handleAddRepository = async ({
repo, repo,
owner, owner,
force = false,
}: { }: {
repo: string; repo: string;
owner: string; owner: string;
force?: boolean;
}) => { }) => {
try { if (!user || !user.id) {
if (!user || !user.id) { return;
return; }
}
const trimmedRepo = repo.trim();
const trimmedOwner = owner.trim();
if (!trimmedRepo || !trimmedOwner) {
toast.error("Please provide both owner and repository name.");
throw new Error("Invalid repository details");
}
const normalizedFullName = `${trimmedOwner}/${trimmedRepo}`.toLowerCase();
if (!force) {
const duplicateRepo = repositories.find(
(existing) => existing.normalizedFullName?.toLowerCase() === normalizedFullName
);
if (duplicateRepo) {
toast.warning("Repository already exists.");
setDuplicateRepoCandidate({ repo: trimmedRepo, owner: trimmedOwner });
setIsDuplicateRepoDialogOpen(true);
throw new Error("Repository already exists");
}
}
try {
const reqPayload: AddRepositoriesApiRequest = { const reqPayload: AddRepositoriesApiRequest = {
userId: user.id, userId: user.id,
repo, repo: trimmedRepo,
owner, owner: trimmedOwner,
force,
}; };
const response = await apiRequest<AddRepositoriesApiResponse>( const response = await apiRequest<AddRepositoriesApiResponse>(
@@ -642,20 +685,28 @@ export default function Repository() {
); );
if (response.success) { if (response.success) {
toast.success(`Repository added successfully`); const message = force
setRepositories((prevRepos) => [...prevRepos, response.repository]); ? "Repository already exists; metadata refreshed."
: "Repository added successfully";
toast.success(message);
await fetchRepositories(false); // Manual refresh after adding repository await fetchRepositories(false);
setFilter((prev) => ({ setFilter((prev) => ({
...prev, ...prev,
searchTerm: repo, searchTerm: trimmedRepo,
})); }));
if (force) {
setDuplicateRepoCandidate(null);
setIsDuplicateRepoDialogOpen(false);
}
} else { } else {
showErrorToast(response.error || "Error adding repository", toast); showErrorToast(response.error || "Error adding repository", toast);
} }
} catch (error) { } catch (error) {
showErrorToast(error, toast); showErrorToast(error, toast);
throw error;
} }
}; };
@@ -673,6 +724,71 @@ export default function Repository() {
) )
).sort(); ).sort();
const handleConfirmDuplicateRepository = async () => {
if (!duplicateRepoCandidate) {
return;
}
setIsProcessingDuplicateRepo(true);
try {
await handleAddRepository({
repo: duplicateRepoCandidate.repo,
owner: duplicateRepoCandidate.owner,
force: true,
});
setIsDialogOpen(false);
} catch (error) {
// Error already shown
} finally {
setIsProcessingDuplicateRepo(false);
}
};
const handleCancelDuplicateRepository = () => {
setDuplicateRepoCandidate(null);
setIsDuplicateRepoDialogOpen(false);
};
const handleRequestDeleteRepository = (repoId: string) => {
const repo = repositories.find((item) => item.id === repoId);
if (!repo) {
toast.error("Repository not found");
return;
}
setRepoToDelete(repo);
setIsDeleteRepoDialogOpen(true);
};
const handleDeleteRepository = async () => {
if (!user || !user.id || !repoToDelete) {
return;
}
setIsDeletingRepo(true);
try {
const response = await apiRequest<{ success: boolean; error?: string }>(
`/repositories/${repoToDelete.id}`,
{
method: "DELETE",
}
);
if (response.success) {
toast.success(`Removed ${repoToDelete.fullName} from Gitea Mirror.`);
await fetchRepositories(false);
} else {
showErrorToast(response.error || "Failed to delete repository", toast);
}
} catch (error) {
showErrorToast(error, toast);
} finally {
setIsDeletingRepo(false);
setIsDeleteRepoDialogOpen(false);
setRepoToDelete(null);
}
};
// Determine what actions are available for selected repositories // Determine what actions are available for selected repositories
const getAvailableActions = () => { const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return []; if (selectedRepoIds.size === 0) return [];
@@ -1198,6 +1314,7 @@ export default function Repository() {
onRefresh={async () => { onRefresh={async () => {
await fetchRepositories(false); await fetchRepositories(false);
}} }}
onDelete={handleRequestDeleteRepository}
/> />
)} )}
@@ -1206,6 +1323,77 @@ export default function Repository() {
isDialogOpen={isDialogOpen} isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen} setIsDialogOpen={setIsDialogOpen}
/> />
<Dialog
open={isDuplicateRepoDialogOpen}
onOpenChange={(open) => {
if (!open) {
handleCancelDuplicateRepository();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Repository already exists</DialogTitle>
<DialogDescription>
{duplicateRepoCandidate ? `${duplicateRepoCandidate.owner}/${duplicateRepoCandidate.repo}` : "This repository"} is already tracked in Gitea Mirror. Continuing will refresh the existing entry without creating a duplicate.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelDuplicateRepository} disabled={isProcessingDuplicateRepo}>
Cancel
</Button>
<Button onClick={handleConfirmDuplicateRepository} disabled={isProcessingDuplicateRepo}>
{isProcessingDuplicateRepo ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Continue"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={isDeleteRepoDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDeleteRepoDialogOpen(false);
setRepoToDelete(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove repository from Gitea Mirror?</DialogTitle>
<DialogDescription>
{repoToDelete?.fullName ?? "This repository"} will be deleted from Gitea Mirror only. The mirror on Gitea will remain untouched; remove it manually in Gitea if needed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setIsDeleteRepoDialogOpen(false);
setRepoToDelete(null);
}}
disabled={isDeletingRepo}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteRepository} disabled={isDeletingRepo}>
{isDeletingRepo ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<span className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
Delete
</span>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown } from "lucide-react"; import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2 } from "lucide-react";
import { SiGithub, SiGitea } from "react-icons/si"; import { SiGithub, SiGitea } from "react-icons/si";
import type { Repository } from "@/lib/db/schema"; import type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -23,6 +23,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
@@ -40,6 +41,7 @@ interface RepositoryTableProps {
selectedRepoIds: Set<string>; selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void; onSelectionChange: (selectedIds: Set<string>) => void;
onRefresh?: () => Promise<void>; onRefresh?: () => Promise<void>;
onDelete?: (repoId: string) => void;
} }
export default function RepositoryTable({ export default function RepositoryTable({
@@ -56,6 +58,7 @@ export default function RepositoryTable({
selectedRepoIds, selectedRepoIds,
onSelectionChange, onSelectionChange,
onRefresh, onRefresh,
onDelete,
}: RepositoryTableProps) { }: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null); const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig(); const { giteaConfig } = useGiteaConfig();
@@ -676,6 +679,7 @@ export default function RepositoryTable({
onSync={() => onSync({ repoId: repo.id ?? "" })} onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })} onRetry={() => onRetry({ repoId: repo.id ?? "" })}
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })} onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
/> />
</div> </div>
{/* Links */} {/* Links */}
@@ -786,6 +790,7 @@ function RepoActionButton({
onSync, onSync,
onRetry, onRetry,
onSkip, onSkip,
onDelete,
}: { }: {
repo: { id: string; status: string }; repo: { id: string; status: string };
isLoading: boolean; isLoading: boolean;
@@ -793,6 +798,7 @@ function RepoActionButton({
onSync: () => void; onSync: () => void;
onRetry: () => void; onRetry: () => void;
onSkip: (skip: boolean) => void; onSkip: (skip: boolean) => void;
onDelete?: () => void;
}) { }) {
// For ignored repos, show an "Include" action // For ignored repos, show an "Include" action
if (repo.status === "ignored") { if (repo.status === "ignored") {
@@ -849,7 +855,7 @@ function RepoActionButton({
); );
} }
// Show primary action with dropdown for skip option // Show primary action with dropdown for additional actions
return ( return (
<DropdownMenu> <DropdownMenu>
<div className="flex"> <div className="flex">
@@ -886,6 +892,18 @@ function RepoActionButton({
<Ban className="h-4 w-4 mr-2" /> <Ban className="h-4 w-4 mr-2" />
Ignore Repository Ignore Repository
</DropdownMenuItem> </DropdownMenuItem>
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete from Mirror
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );

View File

@@ -127,6 +127,7 @@ export const repositorySchema = z.object({
configId: z.string(), configId: z.string(),
name: z.string(), name: z.string(),
fullName: z.string(), fullName: z.string(),
normalizedFullName: z.string(),
url: z.url(), url: z.url(),
cloneUrl: z.url(), cloneUrl: z.url(),
owner: z.string(), owner: z.string(),
@@ -209,6 +210,7 @@ export const organizationSchema = z.object({
userId: z.string(), userId: z.string(),
configId: z.string(), configId: z.string(),
name: z.string(), name: z.string(),
normalizedName: z.string(),
avatarUrl: z.string(), avatarUrl: z.string(),
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"), membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
isIncluded: z.boolean().default(true), isIncluded: z.boolean().default(true),
@@ -334,6 +336,7 @@ export const repositories = sqliteTable("repositories", {
.references(() => configs.id), .references(() => configs.id),
name: text("name").notNull(), name: text("name").notNull(),
fullName: text("full_name").notNull(), fullName: text("full_name").notNull(),
normalizedFullName: text("normalized_full_name").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(), cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(), owner: text("owner").notNull(),
@@ -388,6 +391,7 @@ export const repositories = sqliteTable("repositories", {
index("idx_repositories_is_fork").on(table.isForked), index("idx_repositories_is_fork").on(table.isForked),
index("idx_repositories_is_starred").on(table.isStarred), index("idx_repositories_is_starred").on(table.isStarred),
uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName), uniqueIndex("uniq_repositories_user_full_name").on(table.userId, table.fullName),
uniqueIndex("uniq_repositories_user_normalized_full_name").on(table.userId, table.normalizedFullName),
]); ]);
export const mirrorJobs = sqliteTable("mirror_jobs", { export const mirrorJobs = sqliteTable("mirror_jobs", {
@@ -438,6 +442,7 @@ export const organizations = sqliteTable("organizations", {
.notNull() .notNull()
.references(() => configs.id), .references(() => configs.id),
name: text("name").notNull(), name: text("name").notNull(),
normalizedName: text("normalized_name").notNull(),
avatarUrl: text("avatar_url").notNull(), avatarUrl: text("avatar_url").notNull(),
@@ -469,6 +474,7 @@ export const organizations = sqliteTable("organizations", {
index("idx_organizations_config_id").on(table.configId), index("idx_organizations_config_id").on(table.configId),
index("idx_organizations_status").on(table.status), index("idx_organizations_status").on(table.status),
index("idx_organizations_is_included").on(table.isIncluded), index("idx_organizations_is_included").on(table.isIncluded),
uniqueIndex("uniq_organizations_user_normalized_name").on(table.userId, table.normalizedName),
]); ]);
// ===== Better Auth Tables ===== // ===== Better Auth Tables =====

View File

@@ -62,6 +62,7 @@ describe('normalizeGitRepoToInsert', () => {
expect(insert.description).toBeNull(); expect(insert.description).toBeNull();
expect(insert.lastMirrored).toBeNull(); expect(insert.lastMirrored).toBeNull();
expect(insert.errorMessage).toBeNull(); expect(insert.errorMessage).toBeNull();
expect(insert.normalizedFullName).toBe(repo.fullName.toLowerCase());
}); });
}); });
@@ -72,4 +73,3 @@ describe('calcBatchSizeForInsert', () => {
expect(batch * 29).toBeLessThanOrEqual(999); expect(batch * 29).toBeLessThanOrEqual(999);
}); });
}); });

View File

@@ -33,6 +33,7 @@ export function normalizeGitRepoToInsert(
configId, configId,
name: repo.name, name: repo.name,
fullName: repo.fullName, fullName: repo.fullName,
normalizedFullName: repo.fullName.toLowerCase(),
url: repo.url, url: repo.url,
cloneUrl: repo.cloneUrl, cloneUrl: repo.cloneUrl,
owner: repo.owner, owner: repo.owner,
@@ -68,4 +69,3 @@ export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): nu
const effectiveMax = Math.max(1, maxParams - safety); const effectiveMax = Math.max(1, maxParams - safety);
return Math.max(1, Math.floor(effectiveMax / columnCount)); return Math.max(1, Math.floor(effectiveMax / columnCount));
} }

View File

@@ -99,12 +99,12 @@ async function runScheduledSync(config: any): Promise<void> {
// Check for new repositories // Check for new repositories
const existingRepos = await db const existingRepos = await db
.select({ fullName: repositories.fullName }) .select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories) .from(repositories)
.where(eq(repositories.userId, userId)); .where(eq(repositories.userId, userId));
const existingRepoNames = new Set(existingRepos.map(r => r.fullName)); const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName)); const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (newRepos.length > 0) { if (newRepos.length > 0) {
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`); console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`);
@@ -123,7 +123,7 @@ async function runScheduledSync(config: any): Promise<void> {
await db await db
.insert(repositories) .insert(repositories)
.values(batch) .values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
} }
console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`); console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`);
} else { } else {
@@ -432,12 +432,12 @@ async function performInitialAutoStart(): Promise<void> {
// Check for new repositories // Check for new repositories
const existingRepos = await db const existingRepos = await db
.select({ fullName: repositories.fullName }) .select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories) .from(repositories)
.where(eq(repositories.userId, config.userId)); .where(eq(repositories.userId, config.userId));
const existingRepoNames = new Set(existingRepos.map(r => r.fullName)); const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName)); const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (reposToImport.length > 0) { if (reposToImport.length > 0) {
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`); console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
@@ -456,7 +456,7 @@ async function performInitialAutoStart(): Promise<void> {
await db await db
.insert(repositories) .insert(repositories)
.values(batch) .values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
} }
console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`); console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`);
} else { } else {

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db, organizations } from "@/lib/db"; import { db, organizations, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils"; import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers"; import { requireAuth } from "@/lib/utils/auth-helpers";
@@ -61,3 +61,60 @@ export const PATCH: APIRoute = async (context) => {
return createSecureErrorResponse(error, "Update organization destination", 500); return createSecureErrorResponse(error, "Update organization destination", 500);
} }
}; };
export const DELETE: APIRoute = async (context) => {
try {
const { user, response } = await requireAuth(context);
if (response) return response;
const userId = user!.id;
const orgId = context.params.id;
if (!orgId) {
return new Response(
JSON.stringify({ error: "Organization ID is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const [existingOrg] = await db
.select()
.from(organizations)
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)))
.limit(1);
if (!existingOrg) {
return new Response(
JSON.stringify({ error: "Organization not found" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
await db.delete(repositories).where(
and(
eq(repositories.userId, userId),
eq(repositories.organization, existingOrg.name)
)
);
await db
.delete(organizations)
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)));
return new Response(
JSON.stringify({ success: true }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return createSecureErrorResponse(error, "Delete organization", 500);
}
};

View File

@@ -1,5 +1,5 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { db, repositories } from "@/lib/db"; import { db, repositories, mirrorJobs } from "@/lib/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils"; import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers"; import { requireAuth } from "@/lib/utils/auth-helpers";
@@ -60,4 +60,55 @@ export const PATCH: APIRoute = async (context) => {
} catch (error) { } catch (error) {
return createSecureErrorResponse(error, "Update repository destination", 500); return createSecureErrorResponse(error, "Update repository destination", 500);
} }
}; };
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);
}
};

View File

@@ -66,6 +66,7 @@ export const POST: APIRoute = async ({ request }) => {
configId: config.id, configId: config.id,
name: repo.name, name: repo.name,
fullName: repo.fullName, fullName: repo.fullName,
normalizedFullName: repo.fullName.toLowerCase(),
url: repo.url, url: repo.url,
cloneUrl: repo.cloneUrl, cloneUrl: repo.cloneUrl,
owner: repo.owner, owner: repo.owner,
@@ -97,6 +98,7 @@ export const POST: APIRoute = async ({ request }) => {
userId, userId,
configId: config.id, configId: config.id,
name: org.name, name: org.name,
normalizedName: org.name.toLowerCase(),
avatarUrl: org.avatarUrl, avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole, membershipRole: org.membershipRole,
isIncluded: false, isIncluded: false,
@@ -113,22 +115,22 @@ export const POST: APIRoute = async ({ request }) => {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [existingRepos, existingOrgs] = await Promise.all([ const [existingRepos, existingOrgs] = await Promise.all([
tx tx
.select({ fullName: repositories.fullName }) .select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories) .from(repositories)
.where(eq(repositories.userId, userId)), .where(eq(repositories.userId, userId)),
tx tx
.select({ name: organizations.name }) .select({ normalizedName: organizations.normalizedName })
.from(organizations) .from(organizations)
.where(eq(organizations.userId, userId)), .where(eq(organizations.userId, userId)),
]); ]);
const existingRepoNames = new Set(existingRepos.map((r) => r.fullName)); const existingRepoNames = new Set(existingRepos.map((r) => r.normalizedFullName));
const existingOrgNames = new Set(existingOrgs.map((o) => o.name)); const existingOrgNames = new Set(existingOrgs.map((o) => o.normalizedName));
insertedRepos = newRepos.filter( insertedRepos = newRepos.filter(
(r) => !existingRepoNames.has(r.fullName) (r) => !existingRepoNames.has(r.normalizedFullName)
); );
insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name)); insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.normalizedName));
// Batch insert repositories to avoid SQLite parameter limit (dynamic by column count) // Batch insert repositories to avoid SQLite parameter limit (dynamic by column count)
const sample = newRepos[0]; const sample = newRepos[0];
@@ -140,7 +142,7 @@ export const POST: APIRoute = async ({ request }) => {
await tx await tx
.insert(repositories) .insert(repositories)
.values(batch) .values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
} }
} }

View File

@@ -1,5 +1,4 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, organizations, repositories } from "@/lib/db"; import { configs, db, organizations, repositories } from "@/lib/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
@@ -15,7 +14,7 @@ import { createGitHubClient } from "@/lib/github";
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
const body: AddOrganizationApiRequest = await request.json(); const body: AddOrganizationApiRequest = await request.json();
const { role, org, userId } = body; const { role, org, userId, force = false } = body;
if (!org || !userId || !role) { if (!org || !userId || !role) {
return jsonResponse({ return jsonResponse({
@@ -24,21 +23,58 @@ export const POST: APIRoute = async ({ request }) => {
}); });
} }
// Check if org already exists const trimmedOrg = org.trim();
const existingOrg = await db const normalizedOrg = trimmedOrg.toLowerCase();
// Check if org already exists (case-insensitive)
const [existingOrg] = await db
.select() .select()
.from(organizations) .from(organizations)
.where( .where(
and(eq(organizations.name, org), eq(organizations.userId, userId)) and(
); eq(organizations.userId, userId),
eq(organizations.normalizedName, normalizedOrg)
)
)
.limit(1);
if (existingOrg.length > 0) { if (existingOrg && !force) {
return jsonResponse({ return jsonResponse({
data: { data: {
success: false, success: false,
error: "Organization already exists for this user", error: "Organization already exists for this user",
}, },
status: 400, status: 409,
});
}
if (existingOrg && force) {
const [updatedOrg] = await db
.update(organizations)
.set({
membershipRole: role,
normalizedName: normalizedOrg,
updatedAt: new Date(),
})
.where(eq(organizations.id, existingOrg.id))
.returning();
const resPayload: AddOrganizationApiResponse = {
success: true,
organization: updatedOrg ?? existingOrg,
message: "Organization already exists; using existing record.",
};
return jsonResponse({ data: resPayload, status: 200 });
}
if (existingOrg) {
return jsonResponse({
data: {
success: false,
error: "Organization already exists for this user",
},
status: 409,
}); });
} }
@@ -71,17 +107,21 @@ export const POST: APIRoute = async ({ request }) => {
// Create authenticated Octokit instance with rate limit tracking // Create authenticated Octokit instance with rate limit tracking
const githubUsername = decryptedConfig.githubConfig?.owner || undefined; const githubUsername = decryptedConfig.githubConfig?.owner || undefined;
const octokit = createGitHubClient(decryptedConfig.githubConfig.token, userId, githubUsername); const octokit = createGitHubClient(
decryptedConfig.githubConfig.token,
userId,
githubUsername
);
// Fetch org metadata // Fetch org metadata
const { data: orgData } = await octokit.orgs.get({ org }); const { data: orgData } = await octokit.orgs.get({ org: trimmedOrg });
// Fetch repos based on config settings // Fetch repos based on config settings
const allRepos = []; const allRepos = [];
// Fetch all repos (public, private, and member) to show in UI // Fetch all repos (public, private, and member) to show in UI
const publicRepos = await octokit.paginate(octokit.repos.listForOrg, { const publicRepos = await octokit.paginate(octokit.repos.listForOrg, {
org, org: trimmedOrg,
type: "public", type: "public",
per_page: 100, per_page: 100,
}); });
@@ -89,7 +129,7 @@ export const POST: APIRoute = async ({ request }) => {
// Always fetch private repos to show them in the UI // Always fetch private repos to show them in the UI
const privateRepos = await octokit.paginate(octokit.repos.listForOrg, { const privateRepos = await octokit.paginate(octokit.repos.listForOrg, {
org, org: trimmedOrg,
type: "private", type: "private",
per_page: 100, per_page: 100,
}); });
@@ -97,7 +137,7 @@ export const POST: APIRoute = async ({ request }) => {
// Also fetch member repos (includes private repos the user has access to) // Also fetch member repos (includes private repos the user has access to)
const memberRepos = await octokit.paginate(octokit.repos.listForOrg, { const memberRepos = await octokit.paginate(octokit.repos.listForOrg, {
org, org: trimmedOrg,
type: "member", type: "member",
per_page: 100, per_page: 100,
}); });
@@ -107,38 +147,44 @@ export const POST: APIRoute = async ({ request }) => {
allRepos.push(...uniqueMemberRepos); allRepos.push(...uniqueMemberRepos);
// Insert repositories // Insert repositories
const repoRecords = allRepos.map((repo) => ({ const repoRecords = allRepos.map((repo) => {
id: uuidv4(), const normalizedOwner = repo.owner.login.trim().toLowerCase();
userId, const normalizedRepoName = repo.name.trim().toLowerCase();
configId,
name: repo.name, return {
fullName: repo.full_name, id: uuidv4(),
url: repo.html_url, userId,
cloneUrl: repo.clone_url ?? "", configId,
owner: repo.owner.login, name: repo.name,
organization: fullName: repo.full_name,
repo.owner.type === "Organization" ? repo.owner.login : null, normalizedFullName: `${normalizedOwner}/${normalizedRepoName}`,
mirroredLocation: "", url: repo.html_url,
destinationOrg: null, cloneUrl: repo.clone_url ?? "",
isPrivate: repo.private, owner: repo.owner.login,
isForked: repo.fork, organization:
forkedFrom: null, repo.owner.type === "Organization" ? repo.owner.login : null,
hasIssues: repo.has_issues, mirroredLocation: "",
isStarred: false, destinationOrg: null,
isArchived: repo.archived, isPrivate: repo.private,
size: repo.size, isForked: repo.fork,
hasLFS: false, forkedFrom: null,
hasSubmodules: false, hasIssues: repo.has_issues,
language: repo.language ?? null, isStarred: false,
description: repo.description ?? null, isArchived: repo.archived,
defaultBranch: repo.default_branch ?? "main", size: repo.size,
visibility: (repo.visibility ?? "public") as RepositoryVisibility, hasLFS: false,
status: "imported" as RepoStatus, hasSubmodules: false,
lastMirrored: null, language: repo.language ?? null,
errorMessage: null, description: repo.description ?? null,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(), defaultBranch: repo.default_branch ?? "main",
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(), visibility: (repo.visibility ?? "public") as RepositoryVisibility,
})); status: "imported" as RepoStatus,
lastMirrored: null,
errorMessage: null,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
};
});
// Batch insert repositories to avoid SQLite parameter limit // Batch insert repositories to avoid SQLite parameter limit
// Compute batch size based on column count // Compute batch size based on column count
@@ -150,7 +196,7 @@ export const POST: APIRoute = async ({ request }) => {
await db await db
.insert(repositories) .insert(repositories)
.values(batch) .values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
} }
// Insert organization metadata // Insert organization metadata
@@ -159,6 +205,7 @@ export const POST: APIRoute = async ({ request }) => {
userId, userId,
configId, configId,
name: orgData.login, name: orgData.login,
normalizedName: normalizedOrg,
avatarUrl: orgData.avatar_url, avatarUrl: orgData.avatar_url,
membershipRole: role, membershipRole: role,
isIncluded: false, isIncluded: false,

View File

@@ -15,7 +15,7 @@ import { createMirrorJob } from "@/lib/helpers";
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
const body: AddRepositoriesApiRequest = await request.json(); const body: AddRepositoriesApiRequest = await request.json();
const { owner, repo, userId } = body; const { owner, repo, userId, force = false } = body;
if (!owner || !repo || !userId) { if (!owner || !repo || !userId) {
return new Response( return new Response(
@@ -27,26 +27,43 @@ export const POST: APIRoute = async ({ request }) => {
); );
} }
const trimmedOwner = owner.trim();
const trimmedRepo = repo.trim();
if (!trimmedOwner || !trimmedRepo) {
return jsonResponse({
data: {
success: false,
error: "Missing owner, repo, or userId",
},
status: 400,
});
}
const normalizedOwner = trimmedOwner.toLowerCase();
const normalizedRepo = trimmedRepo.toLowerCase();
const normalizedFullName = `${normalizedOwner}/${normalizedRepo}`;
// Check if repository with the same owner, name, and userId already exists // Check if repository with the same owner, name, and userId already exists
const existingRepo = await db const [existingRepo] = await db
.select() .select()
.from(repositories) .from(repositories)
.where( .where(
and( and(
eq(repositories.owner, owner), eq(repositories.userId, userId),
eq(repositories.name, repo), eq(repositories.normalizedFullName, normalizedFullName)
eq(repositories.userId, userId)
) )
); )
.limit(1);
if (existingRepo.length > 0) { if (existingRepo && !force) {
return jsonResponse({ return jsonResponse({
data: { data: {
success: false, success: false,
error: error:
"Repository with this name and owner already exists for this user", "Repository with this name and owner already exists for this user",
}, },
status: 400, status: 409,
}); });
} }
@@ -68,14 +85,17 @@ export const POST: APIRoute = async ({ request }) => {
const octokit = new Octokit(); // No auth for public repos const octokit = new Octokit(); // No auth for public repos
const { data: repoData } = await octokit.rest.repos.get({ owner, repo }); const { data: repoData } = await octokit.rest.repos.get({
owner: trimmedOwner,
repo: trimmedRepo,
});
const metadata = { const baseMetadata = {
id: uuidv4(),
userId, userId,
configId, configId,
name: repoData.name, name: repoData.name,
fullName: repoData.full_name, fullName: repoData.full_name,
normalizedFullName,
url: repoData.html_url, url: repoData.html_url,
cloneUrl: repoData.clone_url, cloneUrl: repoData.clone_url,
owner: repoData.owner.login, owner: repoData.owner.login,
@@ -94,6 +114,37 @@ export const POST: APIRoute = async ({ request }) => {
description: repoData.description ?? null, description: repoData.description ?? null,
defaultBranch: repoData.default_branch, defaultBranch: repoData.default_branch,
visibility: (repoData.visibility ?? "public") as RepositoryVisibility, visibility: (repoData.visibility ?? "public") as RepositoryVisibility,
lastMirrored: existingRepo?.lastMirrored ?? null,
errorMessage: existingRepo?.errorMessage ?? null,
mirroredLocation: existingRepo?.mirroredLocation ?? "",
destinationOrg: existingRepo?.destinationOrg ?? null,
updatedAt: repoData.updated_at
? new Date(repoData.updated_at)
: new Date(),
};
if (existingRepo && force) {
const [updatedRepo] = await db
.update(repositories)
.set({
...baseMetadata,
normalizedFullName,
configId,
})
.where(eq(repositories.id, existingRepo.id))
.returning();
const resPayload: AddRepositoriesApiResponse = {
success: true,
repository: updatedRepo ?? existingRepo,
message: "Repository already exists; metadata refreshed.",
};
return jsonResponse({ data: resPayload, status: 200 });
}
const metadata = {
id: uuidv4(),
status: "imported" as Repository["status"], status: "imported" as Repository["status"],
lastMirrored: null, lastMirrored: null,
errorMessage: null, errorMessage: null,
@@ -102,15 +153,13 @@ export const POST: APIRoute = async ({ request }) => {
createdAt: repoData.created_at createdAt: repoData.created_at
? new Date(repoData.created_at) ? new Date(repoData.created_at)
: new Date(), : new Date(),
updatedAt: repoData.updated_at ...baseMetadata,
? new Date(repoData.updated_at) } satisfies Repository;
: new Date(),
};
await db await db
.insert(repositories) .insert(repositories)
.values(metadata) .values(metadata)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
createMirrorJob({ createMirrorJob({
userId, userId,

View File

@@ -81,11 +81,12 @@ export interface AddRepositoriesApiRequest {
userId: string; userId: string;
repo: string; repo: string;
owner: string; owner: string;
force?: boolean;
} }
export interface AddRepositoriesApiResponse { export interface AddRepositoriesApiResponse {
success: boolean; success: boolean;
message: string; message: string;
repository: Repository; repository?: Repository;
error?: string; error?: string;
} }

View File

@@ -45,11 +45,12 @@ export interface AddOrganizationApiRequest {
userId: string; userId: string;
org: string; org: string;
role: MembershipRole; role: MembershipRole;
force?: boolean;
} }
export interface AddOrganizationApiResponse { export interface AddOrganizationApiResponse {
success: boolean; success: boolean;
message: string; message: string;
organization: Organization; organization?: Organization;
error?: string; error?: string;
} }