diff --git a/README.md b/README.md
index d128f45..8e09fc5 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)
@@ -307,6 +309,20 @@ If sync logs show authentication failures (for example `terminal prompts disable
1. In Gitea/Forgejo, open repository **Settings → Mirror Settings** and update the mirror authorization password/token.
2. Or delete and re-mirror the repository from Gitea Mirror so it is recreated with current credentials.
+### 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. 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;"
+```
+
+This clears per-repository metadata completion flags so the next sync can re-run metadata import steps.
+
## Development
```bash
@@ -343,6 +359,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
+}
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/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') && (