mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 11:36:44 +03:00
Merge pull request #138 from RayLabsHQ/issue-132-org-repo-duplicates
This commit is contained in:
14
.github/workflows/docker-build.yml
vendored
14
.github/workflows/docker-build.yml
vendored
@@ -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)`;
|
||||||
|
|||||||
18
drizzle/0007_whole_hellion.sql
Normal file
18
drizzle/0007_whole_hellion.sql
Normal 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`);
|
||||||
1999
drizzle/meta/0007_snapshot.json
Normal file
1999
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -61,3 +61,54 @@ export const PATCH: APIRoute = async (context) => {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user