From 18ecdbc252f3ec9a0293571afac5b05fdaf5e339 Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Sat, 13 Sep 2025 23:38:50 +0530 Subject: [PATCH] fix(sync): batch inserts + normalize nulls to avoid SQLite param mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch repository inserts with dynamic sizing under SQLite 999-param limit - Normalize undefined → null to keep multi-row insert shapes consistent - De-duplicate owned + starred repos by fullName (prefer starred variant) - Enforce uniqueness via (user_id, full_name) + onConflictDoNothing - Handle starred name collisions (suffix/prefix) across mirror + metadata - Add repo-utils helpers + tests; guard Octokit.plugin in tests - Remove manual unique index from entrypoint; rely on drizzle-kit migrations --- docker-entrypoint.sh | 5 + drizzle/0005_polite_preak.sql | 1 + drizzle/meta/0005_snapshot.json | 1941 +++++++++++++++++ drizzle/meta/_journal.json | 7 + .../config/GitHubMirrorSettings.tsx | 41 +- src/lib/db/schema.ts | 6 +- src/lib/gitea.ts | 262 ++- src/lib/github.ts | 7 +- src/lib/repo-utils.test.ts | 75 + src/lib/repo-utils.ts | 71 + src/lib/scheduler-service.ts | 97 +- src/pages/api/sync/index.ts | 35 +- src/pages/api/sync/organization.ts | 23 +- src/pages/api/sync/repository.ts | 19 +- src/types/config.ts | 3 + 15 files changed, 2439 insertions(+), 154 deletions(-) create mode 100644 drizzle/0005_polite_preak.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/lib/repo-utils.test.ts create mode 100644 src/lib/repo-utils.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index a3d061a..0a649e0 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -172,6 +172,7 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then owner TEXT NOT NULL, organization TEXT, mirrored_location TEXT DEFAULT '', + destination_org TEXT, is_private INTEGER NOT NULL DEFAULT 0, is_fork INTEGER NOT NULL DEFAULT 0, forked_from TEXT, @@ -181,6 +182,8 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then size INTEGER NOT NULL DEFAULT 0, has_lfs INTEGER NOT NULL DEFAULT 0, has_submodules INTEGER NOT NULL DEFAULT 0, + language TEXT, + description TEXT, default_branch TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', status TEXT NOT NULL DEFAULT 'imported', @@ -192,6 +195,8 @@ if [ ! -f "/app/data/gitea-mirror.db" ]; then FOREIGN KEY (config_id) REFERENCES configs(id) ); + -- Uniqueness of (user_id, full_name) for repositories is enforced via drizzle migrations + CREATE TABLE IF NOT EXISTS organizations ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/drizzle/0005_polite_preak.sql b/drizzle/0005_polite_preak.sql new file mode 100644 index 0000000..f1b8fb2 --- /dev/null +++ b/drizzle/0005_polite_preak.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `uniq_repositories_user_full_name` ON `repositories` (`user_id`,`full_name`); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..799e42e --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1941 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4b92c9e0-363d-4da1-8d8d-c2e6c31cbb70", + "prevId": "284b8db7-ef64-4311-b890-0251bcbe5866", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_accounts_account_id": { + "name": "idx_accounts_account_id", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_accounts_provider": { + "name": "idx_accounts_provider", + "columns": [ + "provider_id", + "provider_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "github_config": { + "name": "github_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "gitea_config": { + "name": "gitea_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "include": { + "name": "include", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"*\"]'" + }, + "exclude": { + "name": "exclude", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_config": { + "name": "cleanup_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "configs_user_id_users_id_fk": { + "name": "configs_user_id_users_id_fk", + "tableFrom": "configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_events_user_channel": { + "name": "idx_events_user_channel", + "columns": [ + "user_id", + "channel" + ], + "isUnique": false + }, + "idx_events_created_at": { + "name": "idx_events_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_events_read": { + "name": "idx_events_read", + "columns": [ + "read" + ], + "isUnique": false + } + }, + "foreignKeys": { + "events_user_id_users_id_fk": { + "name": "events_user_id_users_id_fk", + "tableFrom": "events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mirror_jobs": { + "name": "mirror_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_name": { + "name": "organization_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mirror'" + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_items": { + "name": "total_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_items": { + "name": "completed_items", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_item_ids": { + "name": "completed_item_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "in_progress": { + "name": "in_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_checkpoint": { + "name": "last_checkpoint", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_mirror_jobs_user_id": { + "name": "idx_mirror_jobs_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_batch_id": { + "name": "idx_mirror_jobs_batch_id", + "columns": [ + "batch_id" + ], + "isUnique": false + }, + "idx_mirror_jobs_in_progress": { + "name": "idx_mirror_jobs_in_progress", + "columns": [ + "in_progress" + ], + "isUnique": false + }, + "idx_mirror_jobs_job_type": { + "name": "idx_mirror_jobs_job_type", + "columns": [ + "job_type" + ], + "isUnique": false + }, + "idx_mirror_jobs_timestamp": { + "name": "idx_mirror_jobs_timestamp", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "mirror_jobs_user_id_users_id_fk": { + "name": "mirror_jobs_user_id_users_id_fk", + "tableFrom": "mirror_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_tokens": { + "name": "oauth_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_access_tokens_access_token": { + "name": "idx_oauth_access_tokens_access_token", + "columns": [ + "access_token" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_user_id": { + "name": "idx_oauth_access_tokens_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_client_id": { + "name": "idx_oauth_access_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_tokens_user_id_users_id_fk": { + "name": "oauth_access_tokens_user_id_users_id_fk", + "tableFrom": "oauth_access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_applications": { + "name": "oauth_applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_urls": { + "name": "redirect_urls", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "oauth_applications_client_id_unique": { + "name": "oauth_applications_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "idx_oauth_applications_client_id": { + "name": "idx_oauth_applications_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_applications_user_id": { + "name": "idx_oauth_applications_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consent_given": { + "name": "consent_given", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_consent_user_id": { + "name": "idx_oauth_consent_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_oauth_consent_client_id": { + "name": "idx_oauth_consent_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_consent_user_client": { + "name": "idx_oauth_consent_user_client", + "columns": [ + "user_id", + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_user_id_users_id_fk": { + "name": "oauth_consent_user_id_users_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "is_included": { + "name": "is_included", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repository_count": { + "name": "repository_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_repository_count": { + "name": "public_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "private_repository_count": { + "name": "private_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fork_repository_count": { + "name": "fork_repository_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_organizations_user_id": { + "name": "idx_organizations_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_organizations_config_id": { + "name": "idx_organizations_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_organizations_status": { + "name": "idx_organizations_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_organizations_is_included": { + "name": "idx_organizations_is_included", + "columns": [ + "is_included" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organizations_user_id_users_id_fk": { + "name": "organizations_user_id_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "organizations_config_id_configs_id_fk": { + "name": "organizations_config_id_configs_id_fk", + "tableFrom": "organizations", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limits": { + "name": "rate_limits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "limit": { + "name": "limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reset": { + "name": "reset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retry_after": { + "name": "retry_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_rate_limits_user_provider": { + "name": "idx_rate_limits_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": false + }, + "idx_rate_limits_status": { + "name": "idx_rate_limits_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rate_limits_user_id_users_id_fk": { + "name": "rate_limits_user_id_users_id_fk", + "tableFrom": "rate_limits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "clone_url": { + "name": "clone_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization": { + "name": "organization", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mirrored_location": { + "name": "mirrored_location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "is_private": { + "name": "is_private", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_fork": { + "name": "is_fork", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "forked_from": { + "name": "forked_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_issues": { + "name": "has_issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_starred": { + "name": "is_starred", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "has_lfs": { + "name": "has_lfs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_submodules": { + "name": "has_submodules", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'imported'" + }, + "last_mirrored": { + "name": "last_mirrored", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_org": { + "name": "destination_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_repositories_user_id": { + "name": "idx_repositories_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_repositories_config_id": { + "name": "idx_repositories_config_id", + "columns": [ + "config_id" + ], + "isUnique": false + }, + "idx_repositories_status": { + "name": "idx_repositories_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_repositories_owner": { + "name": "idx_repositories_owner", + "columns": [ + "owner" + ], + "isUnique": false + }, + "idx_repositories_organization": { + "name": "idx_repositories_organization", + "columns": [ + "organization" + ], + "isUnique": false + }, + "idx_repositories_is_fork": { + "name": "idx_repositories_is_fork", + "columns": [ + "is_fork" + ], + "isUnique": false + }, + "idx_repositories_is_starred": { + "name": "idx_repositories_is_starred", + "columns": [ + "is_starred" + ], + "isUnique": false + }, + "uniq_repositories_user_full_name": { + "name": "uniq_repositories_user_full_name", + "columns": [ + "user_id", + "full_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "repositories_user_id_users_id_fk": { + "name": "repositories_user_id_users_id_fk", + "tableFrom": "repositories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "repositories_config_id_configs_id_fk": { + "name": "repositories_config_id_configs_id_fk", + "tableFrom": "repositories", + "tableTo": "configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_sessions_token": { + "name": "idx_sessions_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_sessions_expires_at": { + "name": "idx_sessions_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sso_providers": { + "name": "sso_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "columns": [ + "provider_id" + ], + "isUnique": true + }, + "idx_sso_providers_provider_id": { + "name": "idx_sso_providers_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + }, + "idx_sso_providers_domain": { + "name": "idx_sso_providers_domain", + "columns": [ + "domain" + ], + "isUnique": false + }, + "idx_sso_providers_issuer": { + "name": "idx_sso_providers_issuer", + "columns": [ + "issuer" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_tokens": { + "name": "verification_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "verification_tokens_token_unique": { + "name": "verification_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_verification_tokens_identifier": { + "name": "idx_verification_tokens_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_verifications_identifier": { + "name": "idx_verifications_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8f2126f..ba5f7f0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1757392620734, "tag": "0004_grey_butterfly", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1757786449446, + "tag": "0005_polite_preak", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/config/GitHubMirrorSettings.tsx b/src/components/config/GitHubMirrorSettings.tsx index 9fd67c3..006c88a 100644 --- a/src/components/config/GitHubMirrorSettings.tsx +++ b/src/components/config/GitHubMirrorSettings.tsx @@ -32,7 +32,14 @@ import { Funnel, HardDrive } from "lucide-react"; -import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config"; +import type { GitHubConfig, MirrorOptions, AdvancedOptions, DuplicateNameStrategy } from "@/types/config"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; interface GitHubMirrorSettingsProps { @@ -53,7 +60,7 @@ export function GitHubMirrorSettings({ onAdvancedOptionsChange, }: GitHubMirrorSettingsProps) { - const handleGitHubChange = (field: keyof GitHubConfig, value: boolean) => { + const handleGitHubChange = (field: keyof GitHubConfig, value: boolean | string) => { onGitHubConfigChange({ ...githubConfig, [field]: value }); }; @@ -278,6 +285,34 @@ export function GitHubMirrorSettings({ + + {/* Duplicate name handling for starred repos */} + {githubConfig.mirrorStarred && ( +
+ + +

+ Handle starred repos with duplicate names from different owners. Currently supports suffix and prefix strategies. +

+
+ )} @@ -596,4 +631,4 @@ export function GitHubMirrorSettings({ ); -} \ No newline at end of file +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 17b2153..9f87831 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; // ===== Zod Validation Schemas ===== @@ -28,6 +28,7 @@ export const githubConfigSchema = z.object({ mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"), defaultOrg: z.string().optional(), skipStarredIssues: z.boolean().default(false), + starredDuplicateStrategy: z.enum(["suffix", "prefix", "owner-org"]).default("suffix").optional(), }); export const giteaConfigSchema = z.object({ @@ -379,6 +380,7 @@ export const repositories = sqliteTable("repositories", { index("idx_repositories_organization").on(table.organization), 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), ]); export const mirrorJobs = sqliteTable("mirror_jobs", { @@ -674,4 +676,4 @@ export type Repository = z.infer; export type MirrorJob = z.infer; export type Organization = z.infer; export type Event = z.infer; -export type RateLimit = z.infer; \ No newline at end of file +export type RateLimit = z.infer; diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 211ad42..ee80997 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -274,15 +274,37 @@ export const mirrorGithubRepoToGitea = async ({ // Get the correct owner based on the strategy (with organization overrides) let repoOwner = await getGiteaRepoOwnerAsync({ config, repository }); + // Determine the actual repository name to use (handle duplicates for starred repos) + let targetRepoName = repository.name; + + if (repository.isStarred && config.githubConfig) { + // Extract GitHub owner from full_name (format: owner/repo) + const githubOwner = repository.fullName.split('/')[0]; + + targetRepoName = await generateUniqueRepoName({ + config, + orgName: repoOwner, + baseName: repository.name, + githubOwner, + strategy: config.githubConfig.starredDuplicateStrategy, + }); + + if (targetRepoName !== repository.name) { + console.log( + `Starred repo ${repository.fullName} will be mirrored as ${repoOwner}/${targetRepoName} to avoid naming conflict` + ); + } + } + const isExisting = await isRepoPresentInGitea({ config, owner: repoOwner, - repoName: repository.name, + repoName: targetRepoName, }); if (isExisting) { console.log( - `Repository ${repository.name} already exists in Gitea under ${repoOwner}. Updating database status.` + `Repository ${targetRepoName} already exists in Gitea under ${repoOwner}. Updating database status.` ); // Update database to reflect that the repository is already mirrored @@ -293,7 +315,7 @@ export const mirrorGithubRepoToGitea = async ({ updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, - mirroredLocation: `${repoOwner}/${repository.name}`, + mirroredLocation: `${repoOwner}/${targetRepoName}`, }) .where(eq(repositories.id, repository.id!)); @@ -393,11 +415,11 @@ export const mirrorGithubRepoToGitea = async ({ const existingRepo = await getGiteaRepoInfo({ config, owner: repoOwner, - repoName: repository.name, + repoName: targetRepoName, }); if (existingRepo && !existingRepo.mirror) { - console.log(`Repository ${repository.name} exists but is not a mirror. Handling...`); + console.log(`Repository ${targetRepoName} exists but is not a mirror. Handling...`); // Handle the existing non-mirror repository await handleExistingNonMirrorRepo({ @@ -408,14 +430,14 @@ export const mirrorGithubRepoToGitea = async ({ }); // After handling, proceed with mirror creation - console.log(`Proceeding with mirror creation for ${repository.name}`); + console.log(`Proceeding with mirror creation for ${targetRepoName}`); } const response = await httpPost( apiUrl, { clone_addr: cloneAddress, - repo_name: repository.name, + repo_name: targetRepoName, mirror: true, mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists @@ -460,6 +482,7 @@ export const mirrorGithubRepoToGitea = async ({ octokit, repository, giteaOwner: repoOwner, + giteaRepoName: targetRepoName, }); console.log(`[Metadata] Successfully mirrored issues for ${repository.name}`); } catch (error) { @@ -477,6 +500,7 @@ export const mirrorGithubRepoToGitea = async ({ octokit, repository, giteaOwner: repoOwner, + giteaRepoName: targetRepoName, }); console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name}`); } catch (error) { @@ -494,6 +518,7 @@ export const mirrorGithubRepoToGitea = async ({ octokit, repository, giteaOwner: repoOwner, + giteaRepoName: targetRepoName, }); console.log(`[Metadata] Successfully mirrored labels for ${repository.name}`); } catch (error) { @@ -511,6 +536,7 @@ export const mirrorGithubRepoToGitea = async ({ octokit, repository, giteaOwner: repoOwner, + giteaRepoName: targetRepoName, }); console.log(`[Metadata] Successfully mirrored milestones for ${repository.name}`); } catch (error) { @@ -519,7 +545,7 @@ export const mirrorGithubRepoToGitea = async ({ } } - console.log(`Repository ${repository.name} mirrored successfully`); + console.log(`Repository ${repository.name} mirrored successfully as ${targetRepoName}`); // Mark repos as "mirrored" in DB await db @@ -529,7 +555,7 @@ export const mirrorGithubRepoToGitea = async ({ updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, - mirroredLocation: `${repoOwner}/${repository.name}`, + mirroredLocation: `${repoOwner}/${targetRepoName}`, }) .where(eq(repositories.id, repository.id!)); @@ -538,8 +564,8 @@ export const mirrorGithubRepoToGitea = async ({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, - message: `Successfully mirrored repository: ${repository.name}`, - details: `Repository ${repository.name} was mirrored to Gitea.`, + message: `Successfully mirrored repository: ${repository.name}${targetRepoName !== repository.name ? ` as ${targetRepoName}` : ''}`, + details: `Repository ${repository.fullName} was mirrored to Gitea at ${repoOwner}/${targetRepoName}.`, status: "mirrored", }); @@ -608,6 +634,80 @@ export async function getOrCreateGiteaOrg({ } } +/** + * Generate a unique repository name for starred repos with duplicate names + */ +async function generateUniqueRepoName({ + config, + orgName, + baseName, + githubOwner, + strategy, +}: { + config: Partial; + orgName: string; + baseName: string; + githubOwner: string; + strategy?: string; +}): Promise { + const duplicateStrategy = strategy || "suffix"; + + // First check if base name is available + const baseExists = await isRepoPresentInGitea({ + config, + owner: orgName, + repoName: baseName, + }); + + if (!baseExists) { + return baseName; + } + + // Generate name based on strategy + let candidateName: string; + let attempt = 0; + const maxAttempts = 10; + + while (attempt < maxAttempts) { + switch (duplicateStrategy) { + case "prefix": + // Prefix with owner: owner-reponame + candidateName = attempt === 0 + ? `${githubOwner}-${baseName}` + : `${githubOwner}-${baseName}-${attempt}`; + break; + + case "owner-org": + // This would require creating sub-organizations, not supported in this PR + // Fall back to suffix strategy + case "suffix": + default: + // Suffix with owner: reponame-owner + candidateName = attempt === 0 + ? `${baseName}-${githubOwner}` + : `${baseName}-${githubOwner}-${attempt}`; + break; + } + + const exists = await isRepoPresentInGitea({ + config, + owner: orgName, + repoName: candidateName, + }); + + if (!exists) { + console.log(`Found unique name for duplicate starred repo: ${candidateName}`); + return candidateName; + } + + attempt++; + } + + // If all attempts failed, use timestamp as last resort + const timestamp = Date.now(); + return `${baseName}-${githubOwner}-${timestamp}`; +} + export async function mirrorGitHubRepoToGiteaOrg({ octokit, config, @@ -633,15 +733,37 @@ export async function mirrorGitHubRepoToGiteaOrg({ // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); + // Determine the actual repository name to use (handle duplicates for starred repos) + let targetRepoName = repository.name; + + if (repository.isStarred && config.githubConfig) { + // Extract GitHub owner from full_name (format: owner/repo) + const githubOwner = repository.fullName.split('/')[0]; + + targetRepoName = await generateUniqueRepoName({ + config, + orgName, + baseName: repository.name, + githubOwner, + strategy: config.githubConfig.starredDuplicateStrategy, + }); + + if (targetRepoName !== repository.name) { + console.log( + `Starred repo ${repository.fullName} will be mirrored as ${orgName}/${targetRepoName} to avoid naming conflict` + ); + } + } + const isExisting = await isRepoPresentInGitea({ config, owner: orgName, - repoName: repository.name, + repoName: targetRepoName, }); if (isExisting) { console.log( - `Repository ${repository.name} already exists in Gitea organization ${orgName}. Updating database status.` + `Repository ${targetRepoName} already exists in Gitea organization ${orgName}. Updating database status.` ); // Update database to reflect that the repository is already mirrored @@ -652,7 +774,7 @@ export async function mirrorGitHubRepoToGiteaOrg({ updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, - mirroredLocation: `${orgName}/${repository.name}`, + mirroredLocation: `${orgName}/${targetRepoName}`, }) .where(eq(repositories.id, repository.id!)); @@ -661,19 +783,19 @@ export async function mirrorGitHubRepoToGiteaOrg({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, - message: `Repository ${repository.name} already exists in Gitea organization ${orgName}`, - details: `Repository ${repository.name} was found to already exist in Gitea organization ${orgName} and database status was updated.`, + message: `Repository ${targetRepoName} already exists in Gitea organization ${orgName}`, + details: `Repository ${targetRepoName} was found to already exist in Gitea organization ${orgName} and database status was updated.`, status: "mirrored", }); console.log( - `Repository ${repository.name} database status updated to mirrored in organization ${orgName}` + `Repository ${targetRepoName} database status updated to mirrored in organization ${orgName}` ); return; } console.log( - `Mirroring repository ${repository.name} to organization ${orgName}` + `Mirroring repository ${repository.fullName} to organization ${orgName} as ${targetRepoName}` ); let cloneAddress = repository.cloneUrl; @@ -710,7 +832,7 @@ export async function mirrorGitHubRepoToGiteaOrg({ { clone_addr: cloneAddress, uid: giteaOrgId, - repo_name: repository.name, + repo_name: targetRepoName, mirror: true, mirror_interval: config.giteaConfig?.mirrorInterval || "8h", // Set mirror interval wiki: config.giteaConfig?.wiki || false, // will mirror wiki if it exists @@ -752,10 +874,11 @@ export async function mirrorGitHubRepoToGiteaOrg({ octokit, repository, giteaOwner: orgName, + giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored issues for ${repository.name} to org ${orgName}`); + 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}: ${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 } } @@ -769,10 +892,11 @@ export async function mirrorGitHubRepoToGiteaOrg({ octokit, repository, giteaOwner: orgName, + giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored pull requests for ${repository.name} to org ${orgName}`); + 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}: ${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 } } @@ -786,10 +910,11 @@ export async function mirrorGitHubRepoToGiteaOrg({ octokit, repository, giteaOwner: orgName, + giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored labels for ${repository.name} to org ${orgName}`); + 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}: ${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 } } @@ -803,16 +928,17 @@ export async function mirrorGitHubRepoToGiteaOrg({ octokit, repository, giteaOwner: orgName, + giteaRepoName: targetRepoName, }); - console.log(`[Metadata] Successfully mirrored milestones for ${repository.name} to org ${orgName}`); + 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}: ${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 } } console.log( - `Repository ${repository.name} mirrored successfully to organization ${orgName}` + `Repository ${repository.name} mirrored successfully to organization ${orgName} as ${targetRepoName}` ); // Mark repos as "mirrored" in DB @@ -823,7 +949,7 @@ export async function mirrorGitHubRepoToGiteaOrg({ updatedAt: new Date(), lastMirrored: new Date(), errorMessage: null, - mirroredLocation: `${orgName}/${repository.name}`, + mirroredLocation: `${orgName}/${targetRepoName}`, }) .where(eq(repositories.id, repository.id!)); @@ -832,8 +958,8 @@ export async function mirrorGitHubRepoToGiteaOrg({ userId: config.userId, repositoryId: repository.id, repositoryName: repository.name, - message: `Repository ${repository.name} mirrored successfully`, - details: `Repository ${repository.name} was mirrored to Gitea`, + message: `Repository ${repository.name} mirrored successfully${targetRepoName !== repository.name ? ` as ${targetRepoName}` : ''}`, + details: `Repository ${repository.fullName} was mirrored to Gitea at ${orgName}/${targetRepoName}`, status: "mirrored", }); @@ -1149,11 +1275,13 @@ export const mirrorGitRepoIssuesToGitea = async ({ octokit, repository, giteaOwner, + giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; + giteaRepoName?: string; }) => { //things covered here are- issue, title, body, labels, comments and assignees if ( @@ -1168,23 +1296,26 @@ export const mirrorGitRepoIssuesToGitea = async ({ // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); + // Use provided giteaRepoName or fall back to repository.name + const repoName = giteaRepoName || repository.name; + // Log configuration details for debugging - console.log(`[Issues] Starting issue mirroring for repository ${repository.name}`); + console.log(`[Issues] Starting issue mirroring for repository ${repository.name} as ${repoName}`); console.log(`[Issues] Gitea URL: ${config.giteaConfig!.url}`); console.log(`[Issues] Gitea Owner: ${giteaOwner}`); console.log(`[Issues] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`); // Verify the repository exists in Gitea before attempting to mirror metadata - console.log(`[Issues] Verifying repository ${repository.name} exists at ${giteaOwner}`); + console.log(`[Issues] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, - repoName: repository.name, + repoName: repoName, }); if (!repoExists) { - console.error(`[Issues] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror issues.`); - throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); + console.error(`[Issues] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror issues.`); + throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); @@ -1215,7 +1346,7 @@ export const mirrorGitRepoIssuesToGitea = async ({ // Get existing labels from Gitea const giteaLabelsRes = await httpGet( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -1556,11 +1687,13 @@ export async function mirrorGitRepoPullRequestsToGitea({ octokit, repository, giteaOwner, + giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; + giteaRepoName?: string; }) { if ( !config.githubConfig?.token || @@ -1574,23 +1707,26 @@ export async function mirrorGitRepoPullRequestsToGitea({ // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); + // Use provided giteaRepoName or fall back to repository.name + const repoName = giteaRepoName || repository.name; + // Log configuration details for debugging - console.log(`[Pull Requests] Starting PR mirroring for repository ${repository.name}`); + console.log(`[Pull Requests] Starting PR mirroring for repository ${repository.name} as ${repoName}`); console.log(`[Pull Requests] Gitea URL: ${config.giteaConfig!.url}`); console.log(`[Pull Requests] Gitea Owner: ${giteaOwner}`); console.log(`[Pull Requests] Gitea Default Owner: ${config.giteaConfig!.defaultOwner}`); // Verify the repository exists in Gitea before attempting to mirror metadata - console.log(`[Pull Requests] Verifying repository ${repository.name} exists at ${giteaOwner}`); + console.log(`[Pull Requests] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, - repoName: repository.name, + repoName: repoName, }); if (!repoExists) { - console.error(`[Pull Requests] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror PRs.`); - throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); + console.error(`[Pull Requests] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror PRs.`); + throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); @@ -1622,7 +1758,7 @@ export async function mirrorGitRepoPullRequestsToGitea({ // Get existing labels from Gitea and ensure "pull-request" label exists const giteaLabelsRes = await httpGet( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -1640,7 +1776,7 @@ export async function mirrorGitRepoPullRequestsToGitea({ } else { try { const created = await httpPost( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { name: "pull-request", color: "#0366d6", @@ -1744,7 +1880,7 @@ export async function mirrorGitRepoPullRequestsToGitea({ console.log(`[Pull Requests] Creating enriched issue for PR #${pr.number}: ${pr.title}`); await httpPost( - `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`, + `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`, issueData, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, @@ -1764,7 +1900,7 @@ export async function mirrorGitRepoPullRequestsToGitea({ try { await httpPost( - `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repository.name}/issues`, + `${config.giteaConfig!.url}/api/v1/repos/${giteaOwner}/${repoName}/issues`, basicIssueData, { Authorization: `token ${decryptedConfig.giteaConfig!.token}`, @@ -1795,11 +1931,13 @@ export async function mirrorGitRepoLabelsToGitea({ octokit, repository, giteaOwner, + giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; + giteaRepoName?: string; }) { if ( !config.githubConfig?.token || @@ -1812,17 +1950,20 @@ export async function mirrorGitRepoLabelsToGitea({ // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); + // Use provided giteaRepoName or fall back to repository.name + const repoName = giteaRepoName || repository.name; + // Verify the repository exists in Gitea before attempting to mirror metadata - console.log(`[Labels] Verifying repository ${repository.name} exists at ${giteaOwner}`); + console.log(`[Labels] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, - repoName: repository.name, + repoName: repoName, }); if (!repoExists) { - console.error(`[Labels] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror labels.`); - throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); + console.error(`[Labels] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror labels.`); + throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); @@ -1847,7 +1988,7 @@ export async function mirrorGitRepoLabelsToGitea({ // Get existing labels from Gitea const giteaLabelsRes = await httpGet( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -1862,7 +2003,7 @@ export async function mirrorGitRepoLabelsToGitea({ if (!existingLabels.has(label.name)) { try { await httpPost( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/labels`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/labels`, { name: label.name, color: `#${label.color}`, @@ -1889,11 +2030,13 @@ export async function mirrorGitRepoMilestonesToGitea({ octokit, repository, giteaOwner, + giteaRepoName, }: { config: Partial; octokit: Octokit; repository: Repository; giteaOwner: string; + giteaRepoName?: string; }) { if ( !config.githubConfig?.token || @@ -1906,17 +2049,20 @@ export async function mirrorGitRepoMilestonesToGitea({ // Decrypt config tokens for API usage const decryptedConfig = decryptConfigTokens(config as Config); + // Use provided giteaRepoName or fall back to repository.name + const repoName = giteaRepoName || repository.name; + // Verify the repository exists in Gitea before attempting to mirror metadata - console.log(`[Milestones] Verifying repository ${repository.name} exists at ${giteaOwner}`); + console.log(`[Milestones] Verifying repository ${repoName} exists at ${giteaOwner}`); const repoExists = await isRepoPresentInGitea({ config, owner: giteaOwner, - repoName: repository.name, + repoName: repoName, }); if (!repoExists) { - console.error(`[Milestones] Repository ${repository.name} not found at ${giteaOwner}. Cannot mirror milestones.`); - throw new Error(`Repository ${repository.name} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); + console.error(`[Milestones] Repository ${repoName} not found at ${giteaOwner}. Cannot mirror milestones.`); + throw new Error(`Repository ${repoName} does not exist in Gitea at ${giteaOwner}. Please ensure the repository is mirrored first.`); } const [owner, repo] = repository.fullName.split("/"); @@ -1942,7 +2088,7 @@ export async function mirrorGitRepoMilestonesToGitea({ // Get existing milestones from Gitea const giteaMilestonesRes = await httpGet( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/milestones`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`, { Authorization: `token ${decryptedConfig.giteaConfig.token}`, } @@ -1957,7 +2103,7 @@ export async function mirrorGitRepoMilestonesToGitea({ if (!existingMilestones.has(milestone.title)) { try { await httpPost( - `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repository.name}/milestones`, + `${config.giteaConfig.url}/api/v1/repos/${giteaOwner}/${repoName}/milestones`, { title: milestone.title, description: milestone.description || "", diff --git a/src/lib/github.ts b/src/lib/github.ts index cb0b1fb..d79cd1b 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -18,8 +18,11 @@ if (process.env.NODE_ENV !== "test") { } } -// Extend Octokit with throttling plugin -const MyOctokit = Octokit.plugin(throttling); +// Extend Octokit with throttling plugin when available (tests may stub Octokit) +// Fallback to base Octokit if .plugin is not present +const MyOctokit: any = (Octokit as any)?.plugin?.call + ? (Octokit as any).plugin(throttling) + : Octokit as any; /** * Creates an authenticated Octokit instance with rate limit tracking and throttling diff --git a/src/lib/repo-utils.test.ts b/src/lib/repo-utils.test.ts new file mode 100644 index 0000000..6265bd8 --- /dev/null +++ b/src/lib/repo-utils.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'bun:test'; +import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils'; +import type { GitRepo } from '@/types/Repository'; + +function sampleRepo(overrides: Partial = {}): GitRepo { + const base: GitRepo = { + name: 'repo', + fullName: 'owner/repo', + url: 'https://github.com/owner/repo', + cloneUrl: 'https://github.com/owner/repo.git', + owner: 'owner', + organization: undefined, + mirroredLocation: '', + destinationOrg: null, + isPrivate: false, + isForked: false, + forkedFrom: undefined, + hasIssues: true, + isStarred: false, + isArchived: false, + size: 1, + hasLFS: false, + hasSubmodules: false, + language: null, + description: null, + defaultBranch: 'main', + visibility: 'public', + status: 'imported', + lastMirrored: undefined, + errorMessage: undefined, + createdAt: new Date(), + updatedAt: new Date(), + }; + return { ...base, ...overrides }; +} + +describe('mergeGitReposPreferStarred', () => { + it('keeps unique repos', () => { + const basic = [sampleRepo({ fullName: 'a/x', name: 'x' })]; + const starred: GitRepo[] = []; + const merged = mergeGitReposPreferStarred(basic, starred); + expect(merged).toHaveLength(1); + expect(merged[0].fullName).toBe('a/x'); + }); + + it('prefers starred when duplicate exists', () => { + const basic = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: false })]; + const starred = [sampleRepo({ fullName: 'a/x', name: 'x', isStarred: true })]; + const merged = mergeGitReposPreferStarred(basic, starred); + expect(merged).toHaveLength(1); + expect(merged[0].isStarred).toBe(true); + }); +}); + +describe('normalizeGitRepoToInsert', () => { + it('sets undefined optional fields to null', () => { + const repo = sampleRepo({ organization: undefined, forkedFrom: undefined, language: undefined, description: undefined, lastMirrored: undefined, errorMessage: undefined }); + const insert = normalizeGitRepoToInsert(repo, { userId: 'u', configId: 'c' }); + expect(insert.organization).toBeNull(); + expect(insert.forkedFrom).toBeNull(); + expect(insert.language).toBeNull(); + expect(insert.description).toBeNull(); + expect(insert.lastMirrored).toBeNull(); + expect(insert.errorMessage).toBeNull(); + }); +}); + +describe('calcBatchSizeForInsert', () => { + it('respects 999 parameter limit', () => { + const batch = calcBatchSizeForInsert(29); + expect(batch).toBeGreaterThan(0); + expect(batch * 29).toBeLessThanOrEqual(999); + }); +}); + diff --git a/src/lib/repo-utils.ts b/src/lib/repo-utils.ts new file mode 100644 index 0000000..e33ce43 --- /dev/null +++ b/src/lib/repo-utils.ts @@ -0,0 +1,71 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { GitRepo } from '@/types/Repository'; +import { repositories } from '@/lib/db/schema'; + +export type RepoInsert = typeof repositories.$inferInsert; + +// Merge lists and de-duplicate by fullName, preferring starred variant when present +export function mergeGitReposPreferStarred( + basicAndForked: GitRepo[], + starred: GitRepo[] +): GitRepo[] { + const map = new Map(); + for (const r of [...basicAndForked, ...starred]) { + const existing = map.get(r.fullName); + if (!existing || (!existing.isStarred && r.isStarred)) { + map.set(r.fullName, r); + } + } + return Array.from(map.values()); +} + +// Convert a GitRepo to a normalized DB insert object with all nullable fields set +export function normalizeGitRepoToInsert( + repo: GitRepo, + { + userId, + configId, + }: { userId: string; configId: string } +): RepoInsert { + return { + id: uuidv4(), + userId, + configId, + name: repo.name, + fullName: repo.fullName, + url: repo.url, + cloneUrl: repo.cloneUrl, + owner: repo.owner, + organization: repo.organization ?? null, + mirroredLocation: repo.mirroredLocation || '', + destinationOrg: repo.destinationOrg || null, + isPrivate: repo.isPrivate, + isForked: repo.isForked, + forkedFrom: repo.forkedFrom ?? null, + hasIssues: repo.hasIssues, + isStarred: repo.isStarred, + isArchived: repo.isArchived, + size: repo.size, + hasLFS: repo.hasLFS, + hasSubmodules: repo.hasSubmodules, + language: repo.language ?? null, + description: repo.description ?? null, + defaultBranch: repo.defaultBranch, + visibility: repo.visibility, + status: 'imported', + lastMirrored: repo.lastMirrored ?? null, + errorMessage: repo.errorMessage ?? null, + createdAt: repo.createdAt || new Date(), + updatedAt: repo.updatedAt || new Date(), + }; +} + +// Compute a safe batch size based on SQLite 999-parameter limit +export function calcBatchSizeForInsert(columnCount: number, maxParams = 999): number { + if (columnCount <= 0) return 1; + // Reserve a little headroom in case column count drifts + const safety = 0; + const effectiveMax = Math.max(1, maxParams - safety); + return Math.max(1, Math.floor(effectiveMax / columnCount)); +} + diff --git a/src/lib/scheduler-service.ts b/src/lib/scheduler-service.ts index d51e954..b19219c 100644 --- a/src/lib/scheduler-service.ts +++ b/src/lib/scheduler-service.ts @@ -11,6 +11,7 @@ import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption'; import { parseInterval, formatDuration } from '@/lib/utils/duration-parser'; import type { Repository } from '@/lib/db/schema'; import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository'; +import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils'; let schedulerInterval: NodeJS.Timeout | null = null; let isSchedulerRunning = false; @@ -94,8 +95,7 @@ async function runScheduledSync(config: any): Promise { ? getGithubStarredRepositories({ octokit, config }) : Promise.resolve([]), ]); - - const allGithubRepos = [...basicAndForkedRepos, ...starredRepos]; + const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos); // Check for new repositories const existingRepos = await db @@ -110,37 +110,21 @@ async function runScheduledSync(config: any): Promise { console.log(`[Scheduler] Found ${newRepos.length} new repositories for user ${userId}`); // Insert new repositories - const reposToInsert = newRepos.map(repo => ({ - id: uuidv4(), - userId, - configId: config.id, - name: repo.name, - fullName: repo.fullName, - url: repo.url, - cloneUrl: repo.cloneUrl, - owner: repo.owner, - organization: repo.organization, - mirroredLocation: repo.mirroredLocation || "", - destinationOrg: repo.destinationOrg || null, - isPrivate: repo.isPrivate, - isForked: repo.isForked, - forkedFrom: repo.forkedFrom, - hasIssues: repo.hasIssues, - isStarred: repo.isStarred, - isArchived: repo.isArchived, - size: repo.size, - hasLFS: repo.hasLFS, - hasSubmodules: repo.hasSubmodules, - language: repo.language || null, - description: repo.description || null, - defaultBranch: repo.defaultBranch, - visibility: repo.visibility, - status: 'imported', - createdAt: new Date(), - updatedAt: new Date(), - })); + const reposToInsert = newRepos.map(repo => + normalizeGitRepoToInsert(repo, { userId, configId: config.id }) + ); - await db.insert(repositories).values(reposToInsert); + // Batch insert to avoid SQLite parameter limit + const sample = reposToInsert[0]; + const columnCount = Object.keys(sample ?? {}).length || 1; + const BATCH_SIZE = calcBatchSizeForInsert(columnCount); + for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) { + const batch = reposToInsert.slice(i, i + BATCH_SIZE); + await db + .insert(repositories) + .values(batch) + .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + } console.log(`[Scheduler] Successfully imported ${newRepos.length} new repositories for user ${userId}`); } else { console.log(`[Scheduler] No new repositories found for user ${userId}`); @@ -375,8 +359,7 @@ async function performInitialAutoStart(): Promise { ? getGithubStarredRepositories({ octokit, config }) : Promise.resolve([]), ]); - - const allGithubRepos = [...basicAndForkedRepos, ...starredRepos]; + const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos); // Check for new repositories const existingRepos = await db @@ -391,37 +374,21 @@ async function performInitialAutoStart(): Promise { console.log(`[Scheduler] Importing ${reposToImport.length} repositories for user ${config.userId}...`); // Insert new repositories - const reposToInsert = reposToImport.map(repo => ({ - id: uuidv4(), - userId: config.userId, - configId: config.id, - name: repo.name, - fullName: repo.fullName, - url: repo.url, - cloneUrl: repo.cloneUrl, - owner: repo.owner, - organization: repo.organization, - mirroredLocation: repo.mirroredLocation || "", - destinationOrg: repo.destinationOrg || null, - isPrivate: repo.isPrivate, - isForked: repo.isForked, - forkedFrom: repo.forkedFrom, - hasIssues: repo.hasIssues, - isStarred: repo.isStarred, - isArchived: repo.isArchived, - size: repo.size, - hasLFS: repo.hasLFS, - hasSubmodules: repo.hasSubmodules, - language: repo.language || null, - description: repo.description || null, - defaultBranch: repo.defaultBranch, - visibility: repo.visibility, - status: 'imported', - createdAt: new Date(), - updatedAt: new Date(), - })); + const reposToInsert = reposToImport.map(repo => + normalizeGitRepoToInsert(repo, { userId: config.userId, configId: config.id }) + ); - await db.insert(repositories).values(reposToInsert); + // Batch insert to avoid SQLite parameter limit + const sample = reposToInsert[0]; + const columnCount = Object.keys(sample ?? {}).length || 1; + const BATCH_SIZE = calcBatchSizeForInsert(columnCount); + for (let i = 0; i < reposToInsert.length; i += BATCH_SIZE) { + const batch = reposToInsert.slice(i, i + BATCH_SIZE); + await db + .insert(repositories) + .values(batch) + .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + } console.log(`[Scheduler] Successfully imported ${reposToImport.length} repositories`); } else { console.log(`[Scheduler] No new repositories to import for user ${config.userId}`); @@ -697,4 +664,4 @@ export function stopSchedulerService(): void { */ export function isSchedulerServiceRunning(): boolean { return schedulerInterval !== null; -} \ No newline at end of file +} diff --git a/src/pages/api/sync/index.ts b/src/pages/api/sync/index.ts index 2840d5d..1f08fdd 100644 --- a/src/pages/api/sync/index.ts +++ b/src/pages/api/sync/index.ts @@ -10,6 +10,7 @@ import { getGithubStarredRepositories, } from "@/lib/github"; import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; +import { mergeGitReposPreferStarred, calcBatchSizeForInsert } from "@/lib/repo-utils"; import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; export const POST: APIRoute = async ({ request }) => { @@ -55,7 +56,8 @@ export const POST: APIRoute = async ({ request }) => { getGithubOrganizations({ octokit, config }), ]); - const allGithubRepos = [...basicAndForkedRepos, ...starredRepos]; + // Merge and de-duplicate by fullName, preferring starred variant when duplicated + const allGithubRepos = mergeGitReposPreferStarred(basicAndForkedRepos, starredRepos); // Prepare full list of repos and orgs const newRepos = allGithubRepos.map((repo) => ({ @@ -67,25 +69,25 @@ export const POST: APIRoute = async ({ request }) => { url: repo.url, cloneUrl: repo.cloneUrl, owner: repo.owner, - organization: repo.organization, + organization: repo.organization ?? null, mirroredLocation: repo.mirroredLocation || "", destinationOrg: repo.destinationOrg || null, isPrivate: repo.isPrivate, isForked: repo.isForked, - forkedFrom: repo.forkedFrom, + forkedFrom: repo.forkedFrom ?? null, hasIssues: repo.hasIssues, isStarred: repo.isStarred, isArchived: repo.isArchived, size: repo.size, hasLFS: repo.hasLFS, hasSubmodules: repo.hasSubmodules, - language: repo.language || null, - description: repo.description || null, + language: repo.language ?? null, + description: repo.description ?? null, defaultBranch: repo.defaultBranch, visibility: repo.visibility, status: repo.status, - lastMirrored: repo.lastMirrored, - errorMessage: repo.errorMessage, + lastMirrored: repo.lastMirrored ?? null, + errorMessage: repo.errorMessage ?? null, createdAt: repo.createdAt, updatedAt: repo.updatedAt, })); @@ -128,12 +130,27 @@ export const POST: APIRoute = async ({ request }) => { ); insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name)); + // Batch insert repositories to avoid SQLite parameter limit (dynamic by column count) + const sample = newRepos[0]; + const columnCount = Object.keys(sample ?? {}).length || 1; + const REPO_BATCH_SIZE = calcBatchSizeForInsert(columnCount); if (insertedRepos.length > 0) { - await tx.insert(repositories).values(insertedRepos); + for (let i = 0; i < insertedRepos.length; i += REPO_BATCH_SIZE) { + const batch = insertedRepos.slice(i, i + REPO_BATCH_SIZE); + await tx + .insert(repositories) + .values(batch) + .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + } } + // Batch insert organizations (they have fewer fields, so we can use larger batches) + const ORG_BATCH_SIZE = 100; if (insertedOrgs.length > 0) { - await tx.insert(organizations).values(insertedOrgs); + for (let i = 0; i < insertedOrgs.length; i += ORG_BATCH_SIZE) { + const batch = insertedOrgs.slice(i, i + ORG_BATCH_SIZE); + await tx.insert(organizations).values(batch); + } } }); diff --git a/src/pages/api/sync/organization.ts b/src/pages/api/sync/organization.ts index 9638333..6f40e1d 100644 --- a/src/pages/api/sync/organization.ts +++ b/src/pages/api/sync/organization.ts @@ -122,25 +122,36 @@ export const POST: APIRoute = async ({ request }) => { destinationOrg: null, isPrivate: repo.private, isForked: repo.fork, - forkedFrom: undefined, + 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, + language: repo.language ?? null, + description: repo.description ?? null, defaultBranch: repo.default_branch ?? "main", visibility: (repo.visibility ?? "public") as RepositoryVisibility, status: "imported" as RepoStatus, - lastMirrored: undefined, - errorMessage: undefined, + 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(), })); - await db.insert(repositories).values(repoRecords); + // Batch insert repositories to avoid SQLite parameter limit + // Compute batch size based on column count + const sample = repoRecords[0]; + const columnCount = Object.keys(sample ?? {}).length || 1; + const BATCH_SIZE = Math.max(1, Math.floor(999 / columnCount)); + for (let i = 0; i < repoRecords.length; i += BATCH_SIZE) { + const batch = repoRecords.slice(i, i + BATCH_SIZE); + await db + .insert(repositories) + .values(batch) + .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); + } // Insert organization metadata const organizationRecord = { diff --git a/src/pages/api/sync/repository.ts b/src/pages/api/sync/repository.ts index 3d09eac..fed0590 100644 --- a/src/pages/api/sync/repository.ts +++ b/src/pages/api/sync/repository.ts @@ -80,25 +80,23 @@ export const POST: APIRoute = async ({ request }) => { cloneUrl: repoData.clone_url, owner: repoData.owner.login, organization: - repoData.owner.type === "Organization" - ? repoData.owner.login - : undefined, + repoData.owner.type === "Organization" ? repoData.owner.login : null, isPrivate: repoData.private, isForked: repoData.fork, - forkedFrom: undefined, + forkedFrom: null, hasIssues: repoData.has_issues, isStarred: false, isArchived: repoData.archived, size: repoData.size, hasLFS: false, hasSubmodules: false, - language: repoData.language || null, - description: repoData.description || null, + language: repoData.language ?? null, + description: repoData.description ?? null, defaultBranch: repoData.default_branch, visibility: (repoData.visibility ?? "public") as RepositoryVisibility, status: "imported" as Repository["status"], - lastMirrored: undefined, - errorMessage: undefined, + lastMirrored: null, + errorMessage: null, mirroredLocation: "", destinationOrg: null, createdAt: repoData.created_at @@ -109,7 +107,10 @@ export const POST: APIRoute = async ({ request }) => { : new Date(), }; - await db.insert(repositories).values(metadata); + await db + .insert(repositories) + .values(metadata) + .onConflictDoNothing({ target: [repositories.userId, repositories.fullName] }); createMirrorJob({ userId, diff --git a/src/types/config.ts b/src/types/config.ts index 240ac76..98bf15a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -29,11 +29,14 @@ export interface DatabaseCleanupConfig { nextRun?: Date; } +export type DuplicateNameStrategy = "suffix" | "prefix" | "owner-org"; + export interface GitHubConfig { username: string; token: string; privateRepositories: boolean; mirrorStarred: boolean; + starredDuplicateStrategy?: DuplicateNameStrategy; } export interface MirrorOptions {