From 9bc7bbe33f4ec7b4c60ea00b7e7a16d16af72a28 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Feb 2026 09:23:50 +0530 Subject: [PATCH 1/5] Support release limits above 100 --- .../config/GitHubMirrorSettings.tsx | 5 +- src/lib/gitea.ts | 49 ++++++++++++++----- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index 9c1821f..1d6985a 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -377,14 +377,13 @@ export function GitHubMirrorSettings({ id="release-limit" type="number" min="1" - max="100" value={mirrorOptions.releaseLimit || 10} onChange={(e) => { const value = parseInt(e.target.value) || 10; - const clampedValue = Math.min(100, Math.max(1, value)); + const clampedValue = Math.max(1, value); handleMirrorChange('releaseLimit', clampedValue); }} - className="w-16 px-2 py-1 text-xs border border-input rounded bg-background text-foreground" + className="w-20 px-2 py-1 text-xs border border-input rounded bg-background text-foreground" /> releases diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index ac8f686..99e9b37 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -2026,17 +2026,43 @@ export async function mirrorGitHubReleasesToGitea({ } // Get release limit from config (default to 10) - const releaseLimit = config.giteaConfig?.releaseLimit || 10; - - const releases = await octokit.rest.repos.listReleases({ - owner: repository.owner, - repo: repository.name, - per_page: releaseLimit, // Only fetch the latest N releases - }); + const releaseLimit = Math.max(1, Math.floor(config.giteaConfig?.releaseLimit || 10)); - console.log(`[Releases] Found ${releases.data.length} releases (limited to latest ${releaseLimit}) to mirror for ${repository.fullName}`); + // GitHub API max per page is 100; paginate until we reach the configured limit. + const releases: Awaited< + ReturnType + >["data"] = []; + let page = 1; + const perPage = Math.min(100, releaseLimit); - if (releases.data.length === 0) { + while (releases.length < releaseLimit) { + const response = await octokit.rest.repos.listReleases({ + owner: repository.owner, + repo: repository.name, + per_page: perPage, + page, + }); + + if (response.data.length === 0) { + break; + } + + releases.push(...response.data); + + if (response.data.length < perPage) { + break; + } + + page++; + } + + const limitedReleases = releases.slice(0, releaseLimit); + + console.log( + `[Releases] Found ${limitedReleases.length} releases (limited to latest ${releaseLimit}) to mirror for ${repository.fullName}` + ); + + if (limitedReleases.length === 0) { console.log(`[Releases] No releases to mirror for ${repository.fullName}`); return; } @@ -2044,7 +2070,7 @@ export async function mirrorGitHubReleasesToGitea({ let mirroredCount = 0; let skippedCount = 0; - const getReleaseTimestamp = (release: typeof releases.data[number]) => { + const getReleaseTimestamp = (release: (typeof limitedReleases)[number]) => { // Use published_at first (when the release was published on GitHub) // Fall back to created_at (when the git tag was created) only if published_at is missing // This matches GitHub's sorting behavior and handles cases where multiple tags @@ -2055,10 +2081,9 @@ export async function mirrorGitHubReleasesToGitea({ }; // Capture the latest releases, then process them oldest-to-newest so Gitea mirrors keep chronological order - const releasesToProcess = releases.data + const releasesToProcess = limitedReleases .slice() .sort((a, b) => getReleaseTimestamp(b) - getReleaseTimestamp(a)) - .slice(0, releaseLimit) .sort((a, b) => getReleaseTimestamp(a) - getReleaseTimestamp(b)); console.log(`[Releases] Processing ${releasesToProcess.length} releases in chronological order (oldest to newest by published date)`); From d023b255a75dbaec8f360bf97901251b8e5872a0 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Feb 2026 09:45:06 +0530 Subject: [PATCH 2/5] Add admin CLI password reset flow --- README.md | 14 +++++++ package.json | 1 + scripts/manage-db.ts | 98 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e82f634..390db02 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,20 @@ bun run build - Never stored in plaintext - Secure cookie-based session management +### Admin Password Recovery (CLI) +If email delivery is not configured, an admin with server access can reset a user password from the command line: + +```bash +bun run reset-password -- --email=user@example.com --new-password='new-secure-password' +``` + +What this does: +- Updates the credential password hash for the matching user +- Creates a credential account if one does not already exist +- Invalidates all active sessions for that user (forces re-login) + +Use this only from trusted server/admin environments. + ## Authentication Gitea Mirror supports multiple authentication methods. **Email/password authentication is the default and always enabled.** diff --git a/package.json b/package.json index dc68391..1dae0a1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "check-db": "bun scripts/manage-db.ts check", "fix-db": "bun scripts/manage-db.ts fix", "reset-users": "bun scripts/manage-db.ts reset-users", + "reset-password": "bun scripts/manage-db.ts reset-password", "db:generate": "bun drizzle-kit generate", "db:migrate": "bun drizzle-kit migrate", "db:push": "bun drizzle-kit push", diff --git a/scripts/manage-db.ts b/scripts/manage-db.ts index ae858de..cb09c5c 100644 --- a/scripts/manage-db.ts +++ b/scripts/manage-db.ts @@ -4,9 +4,9 @@ import { Database } from "bun:sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import { v4 as uuidv4 } from "uuid"; -import { users, configs, repositories, organizations, mirrorJobs, events } from "../src/lib/db/schema"; -import bcrypt from "bcryptjs"; -import { eq } from "drizzle-orm"; +import { users, configs, repositories, organizations, mirrorJobs, events, accounts, sessions } from "../src/lib/db/schema"; +import { and, eq } from "drizzle-orm"; +import { hashPassword } from "better-auth/crypto"; // Command line arguments const args = process.argv.slice(2); @@ -194,6 +194,92 @@ async function fixDatabase() { console.log("✅ Database location fixed"); } +/** + * Reset a single user's password (admin recovery flow) + */ +async function resetPassword() { + const emailArg = args.find((arg) => arg.startsWith("--email=")); + const passwordArg = args.find((arg) => arg.startsWith("--new-password=")); + const email = emailArg?.split("=")[1]?.trim().toLowerCase(); + const newPassword = passwordArg?.split("=")[1]; + + if (!email || !newPassword) { + console.log("❌ Missing required arguments"); + console.log("Usage:"); + console.log(" bun run manage-db reset-password --email=user@example.com --new-password='new-secure-password'"); + process.exit(1); + } + + if (newPassword.length < 8) { + console.log("❌ Password must be at least 8 characters"); + process.exit(1); + } + + if (!fs.existsSync(dbPath)) { + console.log("❌ Database does not exist"); + process.exit(1); + } + + const sqlite = new Database(dbPath); + const db = drizzle({ client: sqlite }); + + try { + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (!user) { + console.log(`❌ No user found for email: ${email}`); + sqlite.close(); + process.exit(1); + } + + const hashedPassword = await hashPassword(newPassword); + const now = new Date(); + + const credentialAccount = await db.query.accounts.findFirst({ + where: and( + eq(accounts.userId, user.id), + eq(accounts.providerId, "credential"), + ), + }); + + if (credentialAccount) { + await db + .update(accounts) + .set({ + password: hashedPassword, + updatedAt: now, + }) + .where(eq(accounts.id, credentialAccount.id)); + } else { + await db.insert(accounts).values({ + id: uuidv4(), + accountId: user.id, + userId: user.id, + providerId: "credential", + password: hashedPassword, + createdAt: now, + updatedAt: now, + }); + } + + const deletedSessions = await db + .delete(sessions) + .where(eq(sessions.userId, user.id)) + .returning({ id: sessions.id }); + + console.log(`✅ Password reset for ${email}`); + console.log(`🔒 Cleared ${deletedSessions.length} active session(s)`); + + sqlite.close(); + } catch (error) { + console.error("❌ Error resetting password:", error); + sqlite.close(); + process.exit(1); + } +} + /** * Auto mode - check and initialize if needed */ @@ -224,6 +310,9 @@ switch (command) { case "cleanup": await cleanupDatabase(); break; + case "reset-password": + await resetPassword(); + break; case "auto": await autoMode(); break; @@ -233,7 +322,8 @@ switch (command) { console.log(" check - Check database status"); console.log(" fix - Fix database location issues"); console.log(" reset-users - Remove all users and related data"); + console.log(" reset-password - Reset one user's password and clear sessions"); console.log(" cleanup - Remove all database files"); console.log(" auto - Auto initialize if needed"); process.exit(1); -} \ No newline at end of file +} From 4cce5b7cfe52854eddfcd3f59d565d7c9ea8cda0 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Feb 2026 09:51:54 +0530 Subject: [PATCH 3/5] Document large-repo scheduling guidance --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e82f634..b62933b 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,8 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes **Important Notes**: - **Auto-Start**: When `SCHEDULE_ENABLED=true` or `GITEA_MIRROR_INTERVAL` is set, the service automatically imports all GitHub repositories and mirrors them on startup. No manual "Import" or "Mirror" button clicks required! - The scheduler checks every minute for tasks to run. The `GITEA_MIRROR_INTERVAL` determines how often each repository is actually synced. For example, with `8h`, each repo syncs every 8 hours from its last successful sync. +- **Large repo bootstrap**: For first-time mirroring of large repositories (especially with metadata/LFS), avoid very short intervals (for example `5m`). Start with a longer interval (`1h` to `8h`) or temporarily disable scheduling during the initial import/mirror run, then enable your regular interval after the first pass completes. +- **Why this matters**: If your Gitea instance takes a long time to complete migrations/imports, aggressive schedules can cause repeated retries and duplicate-looking mirror attempts. **🛡️ Backup Protection Features**: - **No Accidental Deletions**: Repository cleanup is automatically skipped if GitHub is inaccessible (account deleted, banned, or API errors) From f4074a37ad18f2594125f0c73e05568a9dcab479 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Feb 2026 09:56:02 +0530 Subject: [PATCH 4/5] Document metadata re-sync recovery steps --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index e82f634..acb50be 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,19 @@ CLEANUP_DRY_RUN=false # Set to true to test without changes If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues with JavaScript files not loading properly, try enabling HTTP/2 support in your proxy configuration. While not required by the application, some proxy configurations may have better compatibility with HTTP/2 enabled. See [issue #43](https://github.com/RayLabsHQ/gitea-mirror/issues/43) for reference. +### Re-sync Metadata After Changing Mirror Options + +If you enable metadata options (issues/PRs/labels/milestones/releases) after repositories were already mirrored: + +1. Go to **Repositories**, select the repositories, and click **Sync** to run a fresh sync pass. +2. If some repositories still miss metadata, reset metadata sync state in SQLite and sync again: + +```bash +sqlite3 data/gitea-mirror.db "UPDATE repositories SET metadata = NULL;" +``` + +This clears per-repository metadata completion flags so the next sync can re-run metadata import steps. + ## Development ```bash From c34056555f42c2ddae88e8fd9ec8923d01c1e1e4 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Feb 2026 09:59:21 +0530 Subject: [PATCH 5/5] Add bulk re-run metadata action --- README.md | 3 +- src/components/repositories/Repository.tsx | 91 ++++++++++++++++ src/pages/api/job/reset-metadata.ts | 116 +++++++++++++++++++++ src/types/reset-metadata.ts | 13 +++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/pages/api/job/reset-metadata.ts create mode 100644 src/types/reset-metadata.ts diff --git a/README.md b/README.md index acb50be..13dacf0 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,8 @@ If using a reverse proxy (e.g., nginx proxy manager) and experiencing issues wit If you enable metadata options (issues/PRs/labels/milestones/releases) after repositories were already mirrored: 1. Go to **Repositories**, select the repositories, and click **Sync** to run a fresh sync pass. -2. If some repositories still miss metadata, reset metadata sync state in SQLite and sync again: +2. For a full metadata refresh, use **Re-run Metadata** on selected repositories. This clears metadata sync state for those repos and immediately starts Sync. +3. If some repositories still miss metadata, reset metadata sync state in SQLite and sync again: ```bash sqlite3 data/gitea-mirror.db "UPDATE repositories SET metadata = NULL;" diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 549abcc..f10e2c7 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -44,6 +44,7 @@ import { toast } from "sonner"; import type { SyncRepoRequest, SyncRepoResponse } from "@/types/sync"; import { OwnerCombobox, OrganizationCombobox } from "./RepositoryComboboxes"; import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry"; +import type { ResetMetadataRequest, ResetMetadataResponse } from "@/types/reset-metadata"; import AddRepositoryDialog from "./AddRepositoryDialog"; import { useLiveRefresh } from "@/hooks/useLiveRefresh"; @@ -378,6 +379,67 @@ export default function Repository() { } }; + const handleBulkRerunMetadata = async () => { + if (selectedRepoIds.size === 0) return; + + const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id)); + const eligibleRepos = selectedRepos.filter( + repo => ["mirrored", "synced", "archived"].includes(repo.status) + ); + + if (eligibleRepos.length === 0) { + toast.info("No eligible repositories to re-run metadata in selection"); + return; + } + + const repoIds = eligibleRepos.map(repo => repo.id as string); + + setLoadingRepoIds(prev => { + const newSet = new Set(prev); + repoIds.forEach(id => newSet.add(id)); + return newSet; + }); + + try { + const resetPayload: ResetMetadataRequest = { + userId: user?.id || "", + repositoryIds: repoIds, + }; + + const resetResponse = await apiRequest("/job/reset-metadata", { + method: "POST", + data: resetPayload, + }); + + if (!resetResponse.success) { + showErrorToast(resetResponse.error || "Failed to reset metadata state", toast); + return; + } + + const syncResponse = await apiRequest("/job/sync-repo", { + method: "POST", + data: { userId: user?.id, repositoryIds: repoIds }, + }); + + if (syncResponse.success) { + toast.success(`Re-running metadata for ${repoIds.length} repositories`); + setRepositories(prevRepos => + prevRepos.map(repo => { + const updated = syncResponse.repositories.find(r => r.id === repo.id); + return updated ? updated : repo; + }) + ); + setSelectedRepoIds(new Set()); + } else { + showErrorToast(syncResponse.error || "Error starting metadata re-sync", toast); + } + } catch (error) { + showErrorToast(error, toast); + } finally { + setLoadingRepoIds(new Set()); + } + }; + const handleBulkRetry = async () => { if (selectedRepoIds.size === 0) return; @@ -806,6 +868,10 @@ export default function Repository() { if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) { actions.push('sync'); } + + if (selectedRepos.some(repo => ["mirrored", "synced", "archived"].includes(repo.status))) { + actions.push('rerun-metadata'); + } // Check if any selected repos are failed if (selectedRepos.some(repo => repo.status === "failed")) { @@ -834,6 +900,7 @@ export default function Repository() { return { mirror: selectedRepos.filter(repo => repo.status === "imported" || repo.status === "failed").length, sync: selectedRepos.filter(repo => repo.status === "mirrored" || repo.status === "synced").length, + rerunMetadata: selectedRepos.filter(repo => ["mirrored", "synced", "archived"].includes(repo.status)).length, retry: selectedRepos.filter(repo => repo.status === "failed").length, ignore: selectedRepos.filter(repo => repo.status !== "ignored").length, include: selectedRepos.filter(repo => repo.status === "ignored").length, @@ -1157,6 +1224,18 @@ export default function Repository() { Sync ({actionCounts.sync}) )} + + {availableActions.includes('rerun-metadata') && ( + + )} {availableActions.includes('retry') && ( )} + + {availableActions.includes('rerun-metadata') && ( + + )} {availableActions.includes('retry') && (