Compare commits

...

15 Commits

Author SHA1 Message Date
ARUNAVO RAY
63d3f0e86c Merge pull request #145 from z0xca/main
fix website install guide command
2025-11-05 11:53:22 +05:30
z0x
25e7d234ba Update clone command to use '&&' for chaining 2025-11-04 22:58:23 -05:00
ARUNAVO RAY
7a3f734728 Merge pull request #142 from RayLabsHQ/fix/issue-141-duplicate-issues-on-sync
fix: add metadata field to repositories table to prevent duplicate issues on sync
2025-10-31 08:51:34 +05:30
Arunavo Ray
d59a07a8c5 fix: add metadata field to repositories table to prevent duplicate issues on sync
Fixes #141

The repository metadata field was missing from the database schema, which
caused the metadata sync state (issues, PRs, releases, etc.) to not persist.
This resulted in duplicate issues being created every time a repository was
synced because the system couldn't track what had already been mirrored.

Changes:
- Added metadata text field to repositories table in schema
- Added metadata field to repositorySchema Zod validation
- Generated database migration 0008_serious_thena.sql

Root cause analysis:
1. Code tried to read/write repository.metadata to track mirrored components
2. The metadata field didn't exist in the database schema
3. On sync, metadataState.components.issues was always false
4. This triggered re-mirroring of all issues, creating duplicates

The fix ensures metadata state persists between mirrors and syncs, preventing
duplicate metadata (issues, PRs, releases) from being created in Gitea.
2025-10-30 10:58:48 +05:30
Arunavo Ray
5a77ae5084 v3.8.10 2025-10-30 10:54:56 +05:30
ARUNAVO RAY
dcb5bd80e3 Merge pull request #138 from RayLabsHQ/issue-132-org-repo-duplicates 2025-10-30 07:11:34 +05:30
Arunavo Ray
3b8fc99f06 workaround to get rid of unknown/unknown in OS arch 2025-10-29 22:01:40 +05:30
Arunavo Ray
bda8d10f10 ci: build arm64 images in PR pipeline 2025-10-29 21:51:37 +05:30
Arunavo Ray
0fe7b433d6 added missing hero_logo.png 2025-10-27 19:43:00 +05:30
Arunavo Ray
8d96e176b4 fix: prevent duplicate orgs and repos 2025-10-27 08:44:45 +05:30
Arunavo Ray
af9bc861cf fixed: Sort order in releases #129 2025-10-27 07:54:38 +05:30
ARUNAVO RAY
ab4bbea9fd Merge pull request #136 from RayLabsHQ/fix/metadata-sync-config-change
fix: sync metadata after config toggles
2025-10-27 07:45:12 +05:30
ARUNAVO RAY
fbd4b3739e Merge pull request #137 from RayLabsHQ/docs/authentik-oidc-notes
Added basic docs on SSO/OIDC
2025-10-26 19:54:53 +05:30
Arunavo Ray
395e71164f Added basic docs on SSO/OIDC 2025-10-26 19:52:44 +05:30
Arunavo Ray
3bf0ccf207 fix: sync metadata after config toggles 2025-10-26 08:41:28 +05:30
33 changed files with 5593 additions and 226 deletions

View File

@@ -101,26 +101,30 @@ jobs:
# Build and push Docker image
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
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)
- name: Load image for scanning
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
load: true
tags: gitea-mirror:scan
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
- name: Wait for image availability
@@ -169,8 +173,8 @@ jobs:
- 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.
> Production images (\`latest\`, version tags) are multi-platform (\`linux/amd64\`, \`linux/arm64\`).
> 💡 **Note:** PR images are tagged as \`pr-<number>\` and built for both \`linux/amd64\` and \`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 File

@@ -326,6 +326,8 @@ Enable users to sign in with external identity providers like Google, Azure AD,
https://your-domain.com/api/auth/sso/callback/{provider-id}
```
Need help? The [SSO & OIDC guide](docs/SSO-OIDC-SETUP.md) now includes a working Authentik walkthrough plus troubleshooting tips. If you upgraded from a version earlier than v3.8.10 and see `TypeError … url.startsWith` after the callback, delete the old provider and add it again using the Discover button (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122)).
### 3. Header Authentication (Reverse Proxy)
Perfect for automatic authentication when using reverse proxies like Authentik, Authelia, or Traefik Forward Auth.

View File

@@ -81,6 +81,26 @@ Replace `{provider-id}` with your chosen Provider ID.
- Client Secret: [Your Okta Client Secret]
- Click "Discover" to auto-fill endpoints
### Example: Authentik SSO Setup
Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mirror/issues/134)) follow these steps:
1. In Authentik, create a new **Application** and OIDC **Provider** (implicit flow works well for testing).
2. Start creating an SSO provider inside Gitea Mirror so you can copy the redirect URL shown (`https://your-domain.com/api/auth/sso/callback/authentik` if you pick `authentik` as your Provider ID).
3. Paste that redirect URL into the Authentik Provider configuration and finish creating the provider.
4. Copy the Authentik issuer URL, client ID, and client secret.
5. Back in Gitea Mirror:
- Issuer URL: the exact value from Authentik (keep any trailing slash Authentik shows).
- Provider ID: match the one you used in step 2.
- Click **Discover** so Gitea Mirror stores the authorization, token, and JWKS endpoints (Authentik publishes them via discovery).
- Domain: enter the email domain you expect to match (e.g. `example.com`).
6. Save the provider and test the login flow.
Notes:
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
- Authentik must report the users email as verified (default behavior) so Gitea Mirror can auto-link accounts.
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
## Setting up OIDC Provider
The OIDC Provider feature allows other applications to use Gitea Mirror as their authentication provider.
@@ -165,6 +185,7 @@ When an application requests authentication:
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
### OIDC Provider Issues

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`);

View File

@@ -0,0 +1 @@
ALTER TABLE `repositories` ADD `metadata` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "3.8.10",
"version": "3.8.11",
"engines": {
"bun": ">=1.2.9"
},

View File

@@ -114,10 +114,10 @@ EOF
echo "======================================"
echo "1. Access Authentik at http://localhost:9000"
echo "2. Login with akadmin / admin-password"
echo "3. Create OAuth2 Provider for Gitea Mirror:"
echo "3. Create an Authentik OIDC Provider for Gitea Mirror:"
echo " - Name: gitea-mirror"
echo " - Redirect URIs:"
echo " http://localhost:4321/api/auth/callback/sso-provider"
echo " - Redirect URI:"
echo " http://localhost:4321/api/auth/sso/callback/authentik"
echo " - Scopes: openid, profile, email"
echo ""
echo "4. Create Application:"
@@ -131,10 +131,14 @@ EOF
echo "6. Configure SSO in Gitea Mirror:"
echo " - Go to Settings → Authentication & SSO"
echo " - Add provider with:"
echo " - Provider ID: authentik"
echo " - Issuer URL: http://localhost:9000/application/o/gitea-mirror/"
echo " - Click Discover to pull Authentik endpoints"
echo " - Client ID: (from Authentik provider)"
echo " - Client Secret: (from Authentik provider)"
echo ""
echo "If you previously registered this provider on a version earlier than v3.8.10, delete it and re-add it after upgrading to avoid missing endpoint data."
echo ""
;;
stop)

View File

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

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
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 { OrganizationList } from "./OrganizationsList";
import AddOrganizationDialog from "./AddOrganizationDialog";
@@ -37,6 +37,14 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
export function Organization() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -52,6 +60,15 @@ export function Organization() {
status: "",
});
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
const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -256,19 +273,45 @@ export function Organization() {
const handleAddOrganization = async ({
org,
role,
force = false,
}: {
org: string;
role: MembershipRole;
force?: boolean;
}) => {
try {
if (!user || !user.id) {
return;
if (!user || !user.id) {
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 = {
userId: user.id,
org,
org: trimmedOrg,
role,
force,
};
const response = await apiRequest<AddOrganizationApiResponse>(
@@ -280,25 +323,100 @@ export function Organization() {
);
if (response.success) {
toast.success(`Organization added successfully`);
setOrganizations((prev) => [...prev, response.organization]);
const message = force
? "Organization already exists; using existing entry."
: "Organization added successfully";
toast.success(message);
await fetchOrganizations();
await fetchOrganizations(false);
setFilter((prev) => ({
...prev,
searchTerm: org,
searchTerm: trimmedOrg,
}));
if (force) {
setIsDuplicateOrgDialogOpen(false);
setDuplicateOrgCandidate(null);
}
} else {
showErrorToast(response.error || "Error adding organization", toast);
}
} catch (error) {
showErrorToast(error, toast);
throw error;
} finally {
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 () => {
try {
if (!user || !user.id || organizations.length === 0) {
@@ -711,6 +829,7 @@ export function Organization() {
onMirror={handleMirrorOrg}
onIgnore={handleIgnoreOrg}
onAddOrganization={() => setIsDialogOpen(true)}
onDelete={handleRequestDeleteOrganization}
onRefresh={async () => {
await fetchOrganizations(false);
}}
@@ -721,6 +840,68 @@ export function Organization() {
isDialogOpen={isDialogOpen}
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>
);
}

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import {
SelectValue,
} from "../ui/select";
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 {
Drawer,
@@ -30,6 +30,14 @@ import {
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useSSE } from "@/hooks/useSEE";
import { useFilterParams } from "@/hooks/useFilterParams";
import { toast } from "sonner";
@@ -69,6 +77,15 @@ export default function Repository() {
}, [setFilter]);
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
const handleNewMessage = useCallback((data: MirrorJob) => {
@@ -618,19 +635,45 @@ export default function Repository() {
const handleAddRepository = async ({
repo,
owner,
force = false,
}: {
repo: string;
owner: string;
force?: boolean;
}) => {
try {
if (!user || !user.id) {
return;
}
if (!user || !user.id) {
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 = {
userId: user.id,
repo,
owner,
repo: trimmedRepo,
owner: trimmedOwner,
force,
};
const response = await apiRequest<AddRepositoriesApiResponse>(
@@ -642,20 +685,28 @@ export default function Repository() {
);
if (response.success) {
toast.success(`Repository added successfully`);
setRepositories((prevRepos) => [...prevRepos, response.repository]);
const message = force
? "Repository already exists; metadata refreshed."
: "Repository added successfully";
toast.success(message);
await fetchRepositories(false); // Manual refresh after adding repository
await fetchRepositories(false);
setFilter((prev) => ({
...prev,
searchTerm: repo,
searchTerm: trimmedRepo,
}));
if (force) {
setDuplicateRepoCandidate(null);
setIsDuplicateRepoDialogOpen(false);
}
} else {
showErrorToast(response.error || "Error adding repository", toast);
}
} catch (error) {
showErrorToast(error, toast);
throw error;
}
};
@@ -673,6 +724,71 @@ export default function Repository() {
)
).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
const getAvailableActions = () => {
if (selectedRepoIds.size === 0) return [];
@@ -1198,6 +1314,7 @@ export default function Repository() {
onRefresh={async () => {
await fetchRepositories(false);
}}
onDelete={handleRequestDeleteRepository}
/>
)}
@@ -1206,6 +1323,77 @@ export default function Repository() {
isDialogOpen={isDialogOpen}
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>
);
}

View File

@@ -1,7 +1,7 @@
import { useMemo, useRef } from "react";
import Fuse from "fuse.js";
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 type { Repository } from "@/lib/db/schema";
import { Button } from "@/components/ui/button";
@@ -23,6 +23,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -40,6 +41,7 @@ interface RepositoryTableProps {
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
onRefresh?: () => Promise<void>;
onDelete?: (repoId: string) => void;
}
export default function RepositoryTable({
@@ -56,6 +58,7 @@ export default function RepositoryTable({
selectedRepoIds,
onSelectionChange,
onRefresh,
onDelete,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig();
@@ -676,6 +679,7 @@ export default function RepositoryTable({
onSync={() => onSync({ repoId: repo.id ?? "" })}
onRetry={() => onRetry({ repoId: repo.id ?? "" })}
onSkip={(skip) => onSkip({ repoId: repo.id ?? "", skip })}
onDelete={onDelete && repo.id ? () => onDelete(repo.id as string) : undefined}
/>
</div>
{/* Links */}
@@ -786,6 +790,7 @@ function RepoActionButton({
onSync,
onRetry,
onSkip,
onDelete,
}: {
repo: { id: string; status: string };
isLoading: boolean;
@@ -793,6 +798,7 @@ function RepoActionButton({
onSync: () => void;
onRetry: () => void;
onSkip: (skip: boolean) => void;
onDelete?: () => void;
}) {
// For ignored repos, show an "Include" action
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 (
<DropdownMenu>
<div className="flex">
@@ -886,6 +892,18 @@ function RepoActionButton({
<Ban className="h-4 w-4 mr-2" />
Ignore Repository
</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>
</DropdownMenu>
);

View File

@@ -127,6 +127,7 @@ export const repositorySchema = z.object({
configId: z.string(),
name: z.string(),
fullName: z.string(),
normalizedFullName: z.string(),
url: z.url(),
cloneUrl: z.url(),
owner: z.string(),
@@ -163,6 +164,7 @@ export const repositorySchema = z.object({
lastMirrored: z.coerce.date().optional().nullable(),
errorMessage: z.string().optional().nullable(),
destinationOrg: z.string().optional().nullable(),
metadata: z.string().optional().nullable(), // JSON string for metadata sync state
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
@@ -209,6 +211,7 @@ export const organizationSchema = z.object({
userId: z.string(),
configId: z.string(),
name: z.string(),
normalizedName: z.string(),
avatarUrl: z.string(),
membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"),
isIncluded: z.boolean().default(true),
@@ -334,6 +337,7 @@ export const repositories = sqliteTable("repositories", {
.references(() => configs.id),
name: text("name").notNull(),
fullName: text("full_name").notNull(),
normalizedFullName: text("normalized_full_name").notNull(),
url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(),
@@ -373,6 +377,8 @@ export const repositories = sqliteTable("repositories", {
destinationOrg: text("destination_org"),
metadata: text("metadata"), // JSON string storing metadata sync state (issues, PRs, releases, etc.)
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
@@ -388,6 +394,7 @@ export const repositories = sqliteTable("repositories", {
index("idx_repositories_is_fork").on(table.isForked),
index("idx_repositories_is_starred").on(table.isStarred),
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", {
@@ -438,6 +445,7 @@ export const organizations = sqliteTable("organizations", {
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
normalizedName: text("normalized_name").notNull(),
avatarUrl: text("avatar_url").notNull(),
@@ -469,6 +477,7 @@ export const organizations = sqliteTable("organizations", {
index("idx_organizations_config_id").on(table.configId),
index("idx_organizations_status").on(table.status),
index("idx_organizations_is_included").on(table.isIncluded),
uniqueIndex("uniq_organizations_user_normalized_name").on(table.userId, table.normalizedName),
]);
// ===== Better Auth Tables =====

View File

@@ -8,6 +8,10 @@ mock.module("@/lib/helpers", () => ({
}));
const mockMirrorGitHubReleasesToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoIssuesToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoPullRequestsToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoLabelsToGitea = mock(() => Promise.resolve());
const mockMirrorGitRepoMilestonesToGitea = mock(() => Promise.resolve());
const mockGetGiteaRepoOwnerAsync = mock(() => Promise.resolve("starred"));
// Mock the database module
@@ -128,6 +132,36 @@ const mockHttpGet = mock(async (url: string, headers?: any) => {
headers: new Headers()
};
}
if (url.includes("/api/v1/repos/starred/metadata-repo")) {
return {
data: {
id: 790,
name: "metadata-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "8h",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
if (url.includes("/api/v1/repos/starred/already-synced-repo")) {
return {
data: {
id: 791,
name: "already-synced-repo",
mirror: true,
owner: { login: "starred" },
mirror_interval: "8h",
private: false,
},
status: 200,
statusText: "OK",
headers: new Headers(),
};
}
if (url.includes("/api/v1/repos/")) {
throw new MockHttpError("Not Found", 404, "Not Found");
}
@@ -224,6 +258,10 @@ describe("Enhanced Gitea Operations", () => {
mockDb.insert.mockClear();
mockDb.update.mockClear();
mockMirrorGitHubReleasesToGitea.mockClear();
mockMirrorGitRepoIssuesToGitea.mockClear();
mockMirrorGitRepoPullRequestsToGitea.mockClear();
mockMirrorGitRepoLabelsToGitea.mockClear();
mockMirrorGitRepoMilestonesToGitea.mockClear();
mockGetGiteaRepoOwnerAsync.mockClear();
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("starred"));
// Reset tracking variables
@@ -426,6 +464,10 @@ describe("Enhanced Gitea Operations", () => {
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
)
).rejects.toThrow("Repository non-mirror-repo is not a mirror. Cannot sync.");
@@ -470,6 +512,10 @@ describe("Enhanced Gitea Operations", () => {
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
@@ -482,6 +528,130 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.config.githubConfig?.token).toBe("github-token");
expect(releaseCall.octokit).toBeDefined();
});
test("mirrors metadata components when enabled and not previously synced", async () => {
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: true,
mirrorStarred: false,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: true,
mirrorMetadata: true,
mirrorIssues: true,
mirrorPullRequests: true,
mirrorLabels: true,
mirrorMilestones: true,
},
};
const repository: Repository = {
id: "repo789",
name: "metadata-repo",
fullName: "user/metadata-repo",
owner: "user",
cloneUrl: "https://github.com/user/metadata-repo.git",
isPrivate: false,
isStarred: false,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
metadata: null,
};
await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(mockMirrorGitHubReleasesToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoIssuesToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoPullRequestsToGitea).toHaveBeenCalledTimes(1);
expect(mockMirrorGitRepoMilestonesToGitea).toHaveBeenCalledTimes(1);
// Labels should be skipped because issues already import them
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
});
test("skips metadata mirroring when components already synced", async () => {
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: true,
mirrorStarred: false,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: false,
mirrorMetadata: true,
mirrorIssues: true,
mirrorPullRequests: true,
mirrorLabels: true,
mirrorMilestones: true,
},
};
const repository: Repository = {
id: "repo790",
name: "already-synced-repo",
fullName: "user/already-synced-repo",
owner: "user",
cloneUrl: "https://github.com/user/already-synced-repo.git",
isPrivate: false,
isStarred: false,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
createdAt: new Date(),
updatedAt: new Date(),
metadata: JSON.stringify({
components: {
releases: true,
issues: true,
pullRequests: true,
labels: true,
milestones: true,
},
lastSyncedAt: new Date().toISOString(),
}),
};
await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(mockMirrorGitHubReleasesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoIssuesToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoPullRequestsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoLabelsToGitea).not.toHaveBeenCalled();
expect(mockMirrorGitRepoMilestonesToGitea).not.toHaveBeenCalled();
});
});
describe("handleExistingNonMirrorRepo", () => {

View File

@@ -15,10 +15,18 @@ import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
import { db, repositories } from "./db";
import { eq } from "drizzle-orm";
import { repoStatusEnum } from "@/types/Repository";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
} from "./metadata-state";
type SyncDependencies = {
getGiteaRepoOwnerAsync: typeof import("./gitea")["getGiteaRepoOwnerAsync"];
mirrorGitHubReleasesToGitea: typeof import("./gitea")["mirrorGitHubReleasesToGitea"];
mirrorGitRepoIssuesToGitea: typeof import("./gitea")["mirrorGitRepoIssuesToGitea"];
mirrorGitRepoPullRequestsToGitea: typeof import("./gitea")["mirrorGitRepoPullRequestsToGitea"];
mirrorGitRepoLabelsToGitea: typeof import("./gitea")["mirrorGitRepoLabelsToGitea"];
mirrorGitRepoMilestonesToGitea: typeof import("./gitea")["mirrorGitRepoMilestonesToGitea"];
};
/**
@@ -330,36 +338,236 @@ export async function syncGiteaRepoEnhanced({
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
});
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
let metadataOctokit: Octokit | null = null;
const ensureOctokit = (): Octokit | null => {
if (metadataOctokit) {
return metadataOctokit;
}
if (!decryptedConfig.githubConfig?.token) {
return null;
}
metadataOctokit = new Octokit({
auth: decryptedConfig.githubConfig.token,
});
return metadataOctokit;
};
const shouldMirrorReleases =
decryptedConfig.giteaConfig?.mirrorReleases &&
!(repository.isStarred && decryptedConfig.githubConfig?.starredCodeOnly);
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
if (shouldMirrorReleases) {
if (!decryptedConfig.githubConfig?.token) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping release mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
const octokit = new Octokit({ auth: decryptedConfig.githubConfig.token });
await dependencies.mirrorGitHubReleasesToGitea({
config: decryptedConfig,
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
});
console.log(`[Sync] Mirrored releases for ${repository.name} after sync`);
metadataState.components.releases = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored releases for ${repository.name} after sync`
);
} catch (releaseError) {
console.error(
`[Sync] Failed to mirror releases for ${repository.name}: ${
releaseError instanceof Error ? releaseError.message : String(releaseError)
releaseError instanceof Error
? releaseError.message
: String(releaseError)
}`
);
}
}
}
if (shouldMirrorIssuesThisRun) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping issue mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
await dependencies.mirrorGitRepoIssuesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
});
metadataState.components.issues = true;
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored issues for ${repository.name} after sync`
);
} catch (issueError) {
console.error(
`[Sync] Failed to mirror issues for ${repository.name}: ${
issueError instanceof Error
? issueError.message
: String(issueError)
}`
);
}
}
} else if (
config.giteaConfig?.mirrorIssues &&
metadataState.components.issues
) {
console.log(
`[Sync] Issues already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorPullRequests) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping pull request mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
await dependencies.mirrorGitRepoPullRequestsToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
});
metadataState.components.pullRequests = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored pull requests for ${repository.name} after sync`
);
} catch (prError) {
console.error(
`[Sync] Failed to mirror pull requests for ${repository.name}: ${
prError instanceof Error ? prError.message : String(prError)
}`
);
}
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Sync] Pull requests already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorLabels) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping label mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
await dependencies.mirrorGitRepoLabelsToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
});
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored labels for ${repository.name} after sync`
);
} catch (labelError) {
console.error(
`[Sync] Failed to mirror labels for ${repository.name}: ${
labelError instanceof Error
? labelError.message
: String(labelError)
}`
);
}
}
} else if (
config.giteaConfig?.mirrorLabels &&
metadataState.components.labels
) {
console.log(
`[Sync] Labels already mirrored for ${repository.name}; skipping`
);
}
if (shouldMirrorMilestones) {
const octokit = ensureOctokit();
if (!octokit) {
console.warn(
`[Sync] Skipping milestone mirroring for ${repository.name}: Missing GitHub token`
);
} else {
try {
await dependencies.mirrorGitRepoMilestonesToGitea({
config,
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
});
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Sync] Mirrored milestones for ${repository.name} after sync`
);
} catch (milestoneError) {
console.error(
`[Sync] Failed to mirror milestones for ${repository.name}: ${
milestoneError instanceof Error
? milestoneError.message
: String(milestoneError)
}`
);
}
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Sync] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
// Mark repo as "synced" in DB
await db
.update(repositories)
@@ -369,6 +577,9 @@ export async function syncGiteaRepoEnhanced({
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${repository.name}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null,
})
.where(eq(repositories.id, repository.id!));

View File

@@ -13,6 +13,10 @@ import { db, organizations, repositories } from "./db";
import { eq, and } from "drizzle-orm";
import { decryptConfigTokens } from "./utils/config-encryption";
import { formatDateShort } from "./utils";
import {
parseRepositoryMetadataState,
serializeRepositoryMetadataState,
} from "./metadata-state";
/**
* Helper function to get organization configuration including destination override
@@ -587,12 +591,18 @@ export const mirrorGithubRepoToGitea = async ({
}
);
//mirror releases
// Skip releases for starred repos if starredCodeOnly is enabled
const shouldMirrorReleases = config.giteaConfig?.mirrorReleases &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`);
// Mirror releases if enabled (always allowed to rerun for updates)
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
console.log(
`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
);
if (shouldMirrorReleases) {
try {
@@ -603,21 +613,32 @@ export const mirrorGithubRepoToGitea = async ({
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`);
metadataState.components.releases = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored releases for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror releases for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror releases for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other operations even if releases fail
}
}
// clone issues
// Skip issues for starred repos if starredCodeOnly is enabled
const shouldMirrorIssues = config.giteaConfig?.mirrorIssues &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
// Determine metadata operations to avoid duplicates
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssues}`);
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssues) {
if (shouldMirrorIssuesThisRun) {
try {
await mirrorGitRepoIssuesToGitea({
config,
@@ -626,19 +647,34 @@ export const mirrorGithubRepoToGitea = async ({
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored issues for ${repository.name}`);
metadataState.components.issues = true;
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored issues for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror issues for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror issues for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if issues fail
}
} else if (config.giteaConfig?.mirrorIssues && metadataState.components.issues) {
console.log(
`[Metadata] Issues already mirrored for ${repository.name}; skipping to avoid duplicates`
);
}
// Mirror pull requests if enabled
// Skip pull requests for starred repos if starredCodeOnly is enabled
const shouldMirrorPullRequests = config.giteaConfig?.mirrorPullRequests &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`);
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
try {
@@ -649,19 +685,37 @@ export const mirrorGithubRepoToGitea = async ({
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name}`);
metadataState.components.pullRequests = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored pull requests for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror pull requests for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror pull requests for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if PRs fail
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Metadata] Pull requests already mirrored for ${repository.name}; skipping`
);
}
// Mirror labels if enabled (and not already done via issues)
// Skip labels for starred repos if starredCodeOnly is enabled
const shouldMirrorLabels = config.giteaConfig?.mirrorLabels && !shouldMirrorIssues &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`);
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
try {
@@ -672,19 +726,33 @@ export const mirrorGithubRepoToGitea = async ({
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored labels for ${repository.name}`);
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored labels for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror labels for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror labels for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if labels fail
}
} else if (config.giteaConfig?.mirrorLabels && metadataState.components.labels) {
console.log(
`[Metadata] Labels already mirrored for ${repository.name}; skipping`
);
}
// Mirror milestones if enabled
// Skip milestones for starred repos if starredCodeOnly is enabled
const shouldMirrorMilestones = config.giteaConfig?.mirrorMilestones &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`);
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
@@ -695,11 +763,30 @@ export const mirrorGithubRepoToGitea = async ({
giteaOwner: repoOwner,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored milestones for ${repository.name}`);
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror milestones for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Metadata] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
console.log(`Repository ${repository.name} mirrored successfully as ${targetRepoName}`);
@@ -713,6 +800,9 @@ export const mirrorGithubRepoToGitea = async ({
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${targetRepoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null,
})
.where(eq(repositories.id, repository.id!));
@@ -1053,12 +1143,17 @@ export async function mirrorGitHubRepoToGiteaOrg({
}
);
//mirror releases
// Skip releases for starred repos if starredCodeOnly is enabled
const shouldMirrorReleases = config.giteaConfig?.mirrorReleases &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const metadataState = parseRepositoryMetadataState(repository.metadata);
let metadataUpdated = false;
const skipMetadataForStarred =
repository.isStarred && config.githubConfig?.starredCodeOnly;
console.log(`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`);
const shouldMirrorReleases =
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
console.log(
`[Metadata] Release mirroring check: mirrorReleases=${config.giteaConfig?.mirrorReleases}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorReleases=${shouldMirrorReleases}`
);
if (shouldMirrorReleases) {
try {
@@ -1069,21 +1164,31 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored releases for ${repository.name}`);
metadataState.components.releases = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored releases for ${repository.name}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror releases for ${repository.name}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror releases for ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other operations even if releases fail
}
}
// Clone issues
// Skip issues for starred repos if starredCodeOnly is enabled
const shouldMirrorIssues = config.giteaConfig?.mirrorIssues &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorIssuesThisRun =
!!config.giteaConfig?.mirrorIssues &&
!skipMetadataForStarred &&
!metadataState.components.issues;
console.log(`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssues}`);
console.log(
`[Metadata] Issue mirroring check: mirrorIssues=${config.giteaConfig?.mirrorIssues}, alreadyMirrored=${metadataState.components.issues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorIssues=${shouldMirrorIssuesThisRun}`
);
if (shouldMirrorIssues) {
if (shouldMirrorIssuesThisRun) {
try {
await mirrorGitRepoIssuesToGitea({
config,
@@ -1092,19 +1197,37 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}/${targetRepoName}`);
metadataState.components.issues = true;
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror issues for ${repository.name} to org ${orgName}/${targetRepoName}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror issues for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if issues fail
}
} else if (
config.giteaConfig?.mirrorIssues &&
metadataState.components.issues
) {
console.log(
`[Metadata] Issues already mirrored for ${repository.name}; skipping`
);
}
// Mirror pull requests if enabled
// Skip pull requests for starred repos if starredCodeOnly is enabled
const shouldMirrorPullRequests = config.giteaConfig?.mirrorPullRequests &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorPullRequests =
!!config.giteaConfig?.mirrorPullRequests &&
!skipMetadataForStarred &&
!metadataState.components.pullRequests;
console.log(`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`);
console.log(
`[Metadata] Pull request mirroring check: mirrorPullRequests=${config.giteaConfig?.mirrorPullRequests}, alreadyMirrored=${metadataState.components.pullRequests}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorPullRequests=${shouldMirrorPullRequests}`
);
if (shouldMirrorPullRequests) {
try {
@@ -1115,19 +1238,37 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}/${targetRepoName}`);
metadataState.components.pullRequests = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror pull requests for ${repository.name} to org ${orgName}/${targetRepoName}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror pull requests for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if PRs fail
}
} else if (
config.giteaConfig?.mirrorPullRequests &&
metadataState.components.pullRequests
) {
console.log(
`[Metadata] Pull requests already mirrored for ${repository.name}; skipping`
);
}
// Mirror labels if enabled (and not already done via issues)
// Skip labels for starred repos if starredCodeOnly is enabled
const shouldMirrorLabels = config.giteaConfig?.mirrorLabels && !shouldMirrorIssues &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorLabels =
!!config.giteaConfig?.mirrorLabels &&
!skipMetadataForStarred &&
!shouldMirrorIssuesThisRun &&
!metadataState.components.labels;
console.log(`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, shouldMirrorIssues=${shouldMirrorIssues}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`);
console.log(
`[Metadata] Label mirroring check: mirrorLabels=${config.giteaConfig?.mirrorLabels}, alreadyMirrored=${metadataState.components.labels}, issuesRunning=${shouldMirrorIssuesThisRun}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorLabels=${shouldMirrorLabels}`
);
if (shouldMirrorLabels) {
try {
@@ -1138,19 +1279,36 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}/${targetRepoName}`);
metadataState.components.labels = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror labels for ${repository.name} to org ${orgName}/${targetRepoName}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror labels for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if labels fail
}
} else if (
config.giteaConfig?.mirrorLabels &&
metadataState.components.labels
) {
console.log(
`[Metadata] Labels already mirrored for ${repository.name}; skipping`
);
}
// Mirror milestones if enabled
// Skip milestones for starred repos if starredCodeOnly is enabled
const shouldMirrorMilestones = config.giteaConfig?.mirrorMilestones &&
!(repository.isStarred && config.githubConfig?.starredCodeOnly);
const shouldMirrorMilestones =
!!config.giteaConfig?.mirrorMilestones &&
!skipMetadataForStarred &&
!metadataState.components.milestones;
console.log(`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`);
console.log(
`[Metadata] Milestone mirroring check: mirrorMilestones=${config.giteaConfig?.mirrorMilestones}, alreadyMirrored=${metadataState.components.milestones}, isStarred=${repository.isStarred}, starredCodeOnly=${config.githubConfig?.starredCodeOnly}, shouldMirrorMilestones=${shouldMirrorMilestones}`
);
if (shouldMirrorMilestones) {
try {
@@ -1161,11 +1319,30 @@ export async function mirrorGitHubRepoToGiteaOrg({
giteaOwner: orgName,
giteaRepoName: targetRepoName,
});
console.log(`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}`);
metadataState.components.milestones = true;
metadataUpdated = true;
console.log(
`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}/${targetRepoName}`
);
} catch (error) {
console.error(`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${error instanceof Error ? error.message : String(error)}`);
console.error(
`[Metadata] Failed to mirror milestones for ${repository.name} to org ${orgName}/${targetRepoName}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Continue with other metadata operations even if milestones fail
}
} else if (
config.giteaConfig?.mirrorMilestones &&
metadataState.components.milestones
) {
console.log(
`[Metadata] Milestones already mirrored for ${repository.name}; skipping`
);
}
if (metadataUpdated) {
metadataState.lastSyncedAt = new Date().toISOString();
}
console.log(
@@ -1181,6 +1358,9 @@ export async function mirrorGitHubRepoToGiteaOrg({
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${orgName}/${targetRepoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null,
})
.where(eq(repositories.id, repository.id!));
@@ -1812,12 +1992,20 @@ export async function mirrorGitHubReleasesToGitea({
let mirroredCount = 0;
let skippedCount = 0;
// Sort releases by created_at to ensure we get the most recent ones
const sortedReleases = releases.data.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
).slice(0, releaseLimit);
const getReleaseTimestamp = (release: typeof releases.data[number]) => {
const sourceDate = release.created_at ?? release.published_at ?? "";
const timestamp = sourceDate ? new Date(sourceDate).getTime() : 0;
return Number.isFinite(timestamp) ? timestamp : 0;
};
for (const release of sortedReleases) {
// Capture the latest releases, then process them oldest-to-newest so Gitea mirrors keep chronological order
const releasesToProcess = releases.data
.slice()
.sort((a, b) => getReleaseTimestamp(b) - getReleaseTimestamp(a))
.slice(0, releaseLimit)
.sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b));
for (const release of releasesToProcess) {
try {
// Check if release already exists
const existingReleasesResponse = await httpGet(

75
src/lib/metadata-state.ts Normal file
View File

@@ -0,0 +1,75 @@
interface MetadataComponentsState {
releases: boolean;
issues: boolean;
pullRequests: boolean;
labels: boolean;
milestones: boolean;
}
export interface RepositoryMetadataState {
components: MetadataComponentsState;
lastSyncedAt?: string;
}
const defaultComponents: MetadataComponentsState = {
releases: false,
issues: false,
pullRequests: false,
labels: false,
milestones: false,
};
export function createDefaultMetadataState(): RepositoryMetadataState {
return {
components: { ...defaultComponents },
};
}
export function parseRepositoryMetadataState(
raw: unknown
): RepositoryMetadataState {
const base = createDefaultMetadataState();
if (!raw) {
return base;
}
let parsed: any = raw;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
return base;
}
}
if (!parsed || typeof parsed !== "object") {
return base;
}
if (parsed.components && typeof parsed.components === "object") {
base.components = {
...base.components,
releases: Boolean(parsed.components.releases),
issues: Boolean(parsed.components.issues),
pullRequests: Boolean(parsed.components.pullRequests),
labels: Boolean(parsed.components.labels),
milestones: Boolean(parsed.components.milestones),
};
}
if (typeof parsed.lastSyncedAt === "string") {
base.lastSyncedAt = parsed.lastSyncedAt;
} else if (typeof parsed.lastMetadataSync === "string") {
base.lastSyncedAt = parsed.lastMetadataSync;
}
return base;
}
export function serializeRepositoryMetadataState(
state: RepositoryMetadataState
): string {
return JSON.stringify(state);
}

View File

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

View File

@@ -33,6 +33,7 @@ export function normalizeGitRepoToInsert(
configId,
name: repo.name,
fullName: repo.fullName,
normalizedFullName: repo.fullName.toLowerCase(),
url: repo.url,
cloneUrl: repo.cloneUrl,
owner: repo.owner,
@@ -68,4 +69,3 @@ export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): nu
const effectiveMax = Math.max(1, maxParams - safety);
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
const existingRepos = await db
.select({ fullName: repositories.fullName })
.select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories)
.where(eq(repositories.userId, userId));
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const newRepos = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (newRepos.length > 0) {
console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`);
@@ -123,7 +123,7 @@ async function runScheduledSync(config: any): Promise<void> {
await db
.insert(repositories)
.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}`);
} else {
@@ -432,12 +432,12 @@ async function performInitialAutoStart(): Promise<void> {
// Check for new repositories
const existingRepos = await db
.select({ fullName: repositories.fullName })
.select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories)
.where(eq(repositories.userId, config.userId));
const existingRepoNames = new Set(existingRepos.map(r => r.fullName));
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName));
const existingRepoNames = new Set(existingRepos.map(r => r.normalizedFullName));
const reposToImport = allGithubRepos.filter(r => !existingRepoNames.has(r.fullName.toLowerCase()));
if (reposToImport.length > 0) {
console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`);
@@ -456,7 +456,7 @@ async function performInitialAutoStart(): Promise<void> {
await db
.insert(repositories)
.values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
.onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
}
console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`);
} else {

View File

@@ -1,5 +1,5 @@
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 { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
@@ -61,3 +61,60 @@ export const PATCH: APIRoute = async (context) => {
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 { db, repositories } from "@/lib/db";
import { db, repositories, mirrorJobs } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import { requireAuth } from "@/lib/utils/auth-helpers";
@@ -61,3 +61,54 @@ export const PATCH: APIRoute = async (context) => {
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,
name: repo.name,
fullName: repo.fullName,
normalizedFullName: repo.fullName.toLowerCase(),
url: repo.url,
cloneUrl: repo.cloneUrl,
owner: repo.owner,
@@ -97,6 +98,7 @@ export const POST: APIRoute = async ({ request }) => {
userId,
configId: config.id,
name: org.name,
normalizedName: org.name.toLowerCase(),
avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole,
isIncluded: false,
@@ -113,22 +115,22 @@ export const POST: APIRoute = async ({ request }) => {
await db.transaction(async (tx) => {
const [existingRepos, existingOrgs] = await Promise.all([
tx
.select({ fullName: repositories.fullName })
.select({ normalizedFullName: repositories.normalizedFullName })
.from(repositories)
.where(eq(repositories.userId, userId)),
tx
.select({ name: organizations.name })
.select({ normalizedName: organizations.normalizedName })
.from(organizations)
.where(eq(organizations.userId, userId)),
]);
const existingRepoNames = new Set(existingRepos.map((r) => r.fullName));
const existingOrgNames = new Set(existingOrgs.map((o) => o.name));
const existingRepoNames = new Set(existingRepos.map((r) => r.normalizedFullName));
const existingOrgNames = new Set(existingOrgs.map((o) => o.normalizedName));
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)
const sample = newRepos[0];
@@ -140,7 +142,7 @@ export const POST: APIRoute = async ({ request }) => {
await tx
.insert(repositories)
.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 { Octokit } from "@octokit/rest";
import { configs, db, organizations, repositories } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
@@ -15,7 +14,7 @@ import { createGitHubClient } from "@/lib/github";
export const POST: APIRoute = async ({ request }) => {
try {
const body: AddOrganizationApiRequest = await request.json();
const { role, org, userId } = body;
const { role, org, userId, force = false } = body;
if (!org || !userId || !role) {
return jsonResponse({
@@ -24,21 +23,58 @@ export const POST: APIRoute = async ({ request }) => {
});
}
// Check if org already exists
const existingOrg = await db
const trimmedOrg = org.trim();
const normalizedOrg = trimmedOrg.toLowerCase();
// Check if org already exists (case-insensitive)
const [existingOrg] = await db
.select()
.from(organizations)
.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({
data: {
success: false,
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
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
const { data: orgData } = await octokit.orgs.get({ org });
const { data: orgData } = await octokit.orgs.get({ org: trimmedOrg });
// Fetch repos based on config settings
const allRepos = [];
// Fetch all repos (public, private, and member) to show in UI
const publicRepos = await octokit.paginate(octokit.repos.listForOrg, {
org,
org: trimmedOrg,
type: "public",
per_page: 100,
});
@@ -89,7 +129,7 @@ export const POST: APIRoute = async ({ request }) => {
// Always fetch private repos to show them in the UI
const privateRepos = await octokit.paginate(octokit.repos.listForOrg, {
org,
org: trimmedOrg,
type: "private",
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)
const memberRepos = await octokit.paginate(octokit.repos.listForOrg, {
org,
org: trimmedOrg,
type: "member",
per_page: 100,
});
@@ -107,38 +147,44 @@ export const POST: APIRoute = async ({ request }) => {
allRepos.push(...uniqueMemberRepos);
// Insert repositories
const repoRecords = allRepos.map((repo) => ({
id: uuidv4(),
userId,
configId,
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url ?? "",
owner: repo.owner.login,
organization:
repo.owner.type === "Organization" ? repo.owner.login : null,
mirroredLocation: "",
destinationOrg: null,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: null,
hasIssues: repo.has_issues,
isStarred: false,
isArchived: repo.archived,
size: repo.size,
hasLFS: false,
hasSubmodules: false,
language: repo.language ?? null,
description: repo.description ?? null,
defaultBranch: repo.default_branch ?? "main",
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(),
}));
const repoRecords = allRepos.map((repo) => {
const normalizedOwner = repo.owner.login.trim().toLowerCase();
const normalizedRepoName = repo.name.trim().toLowerCase();
return {
id: uuidv4(),
userId,
configId,
name: repo.name,
fullName: repo.full_name,
normalizedFullName: `${normalizedOwner}/${normalizedRepoName}`,
url: repo.html_url,
cloneUrl: repo.clone_url ?? "",
owner: repo.owner.login,
organization:
repo.owner.type === "Organization" ? repo.owner.login : null,
mirroredLocation: "",
destinationOrg: null,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: null,
hasIssues: repo.has_issues,
isStarred: false,
isArchived: repo.archived,
size: repo.size,
hasLFS: false,
hasSubmodules: false,
language: repo.language ?? null,
description: repo.description ?? null,
defaultBranch: repo.default_branch ?? "main",
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
// Compute batch size based on column count
@@ -150,7 +196,7 @@ export const POST: APIRoute = async ({ request }) => {
await db
.insert(repositories)
.values(batch)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
.onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
}
// Insert organization metadata
@@ -159,6 +205,7 @@ export const POST: APIRoute = async ({ request }) => {
userId,
configId,
name: orgData.login,
normalizedName: normalizedOrg,
avatarUrl: orgData.avatar_url,
membershipRole: role,
isIncluded: false,

View File

@@ -15,7 +15,7 @@ import { createMirrorJob } from "@/lib/helpers";
export const POST: APIRoute = async ({ request }) => {
try {
const body: AddRepositoriesApiRequest = await request.json();
const { owner, repo, userId } = body;
const { owner, repo, userId, force = false } = body;
if (!owner || !repo || !userId) {
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
const existingRepo = await db
const [existingRepo] = await db
.select()
.from(repositories)
.where(
and(
eq(repositories.owner, owner),
eq(repositories.name, repo),
eq(repositories.userId, userId)
eq(repositories.userId, userId),
eq(repositories.normalizedFullName, normalizedFullName)
)
);
)
.limit(1);
if (existingRepo.length > 0) {
if (existingRepo && !force) {
return jsonResponse({
data: {
success: false,
error:
"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 { data: repoData } = await octokit.rest.repos.get({ owner, repo });
const { data: repoData } = await octokit.rest.repos.get({
owner: trimmedOwner,
repo: trimmedRepo,
});
const metadata = {
id: uuidv4(),
const baseMetadata = {
userId,
configId,
name: repoData.name,
fullName: repoData.full_name,
normalizedFullName,
url: repoData.html_url,
cloneUrl: repoData.clone_url,
owner: repoData.owner.login,
@@ -94,6 +114,37 @@ export const POST: APIRoute = async ({ request }) => {
description: repoData.description ?? null,
defaultBranch: repoData.default_branch,
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"],
lastMirrored: null,
errorMessage: null,
@@ -102,15 +153,13 @@ export const POST: APIRoute = async ({ request }) => {
createdAt: repoData.created_at
? new Date(repoData.created_at)
: new Date(),
updatedAt: repoData.updated_at
? new Date(repoData.updated_at)
: new Date(),
};
...baseMetadata,
} satisfies Repository;
await db
.insert(repositories)
.values(metadata)
.onConflictDoNothing({ target: [repositories.userId, repositories.fullName] });
.onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
createMirrorJob({
userId,

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -22,7 +22,7 @@ export function Installation() {
steps: [
{
title: "Clone the repository",
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git\ncd gitea-mirror",
command: "git clone https://github.com/RayLabsHQ/gitea-mirror.git && cd gitea-mirror",
id: "docker-clone"
},
{