diff --git a/.github/screenshots/org-source/organizations-add-dialog-source-options.png b/.github/screenshots/org-source/organizations-add-dialog-source-options.png new file mode 100644 index 0000000..a9e44cb Binary files /dev/null and b/.github/screenshots/org-source/organizations-add-dialog-source-options.png differ diff --git a/.github/screenshots/org-source/organizations-add-dialog-source.png b/.github/screenshots/org-source/organizations-add-dialog-source.png new file mode 100644 index 0000000..77769c0 Binary files /dev/null and b/.github/screenshots/org-source/organizations-add-dialog-source.png differ diff --git a/.github/screenshots/org-source/organizations-inline-source-editor.png b/.github/screenshots/org-source/organizations-inline-source-editor.png new file mode 100644 index 0000000..0d5b8d5 Binary files /dev/null and b/.github/screenshots/org-source/organizations-inline-source-editor.png differ diff --git a/.github/screenshots/org-source/organizations-source-selection.png b/.github/screenshots/org-source/organizations-source-selection.png new file mode 100644 index 0000000..5d00f47 Binary files /dev/null and b/.github/screenshots/org-source/organizations-source-selection.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index cba9012..6ff981d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Per-organization source selection for multi-source accounts + - Organizations remember which source they import and mirror from; migration 0020 backfills the pin from each organization's repositories when they agree on one + - Source picker in the add-organization dialog and on organization cards when more than one source is connected + - `POST /api/sync/organization` persists `sourceId`, and `PATCH /api/organizations/:id` sets or clears it - Multi-source support: connect multiple source services per user (#375 follow-up) - Sources list on the Configuration page with add, edit and remove per source - Per-repository source attribution, with credentials resolved from each repository's own source diff --git a/docs/API.md b/docs/API.md index 8206a85..7d4e03f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -117,6 +117,14 @@ Starts mirroring in the background and returns straight away. Poll the list endp `role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories. +When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default): + +```json +{ "org": "my-org", "role": "member", "sourceId": "…" } +``` + +`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way. + ### Reconcile the destination `POST /api/cleanup/reconcile` diff --git a/drizzle/0020_org_source.sql b/drizzle/0020_org_source.sql new file mode 100644 index 0000000..5016eac --- /dev/null +++ b/drizzle/0020_org_source.sql @@ -0,0 +1,15 @@ +ALTER TABLE `organizations` ADD `source_id` text;--> statement-breakpoint +-- Pin each organization to the source its repositories came from, but only +-- when they agree on exactly one: organizations whose repos span several +-- sources (or have none) stay unpinned and keep following every source. +-- MIN() keeps the subquery scalar; a HAVING filter with no matching row +-- yields NULL, which leaves the column untouched. +UPDATE `organizations` SET `source_id` = ( + SELECT MIN(`repositories`.`source_id`) + FROM `repositories` + WHERE `repositories`.`user_id` = `organizations`.`user_id` + AND `repositories`.`organization` = `organizations`.`name` + AND `repositories`.`source_id` IS NOT NULL + HAVING COUNT(DISTINCT `repositories`.`source_id`) = 1 +) +WHERE `source_id` IS NULL; diff --git a/drizzle/meta/0020_snapshot.json b/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000..c4345ef --- /dev/null +++ b/drizzle/meta/0020_snapshot.json @@ -0,0 +1,3145 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "06a2c1a2-856b-4831-82c0-996a707c220a", + "prevId": "01e538bc-a176-4a53-a834-4212cd9045e1", + "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 + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "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": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "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": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_api_keys_reference_id": { + "name": "idx_api_keys_reference_id", + "columns": [ + "reference_id" + ], + "isUnique": false + }, + "idx_api_keys_key": { + "name": "idx_api_keys_key", + "columns": [ + "key" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_reference_id_users_id_fk": { + "name": "api_keys_reference_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crv": { + "name": "crv", + "type": "text", + "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 + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resources": { + "name": "resources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmation": { + "name": "confirmation", + "type": "text", + "primaryKey": false, + "notNull": false, + "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_client_assertions": { + "name": "oauth_client_assertions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client_resources": { + "name": "oauth_client_resources", + "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 + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "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())" + } + }, + "indexes": { + "idx_oauth_client_resources_client_id": { + "name": "idx_oauth_client_resources_client_id", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "idx_oauth_client_resources_resource_id": { + "name": "idx_oauth_client_resources_resource_id", + "columns": [ + "resource_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "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 + }, + "resources": { + "name": "resources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "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": { + "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 + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resources": { + "name": "resources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmation": { + "name": "confirmation", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "oauth_resources": { + "name": "oauth_resources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "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_resources_identifier_unique": { + "name": "oauth_resources_identifier_unique", + "columns": [ + "identifier" + ], + "isUnique": true + }, + "idx_oauth_resources_identifier": { + "name": "idx_oauth_resources_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'github'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'https://github.com'" + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_provider": { + "name": "destination_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'gitea'" + }, + "destination_url": { + "name": "destination_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "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", + "source_id", + "full_name" + ], + "isUnique": true + }, + "uniq_repositories_user_normalized_full_name": { + "name": "uniq_repositories_user_normalized_full_name", + "columns": [ + "user_id", + "source_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": {} + }, + "sources": { + "name": "sources", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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_sources_user_id": { + "name": "idx_sources_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "uniq_sources_user_provider_url_username": { + "name": "uniq_sources_user_provider_url_username", + "columns": [ + "user_id", + "provider", + "url", + "username" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sources_user_id_users_id_fk": { + "name": "sources_user_id_users_id_fk", + "tableFrom": "sources", + "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 109d40f..81e346f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1788350408053, "tag": "0019_multi_source", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1788805724170, + "tag": "0020_org_source", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/validate-migrations.ts b/scripts/validate-migrations.ts index d2e4dd8..83e4a7e 100644 --- a/scripts/validate-migrations.ts +++ b/scripts/validate-migrations.ts @@ -957,12 +957,12 @@ function validateStranded0016Repair() { db.close(); } } - const latestUpgradeFixtures: Record = { "0009_nervous_tyger_tiger": { seed: seedPre0009Database, verify: verify0009Migration, }, + "0010_mirrored_location_index": { seed: seedPre0010Database, verify: verify0010Migration, @@ -1003,8 +1003,71 @@ const latestUpgradeFixtures: Record = { seed: seedPre0019Database, verify: verify0019Migration, }, + "0020_org_source": { + seed: seedPre0020Database, + verify: verify0020Migration, + }, }; +function seedPre0020Database(db: any) { + // Migrations 0000-0019 have run: repositories carry source_id, + // organizations have none yet. Seed two sources and three organizations: + // one whose repos all come from a single source (must be pinned), one + // whose repos span both sources (must stay unpinned), and one without + // repos (must stay unpinned). + db.run("INSERT INTO users (id, email, username, name) VALUES ('u-org20', 'org20@example.com', 'org20', 'Org Twenty')"); + db.run( + "INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) " + + "VALUES ('cfg-org20', 'u-org20', 'Default', 1, '{\"provider\":\"github\",\"owner\":\"octocat\",\"token\":\"gh-token\",\"type\":\"personal\"}', '{}', '{}', '{}')", + ); + db.run( + "INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-a20', 'u-org20', 'GitHub (octocat)', 'github', 'https://github.com', 'octocat')", + ); + db.run( + "INSERT INTO sources (id, user_id, name, provider, url, username) VALUES ('src-b20', 'u-org20', 'GitLab (octocat)', 'gitlab', 'https://gitlab.com', 'octocat')", + ); + + const insertOrg = (id: string, name: string) => + db.run( + "INSERT INTO organizations (id, user_id, config_id, name, normalized_name, avatar_url) " + + `VALUES ('${id}', 'u-org20', 'cfg-org20', '${name}', '${name.toLowerCase()}', 'https://example.com/${name}.png')`, + ); + insertOrg("org-acme20", "acme"); + insertOrg("org-mixed20", "mixed"); + insertOrg("org-empty20", "empty"); + + const insertRepo = (id: string, org: string, sourceId: string | null) => + db.run( + "INSERT INTO repositories (id, user_id, config_id, name, full_name, normalized_full_name, url, clone_url, owner, default_branch, organization, source_id) " + + `VALUES ('${id}', 'u-org20', 'cfg-org20', '${id}', '${org}/${id}', '${org}/${id}', 'https://example.com/${org}/${id}', 'https://example.com/${org}/${id}.git', '${org}', 'main', '${org}', ${sourceId ? `'${sourceId}'` : "NULL"})`, + ); + insertRepo("repo-acme1-20", "acme", "src-a20"); + insertRepo("repo-acme2-20", "acme", "src-a20"); + insertRepo("repo-mixed1-20", "mixed", "src-a20"); + insertRepo("repo-mixed2-20", "mixed", "src-b20"); +} + +function verify0020Migration(db: any) { + const orgCols = db.query("PRAGMA table_info(organizations)").all() as TableInfoRow[]; + const sourceIdCol = orgCols.find((column) => column.name === "source_id"); + assert(sourceIdCol, "Expected organizations.source_id column to exist after migration"); + assert(sourceIdCol.notnull === 0, "Expected organizations.source_id to be nullable"); + assert(sourceIdCol.dflt_value === null, `Expected organizations.source_id to have no default, got ${sourceIdCol.dflt_value}`); + + const byName = Object.fromEntries( + (db.query("SELECT name, source_id FROM organizations").all() as Array<{ name: string; source_id: string | null }>).map( + (row) => [row.name, row.source_id], + ), + ); + assert(byName["acme"] === "src-a20", `Expected the single-source organization to be pinned, got ${JSON.stringify(byName["acme"])}`); + assert(byName["mixed"] === null, `Expected the multi-source organization to stay unpinned, got ${JSON.stringify(byName["mixed"])}`); + assert(byName["empty"] === null, `Expected the repo-less organization to stay unpinned, got ${JSON.stringify(byName["empty"])}`); + + db.run("UPDATE organizations SET source_id = 'src-b20' WHERE name = 'acme'"); + const repinned = db.query("SELECT source_id FROM organizations WHERE name = 'acme'").get() as { source_id: string | null } | null; + assert(repinned?.source_id === "src-b20", `Expected a manual pin to round-trip, got ${JSON.stringify(repinned)}`); +} + function lintMigrations(selectedMigrations: Migration[]) { const violations: string[] = []; diff --git a/src/components/organizations/AddOrganizationDialog.tsx b/src/components/organizations/AddOrganizationDialog.tsx index 6969cf2..9dc9cfc 100644 --- a/src/components/organizations/AddOrganizationDialog.tsx +++ b/src/components/organizations/AddOrganizationDialog.tsx @@ -10,16 +10,27 @@ import { DialogTrigger, } from "../ui/dialog"; import { LoaderCircle, Plus } from "lucide-react"; +import { SiGithub } from "react-icons/si"; import type { MembershipRole } from "@/types/organizations"; +import type { SourceApiRecord } from "@/types/config"; import { RadioGroup, RadioGroupItem } from "../ui/radio"; import { Label } from "../ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; import { parseGitHubOwnerReference } from "@/lib/utils/github-url"; import { SOURCE_PROVIDER_DEFAULT_URLS, SOURCE_PROVIDER_LABELS, SOURCE_PROVIDER_ORG_NOUNS, + normalizeSourceUrl, type SourceProviderKind, } from "@/lib/source-providers/kinds"; +import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons"; const inputClassName = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; @@ -31,14 +42,18 @@ interface AddOrganizationDialogProps { org, role, force, + sourceId, }: { org: string; role: MembershipRole; force?: boolean; + sourceId?: string; }) => Promise; sourceProvider?: SourceProviderKind; /** Normalized instance URL of the configured source, for the placeholder. */ sourceUrl?: string; + /** Connected sources; with more than one, the dialog asks which one to add from. */ + sources?: SourceApiRecord[]; } export default function AddOrganizationDialog({ @@ -47,19 +62,35 @@ export default function AddOrganizationDialog({ onAddOrganization, sourceProvider = "github", sourceUrl, + sources, }: AddOrganizationDialogProps) { const [url, setUrl] = useState(""); const [org, setOrg] = useState(""); const [role, setRole] = useState("member"); + const [sourceId, setSourceId] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(""); - const providerLabel = SOURCE_PROVIDER_LABELS[sourceProvider]; - const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[sourceProvider]; + const hasMultipleSources = !!sources && sources.length > 1; + // With several connected sources, the chosen one drives every + // provider-dependent label; otherwise the global source props do. + const selectedSource = hasMultipleSources + ? sources.find((source) => source.id === sourceId) ?? sources[0] + : undefined; + const effectiveProvider: SourceProviderKind = + selectedSource?.provider ?? sourceProvider; + const effectiveSourceUrl = selectedSource + ? normalizeSourceUrl(selectedSource.url, selectedSource.provider) + : sourceUrl; + + const providerLabel = SOURCE_PROVIDER_LABELS[effectiveProvider]; + const orgNoun = SOURCE_PROVIDER_ORG_NOUNS[effectiveProvider]; const orgNounCapitalized = orgNoun.charAt(0).toUpperCase() + orgNoun.slice(1); // "an organization" but "a group". const orgNounWithArticle = `${/^[aeiou]/i.test(orgNoun) ? "an" : "a"} ${orgNoun}`; - const instanceUrl = (sourceUrl || SOURCE_PROVIDER_DEFAULT_URLS[sourceProvider]).replace(/\/+$/, ""); + const instanceUrl = ( + effectiveSourceUrl || SOURCE_PROVIDER_DEFAULT_URLS[effectiveProvider] + ).replace(/\/+$/, ""); const urlPlaceholder = `${instanceUrl}/your-${orgNoun}`; const resetForm = () => { @@ -67,6 +98,7 @@ export default function AddOrganizationDialog({ setUrl(""); setOrg(""); setRole("member"); + setSourceId(""); }; useEffect(() => { @@ -75,6 +107,17 @@ export default function AddOrganizationDialog({ } }, [isDialogOpen]); + // Default the picker to the primary source (oldest first) whenever the + // selection is empty or points at a removed source. + useEffect(() => { + if (!hasMultipleSources) { + return; + } + if (!sources.some((source) => source.id === sourceId)) { + setSourceId(sources[0].id); + } + }, [hasMultipleSources, sources, sourceId]); + /** Fill the name field from anything that names an account. */ const applyReference = (value: string): boolean => { const parsed = parseGitHubOwnerReference(value); @@ -119,7 +162,11 @@ export default function AddOrganizationDialog({ try { setIsLoading(true); - await onAddOrganization({ org, role }); + await onAddOrganization({ + org, + role, + sourceId: selectedSource?.id, + }); resetForm(); setIsDialogOpen(false); @@ -148,6 +195,36 @@ export default function AddOrganizationDialog({
+ {hasMultipleSources && ( +
+ + +
+ )} +
- {sourceProvider === "github" && ( + {effectiveProvider === "github" && (
diff --git a/src/components/organizations/Organization.tsx b/src/components/organizations/Organization.tsx index debe434..03102a5 100644 --- a/src/components/organizations/Organization.tsx +++ b/src/components/organizations/Organization.tsx @@ -51,7 +51,7 @@ export function Organization() { const [isLoading, setIsLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); const { user } = useAuth(); - const { isGitHubConfigured, sourceProvider, sourceUrl } = useConfigStatus(); + const { isGitHubConfigured, sourceProvider, sourceUrl, sources } = useConfigStatus(); const { navigationKey } = useNavigation(); const { registerRefreshCallback } = useLiveRefresh(); const { filter, setFilter } = useFilterParams({ @@ -63,6 +63,7 @@ export function Organization() { const [duplicateOrgCandidate, setDuplicateOrgCandidate] = useState<{ org: string; role: MembershipRole; + sourceId?: string; } | null>(null); const [isDuplicateOrgDialogOpen, setIsDuplicateOrgDialogOpen] = useState(false); const [isProcessingDuplicateOrg, setIsProcessingDuplicateOrg] = useState(false); @@ -274,10 +275,12 @@ export function Organization() { org, role, force = false, + sourceId, }: { org: string; role: MembershipRole; force?: boolean; + sourceId?: string; }) => { if (!user || !user.id) { return; @@ -298,7 +301,7 @@ export function Organization() { if (alreadyExists) { toast.warning("Organization already exists."); - setDuplicateOrgCandidate({ org: trimmedOrg, role }); + setDuplicateOrgCandidate({ org: trimmedOrg, role, sourceId }); setIsDuplicateOrgDialogOpen(true); throw new Error("Organization already exists"); } @@ -312,6 +315,7 @@ export function Organization() { org: trimmedOrg, role, force, + sourceId, }; const response = await apiRequest( @@ -360,6 +364,7 @@ export function Organization() { await handleAddOrganization({ org: duplicateOrgCandidate.org, role: duplicateOrgCandidate.role, + sourceId: duplicateOrgCandidate.sourceId, force: true, }); setIsDialogOpen(false); @@ -888,6 +893,7 @@ export function Organization() { }} sourceProvider={sourceProvider} sourceUrl={sourceUrl} + sources={sources} /> { diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx index 1281b92..922f2c2 100644 --- a/src/components/organizations/OrganizationsList.tsx +++ b/src/components/organizations/OrganizationsList.tsx @@ -2,10 +2,12 @@ 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, SlidersHorizontal, Trash2 } from "lucide-react"; -import { SiGithub, SiGitea, SiGitlab } from "react-icons/si"; +import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, MoreVertical, Ban, SlidersHorizontal, Trash2, Layers } from "lucide-react"; +import { SiGitea } from "react-icons/si"; +import { toast } from "sonner"; import type { MirrorOverrides, Organization } from "@/lib/db/schema"; import type { FilterParams } from "@/types/filter"; +import type { SourceApiRecord } from "@/types/config"; import Fuse from "fuse.js"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -24,6 +26,14 @@ import { normalizeSourceUrl, type SourceProviderKind, } from "@/lib/source-providers/kinds"; +import { SOURCE_PROVIDER_ICONS } from "@/lib/source-providers/icons"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { DropdownMenu, DropdownMenuContent, @@ -47,6 +57,8 @@ interface OrganizationListProps { sourceProvider?: SourceProviderKind; /** Normalized instance URL of the configured source; falls back to the cached config. */ sourceUrl?: string; + /** Connected sources; with more than one, each card shows and edits its own source. */ + sources?: SourceApiRecord[]; } // Helper function to get status badge variant and icon @@ -67,6 +79,67 @@ const getStatusBadge = (status: string | null) => { } }; +// Radix reserves the empty string as a Select value, so unpinned +// organizations use this sentinel instead. +const EVERY_SOURCE_VALUE = "every-source"; + +function OrganizationSourceEditor({ + sources, + value, + disabled = false, + onUpdateSource, +}: { + sources: SourceApiRecord[]; + value?: string | null; + disabled?: boolean; + onUpdateSource: (sourceId: string | null) => Promise; +}) { + const [isUpdating, setIsUpdating] = useState(false); + + const handleValueChange = async (next: string) => { + setIsUpdating(true); + try { + await onUpdateSource(next === EVERY_SOURCE_VALUE ? null : next); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to update source"); + } finally { + setIsUpdating(false); + } + }; + + return ( +
+ + + Source + + +
+ ); +} + export function OrganizationList({ organizations, isLoading, @@ -80,16 +153,15 @@ export function OrganizationList({ onDelete, sourceProvider = "github", sourceUrl: sourceUrlProp, + sources, }: OrganizationListProps) { const { giteaConfig, mirrorOptions, advancedOptions } = useGiteaConfig(); const [overridesTarget, setOverridesTarget] = useState(null); - const sourceLabel = SOURCE_PROVIDER_LABELS[sourceProvider]; + const hasMultipleSources = !!sources && sources.length > 1; const sourceUrl = sourceUrlProp ?? normalizeSourceUrl(getCachedConfig()?.githubConfig?.url, sourceProvider); - const sourceShortLabel = sourceProvider === "gitea" ? "Gitea" : sourceLabel; - const SourceIcon = { github: SiGithub, gitlab: SiGitlab, gitea: SiGitea }[sourceProvider]; const handleUpdateMirrorOverrides = async ( orgId: string, @@ -151,6 +223,27 @@ export function OrganizationList({ } }; + // Pin an organization to one source, or clear the pin with null (the org + // then follows every connected source). + const handleUpdateSource = async (orgId: string, sourceId: string | null) => { + const response = await fetch(`${withBase("/api/organizations")}/${orgId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ sourceId }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to update organization"); + } + + if (onRefresh) { + await onRefresh(); + } + }; + // Plan (dryRun) or perform the move of an organization's mirrors on the // destination when its destination changes (issue #400). const handleMoveMirrors = async ( @@ -252,6 +345,16 @@ export function OrganizationList({ const isLoading = loadingOrgIds.has(org.id ?? ""); const statusBadge = getStatusBadge(org.status); const StatusIcon = statusBadge.icon; + const orgSources = sources ?? []; + const pinnedSource = + orgSources.find((source) => source.id === org.sourceId) ?? null; + const orgProvider: SourceProviderKind = pinnedSource?.provider ?? sourceProvider; + const orgSourceUrl = pinnedSource + ? normalizeSourceUrl(pinnedSource.url, pinnedSource.provider) + : sourceUrl; + const orgSourceLabel = SOURCE_PROVIDER_LABELS[orgProvider]; + const orgSourceShortLabel = orgProvider === "gitea" ? "Gitea" : orgSourceLabel; + const OrgSourceIcon = SOURCE_PROVIDER_ICONS[orgProvider]; return ( {org.membershipRole} + {hasMultipleSources && ( + + {pinnedSource ? : } + {pinnedSource?.name ?? "Every source"} + + )}
{org.repositoryCount} @@ -340,6 +453,18 @@ export function OrganizationList({ isUpdating={isLoading} />
+ + {/* Source picker (multi-source only) */} + {hasMultipleSources && ( +
+ handleUpdateSource(org.id!, newSourceId)} + /> +
+ )}
{/* Desktop Layout */} @@ -367,6 +492,16 @@ export function OrganizationList({ {/* Status badge */}
+ {hasMultipleSources && ( + + {pinnedSource ? : } + {pinnedSource?.name ?? "Every source"} + + )} {hasMirrorOverrides(org.mirrorOverrides) && (
+ {/* Source picker (multi-source only) */} + {hasMultipleSources && ( +
+ handleUpdateSource(org.id!, newSourceId)} + /> +
+ )} + {/* Error message for failed orgs */} {org.status === "failed" && org.errorMessage && (
@@ -602,14 +749,14 @@ export function OrganizationList({ })()}
@@ -771,20 +918,20 @@ export function OrganizationList({ )} - diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index ba5a9f5..f9de477 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -360,6 +360,11 @@ export const organizationSchema = z.object({ name: z.string(), normalizedName: z.string(), avatarUrl: z.string(), + // The sources row this organization mirrors from, matching + // repositories.sourceId below: NULL means "not pinned" (repos of the same + // org name from every connected source), a dangling id falls back the same + // way, and no foreign key is deliberate. + sourceId: z.string().optional().nullable(), membershipRole: z.enum(["member", "admin", "owner", "billing_manager"]).default("member"), isIncluded: z.boolean().default(true), destinationOrg: z.string().optional().nullable(), @@ -673,6 +678,12 @@ export const organizations = sqliteTable("organizations", { avatarUrl: text("avatar_url").notNull(), + // The sources row this organization mirrors from, matching + // repositories.sourceId above: NULL means "not pinned" (repos of the same + // org name from every connected source), a dangling id falls back the same + // way, and no foreign key is deliberate. + sourceId: text("source_id"), + membershipRole: text("membership_role").notNull().default("member"), isIncluded: integer("is_included", { mode: "boolean" }) diff --git a/src/lib/gitea-org-mirror-destination.test.ts b/src/lib/gitea-org-mirror-destination.test.ts index 1de61ca..4054e85 100644 --- a/src/lib/gitea-org-mirror-destination.test.ts +++ b/src/lib/gitea-org-mirror-destination.test.ts @@ -42,6 +42,24 @@ let orgRepoRows: any[] = []; let orgConfigRows: any[] = []; /** Counts selects against the repositories table. */ let repoSelectCount = 0; +/** Leaf values referenced by each repositories select's where() condition. */ +let repoSelectWhereValues: unknown[][] = []; + +/** + * Drizzle conditions are cyclic (JSON.stringify throws) and the mocked + * tables have no column objects, so scoping assertions walk queryChunks + * and collect the primitive leaves (the compared values) they mention. + */ +function conditionLeafValues(node: any, depth = 0): unknown[] { + if (node == null || depth > 8) return []; + if (typeof node !== "object") return [node]; + const leaves: unknown[] = []; + if (Array.isArray(node.value)) leaves.push(...node.value); + for (const chunk of node.queryChunks ?? []) { + leaves.push(...conditionLeafValues(chunk, depth + 1)); + } + return leaves; +} /** Every httpPost call: { url, payload }. */ let httpPostCalls: Array<{ url: string; payload: any }> = []; /** Every org get-or-create: orgName -> deterministic id. */ @@ -80,6 +98,7 @@ mock.module("@/lib/db", () => { where: (_cond: any) => { if (table === repositoriesTable) { repoSelectCount++; + repoSelectWhereValues.push(conditionLeafValues(_cond)); // First repositories select in mirrorGitHubOrgToGitea is the // orgRepos query; everything after (idempotency checks, name // claims) must see no rows. @@ -141,7 +160,7 @@ mock.module("@/lib/sources", () => ({ ), findSourceForRepository: (repo: any, list: any[]) => { if (repo.sourceId) { - const byId = list.find((source) => source.id === repo.sourceId); + const byId = list.find((source: any) => source.id === repo.sourceId); if (byId) return byId; } const source = getRepositorySource(repo); @@ -151,6 +170,8 @@ mock.module("@/lib/sources", () => ({ ) ?? null ); }, + findSourceForOrganization: (org: { sourceId?: string | null }, list: any[]) => + org.sourceId ? list.find((source: any) => source.id === org.sourceId) ?? null : null, decryptSourceToken: (token: string | null | undefined) => token ?? "", resolveGitHubApiBaseUrl: (url: string | null | undefined) => { const trimmed = url?.trim().replace(/\/+$/, "") ?? ""; @@ -290,6 +311,7 @@ beforeEach(() => { orgRepoRows = []; orgConfigRows = []; repoSelectCount = 0; + repoSelectWhereValues = []; httpPostCalls = []; orgCreateCalls = []; }); @@ -489,3 +511,51 @@ describe.skipIf(!isChild)("mirrorGitHubOrgToGitea fork policy", () => { expect(migrateCalls().length).toBe(0); }); }); + +describe.skipIf(!isChild)("mirrorGitHubOrgToGitea source scoping", () => { + // The mock db ignores where() conditions, so the scoping is asserted on + // the compared values of the orgRepos query (the first repositories + // select) rather than on filtered rows. + function orgRepoQueryMentions(value: unknown): boolean { + return (repoSelectWhereValues[0] ?? []).includes(value); + } + + test("an organization pinned to a source scopes the orgRepos query to it", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg({ sourceId: "source-1" }); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + expect(orgRepoQueryMentions("source-1")).toBe(true); + expect(orgRepoQueryMentions("user-1")).toBe(true); + expect(migrateCalls().length).toBe(1); + }); + + test("an unpinned organization keeps the name-only orgRepos query", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg(); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + expect(orgRepoQueryMentions("source-1")).toBe(false); + expect(orgRepoQueryMentions("user-1")).toBe(true); + expect(migrateCalls().length).toBe(1); + }); + + test("a pin whose source was deleted falls back to the name-only query", async () => { + const config = makeConfig({ githubConfig: { mirrorStrategy: "preserve" } }); + const organization = makeOrg({ sourceId: "source-deleted" }); + orgRepoRows = [makeRepo()]; + orgConfigRows = [organization]; + + await mirrorGitHubOrgToGitea({ organization, octokit: fakeOctokit, config }); + + expect(orgRepoQueryMentions("source-deleted")).toBe(false); + expect(orgRepoQueryMentions("user-1")).toBe(true); + expect(migrateCalls().length).toBe(1); + }); +}); diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 8b9edb4..805f1ee 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -22,6 +22,7 @@ import { } from "./source-providers"; import { decryptSourceToken, + findSourceForOrganization, findSourceForRepository, listSources, } from "./sources"; @@ -2198,11 +2199,34 @@ export async function mirrorGitHubOrgToGitea({ targetOrgName = config.giteaConfig?.defaultOwner || ""; } - //query the db with the org name and get the repos - const orgRepos = await db - .select() - .from(repositories) - .where(eq(repositories.organization, organization.name)); + // Query the db with the org name and get the repos. An organization + // pinned to one source only mirrors that source's repositories of the + // name; unpinned organizations (and pins whose source is gone) keep the + // pre-multi-source behavior of following every connected source. + const pinnedSource = findSourceForOrganization( + organization, + await listSources(config.userId) + ); + const orgRepos = pinnedSource + ? await db + .select() + .from(repositories) + .where( + and( + eq(repositories.userId, config.userId), + eq(repositories.organization, organization.name), + eq(repositories.sourceId, pinnedSource.id) + ) + ) + : await db + .select() + .from(repositories) + .where( + and( + eq(repositories.userId, config.userId), + eq(repositories.organization, organization.name) + ) + ); // The organization's fork policy (override -> global skipForks) drops // forked repositories from the batch so they are neither mirrored here diff --git a/src/lib/source-providers/icons.ts b/src/lib/source-providers/icons.ts new file mode 100644 index 0000000..d3da005 --- /dev/null +++ b/src/lib/source-providers/icons.ts @@ -0,0 +1,13 @@ +import type { ComponentType } from "react"; +import { SiGitea, SiGithub, SiGitlab } from "react-icons/si"; + +import type { SourceProviderKind } from "./kinds"; + +export const SOURCE_PROVIDER_ICONS: Record< + SourceProviderKind, + ComponentType<{ className?: string }> +> = { + github: SiGithub, + gitlab: SiGitlab, + gitea: SiGitea, +}; diff --git a/src/lib/sources.test.ts b/src/lib/sources.test.ts index 6be7b2f..d4bef4f 100644 --- a/src/lib/sources.test.ts +++ b/src/lib/sources.test.ts @@ -2,6 +2,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; import { decryptSourceToken, deriveSourceName, + findSourceForOrganization, findSourceForRepository, primarySource, resolveGitHubApiBaseUrl, @@ -93,6 +94,33 @@ describe("findSourceForRepository", () => { }); }); +describe("findSourceForOrganization", () => { + const github = source(); + const gitlab = source({ + id: "src-2", + provider: "gitlab", + url: "https://gitlab.com", + username: "me", + }); + + test("matches by the stored source id", () => { + expect(findSourceForOrganization({ sourceId: "src-2" }, [github, gitlab])?.id).toBe("src-2"); + }); + + test("returns null for an unpinned organization", () => { + expect(findSourceForOrganization({}, [github, gitlab])).toBeNull(); + expect(findSourceForOrganization({ sourceId: null }, [github, gitlab])).toBeNull(); + }); + + test("returns null for a dangling pin instead of another source", () => { + expect(findSourceForOrganization({ sourceId: "deleted-source" }, [github, gitlab])).toBeNull(); + }); + + test("returns null when nothing is connected", () => { + expect(findSourceForOrganization({ sourceId: "src-1" }, [])).toBeNull(); + }); +}); + describe("selectRepositoriesToRelink", () => { const connected = new Set(["src-1"]); const base = { diff --git a/src/lib/sources.ts b/src/lib/sources.ts index 6adfbb0..ee6c4a3 100644 --- a/src/lib/sources.ts +++ b/src/lib/sources.ts @@ -419,6 +419,21 @@ export function findSourceForRepository( ); } +/** + * The source an organization is pinned to: the stored sourceId when it still + * resolves. Null for unpinned organizations and for pins whose source was + * deleted (both fall back to the pre-multi-source behavior of following + * every source's repositories of the same org name), so callers only scope + * their queries when this returns a row. + */ +export function findSourceForOrganization( + org: { sourceId?: string | null }, + list: SourceRecord[] +): SourceRecord | null { + if (!org.sourceId) return null; + return list.find((source) => source.id === org.sourceId) ?? null; +} + /** * Decrypt a source token for API use. Unlike getDecryptedGitHubToken this * never throws: empty or missing means public-only access, which every diff --git a/src/pages/api/github/organizations.ts b/src/pages/api/github/organizations.ts index 7c3bff2..a14c9a2 100644 --- a/src/pages/api/github/organizations.ts +++ b/src/pages/api/github/organizations.ts @@ -45,6 +45,11 @@ export const GET: APIRoute = async ({ request, locals }) => { .where(eq(organizations.userId, userId)) .orderBy(sql`name COLLATE NOCASE`); + const { listSources } = await import("@/lib/sources"); + const connectedSourceIds = new Set( + (await listSources(userId)).map((source) => source.id) + ); + // Calculate repository breakdowns for each organization const orgsWithBreakdown = await Promise.all( rawOrgs.map(async (org) => { @@ -54,6 +59,13 @@ export const GET: APIRoute = async ({ request, locals }) => { eq(repositories.organization, org.name) ]; + // A pinned organization only counts its source's repositories of + // the name; dangling pins (source deleted) fall back to every + // source, the same rule the org mirror applies. + if (org.sourceId && connectedSourceIds.has(org.sourceId)) { + baseConditions.push(eq(repositories.sourceId, org.sourceId)); + } + if (!githubConfig.mirrorStarred) { baseConditions.push(eq(repositories.isStarred, false)); } diff --git a/src/pages/api/organizations/[id].test.ts b/src/pages/api/organizations/[id].test.ts new file mode 100644 index 0000000..2bab4a3 --- /dev/null +++ b/src/pages/api/organizations/[id].test.ts @@ -0,0 +1,248 @@ +/** + * Route tests for the per-organization source pin on PATCH + * /api/organizations/:id: ownership validation of the pin, clearing it with + * null, and leaving it untouched when the field is absent. Plus the DELETE + * repository-deletion scoping for pinned organizations. + * + * NOTE: these tests replace @/lib/db, @/lib/sources and + * @/lib/utils/auth-helpers with mocks. bun's mock.module is process-wide and + * leaks into other test files, so in the shared test process this file + * registers NOTHING: it re-runs itself in an isolated child process (the + * same harness as gitea-org-mirror-destination.test.ts). + */ +import { describe, test, expect, mock, beforeEach } from "bun:test"; + +const CHILD_FLAG = "GM_ORG_ID_ROUTE_ISOLATED"; +const isChild = !!process.env[CHILD_FLAG]; + +if (!isChild) { + test("organization [id] route (source pin) — isolated child suite", () => { + const res = Bun.spawnSync({ + cmd: [process.execPath, "test", import.meta.path], + env: { ...process.env, [CHILD_FLAG]: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + if (res.exitCode !== 0) { + console.error(res.stdout.toString()); + console.error(res.stderr.toString()); + } + expect(res.exitCode).toBe(0); + }, 60_000); +} + +let sourceRows: any[] = []; +let orgRows: any[] = []; +/** Leaf values of each delete's where() condition (drizzle conditions are cyclic). */ +let deleteWhereValues: unknown[][] = []; + +function conditionLeafValues(node: any, depth = 0): unknown[] { + if (node == null || depth > 8) return []; + if (typeof node !== "object") return [node]; + const leaves: unknown[] = []; + if (Array.isArray(node.value)) leaves.push(...node.value); + for (const chunk of node.queryChunks ?? []) { + leaves.push(...conditionLeafValues(chunk, depth + 1)); + } + return leaves; +} + +if (isChild) { + mock.module("@/lib/utils/auth-helpers", () => ({ + requireAuth: mock(async () => ({ user: { id: "user-1" }, response: null })), + })); + + mock.module("@/lib/sources", () => ({ + listSources: mock(async (userId: string) => + sourceRows.filter((source) => source.userId === userId) + ), + })); + + mock.module("@/lib/db", () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => orgRows.slice(0, 1), + }), + }), + }), + update: () => ({ + set: (data: any) => ({ + where: async () => { + if (orgRows[0]) Object.assign(orgRows[0], data); + }, + }), + }), + delete: () => ({ + where: async (cond: any) => { + deleteWhereValues.push(conditionLeafValues(cond)); + }, + }), + }, + organizations: {}, + repositories: {}, + configs: {}, + users: {}, + events: {}, + mirrorJobs: {}, + sessions: {}, + accounts: {}, + verificationTokens: {}, + verifications: {}, + oauthClients: {}, + oauthAccessTokens: {}, + oauthRefreshTokens: {}, + oauthConsents: {}, + jwkss: {}, + ssoProviders: {}, + apikeys: {}, + rateLimits: {}, + sources: {}, + })); +} + +const { PATCH, DELETE } = isChild + ? await import("./[id]") + : { PATCH: undefined as any, DELETE: undefined as any }; + +function patch(body: Record) { + return PATCH({ + params: { id: "org-1" }, + request: new Request("http://localhost/api/organizations/org-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + locals: { session: { userId: "user-1" } }, + } as any); +} + +function remove() { + return DELETE({ + params: { id: "org-1" }, + request: new Request("http://localhost/api/organizations/org-1", { + method: "DELETE", + }), + locals: { session: { userId: "user-1" } }, + } as any); +} + +beforeEach(() => { + deleteWhereValues = []; + sourceRows = [ + { + id: "source-1", + userId: "user-1", + name: "GitHub (octocat)", + provider: "github", + url: "https://github.com", + username: "octocat", + token: null, + enabled: true, + }, + { + id: "source-2", + userId: "user-2", + name: "GitLab (someone)", + provider: "gitlab", + url: "https://gitlab.com", + username: "someone", + token: null, + enabled: true, + }, + ]; + orgRows = [ + { + id: "org-1", + userId: "user-1", + name: "acme", + normalizedName: "acme", + destinationOrg: null, + mirrorOverrides: null, + sourceId: null, + }, + ]; +}); + +describe.skipIf(!isChild)("PATCH /api/organizations/:id source pin", () => { + test("pins the organization to a source the user owns", async () => { + const response = await patch({ sourceId: "source-1" }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(data.sourceId).toBe("source-1"); + expect(orgRows[0].sourceId).toBe("source-1"); + }); + + test("rejects a source that belongs to another user", async () => { + const response = await patch({ sourceId: "source-2" }); + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toContain("No source with id source-2"); + expect(orgRows[0].sourceId).toBeNull(); + }); + + test("rejects an unknown source id", async () => { + const response = await patch({ sourceId: "source-missing" }); + expect(response.status).toBe(400); + expect(orgRows[0].sourceId).toBeNull(); + }); + + test("clears the pin with null", async () => { + orgRows[0].sourceId = "source-1"; + const response = await patch({ sourceId: null }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.sourceId).toBeNull(); + expect(orgRows[0].sourceId).toBeNull(); + }); + + test("leaves the pin untouched when the field is absent", async () => { + orgRows[0].sourceId = "source-1"; + const response = await patch({ destinationOrg: "backup" }); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.sourceId).toBe("source-1"); + expect(data.destinationOrg).toBe("backup"); + }); + + test("answers 404 for an organization the user does not have", async () => { + orgRows = []; + const response = await patch({ sourceId: "source-1" }); + expect(response.status).toBe(404); + }); +}); + +describe.skipIf(!isChild)("DELETE /api/organizations/:id with a source pin", () => { + // The route deletes the organization's repositories first, so the first + // recorded delete is the repositories one. + function repoDeleteLeaves(): unknown[] { + return deleteWhereValues[0] ?? []; + } + + test("a pinned organization deletes only its source's repositories", async () => { + orgRows[0].sourceId = "source-1"; + const response = await remove(); + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + expect(repoDeleteLeaves()).toContain("source-1"); + expect(repoDeleteLeaves()).toContain("acme"); + }); + + test("an unpinned organization keeps the name-wide repository delete", async () => { + const response = await remove(); + expect(response.status).toBe(200); + expect(repoDeleteLeaves()).toContain("acme"); + expect(repoDeleteLeaves()).not.toContain("source-1"); + }); + + test("a pin whose source was deleted keeps the name-wide repository delete", async () => { + orgRows[0].sourceId = "source-gone"; + const response = await remove(); + expect(response.status).toBe(200); + expect(repoDeleteLeaves()).toContain("acme"); + expect(repoDeleteLeaves()).not.toContain("source-gone"); + }); +}); diff --git a/src/pages/api/organizations/[id].ts b/src/pages/api/organizations/[id].ts index 2620c96..076eb56 100644 --- a/src/pages/api/organizations/[id].ts +++ b/src/pages/api/organizations/[id].ts @@ -14,6 +14,7 @@ import { normalizeMirrorOverrides } from "@/lib/utils/mirror-overrides"; const patchBodySchema = z.object({ destinationOrg: z.string().nullable().optional(), mirrorOverrides: mirrorOverridesSchema.nullable().optional(), + sourceId: z.string().nullable().optional(), }); export const PATCH: APIRoute = async (context) => { @@ -72,6 +73,25 @@ export const PATCH: APIRoute = async (context) => { updates.mirrorOverrides = normalizeMirrorOverrides(body.mirrorOverrides); } + if ("sourceId" in body) { + const nextSourceId = body.sourceId?.trim() || null; + if (nextSourceId) { + const { listSources } = await import("@/lib/sources"); + const owned = (await listSources(userId)).some( + (source) => source.id === nextSourceId + ); + if (!owned) { + return new Response( + JSON.stringify({ + error: `No source with id ${nextSourceId} belongs to this user`, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + } + updates.sourceId = nextSourceId; + } + await db .update(organizations) .set(updates) @@ -81,6 +101,7 @@ export const PATCH: APIRoute = async (context) => { .select({ destinationOrg: organizations.destinationOrg, mirrorOverrides: organizations.mirrorOverrides, + sourceId: organizations.sourceId, }) .from(organizations) .where(eq(organizations.id, orgId)) @@ -92,6 +113,7 @@ export const PATCH: APIRoute = async (context) => { message: "Organization updated successfully", destinationOrg: updated?.destinationOrg ?? null, mirrorOverrides: updated?.mirrorOverrides ?? null, + sourceId: updated?.sourceId ?? null, }), { status: 200, @@ -137,12 +159,23 @@ export const DELETE: APIRoute = async (context) => { ); } - await db.delete(repositories).where( - and( - eq(repositories.userId, userId), - eq(repositories.organization, existingOrg.name) - ) - ); + // A pinned organization only owns its source's repositories of the name; + // unpinned ones keep deleting every repository with that org name. + const deleteRepoConditions = [ + eq(repositories.userId, userId), + eq(repositories.organization, existingOrg.name), + ]; + if (existingOrg.sourceId) { + const { listSources } = await import("@/lib/sources"); + const pinned = (await listSources(userId)).some( + (source) => source.id === existingOrg.sourceId + ); + if (pinned) { + deleteRepoConditions.push(eq(repositories.sourceId, existingOrg.sourceId)); + } + } + + await db.delete(repositories).where(and(...deleteRepoConditions)); await db .delete(organizations) diff --git a/src/pages/api/sync/index.ts b/src/pages/api/sync/index.ts index e3c261c..5ab26ca 100644 --- a/src/pages/api/sync/index.ts +++ b/src/pages/api/sync/index.ts @@ -106,6 +106,7 @@ export const POST: APIRoute = async ({ request, locals }) => { avatarUrl: org.avatarUrl, membershipRole: org.membershipRole, isIncluded: false, + sourceId: source.id, status: org.status, repositoryCount: org.repositoryCount, createdAt: new Date(), @@ -122,6 +123,7 @@ export const POST: APIRoute = async ({ request, locals }) => { avatarUrl: org.avatarUrl, membershipRole: "member" as const, isIncluded: false, + sourceId: source.id, status: "failed" as const, errorMessage: org.reason, repositoryCount: 0, @@ -145,7 +147,11 @@ export const POST: APIRoute = async ({ request, locals }) => { .from(repositories) .where(eq(repositories.userId, userId)), tx - .select({ normalizedName: organizations.normalizedName, status: organizations.status }) + .select({ + normalizedName: organizations.normalizedName, + status: organizations.status, + sourceId: organizations.sourceId, + }) .from(organizations) .where(eq(organizations.userId, userId)), ]); @@ -154,7 +160,9 @@ export const POST: APIRoute = async ({ request, locals }) => { const existingRepoKeys = new Set( existingRepos.map((r) => `${r.sourceId ?? ""}|${r.normalizedFullName}`) ); - const existingOrgMap = new Map(existingOrgs.map((o) => [o.normalizedName, o.status])); + const existingOrgMap = new Map( + existingOrgs.map((o) => [o.normalizedName, { status: o.status, sourceId: o.sourceId }]) + ); insertedRepos = newRepos.filter( (r) => @@ -163,9 +171,11 @@ export const POST: APIRoute = async ({ request, locals }) => { ); insertedOrgs = newOrgs.filter((o) => !existingOrgMap.has(o.normalizedName)); - // Update previously failed orgs that now succeeded + // Update previously failed orgs that now succeeded. The pin must + // follow the recovering source, or the org mirror keeps scoping + // to the source the failure was recorded under. const recoveredOrgs = newOrgs.filter( - (o) => existingOrgMap.get(o.normalizedName) === "failed" + (o) => existingOrgMap.get(o.normalizedName)?.status === "failed" ); for (const org of recoveredOrgs) { await tx @@ -176,6 +186,7 @@ export const POST: APIRoute = async ({ request, locals }) => { repositoryCount: org.repositoryCount, avatarUrl: org.avatarUrl, membershipRole: org.membershipRole, + sourceId: org.sourceId, updatedAt: new Date(), }) .where( @@ -187,10 +198,37 @@ export const POST: APIRoute = async ({ request, locals }) => { } recoveredOrgCount = recoveredOrgs.length; + // An org that a second source also lists must not stay pinned to + // the first one: the mirror and the card counts would drop the + // other source's repositories. Clearing the pin matches migration + // 0020, which leaves orgs spanning multiple sources unpinned. + // Failed orgs are excluded — their pin follows the recovering + // source in the update above. + const crossSourceOrgs = newOrgs.filter((o) => { + const existing = existingOrgMap.get(o.normalizedName); + return ( + existing !== undefined && + existing.status !== "failed" && + existing.sourceId != null && + existing.sourceId !== source.id + ); + }); + for (const org of crossSourceOrgs) { + await tx + .update(organizations) + .set({ sourceId: null, updatedAt: new Date() }) + .where( + and( + eq(organizations.userId, userId), + eq(organizations.normalizedName, org.normalizedName), + ) + ); + } + // Insert or update failed orgs (only update orgs already in "failed" state — don't overwrite good state) insertedFailedOrgs = failedOrgRecords.filter((o) => !existingOrgMap.has(o.normalizedName)); const stillFailedOrgs = failedOrgRecords.filter( - (o) => existingOrgMap.get(o.normalizedName) === "failed" + (o) => existingOrgMap.get(o.normalizedName)?.status === "failed" ); for (const org of stillFailedOrgs) { await tx diff --git a/src/pages/api/sync/organization.ts b/src/pages/api/sync/organization.ts index e34f880..0b9a498 100644 --- a/src/pages/api/sync/organization.ts +++ b/src/pages/api/sync/organization.ts @@ -21,8 +21,13 @@ export const POST: APIRoute = async ({ request, locals }) => { const body: AddOrganizationApiRequest = await request.json(); const { role, org, force = false } = body; - // Optional body field: which connected source to import from; defaults to the primary source. - const sourceId = (body as AddOrganizationApiRequest & { sourceId?: string }).sourceId?.trim() || undefined; + if (body.sourceId !== undefined && typeof body.sourceId !== "string") { + return jsonResponse({ + data: { success: false, error: "sourceId must be a string" }, + status: 400, + }); + } + const sourceId = body.sourceId?.trim() || undefined; if (!org || !role) { return jsonResponse({ @@ -34,6 +39,23 @@ export const POST: APIRoute = async ({ request, locals }) => { const trimmedOrg = org.trim(); const normalizedOrg = trimmedOrg.toLowerCase(); + // The force re-add branch returns before the source resolution further + // down, so a pin has to be ownership-checked up front or it would skip + // validation entirely. + if (sourceId) { + const { listSources } = await import("@/lib/sources"); + const owned = (await listSources(userId)).some((s) => s.id === sourceId); + if (!owned) { + return jsonResponse({ + data: { + success: false, + error: `No source with id ${sourceId} belongs to this user`, + }, + status: 400, + }); + } + } + if (!isValidSourceOrgName(trimmedOrg)) { return jsonResponse({ data: { @@ -73,6 +95,7 @@ export const POST: APIRoute = async ({ request, locals }) => { .set({ membershipRole: role, normalizedName: normalizedOrg, + ...(sourceId ? { sourceId } : {}), updatedAt: new Date(), }) .where(eq(organizations.id, existingOrg.id)) @@ -204,6 +227,7 @@ export const POST: APIRoute = async ({ request, locals }) => { avatarUrl: orgData.avatarUrl, membershipRole: role, isIncluded: false, + sourceId: source.id, status: "imported" as RepoStatus, repositoryCount: orgRepos.length, createdAt: orgData.createdAt, diff --git a/src/types/organizations.ts b/src/types/organizations.ts index 2019c41..6bdc25b 100644 --- a/src/types/organizations.ts +++ b/src/types/organizations.ts @@ -46,6 +46,8 @@ export interface AddOrganizationApiRequest { org: string; role: MembershipRole; force?: boolean; + /** Which connected source to import from; defaults to the primary source. */ + sourceId?: string; } export interface AddOrganizationApiResponse { diff --git a/www/src/pages/docs/api.mdx b/www/src/pages/docs/api.mdx index e931e3a..0cc1374 100644 --- a/www/src/pages/docs/api.mdx +++ b/www/src/pages/docs/api.mdx @@ -121,6 +121,14 @@ Starts mirroring in the background and returns straight away. Poll the list endp `role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories. +When several sources are connected, an optional `sourceId` picks which one the organization imports and mirrors from (the first connected source is the default): + +```json +{ "org": "my-org", "role": "member", "sourceId": "…" } +``` + +`PATCH /api/organizations/:id` with `{ "sourceId": "…" }` re-pins an existing organization to another source, and `{ "sourceId": null }` clears the pin so the organization follows every source's repositories of the same name again. A pin whose source was removed falls back the same way. + ### Run cleanup `POST /api/cleanup/trigger` runs the [repository cleanup](./cleanup-and-reconcile.md) now, with the account's saved cleanup settings, and returns how many orphaned repositories were found and processed and any errors. `GET /api/cleanup/trigger` returns the cleanup configuration and the last and next run times.