From 0f700c7197c69bdba85d911fa8ec3473498b2815 Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Thu, 20 Aug 2026 06:03:19 +0530 Subject: [PATCH] feat: per-repository and per-organization mirror option overrides (#362) * feat: per-repository and per-organization mirror option overrides Closes the gap reported in #361: a repository whose LFS fetch fails could not be mirrored at all, because LFS was a single global switch. Gitea runs the LFS fetch inside its own migration, so a failure aborts the whole migration with nothing to salvage. The only fix available to us is to stop asking for LFS on that repository. Mirror options now resolve across three tiers, per flag, most specific first: repository override, then organization override, then the global config. NULL at a tier means inherit, so overriding LFS on one repo leaves its other options alone. Adds resolveMirrorOptions(), which replaces 24 scattered reads of config.giteaConfig.* across three near-identical blocks: the two mirror paths in gitea.ts and the sync path in gitea-enhanced.ts. The third was easy to miss and mattered: without it, overrides would have been honored on first mirror and silently ignored on every scheduled sync afterwards. The starredCodeOnly clamp is folded into the resolver and deliberately outranks explicit overrides, preserving the existing behavior that starred repos mirror code only. Editing is on the objects themselves, via the existing three-dot menus on the Repositories and Organizations pages, not in Configuration, which keeps holding the global defaults. A repositories filter and a row badge make it possible to find which repos deviate. Malformed override JSON degrades to "inherit" rather than throwing, so a bad value can never break a mirror run. * fix(ui): disable mirror toggles that cannot take effect, and explain why A toggle the runtime will ignore should not look editable. Three cases were doing exactly that, so they now share one mechanism: getMirrorOverrideGating() returns a reason string per flag, and the dialog disables the control and prints that reason underneath. Starred clamp. When a repo is starred and starredCodeOnly is set, the resolver forces every metadata flag off regardless of the override. The dialog previously let the user set them anyway. The clamped set now lives in STARRED_CLAMPED_KEYS, shared by the resolver and the gating helper so the flags the UI disables cannot drift from the ones the runtime clamps. LFS is deliberately excluded from that set, since turning LFS off per repository is the point of #361, and a test pins that. Labels. shouldMirrorLabels is `mirrorLabels && !mirrorIssues` in both mirror paths, so labels cannot take effect while issues are mirrored. That gate reacts live to the in-progress edit rather than only the saved state. Inherit hint. For a repository the hint now reflects global -> org instead of global alone, via a new name-keyed GET /api/organizations/mirror-overrides that reuses loadOrganizationMirrorOverrides. Personal repos skip the fetch, a failed fetch degrades to the global values rather than blocking the dialog, and the hint is suppressed while in flight so it is never briefly wrong. Widens the UI to mirrorLabels and mirrorMilestones. mirrorMetadata stays out: no mirror path reads it, so a per-object override would resolve correctly and then do nothing. See the report for that finding. useGiteaConfig additionally returns advancedOptions so callers can read starredCodeOnly without a second request. * fix(ui): read inherited mirror flags from mirrorOptions, not giteaConfig /api/config does not return the mirror flags on giteaConfig. On the way out, mapDbToUiConfig reshapes them into a separate mirrorOptions object using different names (mirrorLFS, not lfs) and nesting the metadata flags under metadataComponents with short names (issues, not mirrorIssues). The dialog was written against the DB shape, so every lookup returned undefined. Coerced to false, that is indistinguishable from a real "off", which produced two symptoms: every inherit hint read "currently off" regardless of the actual global config, and the labels gate never fired because the effective issues value it keys on came from the same dead source. Adds mirrorOptionsToFlags() as the single conversion point, matching the derivation in mapUiToDbConfig exactly, including that mirrorMetadata is a master switch over the metadata components while lfs and mirrorReleases sit outside it. Both dialogs now go through it, and useGiteaConfig returns mirrorOptions alongside advancedOptions. Server-side mirroring was never affected: the resolver reads config straight from the DB, where the flags really do live on giteaConfig. The existing tests could not catch this because they build a synthetic config already in DB shape, so a client/server mismatch is invisible to them. The new tests push flags through the real config-mapper in both directions and assert the derived flags equal what mapUiToDbConfig would persist, so they fail if that mapping changes again. One test pins the root cause directly: that giteaConfig in the API payload carries no flags. * fix(ui): surface the mirror-options filter on desktop, and on organizations The overrides filter only existed inside the mobile filter drawer, so on desktop the Repositories page showed a "Custom" badge on overridden rows with no way to filter to them. Being able to answer "which repos deviate from my defaults" on this page is the reason the Configuration-page listing was dropped, so desktop could not do the one job it was given. Adds the control to the desktop filter row next to status and sort, using the bare Select-with-placeholder style those use rather than the drawer's labelled markup. The mobile control is unchanged. The Organizations page had a wider version of the same gap: it renders the "Custom options" badge but never had this filter on either layout, and OrganizationsList did not filter on it at all. Added the predicate plus the control in both its layouts, so the two pages behave the same. Also folds hasOverrides into activeFilterCount on both pages and into the Organizations clear-all reset. Without that the mobile filter badge undercounted an active overrides filter, and clearing filters left it set. * fix(ui): stop double-applying the mirrorMetadata switch when reading config mirrorOptionsToFlags ANDed each metadata component with mirrorMetadata on the way in. That is a write-path rule: mapUiToDbConfig already applies it when persisting, so a config saved through the settings UI has it baked into the stored flag. Applying it again on read double-applies it. The read path does not need it. mapDbToUiConfig puts the raw stored values into metadataComponents, so metadataComponents.issues is exactly giteaConfig.mirrorIssues, which is the field gitea.ts and gitea-enhanced.ts read. Mapping the components straight through is 1:1 with runtime behavior. Double-applying was invisible while the stored state was self-consistent, since false && false is still false. It diverged when the state did not come from the UI write path, which env vars allow: MIRROR_METADATA=false with MIRROR_ISSUES=true stores mirrorMetadata:false, mirrorIssues:true. The runtime mirrors issues; the dialog reported off. Measured against a config in that state, five flags misreported. I previously described this as an unrecoverable loss in the API shape. That was wrong. Both values reach the client separately and unmerged, so the payload carries full information and the loss was one we introduced. Rewrites the test that asserted the derived flags match what mapUiToDbConfig would persist. Comparing against the write derivation is what pinned the bug in place. It now asserts the property that matters, that flags derived from the API payload equal the stored flags the runtime reads, plus a case checking agreement with resolveMirrorOptions on an inconsistent config. Both fail if the AND returns. --- drizzle/0014_needy_white_tiger.sql | 2 + drizzle/meta/0014_snapshot.json | 2389 +++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/validate-migrations.ts | 64 + .../config/MirrorOverridesDialog.tsx | 245 ++ src/components/organizations/Organization.tsx | 55 +- .../organizations/OrganizationsList.tsx | 84 +- src/components/repositories/Repository.tsx | 56 +- .../repositories/RepositoryTable.tsx | 152 +- src/hooks/useGiteaConfig.ts | 22 +- src/lib/db/schema.ts | 32 + src/lib/gitea-enhanced.ts | 27 +- src/lib/gitea.ts | 99 +- src/lib/utils/mirror-overrides.test.ts | 610 +++++ src/lib/utils/mirror-overrides.ts | 375 +++ src/pages/api/organizations/[id].ts | 61 +- .../api/organizations/mirror-overrides.ts | 47 + src/pages/api/repositories/[id].ts | 65 +- src/types/filter.ts | 1 + 19 files changed, 4291 insertions(+), 102 deletions(-) create mode 100644 drizzle/0014_needy_white_tiger.sql create mode 100644 drizzle/meta/0014_snapshot.json create mode 100644 src/components/config/MirrorOverridesDialog.tsx create mode 100644 src/lib/utils/mirror-overrides.test.ts create mode 100644 src/lib/utils/mirror-overrides.ts create mode 100644 src/pages/api/organizations/mirror-overrides.ts diff --git a/drizzle/0014_needy_white_tiger.sql b/drizzle/0014_needy_white_tiger.sql new file mode 100644 index 0000000..2c6f6ef --- /dev/null +++ b/drizzle/0014_needy_white_tiger.sql @@ -0,0 +1,2 @@ +ALTER TABLE `organizations` ADD `mirror_overrides` text;--> statement-breakpoint +ALTER TABLE `repositories` ADD `mirror_overrides` text; \ No newline at end of file diff --git a/drizzle/meta/0014_snapshot.json b/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..5a86d8f --- /dev/null +++ b/drizzle/meta/0014_snapshot.json @@ -0,0 +1,2389 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "367fa60e-8b76-4b55-8be8-a41a099a5c3c", + "prevId": "6beb5116-f397-4dcb-ac73-f2b81079f61d", + "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 + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "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 + }, + "notification_config": { + "name": "notification_config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"enabled\":false,\"provider\":\"ntfy\",\"notifyOnSyncError\":true,\"notifyOnSyncSuccess\":false,\"notifyOnNewRepo\":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": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_oauth_access_tokens_token": { + "name": "idx_oauth_access_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_client_id": { + "name": "idx_oauth_access_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_access_tokens_user_id": { + "name": "idx_oauth_access_tokens_user_id", + "columns": [ + "user_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_clients": { + "name": "oauth_clients", + "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": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "oauth_clients_client_id_unique": { + "name": "oauth_clients_client_id_unique", + "columns": [ + "client_id" + ], + "isUnique": true + }, + "idx_oauth_clients_client_id": { + "name": "idx_oauth_clients_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_clients_user_id": { + "name": "idx_oauth_clients_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_clients_user_id_users_id_fk": { + "name": "oauth_clients_user_id_users_id_fk", + "tableFrom": "oauth_clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consents": { + "name": "oauth_consents", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "idx_oauth_consents_client_id": { + "name": "idx_oauth_consents_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_consents_user_id": { + "name": "idx_oauth_consents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consents_user_id_users_id_fk": { + "name": "oauth_consents_user_id_users_id_fk", + "tableFrom": "oauth_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_tokens": { + "name": "oauth_refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_tokens_token_unique": { + "name": "oauth_refresh_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_oauth_refresh_tokens_token": { + "name": "idx_oauth_refresh_tokens_token", + "columns": [ + "token" + ], + "isUnique": false + }, + "idx_oauth_refresh_tokens_client_id": { + "name": "idx_oauth_refresh_tokens_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_refresh_tokens_user_id": { + "name": "idx_oauth_refresh_tokens_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_tokens_user_id_users_id_fk": { + "name": "oauth_refresh_tokens_user_id_users_id_fk", + "tableFrom": "oauth_refresh_tokens", + "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 + }, + "normalized_name": { + "name": "normalized_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 + }, + "mirror_overrides": { + "name": "mirror_overrides", + "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 + }, + "uniq_organizations_user_normalized_name": { + "name": "uniq_organizations_user_normalized_name", + "columns": [ + "user_id", + "normalized_name" + ], + "isUnique": true + } + }, + "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 + }, + "normalized_full_name": { + "name": "normalized_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 + }, + "mirror_overrides": { + "name": "mirror_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "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 + }, + "idx_repositories_user_imported_at": { + "name": "idx_repositories_user_imported_at", + "columns": [ + "user_id", + "imported_at" + ], + "isUnique": false + }, + "uniq_repositories_user_full_name": { + "name": "uniq_repositories_user_full_name", + "columns": [ + "user_id", + "full_name" + ], + "isUnique": true + }, + "uniq_repositories_user_normalized_full_name": { + "name": "uniq_repositories_user_normalized_full_name", + "columns": [ + "user_id", + "normalized_full_name" + ], + "isUnique": true + }, + "idx_repositories_mirrored_location": { + "name": "idx_repositories_mirrored_location", + "columns": [ + "user_id", + "mirrored_location" + ], + "isUnique": false + } + }, + "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 + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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 06adde3..01e5cd9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1780377747526, "tag": "0013_slim_galactus", "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1787182504975, + "tag": "0014_needy_white_tiger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/validate-migrations.ts b/scripts/validate-migrations.ts index a91e1cd..3cf1046 100644 --- a/scripts/validate-migrations.ts +++ b/scripts/validate-migrations.ts @@ -294,6 +294,66 @@ function verify0013Migration(db: any) { assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`); } +function seedPre0014Database(db: any) { + // Migrations 0000-0013 have run, so repositories/organizations exist but + // lack mirror_overrides. Seed one of each so the column-add can be verified + // against pre-existing rows (they must come out NULL = "inherit"). + db.run("INSERT INTO users (id, email, username, name) VALUES ('u-ovr', 'ovr@example.com', 'ovr', 'Override User')"); + db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre14', 'u-ovr', 'Default', 1, '{}', '{}', '{}', '{}')"); + db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre14', 'u-ovr', 'cfg-pre14', 'ExampleOrg', 'https://example.com/a.png', 'exampleorg')"); + db.run( + "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch) " + + "VALUES ('repo-pre14', 'u-ovr', 'cfg-pre14', 'browser-use', 'ExampleOrg/browser-use', 'exampleorg/browser-use', 'https://github.com/ExampleOrg/browser-use', 'https://github.com/ExampleOrg/browser-use.git', 'ExampleOrg', 'main')", + ); +} + +function verify0014Migration(db: any) { + // Both tables gained a nullable mirror_overrides column with no default. + for (const table of ["repositories", "organizations"]) { + const cols = db + .query(`PRAGMA table_info(${table})`) + .all() as Array<{ name: string; type: string; notnull: number; dflt_value: string | null }>; + const col = cols.find((c) => c.name === "mirror_overrides"); + assert(col, `Expected ${table}.mirror_overrides column to exist`); + assert(col.notnull === 0, `Expected ${table}.mirror_overrides to be nullable`); + assert( + col.dflt_value === null, + `Expected ${table}.mirror_overrides to have no default, got ${col.dflt_value}`, + ); + } + + // Pre-existing rows inherit (NULL), rather than being backfilled with {}. + const repoRow = db + .query("SELECT id, mirror_overrides FROM repositories WHERE id = 'repo-pre14'") + .get() as { id: string; mirror_overrides: string | null } | null; + assert(repoRow, "Expected pre-existing repository row to survive migration"); + assert( + repoRow.mirror_overrides === null, + `Expected existing repository to inherit (NULL mirror_overrides), got ${repoRow.mirror_overrides}`, + ); + + const orgRow = db + .query("SELECT id, mirror_overrides FROM organizations WHERE id = 'org-pre14'") + .get() as { id: string; mirror_overrides: string | null } | null; + assert(orgRow, "Expected pre-existing organization row to survive migration"); + assert( + orgRow.mirror_overrides === null, + `Expected existing organization to inherit (NULL mirror_overrides), got ${orgRow.mirror_overrides}`, + ); + + // The new column round-trips a JSON overrides payload (the #361 case: + // mirror this repo but skip its LFS objects). + db.run( + "UPDATE repositories SET mirror_overrides = '{\"lfs\":false}' WHERE id = 'repo-pre14'", + ); + const updated = db + .query("SELECT mirror_overrides FROM repositories WHERE id = 'repo-pre14'") + .get() as { mirror_overrides: string } | null; + assert(updated, "Expected repository row after override update"); + const parsed = JSON.parse(updated.mirror_overrides); + assert(parsed.lfs === false, `Expected persisted lfs override false, got ${updated.mirror_overrides}`); +} + const MIGRATION_0012_TIMESTAMP = 1774062000000; const MIGRATION_0013_TIMESTAMP = 1780377747526; @@ -409,6 +469,10 @@ const latestUpgradeFixtures: Record = { seed: seedPre0013Database, verify: verify0013Migration, }, + "0014_needy_white_tiger": { + seed: seedPre0014Database, + verify: verify0014Migration, + }, }; function lintMigrations(selectedMigrations: Migration[]) { diff --git a/src/components/config/MirrorOverridesDialog.tsx b/src/components/config/MirrorOverridesDialog.tsx new file mode 100644 index 0000000..68c14bc --- /dev/null +++ b/src/components/config/MirrorOverridesDialog.tsx @@ -0,0 +1,245 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { MirrorOverrides } from "@/lib/db/schema"; +import { + getMirrorOverrideGating, + MIRROR_OVERRIDE_LABELS, + UI_MIRROR_OVERRIDE_KEYS, + type MirrorOverrideKey, +} from "@/lib/utils/mirror-overrides"; + +/** + * Tri-state value for one flag in the dialog. "inherit" persists as absent, + * which is what lets the next tier out supply the value. + */ +type TriState = "inherit" | "on" | "off"; + +function toTriState(value: boolean | null | undefined): TriState { + if (value === true) return "on"; + if (value === false) return "off"; + return "inherit"; +} + +function fromTriState(value: TriState): boolean | undefined { + if (value === "on") return true; + if (value === "off") return false; + return undefined; +} + +export interface MirrorOverridesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Display name of the repo or org being edited. */ + targetName: string; + /** "repository" or "organization", used for wording and gating rules. */ + targetKind: "repository" | "organization"; + value?: MirrorOverrides | null; + /** + * Effective values from the tiers above this one, used to label what + * "Inherit" actually resolves to right now. For a repository this should + * already be global merged with its organization's overrides. + */ + inheritedFrom?: Partial>; + inheritedLabel?: string; + /** + * True while the inherited values are still being fetched. The hint is + * suppressed rather than shown wrong and then corrected. + */ + inheritedLoading?: boolean; + /** Repository-only: drives the starred-code-only gating. */ + isStarred?: boolean; + starredCodeOnly?: boolean; + onSave: (overrides: MirrorOverrides | null) => Promise; +} + +export function MirrorOverridesDialog({ + open, + onOpenChange, + targetName, + targetKind, + value, + inheritedFrom, + inheritedLabel = "global settings", + inheritedLoading = false, + isStarred, + starredCodeOnly, + onSave, +}: MirrorOverridesDialogProps) { + const [draft, setDraft] = useState>( + () => buildDraft(value) + ); + const [isSaving, setIsSaving] = useState(false); + + // Re-seed whenever the dialog opens or targets a different object, so a + // previous edit never leaks into the next one. + useEffect(() => { + if (open) setDraft(buildDraft(value)); + }, [open, value]); + + // What each flag currently resolves to, including the in-progress edit. + // Drives the labels gate so it reacts live as issues is toggled. + const effective = useMemo(() => { + const next: Partial> = {}; + for (const key of UI_MIRROR_OVERRIDE_KEYS) { + const pinned = fromTriState(draft[key]); + next[key] = pinned ?? inheritedFrom?.[key] ?? false; + } + return next; + }, [draft, inheritedFrom]); + + const gating = useMemo( + () => + getMirrorOverrideGating({ + targetKind, + isStarred, + starredCodeOnly, + effective, + }), + [targetKind, isStarred, starredCodeOnly, effective] + ); + + const overriddenCount = useMemo( + () => Object.values(draft).filter((state) => state !== "inherit").length, + [draft] + ); + + const handleSave = async () => { + setIsSaving(true); + try { + // Gated flags keep whatever the user previously stored. The clamp is a + // runtime concern, so a disabled toggle must not silently erase data. + const next: MirrorOverrides = {}; + for (const key of UI_MIRROR_OVERRIDE_KEYS) { + const resolved = fromTriState(draft[key]); + if (typeof resolved === "boolean") next[key] = resolved; + } + await onSave(Object.keys(next).length > 0 ? next : null); + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + const handleResetAll = () => { + setDraft(buildDraft(null)); + }; + + return ( + + + + Mirror options + + Override what gets mirrored for this {targetKind}.{" "} + {targetName}{" "} + inherits anything left on Inherit. + + + +
+ {UI_MIRROR_OVERRIDE_KEYS.map((key) => { + const disabledReason = gating[key]; + const isDisabled = !!disabledReason; + const inherited = inheritedFrom?.[key]; + const showHint = + !inheritedLoading && + !isDisabled && + draft[key] === "inherit" && + inherited !== undefined; + + return ( +
+
+ + +
+ {isDisabled && ( +

+ {disabledReason} +

+ )} +
+ ); + })} +
+ +

+ Inherit uses the {inheritedLabel}. Git LFS is worth turning off for + repositories whose LFS fetch fails, since Gitea aborts the whole + migration when it cannot pull them. +

+ + + + + + +
+
+ ); +} + +function buildDraft( + value: MirrorOverrides | null | undefined +): Record { + const draft = {} as Record; + for (const key of UI_MIRROR_OVERRIDE_KEYS) { + draft[key] = toTriState(value?.[key]); + } + return draft; +} diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index 8c4d657..12c15e3 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -475,7 +475,7 @@ export function Organization() { // Check if any filters are active const hasActiveFilters = !!(filter.membershipRole || filter.status); - const activeFilterCount = [filter.membershipRole, filter.status].filter(Boolean).length; + const activeFilterCount = [filter.membershipRole, filter.status, filter.hasOverrides].filter(Boolean).length; // Clear all filters const clearFilters = () => { @@ -483,6 +483,7 @@ export function Organization() { searchTerm: filter.searchTerm, membershipRole: "", status: "", + hasOverrides: "", }); }; @@ -653,6 +654,37 @@ export function Organization() { + + {/* Mirror Options Filter */} +
+ + +
@@ -793,6 +825,27 @@ export function Organization() { ))} + + {/* Mirror Options Filter */} + {/* Action buttons */} diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index 654f9be..5be0c70 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -1,16 +1,18 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, Trash2 } from "lucide-react"; +import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2 } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; -import type { Organization } from "@/lib/db/schema"; +import type { MirrorOverrides, Organization } from "@/lib/db/schema"; import type { FilterParams } from "@/types/filter"; import Fuse from "fuse.js"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { buildGiteaWebUrl } from "@/lib/gitea-url"; import { MirrorDestinationEditor } from "./MirrorDestinationEditor"; +import { MirrorOverridesDialog } from "@/components/config/MirrorOverridesDialog"; +import { hasMirrorOverrides, mirrorOptionsToFlags } from "@/lib/utils/mirror-overrides"; import { useGiteaConfig } from "@/hooks/useGiteaConfig"; import { withBase } from "@/lib/base-path"; import { @@ -65,7 +67,28 @@ export function OrganizationList({ onRefresh, onDelete, }: OrganizationListProps) { - const { giteaConfig } = useGiteaConfig(); + const { giteaConfig, mirrorOptions } = useGiteaConfig(); + const [overridesTarget, setOverridesTarget] = useState(null); + + const handleUpdateMirrorOverrides = async ( + orgId: string, + overrides: MirrorOverrides | null + ) => { + const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mirrorOverrides: overrides }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to update mirror options"); + } + + if (onRefresh) { + await onRefresh(); + } + }; // Helper function to construct Gitea organization URL const getGiteaOrgUrl = (organization: Organization): string | null => { @@ -122,6 +145,13 @@ export function OrganizationList({ result = result.filter((org) => org.status === filter.status); } + if (filter.hasOverrides) { + const wantOverridden = filter.hasOverrides === "overridden"; + result = result.filter( + (org) => hasMirrorOverrides(org.mirrorOverrides) === wantOverridden + ); + } + if (filter.searchTerm) { const fuse = new Fuse(result, { keys: ["name", "type"], @@ -281,13 +311,24 @@ export function OrganizationList({ {/* Status badge */} - - {StatusIcon && } - {statusBadge.label} - +
+ {hasMirrorOverrides(org.mirrorOverrides) && ( + + Custom options + + )} + + {StatusIcon && } + {statusBadge.label} + +
{/* Destination override section */} @@ -429,6 +470,10 @@ export function OrganizationList({ + setOverridesTarget(org)}> + + Mirror Options + {org.status !== "ignored" && ( org.id && onIgnore && onIgnore({ orgId: org.id, ignore: true })} @@ -685,6 +730,23 @@ export function OrganizationList({ ); })} + + { + if (!open) setOverridesTarget(null); + }} + targetKind="organization" + targetName={overridesTarget?.name ?? ""} + value={overridesTarget?.mirrorOverrides ?? null} + inheritedFrom={mirrorOptionsToFlags(mirrorOptions)} + inheritedLabel="global settings" + onSave={async (overrides) => { + if (overridesTarget?.id) { + await handleUpdateMirrorOverrides(overridesTarget.id, overrides); + } + }} + /> ); } diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index 1d198aa..ca72c89 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -1028,7 +1028,7 @@ export default function Repository() { // Check if any filters are active const hasActiveFilters = !!(filter.owner || filter.organization || filter.status); - const activeFilterCount = [filter.owner, filter.organization, filter.status].filter(Boolean).length; + const activeFilterCount = [filter.owner, filter.organization, filter.status, filter.hasOverrides].filter(Boolean).length; // Clear all filters const clearFilters = () => { @@ -1037,6 +1037,7 @@ export default function Repository() { status: "", organization: "", owner: "", + hasOverrides: "", sort: filter.sort || "imported-desc", }); }; @@ -1179,6 +1180,39 @@ export default function Repository() { + {/* Mirror Options Filter */} +
+ + +
+ {/* Sort Filter */}