diff --git a/README.md b/README.md index fe63d9bf70..b05c268e49 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ This project is tested with BrowserStack. ### Community sponsors -User avatar: Daniel SadaUser avatar: (justin)randoneeringUser avatar: Bogdan FLOREAUser avatar: Damián CulottaUser avatar: t0yoheiUser avatar: Ryan IlanoUser avatar: Bojan Milevski +User avatar: Daniel SadaUser avatar: (justin)randoneeringUser avatar: Damián CulottaUser avatar: t0yoheiUser avatar: Ryan IlanoUser avatar: Bojan Milevski ## License diff --git a/apps/api/drizzle/0055_huge_sue_storm.sql b/apps/api/drizzle/0055_huge_sue_storm.sql new file mode 100644 index 0000000000..b40ba87d4c --- /dev/null +++ b/apps/api/drizzle/0055_huge_sue_storm.sql @@ -0,0 +1 @@ +CREATE INDEX "external_link_deferred_issue_idx" ON "external_link" USING btree ("id") WHERE "external_link"."resource_type" = 'issue' AND "external_link"."metadata" LIKE '%"deferredIssueEdit":%'; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0055_snapshot.json b/apps/api/drizzle/meta/0055_snapshot.json new file mode 100644 index 0000000000..695a771490 --- /dev/null +++ b/apps/api/drizzle/meta/0055_snapshot.json @@ -0,0 +1,4903 @@ +{ + "id": "34e70d06-88aa-4af7-bb27-8b57ff0819a7", + "prevId": "a1fedcde-206d-4e0b-b639-257249201363", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_data": { + "name": "event_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "external_user_name": { + "name": "external_user_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_user_avatar": { + "name": "external_user_avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_source": { + "name": "external_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "activity_task_id_idx": { + "name": "activity_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_userId_idx": { + "name": "activity_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_task_id_task_id_fk": { + "name": "activity_task_id_task_id_fk", + "tableFrom": "activity", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_user_id_user_id_fk": { + "name": "activity_user_id_user_id_fk", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "activity_task_external_source_external_url_unique": { + "name": "activity_task_external_source_external_url_unique", + "nullsNotDistinct": false, + "columns": [ + "task_id", + "external_source", + "external_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_configId_idx": { + "name": "apikey_configId_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_referenceId_idx": { + "name": "apikey_referenceId_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_userId_idx": { + "name": "apikey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apikey_reference_id_user_id_fk": { + "name": "apikey_reference_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image'" + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'description'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_workspaceId_idx": { + "name": "asset_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_projectId_idx": { + "name": "asset_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_taskId_idx": { + "name": "asset_taskId_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_activityId_idx": { + "name": "asset_activityId_idx", + "columns": [ + { + "expression": "activity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_createdBy_idx": { + "name": "asset_createdBy_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_workspace_id_workspace_id_fk": { + "name": "asset_workspace_id_workspace_id_fk", + "tableFrom": "asset", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_project_id_project_id_fk": { + "name": "asset_project_id_project_id_fk", + "tableFrom": "asset", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_task_id_task_id_fk": { + "name": "asset_task_id_task_id_fk", + "tableFrom": "asset", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_activity_id_activity_id_fk": { + "name": "asset_activity_id_activity_id_fk", + "tableFrom": "asset", + "tableTo": "activity", + "columnsFrom": [ + "activity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "asset_created_by_user_id_fk": { + "name": "asset_created_by_user_id_fk", + "tableFrom": "asset", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_object_key_unique": { + "name": "asset_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "object_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_event": { + "name": "billing_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_reminder_sent": { + "name": "billing_reminder_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_reminder_sent_workspaceId_idx": { + "name": "billing_reminder_sent_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reminder_sent_userId_idx": { + "name": "billing_reminder_sent_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_reminder_sent_user_id_user_id_fk": { + "name": "billing_reminder_sent_user_id_user_id_fk", + "tableFrom": "billing_reminder_sent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "billing_reminder_sent_workspace_id_workspace_id_fk": { + "name": "billing_reminder_sent_workspace_id_workspace_id_fk", + "tableFrom": "billing_reminder_sent", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_reminder_sent_user_type_unique": { + "name": "billing_reminder_sent_user_type_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calendar_feed": { + "name": "calendar_feed", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_ids": { + "name": "label_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calendar_feed_project_id_idx": { + "name": "calendar_feed_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calendar_feed_project_id_project_id_fk": { + "name": "calendar_feed_project_id_project_id_fk", + "tableFrom": "calendar_feed", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calendar_feed_token_unique": { + "name": "calendar_feed_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.column": { + "name": "column", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_final": { + "name": "is_final", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "column_projectId_idx": { + "name": "column_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "column_project_id_project_id_fk": { + "name": "column_project_id_project_id_fk", + "tableFrom": "column", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_task_idx": { + "name": "comment_task_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comment_user_idx": { + "name": "comment_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_task_id_task_id_fk": { + "name": "comment_task_id_task_id_fk", + "tableFrom": "comment", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "comment_user_id_user_id_fk": { + "name": "comment_user_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_field_definition": { + "name": "custom_field_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_field_def_projectId_idx": { + "name": "custom_field_def_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_definition_project_id_project_id_fk": { + "name": "custom_field_definition_project_id_project_id_fk", + "tableFrom": "custom_field_definition", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_field_value": { + "name": "custom_field_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_field_value_taskId_idx": { + "name": "custom_field_value_taskId_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_field_value_fieldId_idx": { + "name": "custom_field_value_fieldId_idx", + "columns": [ + { + "expression": "field_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_field_value_task_id_task_id_fk": { + "name": "custom_field_value_task_id_task_id_fk", + "tableFrom": "custom_field_value", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "custom_field_value_field_id_custom_field_definition_id_fk": { + "name": "custom_field_value_field_id_custom_field_definition_id_fk", + "tableFrom": "custom_field_value", + "tableTo": "custom_field_definition", + "columnsFrom": [ + "field_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "custom_field_value_task_field_unique": { + "name": "custom_field_value_task_field_unique", + "nullsNotDistinct": false, + "columns": [ + "task_id", + "field_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_code_device_code_uidx": { + "name": "device_code_device_code_uidx", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_uidx": { + "name": "device_code_user_code_uidx", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_id_idx": { + "name": "device_code_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_link": { + "name": "external_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "external_link_taskId_idx": { + "name": "external_link_taskId_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_link_integrationId_idx": { + "name": "external_link_integrationId_idx", + "columns": [ + { + "expression": "integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_link_externalId_idx": { + "name": "external_link_externalId_idx", + "columns": [ + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_link_resourceType_idx": { + "name": "external_link_resourceType_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "external_link_deferred_issue_idx": { + "name": "external_link_deferred_issue_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"external_link\".\"resource_type\" = 'issue' AND \"external_link\".\"metadata\" LIKE '%\"deferredIssueEdit\":%'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "external_link_task_id_task_id_fk": { + "name": "external_link_task_id_task_id_fk", + "tableFrom": "external_link", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "external_link_integration_id_integration_id_fk": { + "name": "external_link_integration_id_integration_id_fk", + "tableFrom": "external_link", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_import": { + "name": "github_import", + "schema": "", + "columns": { + "integration_id": { + "name": "integration_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_import_integration_id_integration_id_fk": { + "name": "github_import_integration_id_integration_id_fk", + "tableFrom": "github_import", + "tableTo": "integration", + "columnsFrom": [ + "integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integration": { + "name": "github_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_owner": { + "name": "repository_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_integration_project_id_project_id_fk": { + "name": "github_integration_project_id_project_id_fk", + "tableFrom": "github_integration", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_integration_project_id_unique": { + "name": "github_integration_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integration_projectId_idx": { + "name": "integration_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "integration_type_idx": { + "name": "integration_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_project_id_project_id_fk": { + "name": "integration_project_id_project_id_fk", + "tableFrom": "integration", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_project_type_unique": { + "name": "integration_project_type_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_workspaceId_idx": { + "name": "invitation_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_inviterId_idx": { + "name": "invitation_inviterId_idx", + "columns": [ + { + "expression": "inviter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_id_workspace_id_fk", + "tableFrom": "invitation", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_lease": { + "name": "job_lease", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deletion_started_at": { + "name": "deletion_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "label_task_id_idx": { + "name": "label_task_id_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "label_workspace_id_idx": { + "name": "label_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "label_workspace_cascade_idx": { + "name": "label_workspace_cascade_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"label\".\"task_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "label_workspace_name_unique": { + "name": "label_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"label\".\"task_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "label_task_id_task_id_fk": { + "name": "label_task_id_task_id_fk", + "tableFrom": "label", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "label_workspace_id_workspace_id_fk": { + "name": "label_workspace_id_workspace_id_fk", + "tableFrom": "label", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "label_task_name_unique": { + "name": "label_task_name_unique", + "nullsNotDistinct": false, + "columns": [ + "task_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_state": { + "name": "mcp_oauth_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_state_kind_key_uidx": { + "name": "mcp_oauth_state_kind_key_uidx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_state_expiresAt_idx": { + "name": "mcp_oauth_state_expiresAt_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "event_data": { + "name": "event_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_userId_idx": { + "name": "notification_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_role": { + "name": "workspace_role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_role_workspaceId_idx": { + "name": "workspace_role_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_role_role_idx": { + "name": "workspace_role_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_role_workspace_id_workspace_id_fk": { + "name": "workspace_role_workspace_id_workspace_id_fk", + "tableFrom": "workspace_role", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Layout'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_task_number": { + "name": "last_task_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "background_object_key": { + "name": "background_object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_mime_type": { + "name": "background_mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "background_version": { + "name": "background_version", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "project_workspaceId_position_idx": { + "name": "project_workspaceId_position_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspace_id_workspace_id_fk": { + "name": "project_workspace_id_workspace_id_fk", + "tableFrom": "project", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_workspace_id_id_unique": { + "name": "project_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_relation": { + "name": "task_relation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_task_id": { + "name": "source_task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_task_id": { + "name": "target_task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_relation_source_idx": { + "name": "task_relation_source_idx", + "columns": [ + { + "expression": "source_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_relation_target_idx": { + "name": "task_relation_target_idx", + "columns": [ + { + "expression": "target_task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_relation_source_task_id_task_id_fk": { + "name": "task_relation_source_task_id_task_id_fk", + "tableFrom": "task_relation", + "tableTo": "task", + "columnsFrom": [ + "source_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_relation_target_task_id_task_id_fk": { + "name": "task_relation_target_task_id_task_id_fk", + "tableFrom": "task_relation", + "tableTo": "task", + "columnsFrom": [ + "target_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_reminder_sent": { + "name": "task_reminder_sent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reminder_type": { + "name": "reminder_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_reminder_sent_taskId_idx": { + "name": "task_reminder_sent_taskId_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_reminder_sent_task_id_task_id_fk": { + "name": "task_reminder_sent_task_id_task_id_fk", + "tableFrom": "task_reminder_sent", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_reminder_sent_task_type_unique": { + "name": "task_reminder_sent_task_type_unique", + "nullsNotDistinct": false, + "columns": [ + "task_id", + "reminder_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'to-do'" + }, + "column_id": { + "name": "column_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'low'" + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_projectId_idx": { + "name": "task_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_dueDate_idx": { + "name": "task_dueDate_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_assigneeId_idx": { + "name": "task_assigneeId_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_columnId_idx": { + "name": "task_columnId_idx", + "columns": [ + { + "expression": "column_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_assignee_id_user_id_fk": { + "name": "task_assignee_id_user_id_fk", + "tableFrom": "task", + "tableTo": "user", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "task_column_id_column_id_fk": { + "name": "task_column_id_column_id_fk", + "tableFrom": "task", + "tableTo": "column", + "columnsFrom": [ + "column_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_project_number_unique": { + "name": "task_project_number_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_workspaceId_idx": { + "name": "team_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_workspace_id_workspace_id_fk": { + "name": "team_workspace_id_workspace_id_fk", + "tableFrom": "team", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "teamMember_teamId_idx": { + "name": "teamMember_teamId_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teamMember_userId_idx": { + "name": "teamMember_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry": { + "name": "time_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_entry_taskId_idx": { + "name": "time_entry_taskId_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entry_userId_idx": { + "name": "time_entry_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_task_id_task_id_fk": { + "name": "time_entry_task_id_task_id_fk", + "tableFrom": "time_entry", + "tableTo": "task", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "time_entry_user_id_user_id_fk": { + "name": "time_entry_user_id_user_id_fk", + "tableFrom": "time_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trial_grant": { + "name": "trial_grant", + "schema": "", + "columns": { + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_avatar": { + "name": "user_avatar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_avatar_userId_idx": { + "name": "user_avatar_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_avatar_user_id_user_id_fk": { + "name": "user_avatar_user_id_user_id_fk", + "tableFrom": "user_avatar", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_avatar_user_id_unique": { + "name": "user_avatar_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_preference": { + "name": "user_notification_preference", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_enabled": { + "name": "email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ntfy_enabled": { + "name": "ntfy_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ntfy_server_url": { + "name": "ntfy_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_topic": { + "name": "ntfy_topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntfy_token": { + "name": "ntfy_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gotify_enabled": { + "name": "gotify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "gotify_server_url": { + "name": "gotify_server_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gotify_token": { + "name": "gotify_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_assignment_enabled": { + "name": "task_assignment_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "task_comment_enabled": { + "name": "task_comment_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "task_status_change_enabled": { + "name": "task_status_change_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "due_date_reminder_enabled": { + "name": "due_date_reminder_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "due_date_reminder_lead_time_minutes": { + "name": "due_date_reminder_lead_time_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notification_preference_user_id_user_id_fk": { + "name": "user_notification_preference_user_id_user_id_fk", + "tableFrom": "user_notification_preference", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_notification_preference_user_id_unique": { + "name": "user_notification_preference_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_workspace_project": { + "name": "user_notification_workspace_project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_rule_id": { + "name": "workspace_rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_notification_workspace_project_ruleId_idx": { + "name": "user_notification_workspace_project_ruleId_idx", + "columns": [ + { + "expression": "workspace_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_workspace_project_projectId_idx": { + "name": "user_notification_workspace_project_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_workspace_project_workspaceId_projectId_idx": { + "name": "user_notification_workspace_project_workspaceId_projectId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unwp_workspaceId_workspaceRuleId_idx": { + "name": "unwp_workspaceId_workspaceRuleId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notification_workspace_project_workspace_id_workspace_id_fk": { + "name": "user_notification_workspace_project_workspace_id_workspace_id_fk", + "tableFrom": "user_notification_workspace_project", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_notification_workspace_project_workspace_id_workspace_rule_id_user_notification_workspace_rule_workspace_id_id_fk": { + "name": "user_notification_workspace_project_workspace_id_workspace_rule_id_user_notification_workspace_rule_workspace_id_id_fk", + "tableFrom": "user_notification_workspace_project", + "tableTo": "user_notification_workspace_rule", + "columnsFrom": [ + "workspace_id", + "workspace_rule_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_notification_workspace_project_workspace_id_project_id_project_workspace_id_id_fk": { + "name": "user_notification_workspace_project_workspace_id_project_id_project_workspace_id_id_fk", + "tableFrom": "user_notification_workspace_project", + "tableTo": "project", + "columnsFrom": [ + "workspace_id", + "project_id" + ], + "columnsTo": [ + "workspace_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_notification_workspace_project_rule_project_unique": { + "name": "user_notification_workspace_project_rule_project_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_rule_id", + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_workspace_rule": { + "name": "user_notification_workspace_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_enabled": { + "name": "email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ntfy_enabled": { + "name": "ntfy_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "gotify_enabled": { + "name": "gotify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "webhook_enabled": { + "name": "webhook_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "project_mode": { + "name": "project_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_notification_workspace_rule_userId_idx": { + "name": "user_notification_workspace_rule_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notification_workspace_rule_workspaceId_idx": { + "name": "user_notification_workspace_rule_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notification_workspace_rule_user_id_user_id_fk": { + "name": "user_notification_workspace_rule_user_id_user_id_fk", + "tableFrom": "user_notification_workspace_rule", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_notification_workspace_rule_workspace_id_workspace_id_fk": { + "name": "user_notification_workspace_rule_workspace_id_workspace_id_fk", + "tableFrom": "user_notification_workspace_rule", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_notification_workspace_rule_user_workspace_unique": { + "name": "user_notification_workspace_rule_user_workspace_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "workspace_id" + ] + }, + "user_notification_workspace_rule_workspace_id_id_unique": { + "name": "user_notification_workspace_rule_workspace_id_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_rule": { + "name": "workflow_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_id": { + "name": "column_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_rule_projectId_idx": { + "name": "workflow_rule_projectId_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_rule_columnId_idx": { + "name": "workflow_rule_columnId_idx", + "columns": [ + { + "expression": "column_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_rule_project_id_project_id_fk": { + "name": "workflow_rule_project_id_project_id_fk", + "tableFrom": "workflow_rule", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "workflow_rule_column_id_column_id_fk": { + "name": "workflow_rule_column_id_column_id_fk", + "tableFrom": "workflow_rule", + "tableTo": "column", + "columnsFrom": [ + "column_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_billing": { + "name": "workspace_billing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "founding_free": { + "name": "founding_free", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "creem_customer_id": { + "name": "creem_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_subscription_id": { + "name": "creem_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_product_id": { + "name": "creem_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_billing_workspaceId_idx": { + "name": "workspace_billing_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_billing_workspace_id_workspace_id_fk": { + "name": "workspace_billing_workspace_id_workspace_id_fk", + "tableFrom": "workspace_billing", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_billing_workspace_id_unique": { + "name": "workspace_billing_workspace_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + }, + "workspace_billing_creem_subscription_id_unique": { + "name": "workspace_billing_creem_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "creem_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_member_workspaceId_idx": { + "name": "workspace_member_workspaceId_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_member_userId_idx": { + "name": "workspace_member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_member_user_id_user_id_fk": { + "name": "workspace_member_user_id_user_id_fk", + "tableFrom": "workspace_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 2f039c26b7..ad855a564a 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1790438033113, "tag": "0054_backfill_instance_admin", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1790763801959, + "tag": "0055_huge_sue_storm", + "breakpoints": true } ] } diff --git a/apps/api/src/column/controllers/create-column.ts b/apps/api/src/column/controllers/create-column.ts index 5a608be6fc..35be40e353 100644 --- a/apps/api/src/column/controllers/create-column.ts +++ b/apps/api/src/column/controllers/create-column.ts @@ -1,3 +1,4 @@ +import { publishEvent } from "../../events"; import { eq, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; @@ -81,6 +82,8 @@ async function createColumn({ throw new HTTPException(500, { message: "Failed to create column" }); } + await publishEvent("project.updated", { projectId: created.projectId }); + return created; } diff --git a/apps/api/src/column/controllers/delete-column.ts b/apps/api/src/column/controllers/delete-column.ts index a14fd0c38d..5570a7aaf9 100644 --- a/apps/api/src/column/controllers/delete-column.ts +++ b/apps/api/src/column/controllers/delete-column.ts @@ -1,3 +1,4 @@ +import { publishEvent } from "../../events"; import { eq, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; @@ -26,6 +27,8 @@ async function deleteColumn(id: string) { await db.delete(columnTable).where(eq(columnTable.id, id)); + await publishEvent("project.updated", { projectId: existing.projectId }); + return existing; } diff --git a/apps/api/src/column/controllers/reorder-columns.ts b/apps/api/src/column/controllers/reorder-columns.ts index c8f10281ed..a2d041f1ff 100644 --- a/apps/api/src/column/controllers/reorder-columns.ts +++ b/apps/api/src/column/controllers/reorder-columns.ts @@ -1,3 +1,4 @@ +import { publishEvent } from "../../events"; import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; @@ -7,27 +8,33 @@ async function reorderColumns( projectId: string, columns: Array<{ id: string; position: number }>, ) { - for (const col of columns) { - const [updated] = await db - .update(columnTable) - .set({ position: col.position }) - .where( - and(eq(columnTable.id, col.id), eq(columnTable.projectId, projectId)), - ) - .returning({ id: columnTable.id }); + const updated = await db.transaction(async (tx) => { + for (const col of [...columns].sort((left, right) => + left.id.localeCompare(right.id), + )) { + const [updated] = await tx + .update(columnTable) + .set({ position: col.position }) + .where( + and(eq(columnTable.id, col.id), eq(columnTable.projectId, projectId)), + ) + .returning({ id: columnTable.id }); - if (!updated) { - throw new HTTPException(400, { - message: `Column ${col.id} does not belong to this project`, - }); + if (!updated) { + throw new HTTPException(400, { + message: `Column ${col.id} does not belong to this project`, + }); + } } - } - const updated = await db.query.columnTable.findMany({ - where: eq(columnTable.projectId, projectId), - orderBy: (columns, { asc }) => [asc(columns.position)], + return tx.query.columnTable.findMany({ + where: eq(columnTable.projectId, projectId), + orderBy: (columns, { asc }) => [asc(columns.position)], + }); }); + await publishEvent("project.updated", { projectId }); + return updated; } diff --git a/apps/api/src/column/controllers/update-column.ts b/apps/api/src/column/controllers/update-column.ts index 6c93c5e399..f42ebbeb1c 100644 --- a/apps/api/src/column/controllers/update-column.ts +++ b/apps/api/src/column/controllers/update-column.ts @@ -50,6 +50,8 @@ async function updateColumn( }); } + await publishEvent("project.updated", { projectId: updated.projectId }); + return updated; } diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 9705850fa4..90eba25122 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -972,6 +972,11 @@ export const externalLinkTable = pgTable( index("external_link_integrationId_idx").on(table.integrationId), index("external_link_externalId_idx").on(table.externalId), index("external_link_resourceType_idx").on(table.resourceType), + index("external_link_deferred_issue_idx") + .on(table.id) + .where( + sql`${table.resourceType} = 'issue' AND ${table.metadata} LIKE '%"deferredIssueEdit":%'`, + ), ], ); diff --git a/apps/api/src/label/controllers/update-label.ts b/apps/api/src/label/controllers/update-label.ts index cd465ec339..0fdd74decb 100644 --- a/apps/api/src/label/controllers/update-label.ts +++ b/apps/api/src/label/controllers/update-label.ts @@ -1,10 +1,12 @@ import { and, eq, isNotNull, isNull } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; -import { labelTable } from "../../database/schema"; +import { labelTable, projectTable, taskTable } from "../../database/schema"; + +import { publishEvent } from "../../events"; async function updateLabel(id: string, name: string, color: string) { - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const label = await tx.query.labelTable.findFirst({ where: (label, { eq }) => eq(label.id, id), }); @@ -44,8 +46,35 @@ async function updateLabel(id: string, name: string, color: string) { ); } - return updatedLabel; + // Workspace labels appear in every board's choices, including unassigned + // labels. Publish only project IDs within this workspace after commit. + const projects = + !label.taskId && label.workspaceId + ? await tx + .select({ projectId: projectTable.id }) + .from(projectTable) + .where(eq(projectTable.workspaceId, label.workspaceId)) + : await tx + .selectDistinct({ projectId: taskTable.projectId }) + .from(labelTable) + .innerJoin(taskTable, eq(labelTable.taskId, taskTable.id)) + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) + .where( + and( + label.taskId + ? eq(labelTable.id, id) + : and( + eq(labelTable.workspaceId, label.workspaceId ?? ""), + eq(labelTable.name, name), + ), + eq(projectTable.workspaceId, label.workspaceId ?? ""), + ), + ); + return { updatedLabel, projects }; }); + for (const { projectId } of result.projects) + await publishEvent("project.updated", { projectId }); + return result.updatedLabel; } export default updateLabel; diff --git a/apps/api/src/plugins/gitea/events/task-description-changed.ts b/apps/api/src/plugins/gitea/events/task-description-changed.ts index 8eb8b148c2..2d57d2ce6c 100644 --- a/apps/api/src/plugins/gitea/events/task-description-changed.ts +++ b/apps/api/src/plugins/gitea/events/task-description-changed.ts @@ -1,17 +1,16 @@ +import { syncLatestTaskValue } from "../../github/services/sync-latest-task-value"; +import db from "../../../database"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; +import { findExternalLinksByTask } from "../../github/services/link-manager"; import { - findExternalLinksByTask, - updateExternalLink, -} from "../../github/services/link-manager"; -import { formatIssueBody } from "../../github/utils/format"; + formatIssueBody, + formatTaskDescriptionFromIssue, +} from "../../github/utils/format"; import type { PluginContext, TaskDescriptionChangedEvent } from "../../types"; import type { GiteaConfig } from "../config"; import { createGiteaClient } from "../utils/gitea-api"; -type LinkSyncState = { - timestamp: string; - source: string; - value: string; -}; +type LinkSyncState = import("../../github/utils/sync-echo").SyncStamp; type LinkMetadata = { lastSync?: { @@ -32,6 +31,16 @@ export async function handleTaskDescriptionChanged( const { repositoryOwner, repositoryName } = config; try { + const current = await db.query.taskTable.findFirst({ + where: linkedTaskScope(event.taskId, context.projectId), + columns: { description: true }, + }); + if ( + !current || + (current.description || "") !== (event.newDescription || "") + ) + return; + const links = await findExternalLinksByTask(event.taskId); const issueLink = links.find( (link) => @@ -71,19 +80,6 @@ export async function handleTaskDescriptionChanged( console.log("Skipping description sync - already synced from Gitea"); return; } - - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp).getTime(); - if ( - timeSinceLastSync < 2000 && - lastDescSync.source === "gitea" && - newDescNormalized === lastDescSync.value - ) { - console.log( - `Skipping description sync - recent sync detected (${timeSinceLastSync}ms ago)`, - ); - return; - } } const client = createGiteaClient(config); @@ -97,25 +93,31 @@ export async function handleTaskDescriptionChanged( return; } - const formattedBody = formatIssueBody(event.newDescription, event.taskId); - - await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { - body: formattedBody, - }); - - await updateExternalLink(issueLink.id, { - metadata: { - ...metadata, - lastSync: { - ...metadata.lastSync, - description: { - timestamp: new Date().toISOString(), - source: "kaneo", - value: newDescNormalized, + await syncLatestTaskValue( + event.taskId, + context.projectId, + issueLink, + "description", + newDescNormalized, + async (value) => { + const response = await client.updateIssue( + repositoryOwner, + repositoryName, + issueNumber, + { + body: formatIssueBody(value, event.taskId), }, - }, + ); + return response?.updated_at; }, - }); + async () => + formatTaskDescriptionFromIssue( + (await client.getIssue(repositoryOwner, repositoryName, issueNumber)) + .body ?? null, + event.taskId, + ), + { type: "gitea", config: JSON.stringify(config) }, + ); console.log(`Synced task description to Gitea issue #${issueNumber}`); } catch (error) { diff --git a/apps/api/src/plugins/gitea/events/task-status-changed.ts b/apps/api/src/plugins/gitea/events/task-status-changed.ts index 9570929603..46d8061cde 100644 --- a/apps/api/src/plugins/gitea/events/task-status-changed.ts +++ b/apps/api/src/plugins/gitea/events/task-status-changed.ts @@ -1,7 +1,5 @@ -import { - findExternalLinksByTask, - updateExternalLink, -} from "../../github/services/link-manager"; +import { syncLatestTaskValue } from "../../github/services/sync-latest-task-value"; +import { findExternalLinksByTask } from "../../github/services/link-manager"; import type { PluginContext, TaskStatusChangedEvent } from "../../types"; import type { GiteaConfig } from "../config"; import { createGiteaClient } from "../utils/gitea-api"; @@ -39,30 +37,27 @@ export async function handleTaskStatusChanged( `status:${event.newStatus}`, ]); - if (event.newStatus === "done") { - await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { - state: "closed", - }); - - await updateExternalLink(issueLink.id, { - metadata: { - ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), - state: "closed", - lastOutboundStateSyncAt: Date.now(), - }, - }); - } else if (event.oldStatus === "done" && event.newStatus !== "done") { - await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { - state: "open", - }); - - await updateExternalLink(issueLink.id, { - metadata: { - ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), - state: "open", - lastOutboundStateSyncAt: Date.now(), + if (event.newStatus === "done" || event.oldStatus === "done") { + await syncLatestTaskValue( + event.taskId, + event.projectId, + issueLink, + "state", + event.newStatus === "done" ? "closed" : "open", + async (value) => { + const response = await client.updateIssue( + repositoryOwner, + repositoryName, + issueNumber, + { state: value === "closed" ? "closed" : "open" }, + ); + return response?.updated_at; }, - }); + async () => + (await client.getIssue(repositoryOwner, repositoryName, issueNumber)) + .state ?? "open", + { type: "gitea", config: JSON.stringify(config) }, + ); } } catch (error) { console.error("Failed to update Gitea issue status:", error); diff --git a/apps/api/src/plugins/gitea/events/task-title-changed.ts b/apps/api/src/plugins/gitea/events/task-title-changed.ts index f5be824f11..ed850d78c9 100644 --- a/apps/api/src/plugins/gitea/events/task-title-changed.ts +++ b/apps/api/src/plugins/gitea/events/task-title-changed.ts @@ -1,16 +1,12 @@ -import { - findExternalLinksByTask, - updateExternalLink, -} from "../../github/services/link-manager"; +import { syncLatestTaskValue } from "../../github/services/sync-latest-task-value"; +import db from "../../../database"; +import { linkedTaskScope } from "../../github/services/integration-task-scope"; +import { findExternalLinksByTask } from "../../github/services/link-manager"; import type { PluginContext, TaskTitleChangedEvent } from "../../types"; import type { GiteaConfig } from "../config"; import { createGiteaClient } from "../utils/gitea-api"; -type LinkSyncState = { - timestamp: string; - source: string; - value: string; -}; +type LinkSyncState = import("../../github/utils/sync-echo").SyncStamp; type LinkMetadata = { lastSync?: { @@ -31,6 +27,12 @@ export async function handleTaskTitleChanged( const { repositoryOwner, repositoryName } = config; try { + const current = await db.query.taskTable.findFirst({ + where: linkedTaskScope(event.taskId, context.projectId), + columns: { title: true }, + }); + if (!current || current.title !== event.newTitle) return; + const links = await findExternalLinksByTask(event.taskId); const issueLink = links.find( (link) => @@ -68,15 +70,6 @@ export async function handleTaskTitleChanged( console.log("Skipping title sync - already synced from Gitea"); return; } - - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp).getTime(); - if (lastTitleSync.source === "gitea" && timeSinceLastSync < 2000) { - console.log( - `Skipping title sync - recent sync detected (${timeSinceLastSync}ms ago)`, - ); - return; - } } const client = createGiteaClient(config); @@ -90,24 +83,28 @@ export async function handleTaskTitleChanged( return; } - await client.updateIssue(repositoryOwner, repositoryName, issueNumber, { - title: event.newTitle, - }); - - await updateExternalLink(issueLink.id, { - title: event.newTitle, - metadata: { - ...metadata, - lastSync: { - ...metadata.lastSync, - title: { - timestamp: new Date().toISOString(), - source: "kaneo", - value: event.newTitle, + await syncLatestTaskValue( + event.taskId, + context.projectId, + issueLink, + "title", + event.newTitle, + async (value) => { + const response = await client.updateIssue( + repositoryOwner, + repositoryName, + issueNumber, + { + title: value, }, - }, + ); + return response?.updated_at; }, - }); + async () => + (await client.getIssue(repositoryOwner, repositoryName, issueNumber)) + .title, + { type: "gitea", config: JSON.stringify(config) }, + ); console.log(`Synced task title to Gitea issue #${issueNumber}`); } catch (error) { diff --git a/apps/api/src/plugins/gitea/utils/gitea-api.ts b/apps/api/src/plugins/gitea/utils/gitea-api.ts index 38c3c99df5..01d1ced5e2 100644 --- a/apps/api/src/plugins/gitea/utils/gitea-api.ts +++ b/apps/api/src/plugins/gitea/utils/gitea-api.ts @@ -10,6 +10,7 @@ export type GiteaLabel = { }; export type GiteaIssue = { + updated_at?: string; id: number; number: number; title: string; diff --git a/apps/api/src/plugins/gitea/webhooks/issue-closed.ts b/apps/api/src/plugins/gitea/webhooks/issue-closed.ts index a8e0104864..a661dc75ab 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-closed.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-closed.ts @@ -1,11 +1,25 @@ +import { issueEditScope } from "../../github/utils/deferred-issue-edit"; +import { deferIssueEdit } from "../../github/services/deferred-issue-edits"; +import { inboundStamp } from "../../github/utils/sync-echo"; import { withIntegrationLink } from "../../github/services/with-integration-link"; +import type { GiteaConfig } from "../config"; +import { createGiteaClient } from "../utils/gitea-api"; +import { parseLinkMetadata } from "../../github/utils/parse-link-metadata"; +import type { SyncStamp } from "../../github/utils/sync-echo"; +import { + inboundEcho, + withEchoConfirmation, +} from "../../github/utils/inbound-echo"; import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; -import { updateTaskStatus } from "../../github/services/task-service"; +import { + isTaskInFinalState, + updateTaskStatus, +} from "../../github/services/task-service"; import { findAllIntegrationsByGiteaRepo, repoOwnerLogin, @@ -67,85 +81,127 @@ export async function handleGiteaIssueClosed( continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); - - if (!task) { - return; - } + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GiteaConfig; + return await createGiteaClient(config).getIssue( + config.repositoryOwner, + config.repositoryName, + issue.number, + ); + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn( - "Failed to parse Gitea issue metadata for close sync", - { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }, - ); - } - } + if (!task) { + return; + } - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if (typeof lastOutbound === "number" && Number.isFinite(lastOutbound)) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - return; - } - } + const existingMetadata = parseLinkMetadata< + Record & { lastSync?: { state?: SyncStamp } } + >(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "gitea_issue_closed", + }); + if ( + inboundEcho( + existingMetadata.lastSync?.state, + "closed", + issue.updated_at, + current ? (current.state ?? issue.state) : undefined, + { + linkId: externalLink.id, + field: "state", + localValue: (await isTaskInFinalState(task, db)) + ? "closed" + : "open", + confirmation, + }, + ) + ) + return; + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if ( + typeof lastOutbound === "number" && + Number.isFinite(lastOutbound) && + existingMetadata.state === "closed" + ) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= + OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_closed", - "done", - db, - ); + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_closed", + "done", + db, + ); - const statusResult = await updateTaskStatus(task.id, targetStatus, db); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - afterCommit(() => - publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }), - ); - } + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink( - externalLink.id, + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + lastSync: { + ...existingMetadata.lastSync, + state: inboundStamp( + existingMetadata.lastSync?.state, + "closed", + "gitea", + current?.updated_at ?? issue.updated_at, + ), + }, + }, + }, + db, + ); + }, { - metadata: { - ...existingMetadata, - state: "closed", - }, + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), }, - db, - ); - }, + ), + () => deferIssueEdit(externalLink, integration, ["state"]), ); } } diff --git a/apps/api/src/plugins/gitea/webhooks/issue-edited.ts b/apps/api/src/plugins/gitea/webhooks/issue-edited.ts index 87c2d377b0..730e22500b 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-edited.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-edited.ts @@ -1,5 +1,14 @@ +import { issueEditScope } from "../../github/utils/deferred-issue-edit"; +import { deferIssueEdit } from "../../github/services/deferred-issue-edits"; +import { inboundStamp } from "../../github/utils/sync-echo"; import { publishEvent } from "../../../events"; import { withIntegrationLink } from "../../github/services/with-integration-link"; +import type { GiteaConfig } from "../config"; +import { createGiteaClient } from "../utils/gitea-api"; +import { + inboundEcho, + withEchoConfirmation, +} from "../../github/utils/inbound-echo"; import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { taskTable } from "../../../database/schema"; import { @@ -19,6 +28,7 @@ type IssueEditedPayload = { number: number; title: string; body: string | null; + updated_at?: string; html_url: string; }; changes?: { @@ -64,114 +74,155 @@ export async function handleGiteaIssueEdited( continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); - - if (!task) { - return; - } - - const metadata = externalLink.metadata - ? JSON.parse(externalLink.metadata) - : {}; - - const updateData: Record = {}; - const updatedMetadata = { ...metadata }; - - if (!updatedMetadata.lastSync) { - updatedMetadata.lastSync = {}; - } - - if (changes.title) { - const lastTitleSync = metadata.lastSync?.title; + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GiteaConfig; + return await createGiteaClient(config).getIssue( + config.repositoryOwner, + config.repositoryName, + issue.number, + ); + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); + + if (!task) { + return; + } + const metadata = externalLink.metadata + ? JSON.parse(externalLink.metadata) + : {}; - let shouldUpdateTitle = true; + const updateData: Record = {}; + const updatedMetadata = { ...metadata }; - if (lastTitleSync) { - if ( - lastTitleSync.value === issue.title && - lastTitleSync.source === "kaneo" - ) { - shouldUpdateTitle = false; + if (!updatedMetadata.lastSync) { + updatedMetadata.lastSync = {}; } - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateTitle) { - shouldUpdateTitle = false; + + if (changes.title) { + const lastTitleSync = metadata.lastSync?.title; + + let shouldUpdateTitle = true; + + if (lastTitleSync) { + if ( + inboundEcho( + lastTitleSync, + issue.title, + issue.updated_at, + current?.title, + { + linkId: externalLink.id, + field: "title", + localValue: task.title, + confirmation, + }, + ) + ) { + shouldUpdateTitle = false; + } + } + + if (shouldUpdateTitle) { + updateData.title = issue.title; + updatedMetadata.lastSync.title = inboundStamp( + metadata.lastSync?.title, + issue.title, + "gitea", + issue.updated_at, + ); + } } - } - - if (shouldUpdateTitle) { - updateData.title = issue.title; - updatedMetadata.lastSync.title = { - timestamp: new Date().toISOString(), - source: "gitea", - value: issue.title, - }; - } - } - - if (changes.body) { - const lastDescSync = metadata.lastSync?.description; - const formattedDescription = formatTaskDescriptionFromIssue( - issue.body, - ); - - let shouldUpdateDescription = true; - - if (lastDescSync) { - if ( - lastDescSync.value === formattedDescription && - lastDescSync.source === "kaneo" - ) { - shouldUpdateDescription = false; + + if (changes.body) { + const lastDescSync = metadata.lastSync?.description; + const formattedDescription = formatTaskDescriptionFromIssue( + issue.body, + externalLink.taskId, + ); + + let shouldUpdateDescription = true; + + if (lastDescSync) { + if ( + inboundEcho( + lastDescSync, + formattedDescription, + issue.updated_at, + current + ? formatTaskDescriptionFromIssue( + current.body ?? null, + task.id, + ) + : undefined, + { + linkId: externalLink.id, + field: "description", + localValue: task.description || "", + confirmation, + }, + ) + ) { + shouldUpdateDescription = false; + } + } + + if (shouldUpdateDescription) { + updateData.description = formattedDescription; + updatedMetadata.lastSync.description = inboundStamp( + metadata.lastSync?.description, + formattedDescription, + "gitea", + issue.updated_at, + ); + } } - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateDescription) { - shouldUpdateDescription = false; + + if (Object.keys(updateData).length > 0) { + await db + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(task.id, integration.projectId)); + + await updateExternalLink( + externalLink.id, + { + title: issue.title, + metadata: updatedMetadata, + }, + db, + ); + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: externalLink.taskId, + }), + ); } - } - - if (shouldUpdateDescription) { - updateData.description = formattedDescription; - updatedMetadata.lastSync.description = { - timestamp: new Date().toISOString(), - source: "gitea", - value: formattedDescription, - }; - } - } - - if (Object.keys(updateData).length > 0) { - await db - .update(taskTable) - .set(updateData) - .where(linkedTaskScope(task.id, integration.projectId)); - - await updateExternalLink( - externalLink.id, - { - title: issue.title, - metadata: updatedMetadata, - }, - db, - ); - afterCommit(() => - publishEvent("task.updated", { - projectId: integration.projectId, - taskId: externalLink.taskId, - }), - ); - } - - return; - }, + + return; + }, + { + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), + }, + ), + () => + deferIssueEdit(externalLink, integration, [ + ...(changes.title ? ["title" as const] : []), + ...(changes.body ? ["description" as const] : []), + ]), ); } } diff --git a/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts b/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts index 2f6dd84607..04f79c07b0 100644 --- a/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts +++ b/apps/api/src/plugins/gitea/webhooks/issue-reopened.ts @@ -1,11 +1,26 @@ +import { issueEditScope } from "../../github/utils/deferred-issue-edit"; +import { deferIssueEdit } from "../../github/services/deferred-issue-edits"; +import { inboundStamp } from "../../github/utils/sync-echo"; import { withIntegrationLink } from "../../github/services/with-integration-link"; +import type { GiteaConfig } from "../config"; +import { createGiteaClient } from "../utils/gitea-api"; +import { parseLinkMetadata } from "../../github/utils/parse-link-metadata"; +import type { SyncStamp } from "../../github/utils/sync-echo"; +import { + inboundEcho, + PendingResponseTimeout, + withEchoConfirmation, +} from "../../github/utils/inbound-echo"; import { linkedTaskScope } from "../../github/services/integration-task-scope"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { externalLinkTable } from "../../../database/schema"; import { publishEvent } from "../../../events"; import { updateExternalLink } from "../../github/services/link-manager"; -import { updateTaskStatus } from "../../github/services/task-service"; +import { + isTaskInFinalState, + updateTaskStatus, +} from "../../github/services/task-service"; import { findAllIntegrationsByGiteaRepo, repoOwnerLogin, @@ -68,94 +83,129 @@ export async function handleGiteaIssueReopened( continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GiteaConfig; + return await createGiteaClient(config).getIssue( + config.repositoryOwner, + config.repositoryName, + issue.number, + ); + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); - if (!task) { - return; - } + if (!task) { + return; + } + const existingMetadata = parseLinkMetadata< + Record & { lastSync?: { state?: SyncStamp } } + >(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "gitea_issue_reopened", + }); + if ( + inboundEcho( + existingMetadata.lastSync?.state, + "open", + issue.updated_at, + current ? (current.state ?? issue.state) : undefined, + { + linkId: externalLink.id, + field: "state", + localValue: (await isTaskInFinalState(task, db)) + ? "closed" + : "open", + confirmation, + }, + ) + ) + return; + const lastOutbound = existingMetadata.lastOutboundStateSyncAt; + if ( + typeof lastOutbound === "number" && + Number.isFinite(lastOutbound) && + existingMetadata.state === "open" + ) { + const eventMs = parseIssueUpdatedAtMs(issue); + if ( + eventMs !== null && + Math.abs(eventMs - lastOutbound) <= + OUTBOUND_STATE_ECHO_WINDOW_MS + ) { + return; + } + } - let existingMetadata: Record = {}; - if (externalLink.metadata) { - try { - existingMetadata = JSON.parse(externalLink.metadata) as Record< - string, - unknown - >; - } catch (error) { - console.warn( - "Failed to parse Gitea issue metadata for reopen sync", - { - externalLinkId: externalLink.id, - metadata: externalLink.metadata, - error, - }, + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_reopened", + "to-do", + db, ); - } - } - - const lastOutbound = existingMetadata.lastOutboundStateSyncAt; - if ( - typeof lastOutbound === "number" && - Number.isFinite(lastOutbound) - ) { - const eventMs = parseIssueUpdatedAtMs(issue); - if ( - eventMs !== null && - Math.abs(eventMs - lastOutbound) <= OUTBOUND_STATE_ECHO_WINDOW_MS - ) { - return; - } - } - - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_reopened", - "to-do", - db, - ); - const statusResult = await updateTaskStatus( - task.id, - targetStatus, - db, - ); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - afterCommit(() => - publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }), - ); - } + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } - await updateExternalLink( - externalLink.id, + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "open", + lastSync: { + ...existingMetadata.lastSync, + state: inboundStamp( + existingMetadata.lastSync?.state, + "open", + "gitea", + current?.updated_at ?? issue.updated_at, + ), + }, + }, + }, + db, + ); + }, { - metadata: { - ...existingMetadata, - state: "open", - }, + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), }, - db, - ); - }, + ), + () => deferIssueEdit(externalLink, integration, ["state"]), ); } catch (error) { + if (error instanceof PendingResponseTimeout) throw error; console.error("Gitea issue_reopened handler failed for integration", { integrationId: integration.id, issueNumber: issue.number, diff --git a/apps/api/src/plugins/github/events/task-description-changed.ts b/apps/api/src/plugins/github/events/task-description-changed.ts index efe559293d..5ded462181 100644 --- a/apps/api/src/plugins/github/events/task-description-changed.ts +++ b/apps/api/src/plugins/github/events/task-description-changed.ts @@ -1,10 +1,13 @@ +import { syncLatestTaskValue } from "../services/sync-latest-task-value"; +import db from "../../../database"; +import { linkedTaskScope } from "../services/integration-task-scope"; import type { PluginContext, TaskDescriptionChangedEvent } from "../../types"; import { type GitHubConfig, hasVerifiedGitHubBinding } from "../config"; +import { findExternalLinksByTask } from "../services/link-manager"; import { - findExternalLinksByTask, - updateExternalLink, -} from "../services/link-manager"; -import { formatIssueBody } from "../utils/format"; + formatIssueBody, + formatTaskDescriptionFromIssue, +} from "../utils/format"; import { getGithubApp, getVerifiedInstallationOctokit, @@ -24,6 +27,16 @@ export async function handleTaskDescriptionChanged( const { repositoryOwner, repositoryName } = config; try { + const current = await db.query.taskTable.findFirst({ + where: linkedTaskScope(event.taskId, context.projectId), + columns: { description: true }, + }); + if ( + !current || + (current.description || "") !== (event.newDescription || "") + ) + return; + const links = await findExternalLinksByTask(event.taskId); const issueLink = links.find( (link) => @@ -50,45 +63,40 @@ export async function handleTaskDescriptionChanged( console.log("Skipping description sync - already synced from GitHub"); return; } - - // Skip if recent sync (within 2 seconds) to prevent rapid loops - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp).getTime(); - if (timeSinceLastSync < 2000) { - console.log( - `Skipping description sync - recent sync detected (${timeSinceLastSync}ms ago)`, - ); - return; - } } const octokit = await getVerifiedInstallationOctokit(config); const issueNumber = Number.parseInt(issueLink.externalId, 10); // Format description with task ID footer - const formattedBody = formatIssueBody(event.newDescription, event.taskId); - - await octokit.rest.issues.update({ - owner: repositoryOwner, - repo: repositoryName, - issue_number: issueNumber, - body: formattedBody, - }); - - // Update metadata to track this sync - await updateExternalLink(issueLink.id, { - metadata: { - ...metadata, - lastSync: { - ...metadata.lastSync, - description: { - timestamp: new Date().toISOString(), - source: "kaneo", - value: newDescNormalized, - }, - }, + await syncLatestTaskValue( + event.taskId, + context.projectId, + issueLink, + "description", + newDescNormalized, + async (value) => { + const response = await octokit.rest.issues.update({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + body: formatIssueBody(value, event.taskId), + }); + return response?.data?.updated_at; }, - }); + async () => + formatTaskDescriptionFromIssue( + ( + await octokit.rest.issues.get({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + }) + ).data.body ?? null, + event.taskId, + ), + { type: "github", config: JSON.stringify(config) }, + ); console.log(`Synced task description to GitHub issue #${issueNumber}`); } catch (error) { diff --git a/apps/api/src/plugins/github/events/task-status-changed.ts b/apps/api/src/plugins/github/events/task-status-changed.ts index ef8125b1c2..7962f057a7 100644 --- a/apps/api/src/plugins/github/events/task-status-changed.ts +++ b/apps/api/src/plugins/github/events/task-status-changed.ts @@ -1,9 +1,7 @@ +import { syncLatestTaskValue } from "../services/sync-latest-task-value"; import type { PluginContext, TaskStatusChangedEvent } from "../../types"; import { type GitHubConfig, hasVerifiedGitHubBinding } from "../config"; -import { - findExternalLinksByTask, - updateExternalLink, -} from "../services/link-manager"; +import { findExternalLinksByTask } from "../services/link-manager"; import { getGithubApp, getVerifiedInstallationOctokit, @@ -54,34 +52,32 @@ export async function handleTaskStatusChanged( [`status:${event.newStatus}`], ); - if (event.newStatus === "done") { - await octokit.rest.issues.update({ - owner: repositoryOwner, - repo: repositoryName, - issue_number: issueNumber, - state: "closed", - }); - - await updateExternalLink(issueLink.id, { - metadata: { - ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), - state: "closed", - }, - }); - } else if (event.oldStatus === "done" && event.newStatus !== "done") { - await octokit.rest.issues.update({ - owner: repositoryOwner, - repo: repositoryName, - issue_number: issueNumber, - state: "open", - }); - - await updateExternalLink(issueLink.id, { - metadata: { - ...(issueLink.metadata ? JSON.parse(issueLink.metadata) : {}), - state: "open", + if (event.newStatus === "done" || event.oldStatus === "done") { + await syncLatestTaskValue( + event.taskId, + event.projectId, + issueLink, + "state", + event.newStatus === "done" ? "closed" : "open", + async (value) => { + const response = await octokit.rest.issues.update({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + state: value === "closed" ? "closed" : "open", + }); + return response?.data?.updated_at; }, - }); + async () => + ( + await octokit.rest.issues.get({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + }) + ).data.state ?? "open", + { type: "github", config: JSON.stringify(config) }, + ); } } catch (error) { console.error("Failed to update GitHub issue status:", error); diff --git a/apps/api/src/plugins/github/events/task-title-changed.ts b/apps/api/src/plugins/github/events/task-title-changed.ts index d0e18c8dfb..6140bed038 100644 --- a/apps/api/src/plugins/github/events/task-title-changed.ts +++ b/apps/api/src/plugins/github/events/task-title-changed.ts @@ -1,9 +1,9 @@ +import { syncLatestTaskValue } from "../services/sync-latest-task-value"; +import db from "../../../database"; +import { linkedTaskScope } from "../services/integration-task-scope"; import type { PluginContext, TaskTitleChangedEvent } from "../../types"; import { type GitHubConfig, hasVerifiedGitHubBinding } from "../config"; -import { - findExternalLinksByTask, - updateExternalLink, -} from "../services/link-manager"; +import { findExternalLinksByTask } from "../services/link-manager"; import { getGithubApp, getVerifiedInstallationOctokit, @@ -23,6 +23,12 @@ export async function handleTaskTitleChanged( const { repositoryOwner, repositoryName } = config; try { + const current = await db.query.taskTable.findFirst({ + where: linkedTaskScope(event.taskId, context.projectId), + columns: { title: true }, + }); + if (!current || current.title !== event.newTitle) return; + const links = await findExternalLinksByTask(event.taskId); const issueLink = links.find( (link) => @@ -47,43 +53,36 @@ export async function handleTaskTitleChanged( console.log("Skipping title sync - already synced from GitHub"); return; } - - // Skip if recent sync (within 2 seconds) to prevent rapid loops - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp).getTime(); - if (timeSinceLastSync < 2000) { - console.log( - `Skipping title sync - recent sync detected (${timeSinceLastSync}ms ago)`, - ); - return; - } } const octokit = await getVerifiedInstallationOctokit(config); const issueNumber = Number.parseInt(issueLink.externalId, 10); - await octokit.rest.issues.update({ - owner: repositoryOwner, - repo: repositoryName, - issue_number: issueNumber, - title: event.newTitle, - }); - - // Update metadata to track this sync - await updateExternalLink(issueLink.id, { - title: event.newTitle, - metadata: { - ...metadata, - lastSync: { - ...metadata.lastSync, - title: { - timestamp: new Date().toISOString(), - source: "kaneo", - value: event.newTitle, - }, - }, + await syncLatestTaskValue( + event.taskId, + context.projectId, + issueLink, + "title", + event.newTitle, + async (value) => { + const response = await octokit.rest.issues.update({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + title: value, + }); + return response?.data?.updated_at; }, - }); + async () => + ( + await octokit.rest.issues.get({ + owner: repositoryOwner, + repo: repositoryName, + issue_number: issueNumber, + }) + ).data.title, + { type: "github", config: JSON.stringify(config) }, + ); console.log(`Synced task title to GitHub issue #${issueNumber}`); } catch (error) { diff --git a/apps/api/src/plugins/github/services/apply-observed-task-value.ts b/apps/api/src/plugins/github/services/apply-observed-task-value.ts new file mode 100644 index 0000000000..373578d5c4 --- /dev/null +++ b/apps/api/src/plugins/github/services/apply-observed-task-value.ts @@ -0,0 +1,136 @@ +import type { IntegrationDatabase } from "./integration-task-scope"; +import type { IssueField } from "../utils/deferred-issue-edit"; +import { publishEvent } from "../../../events"; +import { taskTable } from "../../../database/schema"; +import { resolveTargetStatus as resolveGiteaStatus } from "../../gitea/utils/resolve-column"; +import { resolveTargetStatus as resolveGithubStatus } from "../utils/resolve-column"; +import { + hasNewerObservedEdit, + inboundStamp, + type SyncStamp, +} from "../utils/sync-echo"; +import { parseLinkMetadata } from "../utils/parse-link-metadata"; +import { + linkedTaskScope, + integrationTaskRevision, +} from "./integration-task-scope"; +import { withIntegrationLink } from "./with-integration-link"; +import { updateExternalLink } from "./link-manager"; +import { updateTaskStatus } from "./task-service"; + +// The observed matching value is durable even if the inbound HTTP request +// times out waiting for our response. Apply it only after version correlation. +export async function applyObservedTaskValue( + link: { id: string; taskId: string }, + integration: { id: string; projectId: string; type: string }, + field: "title" | "description" | "state", + value: string, + intentId: string, + updatedAt: string | undefined, + expectedRevision: string, + expectedBinding?: Parameters[3], +) { + return withIntegrationLink( + link, + integration, + async (tx, afterCommit, locked) => { + const metadata = parseLinkMetadata< + Record & { lastSync?: Record } + >(locked.metadata, { + externalLinkId: link.id, + source: "observed_provider_edit", + }); + const stamp = metadata.lastSync?.[field]; + if ( + (await integrationTaskRevision( + link.taskId, + integration.projectId, + tx, + )) !== expectedRevision + ) + return false; + if ( + !hasNewerObservedEdit( + stamp?.outbound?.find((entry) => entry.intentId === intentId), + updatedAt, + ) + ) + return; + await writeInboundTaskField( + tx, + afterCommit, + link, + integration, + field, + value, + ); + await updateExternalLink( + link.id, + { + metadata: { + ...metadata, + ...(field === "state" ? { state: value } : {}), + lastSync: { + ...metadata.lastSync, + [field]: inboundStamp( + stamp, + value, + integration.type, + stamp?.outbound?.find((entry) => entry.intentId === intentId) + ?.observedUpdatedAt, + ), + }, + }, + }, + tx, + ); + afterCommit(() => + publishEvent("task.updated", { + taskId: link.taskId, + projectId: integration.projectId, + }), + ); + return true; + }, + expectedBinding, + ); +} + +export async function writeInboundTaskField( + tx: IntegrationDatabase, + afterCommit: (effect: () => Promise) => void, + link: { taskId: string }, + integration: { projectId: string; type: string }, + field: IssueField, + value: string, +) { + if (field === "state") { + const resolveStatus = + integration.type === "gitea" ? resolveGiteaStatus : resolveGithubStatus; + const status = await resolveStatus( + integration.projectId, + value === "closed" ? "issue_closed" : "issue_reopened", + value === "closed" ? "done" : "to-do", + tx, + ); + const result = await updateTaskStatus(link.taskId, status, tx); + if (result.applied && result.before.status !== result.after.status) + afterCommit(() => + publishEvent("task.status_changed", { + taskId: result.after.id, + projectId: result.after.projectId, + userId: null, + oldStatus: result.before.status, + newStatus: result.after.status, + title: result.after.title, + assigneeId: result.after.userId, + type: "status_changed", + }), + ); + } else { + await tx + .update(taskTable) + .set({ [field]: value }) + .where(linkedTaskScope(link.taskId, integration.projectId)); + } +} diff --git a/apps/api/src/plugins/github/services/defer-issue-edit.ts b/apps/api/src/plugins/github/services/defer-issue-edit.ts new file mode 100644 index 0000000000..424a8cfe65 --- /dev/null +++ b/apps/api/src/plugins/github/services/defer-issue-edit.ts @@ -0,0 +1,57 @@ +import { issueEditScope, type IssueField } from "../utils/deferred-issue-edit"; +import { updateExternalLink } from "./link-manager"; +import { withIntegrationLink } from "./with-integration-link"; +type Integration = { + id: string; + projectId: string; + type: string; + config: string; +}; +// Commit the intent before acknowledging a delivery which cannot wait for its writer. +export async function deferIssueEdit( + link: { id: string; taskId: string }, + integration: Integration, + fields: IssueField[], +) { + await withIntegrationLink( + link, + integration, + async (tx) => { + await updateExternalLink( + link.id, + { deferredEdit: { fields, scope: issueEditScope(integration) } }, + tx, + ); + }, + { + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), + }, + ); +} + +// Coalesce remaining local corrections by link and field without storing task text. +export async function deferTaskSync( + link: { id: string; taskId: string }, + integration: Integration, + fields: IssueField[], +) { + await withIntegrationLink( + link, + integration, + async (tx) => { + await updateExternalLink( + link.id, + { + deferredEdit: { + fields: [], + repairFields: fields, + scope: issueEditScope(integration), + }, + }, + tx, + ); + }, + integration, + ); +} diff --git a/apps/api/src/plugins/github/services/deferred-issue-edits.ts b/apps/api/src/plugins/github/services/deferred-issue-edits.ts new file mode 100644 index 0000000000..034017c16e --- /dev/null +++ b/apps/api/src/plugins/github/services/deferred-issue-edits.ts @@ -0,0 +1,446 @@ +import { and, asc, gt, sql } from "drizzle-orm"; +import db from "../../../database"; +import { externalLinkTable } from "../../../database/schema"; +import { publishEvent } from "../../../events"; +import { withJobLease } from "../../../scheduler/leader-lock"; +import type { GiteaConfig } from "../../gitea/config"; +import { createGiteaClient } from "../../gitea/utils/gitea-api"; +import type { GitHubConfig } from "../config"; +import { + issueEditScope, + parseDeferredIssueEdit, + type IssueField, +} from "../utils/deferred-issue-edit"; +import { + formatIssueBody, + formatTaskDescriptionFromIssue, +} from "../utils/format"; +import { getVerifiedInstallationOctokit } from "../utils/github-app"; +import { inboundEcho, PendingEcho } from "../utils/inbound-echo"; +import { parseLinkMetadata } from "../utils/parse-link-metadata"; +import { + inboundStamp, + inboundOccurredAfterIntent, + uncertainOutboundIntents, + type SyncStamp, +} from "../utils/sync-echo"; +import { writeInboundTaskField } from "./apply-observed-task-value"; +import { updateExternalLink } from "./link-manager"; +import { syncLatestTaskValue } from "./sync-latest-task-value"; +import { isTaskInFinalState } from "./task-service"; +import { + linkedTaskScope, + integrationTaskRevision, +} from "./integration-task-scope"; +import { withIntegrationLink } from "./with-integration-link"; + +type Integration = { + id: string; + projectId: string; + type: string; + config: string; +}; +type Metadata = Record & { + lastSync?: Record; +}; +const metadataFor = (link: { id: string; metadata: string | null }) => + parseLinkMetadata(link.metadata, { + externalLinkId: link.id, + source: "deferred_issue_edit", + }); + +export { deferIssueEdit } from "./defer-issue-edit"; + +async function issueAccess( + integration: Integration, + link: { externalId: string; taskId: string }, +) { + const number = Number(link.externalId); + if (!Number.isSafeInteger(number) || number <= 0) + throw new Error("Invalid issue number"); + const config = JSON.parse(integration.config); + const { repositoryOwner: owner, repositoryName: repo } = config; + const payload = (field: IssueField, value: string) => + field === "description" + ? { body: formatIssueBody(value, link.taskId) } + : { [field]: value }; + if (integration.type === "gitea") { + const client = createGiteaClient(config as GiteaConfig); + return { + read: () => client.getIssue(owner, repo, number), + write: async (field: IssueField, value: string) => + (await client.updateIssue(owner, repo, number, payload(field, value))) + ?.updated_at, + }; + } + const octokit = await getVerifiedInstallationOctokit( + config as GitHubConfig, + true, + ); + return { + read: async () => + ( + await octokit.rest.issues.get({ + owner, + repo, + issue_number: number, + request: { timeout: 10_000 }, + }) + ).data, + write: async (field: IssueField, value: string) => + ( + await octokit.rest.issues.update({ + owner, + repo, + issue_number: number, + ...payload(field, value), + request: { timeout: 10_000 }, + }) + )?.data?.updated_at, + }; +} + +let running = false; +let cursor: string | undefined; +export async function replayDeferredIssueEdits() { + return withJobLease( + "deferred-issue-edits", + replayClaimedIssueEdits, + () => ({}), + ); +} + +async function replayClaimedIssueEdits() { + if (running) return {}; + running = true; + let degraded = false; + const deadline = Date.now() + 45_000; + try { + const links = await db.query.externalLinkTable.findMany({ + where: and( + sql`${externalLinkTable.resourceType} = 'issue' AND ${externalLinkTable.metadata} LIKE '%"deferredIssueEdit":%'`, + cursor ? gt(externalLinkTable.id, cursor) : undefined, + ), + with: { integration: true }, + orderBy: asc(externalLinkTable.id), + limit: 20, + }); + if (!links.length) cursor = undefined; + let processed = 0; + for (const link of links) { + if (Date.now() >= deadline) break; + cursor = link.id; + processed++; + try { + const metadata = metadataFor(link); + const job = parseDeferredIssueEdit(metadata.deferredIssueEdit); + if (!job) continue; + const integration = link.integration; + if ( + !integration?.isActive || + !["github", "gitea"].includes(integration.type) || + issueEditScope(integration) !== job.scope + ) { + await updateExternalLink(link.id, { completeDeferredEdit: job.id }); + continue; + } + const fields = [ + ...new Set([...job.fields, ...(job.repairFields ?? [])]), + ]; + // An orphaned writer expires after five minutes; until then let it settle. + if ( + fields.some((field) => + metadata.lastSync?.[field]?.outbound?.some( + (entry) => + entry.pending && + !entry.cancelled && + Date.now() - Date.parse(entry.timestamp) < 300_000, + ), + ) + ) + continue; + const revision = await integrationTaskRevision( + link.taskId, + integration.projectId, + ); + if (!revision) continue; + const provider = await issueAccess(integration, link); + let issue: Awaited>; + try { + issue = await provider.read(); + } catch (error) { + if ( + typeof error !== "object" || + error === null || + !("status" in error) || + error.status !== 404 + ) + throw error; + // Only retire this read's job; credentials, binding, or queued fields + // may have changed while the provider request was in flight. + await withIntegrationLink( + link, + integration, + async (tx) => { + await updateExternalLink( + link.id, + { completeDeferredEdit: job.id }, + tx, + ); + }, + integration, + ); + continue; + } + if ( + typeof issue.title !== "string" || + !["open", "closed"].includes(issue.state) + ) + throw new Error("Invalid issue response"); + const values = { + title: issue.title, + description: formatTaskDescriptionFromIssue( + issue.body ?? null, + link.taskId, + ), + state: issue.state, + }; + const repairs: Array<{ + field: IssueField; + value: string; + intentIds: string[]; + }> = []; + const applied = await withIntegrationLink( + link, + integration, + async (tx, afterCommit, locked) => { + const current = metadataFor(locked); + if ( + parseDeferredIssueEdit(current.deferredIssueEdit)?.id !== job.id + ) + return; + if ( + fields.some( + (field) => + JSON.stringify(current.lastSync?.[field]) !== + JSON.stringify(metadata.lastSync?.[field]), + ) + ) + return; + if ( + (await integrationTaskRevision( + link.taskId, + integration.projectId, + tx, + )) !== revision + ) + return; + const task = await tx.query.taskTable.findFirst({ + where: linkedTaskScope(link.taskId, integration.projectId), + columns: { + title: true, + description: true, + status: true, + columnId: true, + projectId: true, + }, + }); + if (!task) return; + const taskIsClosed = fields.includes("state") + ? await isTaskInFinalState(task, tx) + : false; + for (const field of fields) { + const stamp = current.lastSync?.[field]; + const uncertain = uncertainOutboundIntents(stamp, values[field]); + const intentIds = uncertain.flatMap((entry) => + entry.intentId ? [entry.intentId] : [], + ); + const local = + field === "state" + ? taskIsClosed + ? "closed" + : "open" + : field === "description" + ? task.description || "" + : task.title; + if ( + job.repairFields?.includes(field) && + !job.fields.includes(field) + ) { + repairs.push({ + field, + value: local, + intentIds, + }); + continue; + } + // A crashed older writer can leave the provider behind our completed + // local value. Its missing receipt does not make it a remote edit. + if ( + local !== values[field] && + uncertain.some( + (entry) => + !( + stamp?.source !== "kaneo" && + stamp?.inboundValue === local && + inboundOccurredAfterIntent(stamp, entry) + ), + ) + ) { + repairs.push({ + field, + value: local, + intentIds, + }); + } + } + // Classify every field before writing any: PendingEcho commits only its observation. + const accepted = job.fields.filter( + (field) => + !repairs.some((repair) => repair.field === field) && + !( + field === "state" && + integration.type === "github" && + current.createdFrom === "kaneo" + ) && + !inboundEcho( + current.lastSync?.[field], + values[field], + issue.updated_at, + values[field], + { + linkId: link.id, + field, + localValue: + field === "state" + ? taskIsClosed + ? "closed" + : "open" + : field === "description" + ? task.description || "" + : task.title, + }, + ), + ); + for (const field of accepted) { + await writeInboundTaskField( + tx, + afterCommit, + link, + integration, + field, + values[field], + ); + current.lastSync = { + ...current.lastSync, + [field]: inboundStamp( + current.lastSync?.[field], + values[field], + integration.type, + issue.updated_at, + ), + }; + if (field === "state") current.state = values.state; + } + await updateExternalLink( + link.id, + { + metadata: current, + ...(accepted.includes("title") ? { title: values.title } : {}), + ...(repairs.length ? {} : { completeDeferredEdit: job.id }), + }, + tx, + ); + if (accepted.length) + afterCommit(() => + publishEvent("task.updated", { + taskId: link.taskId, + projectId: integration.projectId, + }), + ); + return true; + }, + integration, + ); + if (applied !== true) continue; + for (const repair of repairs) { + await syncLatestTaskValue( + link.taskId, + integration.projectId, + link, + repair.field, + repair.value, + (value) => provider.write(repair.field, value), + async () => { + const current = await provider.read(); + return repair.field === "description" + ? formatTaskDescriptionFromIssue( + current.body ?? null, + link.taskId, + ) + : current[repair.field]; + }, + integration, + true, + ); + // A later field can fail; persist this field's successful repair first. + if (repair.intentIds.length) + await withIntegrationLink( + link, + integration, + async (tx) => { + await updateExternalLink( + link.id, + { + retireOutboundIntents: { + field: repair.field, + intentIds: repair.intentIds, + }, + }, + tx, + ); + }, + integration, + ); + } + if (repairs.length) + await withIntegrationLink( + link, + integration, + async (tx, _afterCommit, locked) => { + // Writes settled after the receipt need their own later correction. + const current = metadataFor(locked); + const remaining = fields.filter( + (field) => + uncertainOutboundIntents(current.lastSync?.[field]).length, + ); + await updateExternalLink( + link.id, + remaining.length + ? { + deferredEdit: { + fields: [], + repairFields: remaining, + scope: job.scope, + }, + } + : { completeDeferredEdit: job.id }, + tx, + ); + }, + integration, + ); + } catch (error) { + if (error instanceof PendingEcho) continue; + degraded = true; + console.error("Deferred issue edit failed", { + externalLinkId: link.id, + }); + } + } + if (processed === links.length && links.length < 20) cursor = undefined; + return { degraded }; + } finally { + running = false; + } +} diff --git a/apps/api/src/plugins/github/services/integration-task-scope.ts b/apps/api/src/plugins/github/services/integration-task-scope.ts index 1db5efb30b..005428e911 100644 --- a/apps/api/src/plugins/github/services/integration-task-scope.ts +++ b/apps/api/src/plugins/github/services/integration-task-scope.ts @@ -25,6 +25,11 @@ export async function withIntegrationTask( database: IntegrationDatabase, afterCommit: (effect: () => Promise) => void, ) => Promise, + expectedBinding?: { + config?: string; + type?: string; + validate?: (binding: { config: string; type: string }) => boolean; + }, ): Promise { const effects: Array<() => Promise> = []; const result = await db.transaction(async (tx) => { @@ -46,6 +51,32 @@ export async function withIntegrationTask( ) .for("key share", { of: [projectTable, integrationTable] }); if (!project) return undefined; + if (expectedBinding) { + const [binding] = await tx + .select({ + config: integrationTable.config, + type: integrationTable.type, + }) + .from(integrationTable) + .where( + and( + eq(integrationTable.id, integration.id), + expectedBinding.config === undefined + ? undefined + : eq(integrationTable.config, expectedBinding.config), + expectedBinding.type === undefined + ? undefined + : eq(integrationTable.type, expectedBinding.type), + eq(integrationTable.isActive, true), + ), + ) + .for("share"); + if ( + !binding || + (expectedBinding.validate && !expectedBinding.validate(binding)) + ) + return undefined; + } if (taskId !== null) { const [task] = await tx .select({ id: taskTable.id }) @@ -72,3 +103,15 @@ export function linkedTaskScope(taskId: string, projectId: string) { export function externalLinkScope() { return sql`exists (select 1 from ${taskTable} scoped_task join ${integrationTable} scoped_integration on scoped_integration.project_id = scoped_task.project_id where scoped_task.id = ${externalLinkTable.taskId} and scoped_integration.id = ${externalLinkTable.integrationId})`; } + +export async function integrationTaskRevision( + taskId: string, + projectId: string, + database: IntegrationDatabase = db, +) { + const [task] = await database + .select({ revision: sql`${taskTable}.xmin::text` }) + .from(taskTable) + .where(linkedTaskScope(taskId, projectId)); + return task?.revision; +} diff --git a/apps/api/src/plugins/github/services/link-manager.ts b/apps/api/src/plugins/github/services/link-manager.ts index bcde2fcc18..5700130406 100644 --- a/apps/api/src/plugins/github/services/link-manager.ts +++ b/apps/api/src/plugins/github/services/link-manager.ts @@ -1,3 +1,15 @@ +import { randomUUID } from "node:crypto"; +import { + parseDeferredIssueEdit, + type IssueField, +} from "../utils/deferred-issue-edit"; +import { mergeSyncMetadata } from "../utils/merge-sync-metadata"; +import { parseLinkMetadata } from "../utils/parse-link-metadata"; +import { + outboundStamp, + uncertainOutboundIntents, + type SyncStamp, +} from "../utils/sync-echo"; import { and, eq } from "drizzle-orm"; import db from "../../../database"; import { @@ -21,6 +33,28 @@ export type CreateExternalLinkParams = { }; export type UpdateExternalLinkParams = { + deferredEdit?: { + fields: IssueField[]; + repairFields?: IssueField[]; + scope: string; + }; + completeDeferredEdit?: string; + retireUncertainOutbound?: IssueField; + retireOutboundIntents?: { field: IssueField; intentIds: string[] }; + outbound?: { + field: "title" | "description" | "state"; + value: string; + updatedAt?: string; + intentId?: string; + pending?: boolean; + cancelled?: boolean; + uncertain?: boolean; + }; + observedOutbound?: { + field: "title" | "description" | "state"; + intentId: string; + updatedAt: string; + }; title?: string | null; url?: string; metadata?: Record; @@ -116,6 +150,119 @@ export async function updateExternalLink( params: UpdateExternalLinkParams, database: DbOrTx = db, ) { + if ( + params.outbound || + params.metadata || + params.observedOutbound || + params.deferredEdit || + params.completeDeferredEdit || + params.retireOutboundIntents || + params.retireUncertainOutbound + ) { + return database.transaction(async (tx) => { + const link = await lockExternalLink(id, tx); + if (!link) return false; + const metadata = parseLinkMetadata< + Record & { lastSync?: Record } + >(link.metadata, { externalLinkId: id, source: "sync_update" }); + const merged = mergeSyncMetadata(metadata, params.metadata ?? {}); + // Ordinary sync metadata cannot resurrect or clear a scheduler job. + delete merged.deferredIssueEdit; + if (metadata.deferredIssueEdit) + merged.deferredIssueEdit = metadata.deferredIssueEdit; + const previousJob = parseDeferredIssueEdit(metadata.deferredIssueEdit); + if (params.completeDeferredEdit === previousJob?.id) + delete merged.deferredIssueEdit; + if (params.deferredEdit) { + const { fields, repairFields = [], scope } = params.deferredEdit; + // The last queued direction for each field supersedes its older intent. + const repairs = [ + ...new Set([ + ...(previousJob?.scope === scope + ? (previousJob.repairFields ?? []).filter( + (field) => !fields.includes(field), + ) + : []), + ...repairFields, + ]), + ]; + merged.deferredIssueEdit = { + id: randomUUID(), + scope, + ...(repairs.length ? { repairFields: repairs } : {}), + fields: [ + ...new Set([ + ...(previousJob?.scope === scope + ? previousJob.fields.filter( + (field) => !repairFields.includes(field), + ) + : []), + ...fields, + ]), + ], + }; + } + if (params.observedOutbound) { + const { field, intentId, updatedAt } = params.observedOutbound; + const stamp = merged.lastSync?.[field]; + if (stamp) + stamp.outbound = stamp.outbound?.map((entry) => + entry.intentId === intentId + ? { + ...entry, + observedUpdatedAt: + !entry.observedUpdatedAt || + updatedAt > entry.observedUpdatedAt + ? updatedAt + : entry.observedUpdatedAt, + } + : entry, + ); + } + if (params.retireOutboundIntents) { + const { field, intentIds } = params.retireOutboundIntents; + const ids = new Set(intentIds); + const stamp = merged.lastSync?.[field]; + if (stamp) + stamp.outbound = stamp.outbound?.map((entry) => + entry.intentId && ids.has(entry.intentId) + ? { ...entry, pending: false, uncertain: false, cancelled: true } + : entry, + ); + } + if (params.retireUncertainOutbound) { + const stamp = merged.lastSync?.[params.retireUncertainOutbound]; + const settled = new Set(uncertainOutboundIntents(stamp)); + if (stamp) + stamp.outbound = stamp.outbound?.map((entry) => + settled.has(entry) + ? { ...entry, pending: false, uncertain: false, cancelled: true } + : entry, + ); + } + if (params.outbound) { + const { field, value, updatedAt, ...intent } = params.outbound; + merged.lastSync = { + ...merged.lastSync, + [field]: outboundStamp( + merged.lastSync?.[field], + value, + updatedAt, + intent, + ), + }; + } + await tx + .update(externalLinkTable) + .set({ + ...(params.title !== undefined ? { title: params.title } : {}), + ...(params.url !== undefined ? { url: params.url } : {}), + metadata: JSON.stringify(merged), + }) + .where(eq(externalLinkTable.id, id)); + return true; + }); + } const updateData: Record = {}; if (params.title !== undefined) { @@ -124,18 +271,17 @@ export async function updateExternalLink( if (params.url !== undefined) { updateData.url = params.url; } - if (params.metadata !== undefined) { - updateData.metadata = JSON.stringify(params.metadata); - } if (Object.keys(updateData).length === 0) { - return; + return false; } - await database + const updated = await database .update(externalLinkTable) .set(updateData) - .where(eq(externalLinkTable.id, id)); + .where(eq(externalLinkTable.id, id)) + .returning({ id: externalLinkTable.id }); + return updated.length > 0; } export async function createOrUpdateExternalLink( @@ -175,3 +321,12 @@ export async function getExternalLinksByIntegration(integrationId: string) { where: eq(externalLinkTable.integrationId, integrationId), }); } + +export async function lockExternalLink(id: string, database: DbOrTx) { + const [link] = await database + .select({ id: externalLinkTable.id, metadata: externalLinkTable.metadata }) + .from(externalLinkTable) + .where(eq(externalLinkTable.id, id)) + .for("update"); + return link; +} diff --git a/apps/api/src/plugins/github/services/sync-latest-task-value.ts b/apps/api/src/plugins/github/services/sync-latest-task-value.ts new file mode 100644 index 0000000000..1ce89a6f74 --- /dev/null +++ b/apps/api/src/plugins/github/services/sync-latest-task-value.ts @@ -0,0 +1,189 @@ +import { deferTaskSync } from "./defer-issue-edit"; +import { randomUUID } from "node:crypto"; +import db from "../../../database"; +import { + issueEditScope, + parseDeferredIssueEdit, +} from "../utils/deferred-issue-edit"; +import { parseLinkMetadata } from "../utils/parse-link-metadata"; +import { hasNewerObservedEdit, type SyncStamp } from "../utils/sync-echo"; +import { applyObservedTaskValue } from "./apply-observed-task-value"; +import { + linkedTaskScope, + integrationTaskRevision, +} from "./integration-task-scope"; +import { findExternalLinksByTask, updateExternalLink } from "./link-manager"; +import { isTaskInFinalState } from "./task-service"; + +// Provider requests may complete out of order across API instances. Every late +// completion repairs the provider using the current, still-linked task value. +export async function syncLatestTaskValue( + taskId: string, + projectId: string, + link: { id: string; integrationId: string | null }, + field: "title" | "description" | "state", + initialValue: string, + write: (value: string) => Promise, + readCurrent?: () => Promise, + expectedBinding?: { type?: string; config?: string }, + repair = false, +) { + let value = initialValue; + let attempts = 0; + let identity = expectedBinding; + let lastIntentId: string | undefined; + const currentBinding = async () => { + const binding = (await findExternalLinksByTask(taskId)).find( + (candidate) => + candidate.id === link.id && + candidate.integrationId === link.integrationId && + candidate.resourceType === "issue", + ); + if ( + !binding || + (binding.integration && + (binding.integration.isActive === false || + binding.integration.projectId !== projectId || + (identity?.type !== undefined && + binding.integration.type !== identity.type) || + (identity?.config !== undefined && + binding.integration.config !== identity.config))) + ) { + // The old request cannot identify deliveries from the retained new binding. + // Retire only its UUID, preserving newer intents and current field metadata. + if (lastIntentId) + await updateExternalLink(link.id, { + retireOutboundIntents: { field, intentIds: [lastIntentId] }, + }); + return; + } + return binding; + }; + for (;;) { + const binding = await currentBinding(); + if (!binding) return; + identity ??= binding.integration + ? { type: binding.integration.type, config: binding.integration.config } + : undefined; + const queued = parseDeferredIssueEdit( + parseLinkMetadata<{ deferredIssueEdit?: unknown }>(binding.metadata, { + externalLinkId: link.id, + source: "outbound_repair", + }).deferredIssueEdit, + ); + const repairing = + repair || + !!( + queued?.repairFields?.includes(field) && + binding.integration && + issueEditScope(binding.integration) === queued.scope + ); + const intentId = randomUUID(); + lastIntentId = intentId; + // Webhooks may arrive before PATCH returns, including from another instance. + const persisted = await updateExternalLink(link.id, { + outbound: { field, value, intentId, pending: true }, + }); + if (persisted === false || !(await currentBinding())) return; + let updatedAt: string | undefined; + try { + updatedAt = await write(value); + attempts++; + } catch (error) { + const status = + typeof error === "object" && error && "status" in error + ? Number(error.status) + : undefined; + const rejected = + status !== undefined && status >= 400 && status < 500 && status !== 408; + await updateExternalLink(link.id, { + outbound: { + field, + value, + intentId, + pending: false, + cancelled: rejected, + uncertain: !rejected, + }, + }).catch(() => {}); + if (!rejected && binding.integration?.config && (await currentBinding())) + await deferTaskSync({ id: link.id, taskId }, binding.integration, [ + field, + ]).catch(() => {}); + throw error; + } + if (!(await currentBinding())) return; + await updateExternalLink(link.id, { + ...(field === "title" ? { title: value } : {}), + outbound: { field, value, updatedAt, intentId, pending: false }, + ...(repairing ? { retireUncertainOutbound: field } : {}), + ...(field === "state" + ? { metadata: { state: value, lastOutboundStateSyncAt: Date.now() } } + : {}), + }); + const currentLink = await currentBinding(); + if (!currentLink) return; + const metadata = parseLinkMetadata<{ + lastSync?: Record; + }>(currentLink.metadata, { + externalLinkId: link.id, + source: "outbound_completion", + }); + const observed = metadata.lastSync?.[field]?.outbound?.find( + (entry) => entry.intentId === intentId, + ); + // The waiting inbound handler applies this newer provider edit after the + // exact response version is available; do not overwrite it with a repair. + if ( + hasNewerObservedEdit(observed, updatedAt) && + currentLink.integration && + readCurrent + ) { + const revision = await integrationTaskRevision(taskId, projectId); + if (!(await currentBinding())) return; + if ( + revision && + (await readCurrent()) === value && + (await applyObservedTaskValue( + currentLink, + currentLink.integration, + field, + value, + intentId, + updatedAt, + revision, + identity, + )) === true + ) + return; + } + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope(taskId, projectId), + columns: { + title: true, + description: true, + status: true, + columnId: true, + projectId: true, + }, + }); + if (!task) return; + const current = + field === "state" + ? (await isTaskInFinalState(task)) + ? "closed" + : "open" + : field === "title" + ? task.title + : task.description || ""; + if (current === value) return; + if (attempts >= 3) { + if (binding.integration) + await deferTaskSync({ id: link.id, taskId }, binding.integration, [ + field, + ]); + return; + } + value = current; + } +} diff --git a/apps/api/src/plugins/github/services/with-integration-link.ts b/apps/api/src/plugins/github/services/with-integration-link.ts index bc65a35131..e2a97dd40b 100644 --- a/apps/api/src/plugins/github/services/with-integration-link.ts +++ b/apps/api/src/plugins/github/services/with-integration-link.ts @@ -1,4 +1,6 @@ import { and, eq } from "drizzle-orm"; +import { PendingEcho } from "../utils/inbound-echo"; +import { updateExternalLink } from "./link-manager"; import { externalLinkTable } from "../../../database/schema"; import { withIntegrationTask, @@ -13,6 +15,7 @@ export function withIntegrationLink( afterCommit: (effect: () => Promise) => void, lockedLink: typeof externalLinkTable.$inferSelect, ) => Promise, + expectedBinding?: Parameters[3], ) { return withIntegrationTask( link.taskId, @@ -30,7 +33,27 @@ export function withIntegrationLink( ) .for("update"); if (!lockedLink) return; - return apply(database, afterCommit, lockedLink); + try { + return await apply(database, afterCommit, lockedLink); + } catch (error) { + if (!(error instanceof PendingEcho)) throw error; + if (error.context && error.intentId && error.updatedAt) { + await updateExternalLink( + lockedLink.id, + { + observedOutbound: { + field: error.context.field, + intentId: error.intentId, + updatedAt: error.updatedAt, + }, + }, + database, + ); + error.recorded = true; + } + return error; + } }, + expectedBinding, ); } diff --git a/apps/api/src/plugins/github/utils/deferred-issue-edit.ts b/apps/api/src/plugins/github/utils/deferred-issue-edit.ts new file mode 100644 index 0000000000..43db72a72b --- /dev/null +++ b/apps/api/src/plugins/github/utils/deferred-issue-edit.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { normalizeGiteaBaseUrl } from "../../gitea/config"; + +export type IssueField = "title" | "description" | "state"; +export type DeferredIssueEdit = { + id: string; + fields: IssueField[]; + repairFields?: IssueField[]; + scope: string; +}; + +export function parseDeferredIssueEdit( + value: unknown, +): DeferredIssueEdit | undefined { + if (!value || typeof value !== "object") return; + const job = value as DeferredIssueEdit; + if ( + typeof job.id !== "string" || + typeof job.scope !== "string" || + !Array.isArray(job.fields) || + (job.repairFields !== undefined && + (!Array.isArray(job.repairFields) || + job.repairFields.some( + (field) => !["title", "description", "state"].includes(field), + ))) || + (!job.fields.length && !job.repairFields?.length) || + job.fields.some( + (field) => !["title", "description", "state"].includes(field), + ) + ) + return; + return job; +} + +// Bind only repository identity; tokens and webhook secrets never enter metadata. +export function issueEditScope(integration: { type: string; config: string }) { + const config = JSON.parse(integration.config); + return createHash("sha256") + .update( + JSON.stringify([ + integration.type, + integration.type === "gitea" + ? normalizeGiteaBaseUrl(config.baseUrl) + : null, + config.repositoryOwner, + config.repositoryName, + integration.type === "github" ? config.installationId : null, + integration.type === "github" ? config.repositoryId : null, + ]), + ) + .digest("hex"); +} diff --git a/apps/api/src/plugins/github/utils/inbound-echo.ts b/apps/api/src/plugins/github/utils/inbound-echo.ts new file mode 100644 index 0000000000..3ecfa9abf8 --- /dev/null +++ b/apps/api/src/plugins/github/utils/inbound-echo.ts @@ -0,0 +1,249 @@ +import { createHash } from "node:crypto"; +import { updateExternalLink } from "../services/link-manager"; +import { + ambiguousOutboundEcho, + isOutboundEcho, + pendingOutboundIntent, + uncertainOutboundIntents, + inboundOccurredAfterIntent, + type SyncStamp, +} from "./sync-echo"; +type ConfirmationSnapshot = { + fingerprint: string; + localValueHash: string; +}; +type EchoConfirmation = ReadonlyMap; +class ConfirmationRequired extends Error { + constructor( + public key?: string, + public snapshot?: ConfirmationSnapshot, + ) { + super("Current provider confirmation is required"); + } +} +class RepairRequired extends Error {} +export class PendingResponseTimeout extends Error {} +export class PendingEcho extends Error { + recorded = false; + constructor( + public intentId: string | undefined, + public updatedAt: string | undefined, + public context?: { + linkId: string; + field: "title" | "description" | "state"; + }, + ) { + super("Outbound response is pending"); + } +} +export function inboundEcho( + stamp: SyncStamp | undefined, + value: string, + updatedAt?: string, + providerValue?: string, + context?: { + linkId: string; + field: "title" | "description" | "state"; + localValue?: string; + confirmation?: EchoConfirmation; + }, +) { + const confirmedValue = () => { + const key = context ? `${context.linkId}:${context.field}` : undefined; + const localValueHash = createHash("sha256") + .update(context?.localValue ?? stamp?.value ?? "") + .digest("hex"); + const snapshot = key + ? { + fingerprint: createHash("sha256") + .update(JSON.stringify({ stamp, localValueHash })) + .digest("hex"), + localValueHash, + } + : undefined; + const previous = key ? context?.confirmation?.get(key) : undefined; + // A newer local or provider edit owns reconciliation after the read began. + if ( + providerValue !== undefined && + previous && + previous.localValueHash !== localValueHash + ) + return undefined; + if ( + providerValue === undefined || + (key && + context?.confirmation && + previous?.fingerprint !== snapshot?.fingerprint) + ) + throw new ConfirmationRequired(key, snapshot); + return providerValue; + }; + const version = Date.parse(updatedAt ?? ""); + const inboundVersion = Date.parse(stamp?.inboundUpdatedAt ?? ""); + const knownVersions = [ + inboundVersion, + ...(stamp?.outbound ?? []) + .filter((entry) => !entry.cancelled && !entry.pending && !entry.uncertain) + .map((entry) => Date.parse(entry.updatedAt ?? "")), + ].filter(Number.isFinite); + if ( + Number.isFinite(version) && + knownVersions.some((known) => known > version) + ) + return true; + if ( + Number.isFinite(version) && + version === inboundVersion && + stamp?.inboundValue !== value + ) { + if (confirmedValue() !== value) return true; + } + const uncertain = uncertainOutboundIntents(stamp, value); + const localValue = context?.localValue ?? stamp?.value; + if ( + localValue !== value && + uncertain.some( + (entry) => + !( + stamp?.source !== "kaneo" && + stamp?.inboundValue === localValue && + inboundOccurredAfterIntent(stamp, entry) + ), + ) + ) { + if (confirmedValue() !== value) return true; + throw new RepairRequired(); + } + const pending = pendingOutboundIntent(stamp, value); + if (pending) throw new PendingEcho(pending.intentId, updatedAt, context); + if (!isOutboundEcho(stamp, value, updatedAt)) { + const colliding = + updatedAt && + stamp?.outbound?.some( + (entry) => !entry.cancelled && entry.updatedAt === updatedAt, + ); + if (!colliding) return false; + return confirmedValue() !== value; + } + if (stamp?.value === value && !ambiguousOutboundEcho(stamp, value, updatedAt)) + return true; + return confirmedValue() !== value; +} +// Every retry releases the transaction first. Persist observed provider versions +// so an outbound completion cannot repair over a newer edit waiting for it. +export async function withEchoConfirmation( + read: () => Promise, + apply: ( + current?: Provider, + confirmation?: EchoConfirmation, + ) => Promise, + defer?: () => Promise, +): Promise { + const recorded = new Set(); + const confirmation = new Map(); + let current: Provider | undefined; + let delay = 50; + const deadline = Date.now() + 5000; + for (;;) { + try { + const result = await apply(current, confirmation); + if (result instanceof PendingEcho) throw result; + return result; + } catch (error) { + if (error instanceof RepairRequired) { + if (!defer) + throw new PendingResponseTimeout( + "Uncertain outbound write needs durable repair", + ); + try { + await defer(); + return; + } catch (cause) { + throw new PendingResponseTimeout( + "Could not persist deferred webhook delivery", + { cause }, + ); + } + } else if (error instanceof PendingEcho) { + const { context, intentId, updatedAt } = error; + const key = `${intentId}:${updatedAt}`; + if ( + !error.recorded && + context && + intentId && + updatedAt && + !recorded.has(key) + ) { + await updateExternalLink(context.linkId, { + observedOutbound: { field: context.field, intentId, updatedAt }, + }); + recorded.add(key); + } + if (Date.now() >= deadline) { + if (defer) { + try { + await defer(); + return; + } catch (cause) { + throw new PendingResponseTimeout( + "Could not persist deferred webhook delivery", + { cause }, + ); + } + } + throw new PendingResponseTimeout( + "Outbound response is still pending; retry this webhook delivery", + ); + } + await new Promise((resolve) => + setTimeout(resolve, Math.min(delay, deadline - Date.now())), + ); + delay = Math.min(delay * 2, 1000); + current = undefined; + } else if (error instanceof ConfirmationRequired) { + if (current !== undefined) { + await new Promise((resolve) => + setTimeout( + resolve, + Math.min(delay, Math.max(0, deadline - Date.now())), + ), + ); + delay = Math.min(delay * 2, 1000); + } + if (Date.now() >= deadline) { + if (!defer) + throw new PendingResponseTimeout( + "Provider confirmation changed; retry this webhook delivery", + ); + try { + await defer(); + return; + } catch (cause) { + throw new PendingResponseTimeout( + "Could not persist deferred webhook delivery", + { cause }, + ); + } + } + if (error.key && error.snapshot) + confirmation.set(error.key, error.snapshot); + try { + current = await read(); + } catch (error) { + if (!defer) throw error; + try { + await defer(); + return; + } catch (cause) { + throw new PendingResponseTimeout( + "Could not persist deferred webhook delivery", + { cause }, + ); + } + } + } else { + throw error; + } + } + } +} diff --git a/apps/api/src/plugins/github/utils/merge-sync-metadata.ts b/apps/api/src/plugins/github/utils/merge-sync-metadata.ts new file mode 100644 index 0000000000..c2455792ea --- /dev/null +++ b/apps/api/src/plugins/github/utils/merge-sync-metadata.ts @@ -0,0 +1,65 @@ +import { + boundOutboundHistory, + type OutboundEntry, + type SyncStamp, +} from "./sync-echo"; +type SyncMetadata = Record & { + lastSync?: Record; +}; + +const historyOf = (stamp: SyncStamp | undefined) => + Array.isArray(stamp?.outbound) + ? stamp.outbound.filter( + (entry) => + typeof entry?.hash === "string" && + typeof entry?.timestamp === "string", + ) + : []; + +export function mergeSyncMetadata( + current: SyncMetadata, + incoming: SyncMetadata, +): SyncMetadata { + const lastSync = { ...current.lastSync, ...incoming.lastSync }; + for (const [field, stamp] of Object.entries(lastSync)) { + const history = [ + ...historyOf(current.lastSync?.[field]), + ...historyOf(incoming.lastSync?.[field]), + ]; + const prior = current.lastSync?.[field]; + const priorTime = Date.parse(prior?.timestamp ?? ""); + const incomingTime = Date.parse(stamp?.timestamp ?? ""); + const latest = + Number.isFinite(priorTime) && + (!Number.isFinite(incomingTime) || priorTime > incomingTime) + ? prior + : stamp; + lastSync[field] = latest ?? stamp; + if (history.length) + lastSync[field] = { + ...latest, + outbound: boundOutboundHistory([ + ...history + .reduce((entries, entry) => { + const key = entry.intentId ?? JSON.stringify(entry); + const prior = entries.get(key); + if ( + !prior || + entry.timestamp > prior.timestamp || + (entry.timestamp === prior.timestamp && + prior.pending && + !entry.pending) + ) + entries.set(key, entry); + return entries; + }, new Map()) + .values(), + ]), + }; + } + return { + ...current, + ...incoming, + ...(Object.keys(lastSync).length ? { lastSync } : {}), + }; +} diff --git a/apps/api/src/plugins/github/utils/sync-echo.ts b/apps/api/src/plugins/github/utils/sync-echo.ts new file mode 100644 index 0000000000..ca80894a51 --- /dev/null +++ b/apps/api/src/plugins/github/utils/sync-echo.ts @@ -0,0 +1,220 @@ +import { createHash, randomUUID } from "node:crypto"; + +export type OutboundIntent = { + intentId?: string; + pending?: boolean; + cancelled?: boolean; + uncertain?: boolean; + observedUpdatedAt?: string; + startedAt?: string; + priorInboundId?: string; + ambiguous?: boolean; +}; +export type OutboundEntry = OutboundIntent & { + hash: string; + timestamp: string; + updatedAt?: string; +}; +export type SyncStamp = { + timestamp?: string; + source?: string; + value?: string; + inboundAt?: string; + inboundId?: string; + inboundValue?: string; + inboundUpdatedAt?: string; + outbound?: OutboundEntry[]; +}; + +const hash = (value: string) => + createHash("sha256").update(value).digest("hex"); + +export function outboundStamp( + previous: SyncStamp | undefined, + value: string, + updatedAt?: string, + intent: OutboundIntent = {}, +): SyncStamp { + const timestamp = new Date().toISOString(); + const outbound = (previous?.outbound ?? []).filter( + (entry) => !intent.intentId || entry.intentId !== intent.intentId, + ); + // Older installations have only the last value, without a provider timestamp. + if ( + previous?.source === "kaneo" && + !previous.outbound?.length && + previous.value !== undefined && + previous.timestamp + ) + outbound.push({ + hash: hash(previous.value), + timestamp: previous.timestamp, + }); + const previousIntent = previous?.outbound?.find( + (entry) => intent.intentId && entry.intentId === intent.intentId, + ); + outbound.push({ + hash: hash(value), + timestamp, + updatedAt, + observedUpdatedAt: previousIntent?.observedUpdatedAt, + startedAt: + previousIntent?.startedAt ?? (intent.pending ? timestamp : undefined), + priorInboundId: + previousIntent?.priorInboundId ?? + (intent.pending ? previous?.inboundId : undefined), + ambiguous: + previousIntent?.ambiguous || + !!( + updatedAt && + previousIntent?.observedUpdatedAt === updatedAt && + inboundOccurredAfterIntent(previous, previousIntent) && + previous?.inboundValue !== value + ), + ...intent, + }); + const bounded = boundOutboundHistory(outbound); + if (intent.pending || intent.cancelled || intent.uncertain) + return { ...previous, outbound: bounded }; + return { ...previous, timestamp, source: "kaneo", value, outbound: bounded }; +} + +export function boundOutboundHistory(entries: OutboundEntry[]) { + // Keep active intents until they settle; completed history stays bounded. + const recent = entries + .map((entry) => + entry.pending && Date.now() - Date.parse(entry.timestamp) >= 300_000 + ? { ...entry, pending: false, uncertain: true } + : entry, + ) + .sort((left, right) => left.timestamp.localeCompare(right.timestamp)); + const completed = recent.filter((entry) => !entry.pending).slice(-32); + return [...completed, ...recent.filter((entry) => entry.pending)].sort( + (left, right) => left.timestamp.localeCompare(right.timestamp), + ); +} + +export function pendingOutboundIntent( + stamp: SyncStamp | undefined, + value: string, +) { + return (stamp?.outbound ?? []).find( + (entry) => + entry.pending && + !entry.cancelled && + entry.hash === hash(value) && + Date.now() - Date.parse(entry.timestamp) < 300_000, + ); +} + +export function isPendingOutboundEcho( + stamp: SyncStamp | undefined, + value: string, +) { + return !!pendingOutboundIntent(stamp, value); +} + +export function isOutboundEcho( + stamp: SyncStamp | undefined, + value: string, + updatedAt?: string, +): boolean { + if (!stamp) return false; + const entries = stamp.outbound ?? []; + const valueHash = hash(value); + if ( + entries.some( + (entry) => + !entry.cancelled && + entry.hash === valueHash && + (entry.updatedAt && updatedAt + ? entry.updatedAt === updatedAt + : Date.now() - Date.parse(entry.timestamp) < 300_000), + ) + ) + return true; + // Preserve compatibility with stamps written before outbound history existed. + return ( + entries.length === 0 && stamp.source === "kaneo" && stamp.value === value + ); +} + +/** Provider timestamps can collide; historical values need current-provider confirmation. */ +export async function confirmedOutboundEcho( + stamp: SyncStamp | undefined, + value: string, + updatedAt: string | undefined, + readCurrent: () => Promise, +) { + if (isPendingOutboundEcho(stamp, value)) return true; + if (!isOutboundEcho(stamp, value, updatedAt)) return false; + if (stamp?.value === value) return true; + return (await readCurrent()) !== value; +} + +export function inboundStamp( + previous: SyncStamp | undefined, + value: string, + source: string, + updatedAt?: string, +): SyncStamp { + const timestamp = new Date().toISOString(); + return { + ...previous, + timestamp, + source, + value, + inboundAt: timestamp, + inboundId: randomUUID(), + inboundValue: value, + inboundUpdatedAt: updatedAt ?? previous?.inboundUpdatedAt, + }; +} +export function ambiguousOutboundEcho( + stamp: SyncStamp | undefined, + value: string, + updatedAt?: string, +) { + return ( + stamp?.outbound?.some( + (entry) => + entry.ambiguous && + entry.hash === hash(value) && + entry.updatedAt === updatedAt, + ) ?? false + ); +} +export function hasNewerObservedEdit( + entry: OutboundEntry | undefined, + updatedAt: string | undefined, +) { + if (!updatedAt || !entry?.observedUpdatedAt) return false; + return ( + Date.parse(entry.observedUpdatedAt) > Date.parse(updatedAt) || + (entry.observedUpdatedAt === updatedAt && !!entry.ambiguous) + ); +} + +export function uncertainOutboundIntents( + stamp: SyncStamp | undefined, + value?: string, +) { + const valueHash = value === undefined ? undefined : hash(value); + return (stamp?.outbound ?? []).filter( + (entry) => + !entry.cancelled && + (valueHash === undefined || entry.hash === valueHash) && + ((!entry.pending && entry.uncertain) || + (entry.pending && Date.now() - Date.parse(entry.timestamp) >= 300_000)), + ); +} + +export function inboundOccurredAfterIntent( + stamp: SyncStamp | undefined, + intent: OutboundEntry | undefined, +) { + if (!stamp?.inboundAt || !intent?.startedAt) return false; + // IDs preserve ordering even when two persisted stamps share a millisecond. + if (stamp.inboundId) return stamp.inboundId !== intent.priorInboundId; + return Date.parse(stamp.inboundAt) > Date.parse(intent.startedAt); +} diff --git a/apps/api/src/plugins/github/webhooks/issue-closed.ts b/apps/api/src/plugins/github/webhooks/issue-closed.ts index dca9644028..22fcee8209 100644 --- a/apps/api/src/plugins/github/webhooks/issue-closed.ts +++ b/apps/api/src/plugins/github/webhooks/issue-closed.ts @@ -1,3 +1,9 @@ +import { issueEditScope } from "../utils/deferred-issue-edit"; +import { deferIssueEdit } from "../services/deferred-issue-edits"; +import type { GitHubConfig } from "../config"; +import { getVerifiedInstallationOctokit } from "../utils/github-app"; +import { inboundEcho, withEchoConfirmation } from "../utils/inbound-echo"; +import { inboundStamp, type SyncStamp } from "../utils/sync-echo"; import { withIntegrationLink } from "../services/with-integration-link"; import { linkedTaskScope } from "../services/integration-task-scope"; import { and, eq } from "drizzle-orm"; @@ -7,6 +13,7 @@ import { publishEvent } from "../../../events"; import { updateExternalLink } from "../services/link-manager"; import { findAllIntegrationsByRepo, + isTaskInFinalState, updateTaskStatus, } from "../services/task-service"; import { parseLinkMetadata } from "../utils/parse-link-metadata"; @@ -19,6 +26,7 @@ type IssueClosedPayload = { title: string; html_url: string; state: string; + updated_at?: string; }; installation?: { id: number }; repository: { @@ -47,64 +55,120 @@ export async function handleIssueClosed(payload: IssueClosedPayload) { continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GitHubConfig; + const octokit = await getVerifiedInstallationOctokit(config); + return ( + await octokit.rest.issues.get({ + owner: config.repositoryOwner, + repo: config.repositoryName, + issue_number: issue.number, + }) + ).data; + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); - if (!task) { - return; - } + if (!task) { + return; + } - const existingMetadata = parseLinkMetadata(externalLink.metadata, { - externalLinkId: externalLink.id, - source: "issue_closed", - }); + const existingMetadata = parseLinkMetadata< + Record & { lastSync?: { state?: SyncStamp } } + >(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "issue_closed", + }); - if (existingMetadata.createdFrom === "kaneo") { - return; - } + if (existingMetadata.createdFrom === "kaneo") { + return; + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_closed", - "done", - db, - ); + if ( + inboundEcho( + existingMetadata.lastSync?.state, + "closed", + issue.updated_at, + current?.state, + { + linkId: externalLink.id, + field: "state", + localValue: (await isTaskInFinalState(task, db)) + ? "closed" + : "open", + confirmation, + }, + ) + ) + return; - const statusResult = await updateTaskStatus(task.id, targetStatus, db); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - afterCommit(() => - publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }), - ); - } + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_closed", + "done", + db, + ); - await updateExternalLink( - externalLink.id, + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "closed", + lastSync: { + ...existingMetadata.lastSync, + state: inboundStamp( + existingMetadata.lastSync?.state, + "closed", + "github", + current?.updated_at ?? issue.updated_at, + ), + }, + }, + }, + db, + ); + }, { - metadata: { - ...existingMetadata, - state: "closed", - }, + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), }, - db, - ); - }, + ), + () => deferIssueEdit(externalLink, integration, ["state"]), ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-edited.ts b/apps/api/src/plugins/github/webhooks/issue-edited.ts index 4d40cc2426..610b690509 100644 --- a/apps/api/src/plugins/github/webhooks/issue-edited.ts +++ b/apps/api/src/plugins/github/webhooks/issue-edited.ts @@ -1,5 +1,11 @@ +import { issueEditScope } from "../utils/deferred-issue-edit"; +import { deferIssueEdit } from "../services/deferred-issue-edits"; +import { inboundStamp } from "../../github/utils/sync-echo"; import { publishEvent } from "../../../events"; import { withIntegrationLink } from "../services/with-integration-link"; +import type { GitHubConfig } from "../config"; +import { getVerifiedInstallationOctokit } from "../utils/github-app"; +import { inboundEcho, withEchoConfirmation } from "../utils/inbound-echo"; import { linkedTaskScope } from "../services/integration-task-scope"; import { taskTable } from "../../../database/schema"; import { findExternalLink, updateExternalLink } from "../services/link-manager"; @@ -9,11 +15,7 @@ import { parseLinkMetadata } from "../utils/parse-link-metadata"; // What this handler reads back out of the row. Every field is optional, // because the row may predate any of them. -type SyncStamp = { - timestamp?: string; - source?: string; - value?: string; -}; +type SyncStamp = import("../utils/sync-echo").SyncStamp; type IssueEditedMetadata = { lastSync?: { @@ -28,6 +30,7 @@ type IssueEditedPayload = { number: number; title: string; body: string | null; + updated_at?: string; html_url: string; }; changes?: { @@ -70,142 +73,181 @@ export async function handleIssueEdited(payload: IssueEditedPayload) { continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); - - if (!task) { - console.error(`Task ${externalLink.taskId} not found`); - return; - } - - const metadata = parseLinkMetadata( - externalLink.metadata, - { - externalLinkId: externalLink.id, - source: "issue_edited", - }, - ); + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GitHubConfig; + const octokit = await getVerifiedInstallationOctokit(config); + return ( + await octokit.rest.issues.get({ + owner: config.repositoryOwner, + repo: config.repositoryName, + issue_number: issue.number, + }) + ).data; + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); + + if (!task) { + console.error(`Task ${externalLink.taskId} not found`); + return; + } + const metadata = parseLinkMetadata( + externalLink.metadata, + { + externalLinkId: externalLink.id, + source: "issue_edited", + }, + ); - const updateData: Record = {}; - const updatedMetadata: IssueEditedMetadata = { ...metadata }; + const updateData: Record = {}; + const updatedMetadata: IssueEditedMetadata = { ...metadata }; - if (!updatedMetadata.lastSync) { - updatedMetadata.lastSync = {}; - } + if (!updatedMetadata.lastSync) { + updatedMetadata.lastSync = {}; + } - if (changes.title) { - const lastTitleSync = metadata.lastSync?.title; + if (changes.title) { + const lastTitleSync = metadata.lastSync?.title; + + let shouldUpdateTitle = true; + + if (lastTitleSync) { + if ( + inboundEcho( + lastTitleSync, + issue.title, + issue.updated_at, + current?.title, + { + linkId: externalLink.id, + field: "title", + localValue: task.title, + confirmation, + }, + ) + ) { + console.log( + "Skipping title update - already synced from Kaneo", + ); + shouldUpdateTitle = false; + } + } + + if (shouldUpdateTitle) { + updateData.title = issue.title; + updatedMetadata.lastSync.title = inboundStamp( + metadata.lastSync?.title, + issue.title, + "github", + issue.updated_at, + ); + console.log( + `Updating task title from GitHub: "${changes.title.from}" → "${issue.title}"`, + ); + } + } - let shouldUpdateTitle = true; + if (changes.body) { + const lastDescSync = metadata.lastSync?.description; + const formattedDescription = formatTaskDescriptionFromIssue( + issue.body, + task.id, + ); - if (lastTitleSync) { - if ( - lastTitleSync.value === issue.title && - lastTitleSync.source === "kaneo" - ) { - console.log("Skipping title update - already synced from Kaneo"); - shouldUpdateTitle = false; + let shouldUpdateDescription = true; + + if (lastDescSync) { + if ( + inboundEcho( + lastDescSync, + formattedDescription, + issue.updated_at, + current + ? formatTaskDescriptionFromIssue( + current.body ?? null, + task.id, + ) + : undefined, + { + linkId: externalLink.id, + field: "description", + localValue: task.description || "", + confirmation, + }, + ) + ) { + console.log( + "Skipping description update - already synced from Kaneo", + ); + shouldUpdateDescription = false; + } + } + + if (shouldUpdateDescription) { + updateData.description = formattedDescription; + updatedMetadata.lastSync.description = inboundStamp( + metadata.lastSync?.description, + formattedDescription, + "github", + issue.updated_at, + ); + console.log("Updating task description from GitHub"); + } } - const timeSinceLastSync = - Date.now() - new Date(lastTitleSync.timestamp ?? 0).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateTitle) { - console.log( - `Skipping title update - recent sync detected (${timeSinceLastSync}ms ago)`, + + if (Object.keys(updateData).length > 0) { + await db + .update(taskTable) + .set(updateData) + .where(linkedTaskScope(task.id, integration.projectId)); + + await updateExternalLink( + externalLink.id, + { + title: issue.title, + metadata: updatedMetadata, + }, + db, ); - shouldUpdateTitle = false; - } - } - - if (shouldUpdateTitle) { - updateData.title = issue.title; - updatedMetadata.lastSync.title = { - timestamp: new Date().toISOString(), - source: "github", - value: issue.title, - }; - console.log( - `Updating task title from GitHub: "${changes.title.from}" → "${issue.title}"`, - ); - } - } - - if (changes.body) { - const lastDescSync = metadata.lastSync?.description; - const formattedDescription = formatTaskDescriptionFromIssue( - issue.body, - task.id, - ); - - let shouldUpdateDescription = true; - - if (lastDescSync) { - if ( - lastDescSync.value === formattedDescription && - lastDescSync.source === "kaneo" - ) { + afterCommit(() => + publishEvent("task.updated", { + projectId: integration.projectId, + taskId: externalLink.taskId, + }), + ); + console.log( - "Skipping description update - already synced from Kaneo", + `Synced ${Object.keys(updateData).join(", ")} from GitHub issue #${issue.number} to task ${task.id}`, ); - shouldUpdateDescription = false; - } - const timeSinceLastSync = - Date.now() - new Date(lastDescSync.timestamp ?? 0).getTime(); - if (timeSinceLastSync < 2000 && shouldUpdateDescription) { + } else { console.log( - `Skipping description update - recent sync detected (${timeSinceLastSync}ms ago)`, + `No updates needed for task ${task.id} from issue #${issue.number}`, ); - shouldUpdateDescription = false; } - } - - if (shouldUpdateDescription) { - updateData.description = formattedDescription; - updatedMetadata.lastSync.description = { - timestamp: new Date().toISOString(), - source: "github", - value: formattedDescription, - }; - console.log("Updating task description from GitHub"); - } - } - - if (Object.keys(updateData).length > 0) { - await db - .update(taskTable) - .set(updateData) - .where(linkedTaskScope(task.id, integration.projectId)); - - await updateExternalLink( - externalLink.id, - { - title: issue.title, - metadata: updatedMetadata, - }, - db, - ); - afterCommit(() => - publishEvent("task.updated", { - projectId: integration.projectId, - taskId: externalLink.taskId, - }), - ); - - console.log( - `Synced ${Object.keys(updateData).join(", ")} from GitHub issue #${issue.number} to task ${task.id}`, - ); - } else { - console.log( - `No updates needed for task ${task.id} from issue #${issue.number}`, - ); - } - - return; - }, + + return; + }, + { + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), + }, + ), + () => + deferIssueEdit(externalLink, integration, [ + ...(changes.title ? ["title" as const] : []), + ...(changes.body ? ["description" as const] : []), + ]), ); } } diff --git a/apps/api/src/plugins/github/webhooks/issue-reopened.ts b/apps/api/src/plugins/github/webhooks/issue-reopened.ts index ff22d069f9..fe4aa2beb0 100644 --- a/apps/api/src/plugins/github/webhooks/issue-reopened.ts +++ b/apps/api/src/plugins/github/webhooks/issue-reopened.ts @@ -1,3 +1,9 @@ +import { issueEditScope } from "../utils/deferred-issue-edit"; +import { deferIssueEdit } from "../services/deferred-issue-edits"; +import type { GitHubConfig } from "../config"; +import { getVerifiedInstallationOctokit } from "../utils/github-app"; +import { inboundEcho, withEchoConfirmation } from "../utils/inbound-echo"; +import { inboundStamp, type SyncStamp } from "../utils/sync-echo"; import { withIntegrationLink } from "../services/with-integration-link"; import { linkedTaskScope } from "../services/integration-task-scope"; import { and, eq } from "drizzle-orm"; @@ -7,6 +13,7 @@ import { publishEvent } from "../../../events"; import { updateExternalLink } from "../services/link-manager"; import { findAllIntegrationsByRepo, + isTaskInFinalState, updateTaskStatus, } from "../services/task-service"; import { parseLinkMetadata } from "../utils/parse-link-metadata"; @@ -19,6 +26,7 @@ type IssueReopenedPayload = { title: string; html_url: string; state: string; + updated_at?: string; }; installation?: { id: number }; repository: { @@ -47,67 +55,123 @@ export async function handleIssueReopened(payload: IssueReopenedPayload) { continue; } - await withIntegrationLink( - externalLink, - integration, - async (db, afterCommit, externalLink) => { - const task = await db.query.taskTable.findFirst({ - where: linkedTaskScope(externalLink.taskId, integration.projectId), - }); + const readCurrent = async () => { + const config = JSON.parse(integration.config) as GitHubConfig; + const octokit = await getVerifiedInstallationOctokit(config); + return ( + await octokit.rest.issues.get({ + owner: config.repositoryOwner, + repo: config.repositoryName, + issue_number: issue.number, + }) + ).data; + }; + await withEchoConfirmation( + readCurrent, + (current, confirmation) => + withIntegrationLink( + externalLink, + integration, + async (db, afterCommit, externalLink) => { + const task = await db.query.taskTable.findFirst({ + where: linkedTaskScope( + externalLink.taskId, + integration.projectId, + ), + }); - if (!task) { - return; - } + if (!task) { + return; + } - const existingMetadata = parseLinkMetadata(externalLink.metadata, { - externalLinkId: externalLink.id, - source: "issue_reopened", - }); + const existingMetadata = parseLinkMetadata< + Record & { lastSync?: { state?: SyncStamp } } + >(externalLink.metadata, { + externalLinkId: externalLink.id, + source: "issue_reopened", + }); - if ( - existingMetadata.createdFrom === "kaneo" || - existingMetadata.state === "open" - ) { - return; - } + if ( + existingMetadata.createdFrom === "kaneo" || + existingMetadata.state === "open" + ) { + return; + } - const targetStatus = await resolveTargetStatus( - task.projectId, - "issue_reopened", - "to-do", - db, - ); + if ( + inboundEcho( + existingMetadata.lastSync?.state, + "open", + issue.updated_at, + current?.state, + { + linkId: externalLink.id, + field: "state", + localValue: (await isTaskInFinalState(task, db)) + ? "closed" + : "open", + confirmation, + }, + ) + ) + return; - const statusResult = await updateTaskStatus(task.id, targetStatus, db); - if ( - statusResult.applied && - statusResult.before.status !== statusResult.after.status - ) { - afterCommit(() => - publishEvent("task.status_changed", { - taskId: statusResult.after.id, - projectId: statusResult.after.projectId, - userId: null, - oldStatus: statusResult.before.status, - newStatus: statusResult.after.status, - title: statusResult.after.title, - assigneeId: statusResult.after.userId, - type: "status_changed", - }), - ); - } + const targetStatus = await resolveTargetStatus( + task.projectId, + "issue_reopened", + "to-do", + db, + ); - await updateExternalLink( - externalLink.id, + const statusResult = await updateTaskStatus( + task.id, + targetStatus, + db, + ); + if ( + statusResult.applied && + statusResult.before.status !== statusResult.after.status + ) { + afterCommit(() => + publishEvent("task.status_changed", { + taskId: statusResult.after.id, + projectId: statusResult.after.projectId, + userId: null, + oldStatus: statusResult.before.status, + newStatus: statusResult.after.status, + title: statusResult.after.title, + assigneeId: statusResult.after.userId, + type: "status_changed", + }), + ); + } + + await updateExternalLink( + externalLink.id, + { + metadata: { + ...existingMetadata, + state: "open", + lastSync: { + ...existingMetadata.lastSync, + state: inboundStamp( + existingMetadata.lastSync?.state, + "open", + "github", + current?.updated_at ?? issue.updated_at, + ), + }, + }, + }, + db, + ); + }, { - metadata: { - ...existingMetadata, - state: "open", - }, + validate: (binding) => + issueEditScope(binding) === issueEditScope(integration), }, - db, - ); - }, + ), + () => deferIssueEdit(externalLink, integration, ["state"]), ); } } diff --git a/apps/api/src/scheduler/index.ts b/apps/api/src/scheduler/index.ts index 7e671422ff..0b596374c4 100644 --- a/apps/api/src/scheduler/index.ts +++ b/apps/api/src/scheduler/index.ts @@ -1,3 +1,4 @@ +import { replayDeferredIssueEdits } from "../plugins/github/services/deferred-issue-edits"; import * as Sentry from "@sentry/node"; import { Cron } from "croner"; import { checkDueDateReminders } from "./due-date-reminders"; @@ -45,6 +46,12 @@ function withCheckIn(name: string, fn: () => Promise) { } export function initializeScheduler(): void { + jobs.push( + new Cron( + "* * * * *", + withCheckIn("deferred-issue-edits", replayDeferredIssueEdits), + ), + ); jobs.push( new Cron( "*/5 * * * *", @@ -67,7 +74,7 @@ export function initializeScheduler(): void { new Cron("23 * * * *", withCheckIn("trial-reminders", checkTrialReminders)), ); console.log( - "⏰ Scheduler started (reminders every 5 minutes, seat reconciliation and trial reminders hourly)", + "⏰ Scheduler started (deferred integration edits every minute, reminders every 5 minutes, seat reconciliation and trial reminders hourly)", ); } diff --git a/apps/api/src/task/controllers/bulk-update-tasks.ts b/apps/api/src/task/controllers/bulk-update-tasks.ts index 0b209267a1..0b8114226c 100644 --- a/apps/api/src/task/controllers/bulk-update-tasks.ts +++ b/apps/api/src/task/controllers/bulk-update-tasks.ts @@ -445,11 +445,27 @@ async function bulkUpdateTasks({ const { updatedTasks, beforeById } = await db.transaction(async (tx) => { const before = await tx - .select({ id: taskTable.id, dueDate: taskTable.dueDate }) + .select({ + id: taskTable.id, + projectId: taskTable.projectId, + dueDate: taskTable.dueDate, + }) .from(taskTable) .where(inArray(taskTable.id, foundIds)) .orderBy(asc(taskTable.id)) .for("update"); + const originalProjects = new Map( + tasks.map((task) => [task.id, task.projectId]), + ); + if ( + before.length !== foundIds.length || + before.some( + (task) => task.projectId !== originalProjects.get(task.id), + ) + ) + throw new HTTPException(409, { + message: "Tasks changed projects; retry the operation", + }); const beforeById = new Map(before.map((task) => [task.id, task])); const changedIds = before .filter((task) => task.dueDate?.getTime() !== parsedDate?.getTime()) diff --git a/apps/api/src/task/controllers/get-task.ts b/apps/api/src/task/controllers/get-task.ts index 0c33c9d396..e742e216f1 100644 --- a/apps/api/src/task/controllers/get-task.ts +++ b/apps/api/src/task/controllers/get-task.ts @@ -1,15 +1,30 @@ -import { eq } from "drizzle-orm"; +import { boundedTaskRead } from "../bounded-read"; +import { alias } from "drizzle-orm/pg-core"; +import { boardDescription, descriptionDeferred } from "../description-pages"; +import { getSubtaskCounts } from "../get-subtask-counts"; +import { and, eq, sql } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import db from "../../database"; -import { taskTable, userTable } from "../../database/schema"; +import { + taskTable, + userTable, + projectTable, + taskRelationTable, +} from "../../database/schema"; -async function getTask(taskId: string) { +async function getTask(taskId: string, board = false) { const task = await db .select({ id: taskTable.id, title: taskTable.title, number: taskTable.number, - description: taskTable.description, + description: board ? boardDescription : taskTable.description, + ...(board + ? { + descriptionDeferred, + workspaceId: sql`(select ${projectTable.workspaceId} from ${projectTable} where ${projectTable.id} = ${taskTable.projectId})`, + } + : {}), status: taskTable.status, priority: taskTable.priority, startDate: taskTable.startDate, @@ -32,7 +47,36 @@ async function getTask(taskId: string) { }); } - return task[0]; + const { workspaceId, ...result } = task[0]; + if (!board || !workspaceId) return result; + const parent = alias(taskTable, "parent"); + const parents = await db + .selectDistinct({ id: parent.id }) + .from(taskRelationTable) + .innerJoin(parent, eq(taskRelationTable.sourceTaskId, parent.id)) + .where( + and( + eq(taskRelationTable.targetTaskId, taskId), + eq(taskRelationTable.relationType, "subtask"), + eq(parent.projectId, result.projectId), + ), + ); + const counts = await boundedTaskRead((tx) => + getSubtaskCounts( + tx, + [taskId, ...parents.map((task) => task.id)], + workspaceId, + false, + ), + ); + return { + ...result, + subtaskCounts: counts.get(taskId) ?? { completed: 0, total: 0 }, + parentSubtaskCounts: parents.map(({ id }) => ({ + taskId: id, + ...(counts.get(id) ?? { completed: 0, total: 0 }), + })), + }; } export default getTask; diff --git a/apps/api/src/task/controllers/get-tasks.ts b/apps/api/src/task/controllers/get-tasks.ts index 9b52542d0f..f606f3952e 100644 --- a/apps/api/src/task/controllers/get-tasks.ts +++ b/apps/api/src/task/controllers/get-tasks.ts @@ -8,8 +8,10 @@ import { inArray, lte, type SQL, + type SQLWrapper, sql, } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import { HTTPException } from "hono/http-exception"; import { columnTable, @@ -17,6 +19,7 @@ import { labelTable, projectTable, taskTable, + taskRelationTable, userTable, } from "../../database/schema"; import { boundedTaskRead, type TaskReadDatabase } from "../bounded-read"; @@ -26,6 +29,7 @@ import { descriptionDeferred, projectDescriptionDeferred, } from "../description-pages"; +import { taskIsCompleted } from "../task-is-completed"; import { getSubtaskCounts } from "../get-subtask-counts"; export type GetTasksOptions = { @@ -56,27 +60,37 @@ const priorityCaseExpr = sql`CASE ELSE 0 END`; -function buildOrderBy( - sortBy: GetTasksOptions["sortBy"], - sortOrder: GetTasksOptions["sortOrder"], -): SQL { - const direction = sortOrder === "desc" ? desc : asc; - +function sortValue(sortBy: GetTasksOptions["sortBy"]): SQLWrapper { switch (sortBy) { case "createdAt": - return direction(taskTable.createdAt); + return taskTable.createdAt; case "priority": - return direction(priorityCaseExpr); + return priorityCaseExpr; case "dueDate": - return direction(taskTable.dueDate); + return taskTable.dueDate; case "title": - return direction(taskTable.title); + return taskTable.title; case "number": - return direction(taskTable.number); + return taskTable.number; default: - return direction(taskTable.position); + return taskTable.position; } } +function buildOrderBy( + sortBy: GetTasksOptions["sortBy"], + sortOrder: GetTasksOptions["sortOrder"], +): SQL { + return (sortOrder === "desc" ? desc : asc)(sortValue(sortBy)); +} + +function boardRevision( + publicOnly: boolean | undefined, + fields: SQLWrapper[], +): SQL { + return publicOnly + ? sql`coalesce(sum(hashtextextended(jsonb_build_array(${sql.join(fields, sql`, `)})::text, 0)::numeric), 0)::text` + : sql`'0'`; +} async function getTasksPage( db: TaskReadDatabase, @@ -86,6 +100,9 @@ async function getTasksPage( const [project] = await db .select({ ...getTableColumns(projectTable), + revision: options.publicOnly + ? sql`${projectTable}.xmin::text` + : sql`'0'`, description: boardProjectDescription, descriptionDeferred: projectDescriptionDeferred, }) @@ -135,10 +152,26 @@ async function getTasksPage( options.sortOrder ?? "asc", ); - const [taskCount] = await db - .select({ count: sql`count(*)` }) + const taskCountQuery = db + .select({ + count: sql`count(*)`, + revision: boardRevision(options.publicOnly, [ + taskTable.id, + sortValue(options.sortBy), + taskTable.status, + // Row versions detect content edits without hashing large descriptions. + sql`${taskTable}.xmin::text`, + userTable.name, + userTable.image, + ]), + }) .from(taskTable) - .where(whereClause); + .$dynamic(); + const [taskCount] = await ( + options.publicOnly + ? taskCountQuery.leftJoin(userTable, eq(taskTable.userId, userTable.id)) + : taskCountQuery + ).where(whereClause); const total = Number(taskCount?.count ?? 0); @@ -286,22 +319,100 @@ async function getTasksPage( projectColumns.push(...taskColumns); } const [columnCount] = await db - .select({ count: sql`count(*)` }) + .select({ + count: sql`count(*)`, + revision: boardRevision(options.publicOnly, [ + columnTable.id, + columnTable.slug, + columnTable.position, + columnTable.name, + columnTable.icon, + columnTable.isFinal, + ]), + }) .from(columnTable) .where(eq(columnTable.projectId, projectId)); let labelCount = 0; let linkCount = 0; + let labelRevision = "0"; + let linkRevision = "0"; if (taskIds.length) { const [labels] = await db - .select({ count: sql`count(*)` }) + .select({ + count: sql`count(*)`, + revision: boardRevision(options.publicOnly, [ + labelTable.id, + labelTable.taskId, + labelTable.name, + labelTable.color, + ]), + }) .from(labelTable) .where(inArray(labelTable.taskId, taskIds)); const [links] = await db - .select({ count: sql`count(*)` }) + .select({ + count: sql`count(*)`, + revision: boardRevision(options.publicOnly, [ + externalLinkTable.id, + externalLinkTable.taskId, + sql`${externalLinkTable}.xmin::text`, + ]), + }) .from(externalLinkTable) .where(inArray(externalLinkTable.taskId, taskIds)); labelCount = Number(labels?.count ?? 0); linkCount = Number(links?.count ?? 0); + labelRevision = labels?.revision ?? "0"; + linkRevision = links?.revision ?? "0"; + } + + let publicRelatedRevision = "0"; + if (options.publicOnly) { + // Every task page must detect edits to cards or metadata loaded earlier. + const [labels] = await db + .select({ + revision: boardRevision(true, [ + labelTable.id, + labelTable.taskId, + labelTable.name, + labelTable.color, + ]), + }) + .from(labelTable) + .innerJoin(taskTable, eq(labelTable.taskId, taskTable.id)) + .where(whereClause); + const [links] = await db + .select({ + revision: boardRevision(true, [ + externalLinkTable.id, + sql`${externalLinkTable}.xmin::text`, + ]), + }) + .from(externalLinkTable) + .innerJoin(taskTable, eq(externalLinkTable.taskId, taskTable.id)) + .where(whereClause); + const parent = alias(taskTable, "board_parent"); + const [children] = await db + .select({ + revision: boardRevision(true, [ + taskRelationTable.id, + taskTable.id, + taskIsCompleted, + ]), + }) + .from(taskRelationTable) + .innerJoin(parent, eq(taskRelationTable.sourceTaskId, parent.id)) + .innerJoin(taskTable, eq(taskRelationTable.targetTaskId, taskTable.id)) + .innerJoin(projectTable, eq(taskTable.projectId, projectTable.id)) + .where( + and( + eq(parent.projectId, projectId), + eq(taskRelationTable.relationType, "subtask"), + eq(projectTable.workspaceId, project.workspaceId), + eq(projectTable.isPublic, true), + ), + ); + publicRelatedRevision = `${labels?.revision}:${links?.revision}:${children?.revision}`; } const columns = projectColumns.map((column) => ({ @@ -356,6 +467,12 @@ async function getTasksPage( }, pagination: { total, + ...(options.publicOnly + ? { + revision: `${project.revision}:${total}:${taskCount?.revision}:${columnCount?.count}:${columnCount?.revision}:${publicRelatedRevision}`, + relatedRevision: `${labelCount}:${labelRevision}:${linkCount}:${linkRevision}`, + } + : {}), page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), diff --git a/apps/api/src/task/controllers/task-mutation-effects.ts b/apps/api/src/task/controllers/task-mutation-effects.ts index 46c7d5d981..3d2241d296 100644 --- a/apps/api/src/task/controllers/task-mutation-effects.ts +++ b/apps/api/src/task/controllers/task-mutation-effects.ts @@ -9,6 +9,7 @@ import { import { publishEvent } from "../../events"; import createNotification from "../../notification/controllers/create-notification"; import { deleteOrphanedAssets } from "../../storage/cleanup-assets"; +import { getSubtaskParentProjects } from "../get-subtask-parent-projects"; import { parseMentionIds } from "../../utils/parse-mentions"; type Task = typeof taskTable.$inferSelect; @@ -22,7 +23,7 @@ type Changes = Partial< export type TaskBefore = Pick< Task, "id" | "projectId" | "title" | "status" | "priority" | "userId" | "dueDate" -> & { description?: string | null }; +> & { description?: string | null; columnId?: string | null }; export async function recordTaskMutation( tx: Transaction, @@ -80,12 +81,21 @@ export async function publishTaskMutation( ? { skipSubtaskParentRefresh: true } : {}), }); - if (!options.skipRelationRefresh) - await publishEvent("task-relation.refresh", { - projectId: after.projectId, - userId, - }); } + const columnChanged = + (!options.fields || options.fields.includes("status")) && + before.columnId !== undefined && + after.columnId !== undefined && + before.columnId !== after.columnId; + if (columnChanged && !changed("status") && !options.skipSubtaskParentRefresh) + await publishEvent("subtask-parents.refresh", { + projects: await getSubtaskParentProjects([after.id]), + }); + if (!options.skipRelationRefresh && (changed("status") || columnChanged)) + await publishEvent("task-relation.refresh", { + projectId: after.projectId, + userId, + }); if (changed("title")) await publishEvent("task.title_changed", { ...common, diff --git a/apps/api/src/task/controllers/update-task-status.ts b/apps/api/src/task/controllers/update-task-status.ts index 5e76fe8d08..f949264b1a 100644 --- a/apps/api/src/task/controllers/update-task-status.ts +++ b/apps/api/src/task/controllers/update-task-status.ts @@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm"; import { HTTPException } from "hono/http-exception"; import { withLockedTask } from "./with-locked-task"; import { columnTable, taskTable } from "../../database/schema"; +import { publishEvent } from "../../events"; import { publishTaskMutation } from "./task-mutation-effects"; import { assertValidTaskStatus } from "../validate-task-fields"; @@ -46,6 +47,16 @@ async function updateTaskStatus({ fields: ["status"], }); + if ( + existingTask.status === updatedTask.status && + existingTask.columnId !== updatedTask.columnId + ) + await publishEvent("task.updated", { + taskId: updatedTask.id, + projectId: updatedTask.projectId, + userId: currentUserId, + }); + return updatedTask; } diff --git a/apps/api/src/task/controllers/update-task.ts b/apps/api/src/task/controllers/update-task.ts index 48278edb38..ca5d57adc5 100644 --- a/apps/api/src/task/controllers/update-task.ts +++ b/apps/api/src/task/controllers/update-task.ts @@ -97,6 +97,7 @@ async function updateTask( description: description === undefined ? sql`null` : taskTable.description, status: taskTable.status, + columnId: taskTable.columnId, position: taskTable.position, projectId: taskTable.projectId, }) diff --git a/apps/api/src/task/index.ts b/apps/api/src/task/index.ts index 9a27e9e7c7..bd4dd6c601 100644 --- a/apps/api/src/task/index.ts +++ b/apps/api/src/task/index.ts @@ -253,9 +253,13 @@ const getTaskRoute = createRoute({ path: "/{id}", tags: ["Tasks"], summary: "Get task", - description: "Get a single task by ID, with its assignee's name resolved.", + description: + "Get a single task by ID, with its assignee name. The board view omits descriptions above 64 KiB and includes task and same-project parent subtask progress.", middleware: [workspaceAccess.fromTask()] as const, - request: { params: taskParam }, + request: { + params: taskParam, + query: z.object({ view: z.enum(["detail", "board"]).optional() }), + }, responses: { 200: jsonResponse("Task details", taskWithAssigneeSchema), 400: errorResponse( @@ -812,7 +816,7 @@ const task = apiRouter() .openapi(getTaskRoute, async (c) => { const { id } = c.req.valid("param"); - const task = await getTask(id); + const task = await getTask(id, c.req.valid("query").view === "board"); return c.json(task, 200); }) diff --git a/apps/api/src/task/response.ts b/apps/api/src/task/response.ts index 839318660f..7ea9e15281 100644 --- a/apps/api/src/task/response.ts +++ b/apps/api/src/task/response.ts @@ -37,6 +37,18 @@ export const taskSchema = z export const taskWithAssigneeSchema = taskSchema .extend({ + subtaskCounts: z + .object({ completed: z.number(), total: z.number() }) + .optional(), + parentSubtaskCounts: z + .array( + z.object({ + taskId: z.string(), + completed: z.number(), + total: z.number(), + }), + ) + .optional(), assigneeName: z.string().nullable(), assigneeId: z.string().nullable(), }) @@ -141,6 +153,14 @@ export const boardSchema = z relatedPage: z.number(), relatedPageSize: z.number(), relatedTotalPages: z.number(), + relatedRevision: z.string().optional().openapi({ + description: + "Public board labels and external links revision for this task page. Restart pagination if it changes during related-page continuations.", + }), + revision: z.string().optional().openapi({ + description: + "Public board content, membership and ordering revision, including related records and visible subtask progress. Restart pagination if it changes between task or related pages.", + }), }) .openapi({ description: diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts index 3d8d1256b7..6b560c4b8e 100644 --- a/apps/api/src/ws/index.ts +++ b/apps/api/src/ws/index.ts @@ -415,7 +415,7 @@ subscribeToEvent<{ { type: "TASK_MOVED", projectId: fromProjectId, taskId }, initiatorId, ); - refreshParentBoards(await getSubtaskParentProjects([taskId]), fromProjectId); + refreshParentBoards(await getSubtaskParentProjects([taskId]), toProjectId); }); subscribeToEvent<{ diff --git a/apps/docs/openapi.json b/apps/docs/openapi.json index ce4f7076b5..454d08c9b0 100644 --- a/apps/docs/openapi.json +++ b/apps/docs/openapi.json @@ -336,6 +336,14 @@ }, "relatedTotalPages": { "type": "number" + }, + "relatedRevision": { + "type": "string", + "description": "Public board labels and external links revision for this task page. Restart pagination if it changes during related-page continuations." + }, + "revision": { + "type": "string", + "description": "Public board content, membership and ordering revision, including related records and visible subtask progress. Restart pagination if it changes between task or related pages." } }, "required": [ @@ -1024,6 +1032,43 @@ { "type": "object", "properties": { + "subtaskCounts": { + "type": "object", + "properties": { + "completed": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "completed", + "total" + ] + }, + "parentSubtaskCounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "completed": { + "type": "number" + }, + "total": { + "type": "number" + } + }, + "required": [ + "taskId", + "completed", + "total" + ] + } + }, "assigneeName": { "type": [ "string", @@ -9040,7 +9085,7 @@ "Tasks" ], "summary": "Get task", - "description": "Get a single task by ID, with its assignee's name resolved.", + "description": "Get a single task by ID, with its assignee name. The board view omits descriptions above 64 KiB and includes task and same-project parent subtask progress.", "parameters": [ { "schema": { @@ -9049,6 +9094,18 @@ "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "string", + "enum": [ + "detail", + "board" + ] + }, + "required": false, + "name": "view", + "in": "query" } ], "responses": { diff --git a/apps/site/constants/sponsors.json b/apps/site/constants/sponsors.json index 408064a8f4..5605d4cfbe 100644 --- a/apps/site/constants/sponsors.json +++ b/apps/site/constants/sponsors.json @@ -28,13 +28,6 @@ "tier": 5, "founding": true }, - { - "login": "floreabogdan", - "name": "Bogdan FLOREA", - "avatarUrl": "https://avatars.githubusercontent.com/u/983985?v=4", - "tier": 5, - "founding": false - }, { "login": "randoneering", "name": "(justin)randoneering", @@ -65,6 +58,13 @@ "tier": 10, "founding": true }, + { + "login": "floreabogdan", + "name": "Bogdan FLOREA", + "avatarUrl": "https://avatars.githubusercontent.com/u/983985?v=4", + "tier": 5, + "founding": false + }, { "login": "KnudH", "name": "Knud Hollander", diff --git a/apps/web/src/components/kanban-board/column/column-header.test.tsx b/apps/web/src/components/kanban-board/column/column-header.test.tsx new file mode 100644 index 0000000000..8ff338951a --- /dev/null +++ b/apps/web/src/components/kanban-board/column/column-header.test.tsx @@ -0,0 +1,75 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vite-plus/test"; +import type { ProjectWithTasks } from "@/types/project"; +import { ColumnHeader } from "./column-header"; +const m = vi.hoisted(() => ({ + project: undefined as ProjectWithTasks | undefined, + archive: {} as { + onConfirm?: () => void; + disabled?: boolean; + taskCount?: number; + }, + mutate: vi.fn(), + setProject: vi.fn(), +})); +vi.mock("@/hooks/mutations/task/use-update-task", () => ({ + useUpdateTask: () => ({ mutate: m.mutate }), +})); +vi.mock("@/hooks/use-workspace-permission", () => ({ + useWorkspacePermission: () => ({ + canUpdateTasks: () => true, + canCreateTasks: () => true, + }), +})); +vi.mock("@/store/project", () => ({ + default: () => ({ project: m.project, setProject: m.setProject }), +})); +vi.mock("@/components/shared/modals/create-task-modal", () => ({ + default: () => null, +})); +vi.mock("../../shared/modals/archive-tasks-modal", () => ({ + ArchiveTasksModal: (props: typeof m.archive) => { + m.archive = props; + return null; + }, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +it("prevents archive confirmation while the board is partial and uses the completed column afterward", () => { + const column = { + id: "done", + name: "Done", + slug: "done", + isFinal: true, + tasks: [{ id: "a" }], + } as unknown as ProjectWithTasks["columns"][number]; + m.project = { id: "p", columns: [column] } as ProjectWithTasks; + const { rerender } = render(); + fireEvent.click(screen.getByTitle(/archiveAllTooltip/)); + rerender(); + expect(screen.getByTitle(/archiveAllTooltip/).hasAttribute("disabled")).toBe( + true, + ); + expect(m.archive.disabled).toBe(true); + act(() => m.archive.onConfirm?.()); + expect(m.mutate).not.toHaveBeenCalled(); + expect(m.setProject).not.toHaveBeenCalled(); + const complete = { + ...column, + tasks: [...column.tasks, { id: "b" }], + } as typeof column; + m.project = { ...m.project, columns: [complete] }; + rerender(); + expect(m.archive.taskCount).toBe(2); + act(() => m.archive.onConfirm?.()); + expect(m.mutate).toHaveBeenCalledTimes(2); + expect(m.setProject).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/kanban-board/column/column-header.tsx b/apps/web/src/components/kanban-board/column/column-header.tsx index 08464c22de..ba0b11621f 100644 --- a/apps/web/src/components/kanban-board/column/column-header.tsx +++ b/apps/web/src/components/kanban-board/column/column-header.tsx @@ -13,9 +13,13 @@ import { ArchiveTasksModal } from "../../shared/modals/archive-tasks-modal"; type ColumnHeaderProps = { column: ProjectWithTasks["columns"][number]; + disableCollectionActions?: boolean; }; -export function ColumnHeader({ column }: ColumnHeaderProps) { +export function ColumnHeader({ + column, + disableCollectionActions = false, +}: ColumnHeaderProps) { const { t } = useTranslation(); const { project, setProject } = useProjectStore(); const { mutate: updateTask } = useUpdateTask(); @@ -27,7 +31,7 @@ export function ColumnHeader({ column }: ColumnHeaderProps) { const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const handleConfirmArchive = () => { - if (!column.isFinal || !project) return; + if (disableCollectionActions || !column.isFinal || !project) return; const updatedProject = produce(project, (draft) => { const archivedColumn = draft?.columns?.find( @@ -68,6 +72,7 @@ export function ColumnHeader({ column }: ColumnHeaderProps) { {canTask && column.isFinal && column.tasks.length > 0 && ( + DndContext: ({ + onDragStart, + onDragEnd, + }: { + onDragStart: (event: unknown) => void; + onDragEnd: (event: unknown) => void; + }) => ( + <> + + + ), DragOverlay: () => null, MouseSensor: {}, @@ -58,6 +76,8 @@ vi.mock("@dnd-kit/core", () => ({ defaultDropAnimationSideEffects: vi.fn(), })); +beforeEach(() => vi.clearAllMocks()); +afterEach(cleanup); describe("filtered board dragging", () => { it("moves visible tasks in canonical state and sends one ordering mutation", () => { const columns = [ @@ -98,3 +118,29 @@ describe("filtered board dragging", () => { view.unmount(); }); }); + +it("rejects an in-flight Kanban drop after a failed refresh disables dragging", () => { + const canonical = { + id: "p", + columns: [ + { + id: "todo", + slug: "todo", + tasks: [ + { id: "a", status: "todo", position: 0 }, + { id: "b", status: "todo", position: 1 }, + ], + }, + ], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + mocks.project = canonical; + const view = render(); + fireEvent.click(view.getByText("start")); + view.rerender(); + fireEvent.click(view.getByText("drop")); + expect(mocks.reorder).not.toHaveBeenCalled(); + expect(mocks.setProject).not.toHaveBeenCalled(); + expect(mocks.setQueryData).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 082ac8ba33..3b0e7f09fb 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -1,3 +1,4 @@ +import { markBoardCacheChanged } from "@/lib/board-cache-version"; import { selectReorderBoard } from "./select-reorder-board"; import { closestCorners, @@ -37,12 +38,14 @@ import TaskCard from "./task-card"; type KanbanBoardProps = { project: ProjectWithTasks; disableDragDrop?: boolean; + disableCollectionActions?: boolean; sortedByNumber?: boolean; }; function KanbanBoard({ project, disableDragDrop = false, + disableCollectionActions = false, sortedByNumber = false, }: KanbanBoardProps) { const queryClient = useQueryClient(); @@ -188,7 +191,13 @@ function KanbanBoard({ const activeId = active.id.toString(); const overId = over.id.toString(); - if (isReordering) return; + if ( + disableDragDrop || + isReordering || + queryClient.getQueryState(["tasks", project.id])?.fetchStatus === + "fetching" + ) + return; const canonical = selectReorderBoard( project.id, activeId, @@ -199,6 +208,8 @@ function KanbanBoard({ const moved = moveBoardTask(canonical, activeId, overId, sortedByNumber); if (!moved || !moved.tasks.length) return; + for (const task of moved.tasks) + markBoardCacheChanged(queryClient, project.id, task.id); setProject(moved.project); queryClient.setQueryData(["tasks", project.id], moved.project); reorder({ @@ -278,7 +289,11 @@ function KanbanBoard({ "h-fit": !!background, })} > - + ))} diff --git a/apps/web/src/components/list-view/index.test.tsx b/apps/web/src/components/list-view/index.test.tsx new file mode 100644 index 0000000000..9e9f789d3e --- /dev/null +++ b/apps/web/src/components/list-view/index.test.tsx @@ -0,0 +1,126 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vite-plus/test"; +import type { DragEndEvent } from "@dnd-kit/core"; +import type { ProjectWithTasks } from "@/types/project"; +import ListView from "./index"; +const m = vi.hoisted(() => ({ + props: {} as { + sensors?: unknown[]; + onDragEnd?: (event: DragEndEvent) => void; + }, + archive: {} as { + onConfirm?: () => void; + disabled?: boolean; + taskCount?: number; + }, + mutate: vi.fn(), + setProject: vi.fn(), +})); +vi.mock("@dnd-kit/core", async (original) => ({ + ...(await original()), + DndContext: (props: typeof m.props & { children: React.ReactNode }) => { + m.props = props; + return props.children; + }, +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ + useRegisterShortcuts: () => {}, +})); +vi.mock("@/hooks/mutations/task/use-update-task", () => ({ + useUpdateTask: () => ({ mutate: m.mutate }), +})); +vi.mock("@/store/project", () => ({ + default: () => ({ setProject: m.setProject }), +})); +vi.mock("./task-row", () => ({ default: () => null })); +vi.mock("../bulk-selection/bulk-toolbar", () => ({ default: () => null })); +vi.mock("../shared/modals/create-task-modal", () => ({ default: () => null })); +vi.mock("../shared/modals/archive-tasks-modal", () => ({ + ArchiveTasksModal: (props: typeof m.archive) => { + m.archive = props; + return null; + }, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); +it("disables keyboard sensors and rejects drag completion on a partial board", () => { + const project = { + id: "p", + workspaceId: "w", + columns: [ + { + id: "todo", + slug: "todo", + name: "To do", + tasks: [ + { id: "a", position: 0 }, + { id: "b", position: 1 }, + ], + }, + ], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + const { rerender } = render(); + expect(m.props.sensors?.length).toBeGreaterThan(0); + rerender(); + expect(m.props.sensors).toEqual([]); + act(() => + m.props.onDragEnd?.({ + active: { id: "a" }, + over: { id: "b" }, + } as DragEndEvent), + ); + expect(m.mutate).not.toHaveBeenCalled(); + expect(m.setProject).not.toHaveBeenCalled(); +}); + +it("blocks archive-all during pagination or errors, including an already-open confirmation", () => { + const column = { + id: "done", + name: "Done", + slug: "done", + isFinal: true, + tasks: [{ id: "a" }], + }; + const project = { + id: "p", + workspaceId: "w", + columns: [column], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + const { rerender } = render(); + fireEvent.click(screen.getByTitle(/archiveAllTooltip/)); + const complete = { + ...project, + columns: [ + { + ...project.columns[0], + tasks: [...project.columns[0].tasks, { id: "b" }], + }, + ], + } as unknown as ProjectWithTasks; + rerender(); + expect(screen.getByTitle(/archiveAllTooltip/).hasAttribute("disabled")).toBe( + true, + ); + expect(m.archive.disabled).toBe(true); + act(() => m.archive.onConfirm?.()); + expect(m.mutate).not.toHaveBeenCalled(); + expect(m.setProject).not.toHaveBeenCalled(); + rerender(); + expect(m.archive.taskCount).toBe(2); + act(() => m.archive.onConfirm?.()); + expect(m.mutate).toHaveBeenCalledTimes(2); + expect(m.setProject).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/components/list-view/index.tsx b/apps/web/src/components/list-view/index.tsx index 9246252a58..e8b64d1e5c 100644 --- a/apps/web/src/components/list-view/index.tsx +++ b/apps/web/src/components/list-view/index.tsx @@ -41,9 +41,14 @@ import TaskRow from "./task-row"; type ListViewProps = { project: ProjectWithTasks; disableDragDrop?: boolean; + disableCollectionActions?: boolean; }; -function ListView({ project, disableDragDrop = false }: ListViewProps) { +function ListView({ + project, + disableDragDrop = false, + disableCollectionActions = false, +}: ListViewProps) { const { t } = useTranslation(); const { setProject } = useProjectStore(); const { @@ -71,9 +76,10 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [activeColumn, setActiveColumn] = useState(null); const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); - const [columnToArchive, setColumnToArchive] = useState< - ProjectWithTasks["columns"][number] | null - >(null); + const [columnToArchive, setColumnToArchive] = useState(null); + const archiveColumn = project.columns.find( + (column) => column.id === columnToArchive, + ); useEffect(() => { if (project?.columns) { @@ -133,6 +139,7 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { ); const handleDragStart = (event: DragStartEvent) => { + if (disableDragDrop) return; setActiveId(event.active.id); }; @@ -165,7 +172,7 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { setActiveId(null); setOverColumnId(null); - if (!over || !project?.columns) return; + if (disableDragDrop || !over || !project?.columns) return; const activeTaskId = active.id.toString(); const overId = over.id.toString(); @@ -246,17 +253,22 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { }; const handleArchiveClick = (column: ProjectWithTasks["columns"][number]) => { - if (!column.isFinal || column.tasks.length === 0) return; - setColumnToArchive(column); + if ( + disableCollectionActions || + !column.isFinal || + column.tasks.length === 0 + ) + return; + setColumnToArchive(column.id); setIsArchiveModalOpen(true); }; const handleConfirmArchive = () => { - if (!columnToArchive) return; + if (disableCollectionActions || !archiveColumn?.isFinal) return; const updatedProject = produce(project, (draft) => { const archivedColumn = draft?.columns?.find( - (col) => col.id === columnToArchive.id, + (col) => col.id === columnToArchive, ); if (!archivedColumn) return; @@ -272,7 +284,7 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { setProject(updatedProject); toast.success( - t("tasks:archive.success", { count: columnToArchive.tasks.length }), + t("tasks:archive.success", { count: archiveColumn.tasks.length }), ); setIsArchiveModalOpen(false); @@ -340,6 +352,7 @@ function ListView({ project, disableDragDrop = false }: ListViewProps) { {column.isFinal && column.tasks.length > 0 && ( +

+ )}
) : ( ) - ) : ( + ) : boardError ? null : ( )}
diff --git a/apps/web/src/routes/public-project.$projectId.test.tsx b/apps/web/src/routes/public-project.$projectId.test.tsx new file mode 100644 index 0000000000..433fcf672b --- /dev/null +++ b/apps/web/src/routes/public-project.$projectId.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, expect, it, vi } from "vite-plus/test"; +import type { ProjectWithTasks } from "@/types/project"; +import type Task from "@/types/task"; +import { Route } from "./public-project.$projectId"; +const m = vi.hoisted(() => ({ + project: undefined as ProjectWithTasks | undefined, + modal: {} as { task?: Task | null; open?: boolean }, +})); +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: () => (options: unknown) => ({ + options, + useParams: () => ({ projectId: "p" }), + }), +})); +vi.mock("@/hooks/queries/project/use-get-public-project", () => ({ + default: () => ({ data: m.project, isLoading: false }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); +vi.mock("@/components/public-project/project-description", () => ({ + PublicProjectDescription: () => null, +})); +vi.mock("@/components/page-title", () => ({ default: () => null })); +vi.mock("@/components/public-project/kanban-view", () => ({ + PublicKanbanView: ({ + project, + onTaskClick, + }: { + project: ProjectWithTasks; + onTaskClick: (task: Task) => void; + }) => ( + + ), +})); +vi.mock("@/components/public-project/list-view", () => ({ + PublicListView: () => null, +})); +vi.mock("@/components/public-project/task-detail-modal", () => ({ + PublicTaskDetailModal: (props: typeof m.modal) => { + m.modal = props; + return props.open ? ( + + {props.task?.labels?.map((label) => label.name).join(",")} + + ) : null; + }, +})); +afterEach(cleanup); +it("updates an open task dialog as related pages hydrate and closes when the task disappears", () => { + localStorage.setItem("kaneo-public-view-mode", "kanban"); + const task = { + id: "task", + title: "Card", + labels: [{ id: "one", name: "First", color: "red" }], + externalLinks: [], + } as unknown as Task; + m.project = { + id: "p", + name: "Public", + slug: "PUB", + icon: null, + description: null, + columns: [{ id: "todo", tasks: [task] }], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + const Component = Route.options.component as ComponentType; + const { rerender } = render(); + fireEvent.click(screen.getByText("Open task")); + expect(screen.getByTestId("detail").textContent).toBe("First"); + const hydrated = { + ...task, + labels: [...task.labels!, { id: "two", name: "Second", color: "blue" }], + externalLinks: [{ id: "pr", title: "Pull request" }], + } as Task; + m.project = { + ...m.project, + columns: [{ ...m.project.columns[0], tasks: [hydrated] }], + }; + rerender(); + expect(screen.getByTestId("detail").textContent).toBe("First,Second"); + expect(m.modal.task?.externalLinks).toEqual(hydrated.externalLinks); + m.project = { + ...m.project, + columns: [{ ...m.project.columns[0], tasks: [] }], + }; + rerender(); + expect(m.modal.task).toBeNull(); + expect(m.modal.open).toBe(false); +}); diff --git a/apps/web/src/routes/public-project.$projectId.tsx b/apps/web/src/routes/public-project.$projectId.tsx index 6d6ec794b4..db9c9a1a0c 100644 --- a/apps/web/src/routes/public-project.$projectId.tsx +++ b/apps/web/src/routes/public-project.$projectId.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { Layout, List } from "lucide-react"; -import { createElement, useEffect, useState } from "react"; +import { createElement, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import PageTitle from "@/components/page-title"; import { CopyUrlButton } from "@/components/public-project/copy-url-button"; @@ -38,7 +38,19 @@ function RouteComponent() { return "kanban"; }); - const [selectedTask, setSelectedTask] = useState(null); + const [selectedTaskId, setSelectedTaskId] = useState(null); + const selectedTask = useMemo(() => { + if (!selectedTaskId || !project) return null; + for (const column of project.columns) { + const task = column.tasks.find((task) => task.id === selectedTaskId); + if (task) return task; + } + return ( + project.plannedTasks.find((task) => task.id === selectedTaskId) ?? + project.archivedTasks.find((task) => task.id === selectedTaskId) ?? + null + ); + }, [project, selectedTaskId]); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); useEffect(() => { @@ -46,16 +58,16 @@ function RouteComponent() { }, [viewMode]); const handleTaskClick = (task: Task) => { - setSelectedTask(task); + setSelectedTaskId(task.id); setIsTaskModalOpen(true); }; const handleTaskModalClose = () => { setIsTaskModalOpen(false); - setSelectedTask(null); + setSelectedTaskId(null); }; - if (isLoading) { + if (isLoading && !project) { return ; } @@ -142,7 +154,7 @@ function RouteComponent() { task={selectedTask} projectSlug={project.slug} columns={project.columns} - open={isTaskModalOpen} + open={isTaskModalOpen && !!selectedTask} onOpenChange={handleTaskModalClose} /> diff --git a/tests/api-integration/column-live-updates.test.ts b/tests/api-integration/column-live-updates.test.ts new file mode 100644 index 0000000000..e0e55832a9 --- /dev/null +++ b/tests/api-integration/column-live-updates.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { eq } from "drizzle-orm"; +import { columnTable } from "../../apps/api/src/database/schema"; +import db from "../../apps/api/src/database"; +import createColumn from "../../apps/api/src/column/controllers/create-column"; +import updateColumn from "../../apps/api/src/column/controllers/update-column"; +import deleteColumn from "../../apps/api/src/column/controllers/delete-column"; +import reorderColumns from "../../apps/api/src/column/controllers/reorder-columns"; +import { resetTestDatabase } from "./helpers/database"; +import { + createWorkspaceMember, + createProjectFixture, +} from "./helpers/fixtures"; +const publish = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../../apps/api/src/events", () => ({ publishEvent: publish })); +beforeEach(async () => { + await resetTestDatabase(); + publish.mockClear(); +}); +it("announces committed column changes for focused remote boards", async () => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const column = await createColumn({ projectId: project.id, name: "Review" }); + await updateColumn(column.id, { name: "Reviewed", color: "#123456" }); + await reorderColumns(project.id, [{ id: column.id, position: 0 }]); + await deleteColumn(column.id); + expect(publish.mock.calls).toEqual( + Array.from({ length: 4 }, () => [ + "project.updated", + { projectId: project.id }, + ]), + ); + expect( + await db.query.columnTable.findFirst({ + where: (table, { eq }) => eq(table.id, column.id), + }), + ).toBeUndefined(); + await expect(deleteColumn(column.id)).rejects.toThrow("Column not found"); + expect(publish).toHaveBeenCalledTimes(4); +}); + +it.each(["missing", "foreign"])( + "rolls back all positions when a later %s column is invalid", + async (kind) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const before = await db.query.columnTable.findMany({ + where: eq(columnTable.projectId, project.id), + orderBy: (table, { asc }) => asc(table.id), + }); + let invalidId = "zz-invalid-column"; + if (kind === "foreign") { + const other = await createWorkspaceMember(); + const fixture = await createProjectFixture({ + workspaceId: other.workspace.id, + }); + const foreign = await db.query.columnTable.findFirst({ + where: eq(columnTable.projectId, fixture.project.id), + }); + invalidId = foreign!.id; + } + await expect( + reorderColumns(project.id, [ + { id: before[0].id, position: 42 }, + { id: invalidId, position: 43 }, + ]), + ).rejects.toMatchObject({ status: 400 }); + expect( + await db.query.columnTable.findMany({ + where: eq(columnTable.projectId, project.id), + orderBy: (table, { asc }) => asc(table.id), + }), + ).toEqual(before); + expect(publish).not.toHaveBeenCalled(); + }, +); diff --git a/tests/api-integration/deferred-issue-edits.test.ts b/tests/api-integration/deferred-issue-edits.test.ts new file mode 100644 index 0000000000..1716965b59 --- /dev/null +++ b/tests/api-integration/deferred-issue-edits.test.ts @@ -0,0 +1,2115 @@ +import { eq, sql } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import db, { schema } from "../../apps/api/src/database"; +import { publishEvent } from "../../apps/api/src/events"; +import { + deferIssueEdit, + replayDeferredIssueEdits, +} from "../../apps/api/src/plugins/github/services/deferred-issue-edits"; +import { deferTaskSync } from "../../apps/api/src/plugins/github/services/defer-issue-edit"; +import { syncLatestTaskValue } from "../../apps/api/src/plugins/github/services/sync-latest-task-value"; +import { updateExternalLink } from "../../apps/api/src/plugins/github/services/link-manager"; +import { handleGiteaIssueEdited } from "../../apps/api/src/plugins/gitea/webhooks/issue-edited"; +import { handleIssueEdited } from "../../apps/api/src/plugins/github/webhooks/issue-edited"; +import { handleIssueClosed } from "../../apps/api/src/plugins/github/webhooks/issue-closed"; +import { handleIssueReopened } from "../../apps/api/src/plugins/github/webhooks/issue-reopened"; +import { handleGiteaIssueClosed } from "../../apps/api/src/plugins/gitea/webhooks/issue-closed"; +import { handleGiteaIssueReopened } from "../../apps/api/src/plugins/gitea/webhooks/issue-reopened"; +import { + inboundStamp, + outboundStamp, +} from "../../apps/api/src/plugins/github/utils/sync-echo"; +import { resetTestDatabase } from "./helpers/database"; +import { + createProjectFixture, + createWorkspaceMember, +} from "./helpers/fixtures"; +const m = vi.hoisted(() => ({ + integrations: [] as unknown[], + read: vi.fn(), + write: vi.fn(), +})); +vi.mock("../../apps/api/src/events", () => ({ + publishEvent: vi.fn(async () => undefined), +})); +vi.mock( + "../../apps/api/src/plugins/github/services/task-service", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/github/services/task-service") + >()), + findAllIntegrationsByRepo: async () => m.integrations, + }), +); +vi.mock("../../apps/api/src/plugins/github/utils/github-app", () => ({ + getVerifiedInstallationOctokit: async () => ({ + rest: { + issues: { + get: async () => ({ data: await m.read() }), + update: async (params: Record) => ({ + data: await m.write(params), + }), + }, + }, + }), +})); +vi.mock("../../apps/api/src/plugins/gitea/utils/gitea-api", () => ({ + createGiteaClient: () => ({ + getIssue: () => m.read(), + updateIssue: ( + _owner: string, + _repo: string, + _number: number, + params: Record, + ) => m.write(params), + }), +})); +beforeEach(async () => { + await resetTestDatabase(); + vi.clearAllMocks(); + m.write.mockReset().mockResolvedValue({ updated_at: "2026-09-30T00:00:04Z" }); + m.read.mockReset().mockResolvedValue({ + title: "A", + body: "recovered body", + state: "closed", + updated_at: "2026-09-30T00:00:03Z", + }); +}); +async function seed(type = "github") { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "B", + description: "old body", + status: "to-do", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type, + config: JSON.stringify({ + baseUrl: "https://git.example", + repositoryOwner: "owner", + repositoryName: "repo", + installationId: 10, + repositoryId: 20, + accessToken: "test-secret-never-metadata", + }), + }) + .returning(); + const stamp = inboundStamp( + outboundStamp(undefined, "A", undefined, { + intentId: "dead-writer", + pending: true, + }), + "B", + type, + ); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://git.example/owner/repo/issues/1", + metadata: JSON.stringify({ lastSync: { title: stamp } }), + }) + .returning(); + m.integrations = [integration]; + return { project, task, integration, link }; +} +async function metadata(linkId: string) { + const link = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, linkId), + }); + return JSON.parse(link?.metadata ?? "{}"); +} +async function current(taskId: string) { + return db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, taskId), + }); +} +it("durably acknowledges an orphaned delivery and later recovers every changed field", async () => { + const { task, integration, link } = await seed(); + await handleIssueEdited({ + action: "edited", + issue: { + number: 1, + title: "A", + body: "recovered body", + html_url: link.url, + updated_at: "2026-09-30T00:00:03Z", + }, + changes: { title: { from: "B" }, body: { from: "old body" } }, + repository: { + id: 20, + owner: { login: "owner" }, + name: "repo", + full_name: "owner/repo", + }, + }); + const queued = await metadata(link.id); + expect(queued.deferredIssueEdit.fields).toEqual(["title", "description"]); + expect(JSON.stringify(queued)).not.toContain("test-secret-never-metadata"); + expect(JSON.stringify(queued)).not.toContain("recovered body"); + expect((await current(task.id))?.title).toBe("B"); + await replayDeferredIssueEdits(); + expect(m.read).not.toHaveBeenCalled(); + queued.lastSync.title.outbound[0].timestamp = new Date( + Date.now() - 300_001, + ).toISOString(); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify(queued) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + title: "A", + description: "recovered body", + }); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).toHaveBeenCalledWith("task.updated", { + taskId: task.id, + projectId: integration.projectId, + }); +}, 15_000); +it.each(["github", "gitea"])( + "recovers orphaned %s state through project workflows", + async (type) => { + const { task, integration, link } = await seed(type); + await deferIssueEdit(link, integration, ["state"]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.status).toBe("done"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).toHaveBeenCalledWith( + "task.status_changed", + expect.objectContaining({ + taskId: task.id, + oldStatus: "to-do", + newStatus: "done", + }), + ); + }, +); +it("retains failed provider reads and retries without sender redelivery", async () => { + const { task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockRejectedValueOnce(new Error("provider unavailable")); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([ + "description", + ]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("recovered body"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); +it("does not clear fields queued concurrently with a provider read", async () => { + const { task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["state"]); + m.read.mockImplementationOnce(async () => { + await deferIssueEdit(link, integration, ["description"]); + return { title: "A", body: "stale read", state: "closed" }; + }); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + status: "to-do", + description: "old body", + }); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([ + "state", + "description", + ]); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + status: "done", + description: "recovered body", + }); +}); +it.each(["disabled", "rebound", "moved"])( + "guards deferred edits when their integration is %s during HTTP", + async (change) => { + const { project, task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockImplementationOnce(async () => { + if (change === "moved") { + const other = await createProjectFixture({ + workspaceId: project.workspaceId, + }); + await db + .update(schema.taskTable) + .set({ projectId: other.project.id }) + .where(eq(schema.taskTable.id, task.id)); + } else { + await db + .update(schema.integrationTable) + .set( + change === "disabled" + ? { isActive: false } + : { + config: JSON.stringify({ + ...JSON.parse(integration.config), + repositoryName: "other", + }), + }, + ) + .where(eq(schema.integrationTable.id, integration.id)); + } + return { title: "A", body: "wrong source", state: "closed" }; + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("old body"); + expect(publishEvent).not.toHaveBeenCalled(); + if (change !== "moved") { + await replayDeferredIssueEdits(); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + } + }, +); +it("rechecks provider state after a newer inbound edit during HTTP", async () => { + const { task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockImplementationOnce(async () => { + await db + .update(schema.taskTable) + .set({ description: "newer edit" }) + .where(eq(schema.taskTable.id, task.id)); + await updateExternalLink(link.id, { + metadata: { + lastSync: { + description: inboundStamp(undefined, "newer edit", "github"), + }, + }, + }); + return { title: "A", body: "stale read", state: "closed" }; + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("newer edit"); + expect((await metadata(link.id)).deferredIssueEdit).toBeDefined(); + m.read.mockResolvedValueOnce({ + title: "A", + body: "newer edit", + state: "closed", + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("newer edit"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +it("retains deferred edits across credential rotation on the same repository", async () => { + const { task, integration, link } = await seed("gitea"); + await db + .update(schema.integrationTable) + .set({ + config: JSON.stringify({ + ...JSON.parse(integration.config), + accessToken: "rotated-test-secret", + }), + }) + .where(eq(schema.integrationTable.id, integration.id)); + await deferIssueEdit(link, integration, ["description"]); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([ + "description", + ]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("recovered body"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +it("does not overwrite a local edit committed before its integration subscriber stamps it", async () => { + const { task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockImplementationOnce(async () => { + await db + .update(schema.taskTable) + .set({ description: "new local edit" }) + .where(eq(schema.taskTable.id, task.id)); + return { title: "A", body: "stale provider body", state: "closed" }; + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("new local edit"); + expect((await metadata(link.id)).deferredIssueEdit).toBeDefined(); + expect(publishEvent).not.toHaveBeenCalled(); +}); +it.each( + ["github", "gitea"].flatMap((provider) => + ["title", "description", "state"].map((field) => ({ provider, field })), + ), +)( + "repairs an expired older $provider $field writer without importing its stale value", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + const local = + field === "title" ? "B" : field === "description" ? "old body" : "open"; + const stale = + field === "title" + ? "A" + : field === "description" + ? "recovered body" + : "closed"; + const stamp = outboundStamp( + outboundStamp(undefined, stale, undefined, { + intentId: "orphan", + pending: true, + }), + local, + "2026-09-30T00:00:02Z", + { intentId: "newer-completed", pending: false }, + ); + const orphan = stamp.outbound?.find((entry) => entry.intentId === "orphan"); + if (!orphan) throw new Error("Missing orphan fixture"); + orphan.timestamp = new Date(Date.now() - 300_001).toISOString(); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { [field]: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferIssueEdit(link, integration, [ + field as "title" | "description" | "state", + ]); + expect( + (await metadata(link.id)).lastSync[field].outbound.find( + (entry: { intentId: string }) => entry.intentId === "orphan", + ), + ).toMatchObject({ pending: false, uncertain: true }); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "old body", + status: "to-do", + }); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining( + field === "description" + ? { body: expect.stringContaining("old body") } + : { [field]: local }, + ), + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).not.toHaveBeenCalled(); + }, +); + +it("repairs an orphaned write using a newer local value whose subscriber has not stamped it", async () => { + const { task, integration, link } = await seed(); + const stamp = outboundStamp( + outboundStamp(undefined, "A", undefined, { + intentId: "orphan", + pending: true, + }), + "B", + "2026-09-30T00:00:02Z", + { intentId: "completed" }, + ); + const orphan = stamp.outbound?.find((entry) => entry.intentId === "orphan"); + if (!orphan) throw new Error("Missing orphan fixture"); + orphan.timestamp = new Date(Date.now() - 300_001).toISOString(); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await db + .update(schema.taskTable) + .set({ title: "C" }) + .where(eq(schema.taskTable.id, task.id)); + await deferIssueEdit(link, integration, ["title"]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.title).toBe("C"); + expect(m.write).toHaveBeenCalledWith(expect.objectContaining({ title: "C" })); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +it.each(["github", "gitea"])( + "preserves the final %s value when colliding-version webhooks arrive in reverse", + async (provider) => { + const { task, integration, link } = await seed(provider); + await db + .update(schema.taskTable) + .set({ title: "A" }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: outboundStamp(undefined, "A", "2026-09-30T00:00:03Z"), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + const apply = async (title: string, from: string) => { + const payload = { + action: "edited", + issue: { + number: 1, + title, + body: "body", + updated_at: "2026-09-30T00:00:03Z", + html_url: link.url, + }, + changes: { title: { from } }, + repository: { + id: 20, + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + html_url: "https://git.example/owner/repo", + }, + }; + if (provider === "github") await handleIssueEdited(payload); + else await handleGiteaIssueEdited(payload, integration.id); + }; + await apply("A", "B"); + await apply("B", "A"); + expect((await current(task.id))?.title).toBe("A"); + expect(m.read).toHaveBeenCalledOnce(); + m.read.mockResolvedValueOnce({ + title: "B", + body: "body", + state: "open", + updated_at: "2026-09-30T00:00:03Z", + }); + await apply("B", "A"); + expect((await current(task.id))?.title).toBe("B"); + }, +); + +it.each([false, true])( + "repairs a crashed first write after a later local edit (previousInbound=%s)", + async (previousInbound) => { + const { task, integration, link } = await seed(); + const prior = previousInbound + ? inboundStamp(undefined, "B", "github") + : undefined; + const stamp = outboundStamp(prior, "A", undefined, { + intentId: "first-orphan", + pending: true, + }); + const orphan = stamp.outbound?.find( + (entry) => entry.intentId === "first-orphan", + ); + if (!orphan) throw new Error("Missing orphan fixture"); + orphan.timestamp = new Date(Date.now() - 300_001).toISOString(); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await db + .update(schema.taskTable) + .set({ title: "B" }) + .where(eq(schema.taskTable.id, task.id)); + await deferIssueEdit(link, integration, ["title"]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.title).toBe("B"); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining({ title: "B" }), + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + }, +); + +it("protects a local ABA edit even when its final fields and timestamp are unchanged", async () => { + const { task, integration, link } = await seed(); + const updatedAt = new Date("2026-09-30T00:00:00Z"); + await db + .update(schema.taskTable) + .set({ updatedAt }) + .where(eq(schema.taskTable.id, task.id)); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockImplementationOnce(async () => { + await db.transaction(async (tx) => { + await tx + .update(schema.taskTable) + .set({ description: "temporary edit", updatedAt }) + .where(eq(schema.taskTable.id, task.id)); + await tx + .update(schema.taskTable) + .set({ description: "old body", updatedAt }) + .where(eq(schema.taskTable.id, task.id)); + }); + return { title: "A", body: "stale provider body", state: "closed" }; + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("old body"); + expect((await metadata(link.id)).deferredIssueEdit).toBeDefined(); +}); + +it.each(["github", "gitea"])( + "retires a deleted %s issue's deferred read without removing its link or task", + async (provider) => { + const { task, integration, link } = await seed(provider); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockRejectedValue( + Object.assign(new Error("not found"), { status: 404 }), + ); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: false }); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "old body", + }); + expect( + await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }), + ).toBeDefined(); + expect(publishEvent).not.toHaveBeenCalled(); + expect(m.write).not.toHaveBeenCalled(); + await replayDeferredIssueEdits(); + expect(m.read).toHaveBeenCalledOnce(); + }, +); +it.each([403, 429, 500, 408])( + "retains a deferred issue read after HTTP %s", + async (status) => { + const { integration, link } = await seed(); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockRejectedValue( + Object.assign(new Error("provider unavailable"), { status }), + ); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([ + "description", + ]); + }, +); +it("preserves a newer job queued while a missing-issue read is in flight", async () => { + const { task, integration, link } = await seed(); + await deferIssueEdit(link, integration, ["state"]); + m.read.mockImplementationOnce(async () => { + await deferIssueEdit(link, integration, ["description"]); + throw Object.assign(new Error("not found"), { status: 404 }); + }); + await replayDeferredIssueEdits(); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([ + "state", + "description", + ]); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + status: "done", + description: "recovered body", + }); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); +it("retains missing-issue work when credentials rotate during the read", async () => { + const { task, integration, link } = await seed("gitea"); + await deferIssueEdit(link, integration, ["description"]); + m.read.mockImplementationOnce(async () => { + await db + .update(schema.integrationTable) + .set({ + config: JSON.stringify({ + ...JSON.parse(integration.config), + accessToken: "rotated-test-secret", + }), + }) + .where(eq(schema.integrationTable.id, integration.id)); + throw Object.assign(new Error("not found"), { status: 404 }); + }); + await replayDeferredIssueEdits(); + expect((await metadata(link.id)).deferredIssueEdit).toBeDefined(); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.description).toBe("recovered body"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +type IssueField = "title" | "description" | "state"; +async function deliverField( + provider: string, + field: IssueField, + value: string, + version: string, + integrationId: string, + repositoryName = "repo", +) { + const payload = { + action: + field === "state" + ? value === "closed" + ? "closed" + : "reopened" + : "edited", + issue: { + number: 1, + title: field === "title" ? value : "unchanged", + body: field === "description" ? value : "unchanged", + state: field === "state" ? value : "open", + updated_at: version, + html_url: `https://git.example/owner/${repositoryName}/issues/1`, + }, + changes: + field === "title" + ? { title: { from: "before" } } + : { body: { from: "before" } }, + repository: { + id: 20, + name: repositoryName, + full_name: `owner/${repositoryName}`, + owner: { login: "owner" }, + html_url: `https://git.example/owner/${repositoryName}`, + }, + }; + if (field !== "state") { + if (provider === "github") await handleIssueEdited(payload); + else await handleGiteaIssueEdited(payload, integrationId); + } else if (provider === "github") { + await (value === "closed" ? handleIssueClosed : handleIssueReopened)( + payload, + ); + } else { + await ( + value === "closed" ? handleGiteaIssueClosed : handleGiteaIssueReopened + )(payload, integrationId); + } +} +const providerFields = ["github", "gitea"].flatMap((provider) => + (["title", "description", "state"] as const).map((field) => ({ + provider, + field, + })), +); +it.each( + providerFields.flatMap((entry) => + [false, true].map((collision) => ({ ...entry, collision })), + ), +)( + "preserves the latest genuine $provider $field delivery (sameVersion=$collision)", + async ({ provider, field, collision }) => { + const { task, integration, link } = await seed(provider); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + const latest = field === "state" ? "open" : "remote-new"; + const older = field === "state" ? "closed" : "remote-old"; + const version = "2026-09-30T00:00:03Z"; + m.read.mockResolvedValue({ + title: latest, + body: latest, + state: "open", + updated_at: version, + }); + await deliverField(provider, field, latest, version, integration.id); + await deliverField( + provider, + field, + older, + collision ? version : "2026-09-30T00:00:02Z", + integration.id, + ); + const row = await current(task.id); + expect(field === "state" ? row?.status : row?.[field]).toBe( + field === "state" ? "to-do" : latest, + ); + expect((await metadata(link.id)).lastSync[field].inboundUpdatedAt).toBe( + version, + ); + if (collision) expect(m.read).toHaveBeenCalledOnce(); + else expect(m.read).not.toHaveBeenCalled(); + await deliverField( + provider, + field, + older, + "2026-09-30T00:00:04Z", + integration.id, + ); + const after = await current(task.id); + expect(field === "state" ? after?.status : after?.[field]).toBe( + field === "state" ? "done" : older, + ); + }, +); +it.each( + providerFields.flatMap((entry) => + [1, 2].map((count) => ({ ...entry, count })), + ), +)( + "repairs a late timed-out $provider $field write without reverting the newer local value (uncertain=$count)", + async ({ provider, field, count }) => { + const { task, integration, link } = await seed(provider); + const older = field === "state" ? "closed" : "remote-old"; + const latest = + field === "state" ? "open" : field === "title" ? "B" : "old body"; + const pending = outboundStamp(undefined, older, undefined, { + intentId: "timed-out", + pending: true, + }); + let uncertain = outboundStamp(pending, older, undefined, { + intentId: "timed-out", + pending: false, + uncertain: true, + }); + if (count === 2) + uncertain = outboundStamp(uncertain, older, undefined, { + intentId: "second-timed-out", + pending: false, + uncertain: true, + }); + const stamp = outboundStamp(uncertain, latest, "2026-09-30T00:00:02Z", { + intentId: "newer-completed", + }); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { [field]: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: older, + body: older, + state: "closed", + updated_at: "2026-09-30T00:00:03Z", + }); + await deliverField( + provider, + field, + older, + "2026-09-30T00:00:03Z", + integration.id, + ); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual([field]); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "old body", + status: "to-do", + }); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "old body", + status: "to-do", + }); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining( + field === "description" + ? { body: expect.stringContaining(latest) } + : { [field]: latest }, + ), + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).not.toHaveBeenCalled(); + await deliverField( + provider, + field, + older, + "2026-09-30T00:00:05Z", + integration.id, + ); + const later = await current(task.id); + expect(field === "state" ? later?.status : later?.[field]).toBe( + field === "state" ? "done" : older, + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + }, +); + +it("completes uncertain replay when the task already equals the provider", async () => { + const { task, integration, link } = await seed(); + const uncertain = outboundStamp( + outboundStamp(undefined, "A", undefined, { + intentId: "uncertain", + pending: true, + }), + "A", + undefined, + { intentId: "uncertain", pending: false, uncertain: true }, + ); + const stamp = outboundStamp(uncertain, "B", "2026-09-30T00:00:02Z"); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await db + .update(schema.taskTable) + .set({ title: "A" }) + .where(eq(schema.taskTable.id, task.id)); + await deferIssueEdit(link, integration, ["title"]); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: false }); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect((await current(task.id))?.title).toBe("A"); + expect(m.write).not.toHaveBeenCalled(); +}); +it("repairs an uncertain delivery over an unstamped local edit after a genuine provider edit", async () => { + const { task, integration, link } = await seed(); + const pending = outboundStamp(undefined, "A", undefined, { + intentId: "uncertain", + pending: true, + }); + const stamp = inboundStamp( + outboundStamp(pending, "A", undefined, { + intentId: "uncertain", + pending: false, + uncertain: true, + }), + "B", + "github", + "2026-09-30T00:00:02Z", + ); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await db + .update(schema.taskTable) + .set({ title: "C" }) + .where(eq(schema.taskTable.id, task.id)); + await deliverField( + "github", + "title", + "A", + "2026-09-30T00:00:03Z", + integration.id, + ); + expect((await current(task.id))?.title).toBe("C"); + expect((await metadata(link.id)).deferredIssueEdit.fields).toEqual(["title"]); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenCalledWith(expect.objectContaining({ title: "C" })); +}); + +it("retains the uncertain intent and job when corrective provider HTTP fails", async () => { + const { task, integration, link } = await seed(); + const pending = outboundStamp(undefined, "A", undefined, { + intentId: "failed-repair", + pending: true, + }); + const stamp = outboundStamp( + outboundStamp(pending, "A", undefined, { + intentId: "failed-repair", + pending: false, + uncertain: true, + }), + "B", + "2026-09-30T00:00:02Z", + ); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferIssueEdit(link, integration, ["title"]); + m.write.mockRejectedValueOnce(new Error("provider unavailable")); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + const queued = await metadata(link.id); + expect(queued.deferredIssueEdit).toBeDefined(); + const retained = queued.lastSync.title.outbound.find( + (entry: { intentId: string }) => entry.intentId === "failed-repair", + ); + expect(retained.uncertain).toBe(true); + expect(retained.cancelled).not.toBe(true); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.title).toBe("B"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +it.each(providerFields)( + "rechecks a stale $provider $field confirmation after a newer colliding webhook", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + const version = "2026-09-30T00:00:03Z"; + const initial = field === "state" ? "closed" : "A"; + const stale = field === "state" ? "open" : "B"; + const latest = field === "state" ? "closed" : "C"; + await db + .update(schema.taskTable) + .set({ + [field === "state" ? "status" : field]: + field === "state" ? "done" : initial, + }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + [field]: inboundStamp(undefined, initial, provider, version), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: latest, + body: latest, + state: "closed", + updated_at: version, + }); + m.read.mockImplementationOnce(async () => { + await deliverField(provider, field, latest, version, integration.id); + return { title: stale, body: stale, state: "open", updated_at: version }; + }); + await deliverField(provider, field, stale, version, integration.id); + const row = await current(task.id); + expect(field === "state" ? row?.status : row?.[field]).toBe( + field === "state" ? "done" : latest, + ); + expect((await metadata(link.id)).lastSync[field].inboundValue).toBe(latest); + expect(m.read.mock.calls.length).toBeGreaterThanOrEqual(2); + }, +); +it.each(["github", "gitea"])( + "validates each changed %s field when a shared confirmation GET becomes stale", + async (provider) => { + const { task, integration, link } = await seed(provider); + const version = "2026-09-30T00:00:03Z"; + await db + .update(schema.taskTable) + .set({ title: "A", description: "A" }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: inboundStamp(undefined, "A", provider, version), + description: inboundStamp(undefined, "A", provider, version), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: "B", + body: "C", + state: "open", + updated_at: version, + }); + m.read.mockImplementationOnce(async () => { + await deliverField(provider, "description", "C", version, integration.id); + return { title: "B", body: "B", state: "open", updated_at: version }; + }); + const payload = { + action: "edited", + issue: { + number: 1, + title: "B", + body: "B", + updated_at: version, + html_url: link.url, + }, + changes: { title: { from: "A" }, body: { from: "A" } }, + repository: { + id: 20, + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + html_url: "https://git.example/owner/repo", + }, + }; + if (provider === "github") await handleIssueEdited(payload); + else await handleGiteaIssueEdited(payload, integration.id); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "C", + }); + }, +); + +it.each(["github", "gitea"])( + "preserves a newer local edit committed during %s provider confirmation", + async (provider) => { + const { task, integration, link } = await seed(provider); + const version = "2026-09-30T00:00:03Z"; + await db + .update(schema.taskTable) + .set({ title: "A" }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { title: inboundStamp(undefined, "A", provider, version) }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: "B", + body: "body", + state: "open", + updated_at: version, + }); + m.read.mockImplementationOnce(async () => { + await db + .update(schema.taskTable) + .set({ title: "C" }) + .where(eq(schema.taskTable.id, task.id)); + return { title: "B", body: "body", state: "open", updated_at: version }; + }); + await deliverField(provider, "title", "B", version, integration.id); + expect((await current(task.id))?.title).toBe("C"); + expect(publishEvent).not.toHaveBeenCalled(); + }, +); + +it.each( + (["github", "gitea"] as const).flatMap((provider) => + (["title", "description", "state"] as const).map((field) => ({ + provider, + field, + })), + ), +)( + "bounds sustained $provider/$field correction and durably syncs the latest task", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + const initial = field === "state" ? "open" : "A"; + const column = field === "state" ? "status" : field; + const write = vi.fn(async () => { + if (write.mock.calls.length > 6) + throw new Error("unbounded repair regression"); + await db + .update(schema.taskTable) + .set({ + [column]: + field === "state" + ? write.mock.calls.length % 2 + ? "done" + : "to-do" + : `edit-${write.mock.calls.length}`, + }) + .where(eq(schema.taskTable.id, task.id)); + return "2026-09-30T00:00:04Z"; + }); + await syncLatestTaskValue( + task.id, + integration.projectId, + link, + field, + initial, + write, + ).catch(() => undefined); + expect(write).toHaveBeenCalledTimes(3); + expect((await metadata(link.id)).deferredIssueEdit).toMatchObject({ + fields: [], + repairFields: [field], + }); + expect(JSON.stringify(await metadata(link.id))).not.toContain( + "test-secret-never-metadata", + ); + const latest = field === "state" ? "closed" : "latest local"; + await db + .update(schema.taskTable) + .set({ [column]: field === "state" ? "done" : latest }) + .where(eq(schema.taskTable.id, task.id)); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenCalledTimes(1); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining( + field === "description" + ? { body: expect.stringContaining(latest) } + : { [field]: latest }, + ), + ); + expect((await current(task.id))?.[column]).toBe( + field === "state" ? "done" : latest, + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).not.toHaveBeenCalled(); + }, +); + +it("coalesces local correction fields with inbound deliveries without importing stale values", async () => { + const { task, integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await Promise.all([ + deferTaskSync(link, integration, ["title"]), + deferTaskSync(link, integration, ["description"]), + deferIssueEdit(link, integration, ["state"]), + ]); + const job = (await metadata(link.id)).deferredIssueEdit; + expect(job.fields).toEqual(["state"]); + expect(job.repairFields.sort()).toEqual(["description", "title"]); + await replayDeferredIssueEdits(); + expect(await current(task.id)).toMatchObject({ + title: "B", + description: "old body", + status: "done", + }); + expect(m.write).toHaveBeenCalledTimes(2); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); +it("retains failed queued corrections and retries the current task value", async () => { + const { task, integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + m.write.mockRejectedValueOnce(new Error("provider unavailable")); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + expect((await metadata(link.id)).deferredIssueEdit.repairFields).toEqual([ + "title", + ]); + await db + .update(schema.taskTable) + .set({ title: "latest" }) + .where(eq(schema.taskTable.id, task.id)); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenLastCalledWith( + expect.objectContaining({ title: "latest" }), + ); + expect((await current(task.id))?.title).toBe("latest"); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(publishEvent).not.toHaveBeenCalled(); +}); +it("preserves a newer correction queued while its worker reads the provider", async () => { + const { integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + const first = (await metadata(link.id)).deferredIssueEdit.id; + m.read.mockImplementationOnce(async () => { + await deferTaskSync(link, integration, ["description"]); + return { title: "A", body: "remote", state: "open" }; + }); + await replayDeferredIssueEdits(); + expect(m.write).not.toHaveBeenCalled(); + const next = (await metadata(link.id)).deferredIssueEdit; + expect(next.id).not.toBe(first); + expect(next.repairFields.sort()).toEqual(["description", "title"]); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenCalledTimes(2); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); +}); + +it.each( + ["github", "gitea"].flatMap((provider) => + [1, 2].map((count) => ({ provider, count })), + ), +)( + "retires uncertain intents after queued $provider correction so future genuine edits apply (uncertain=$count)", + async ({ provider, count }) => { + const { task, integration, link } = await seed(provider); + let stamp = outboundStamp(undefined, "A", undefined, { + intentId: "old-unknown", + pending: false, + uncertain: true, + }); + if (count === 2) + stamp = outboundStamp(stamp, "A", undefined, { + intentId: "second-unknown", + pending: false, + uncertain: true, + }); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: stamp, + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining({ title: "B" }), + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + m.read.mockResolvedValue({ + title: "A", + body: "remote", + state: "open", + updated_at: "2026-09-30T00:00:05Z", + }); + await deliverField( + provider, + "title", + "A", + "2026-09-30T00:00:05Z", + integration.id, + ); + expect((await current(task.id))?.title).toBe("A"); + }, +); + +it.each( + ["github", "gitea"].flatMap((provider) => + ["webhook", "worker"].map((mode) => ({ provider, mode })), + ), +)( + "protects a local edit when only the older of two uncertain $provider writes precedes it ($mode)", + async ({ provider, mode }) => { + const { task, integration, link } = await seed(provider); + let stamp = outboundStamp(undefined, "A", undefined, { + intentId: "before-local", + pending: true, + }); + stamp = inboundStamp(stamp, "B", provider); + stamp = outboundStamp(stamp, "A", undefined, { + intentId: "after-local", + pending: true, + }); + stamp = outboundStamp(stamp, "A", undefined, { + intentId: "before-local", + pending: false, + uncertain: true, + }); + stamp = outboundStamp(stamp, "A", undefined, { + intentId: "after-local", + pending: false, + uncertain: true, + }); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + if (mode === "webhook") + await deliverField( + provider, + "title", + "A", + "2026-09-30T00:00:03Z", + integration.id, + ); + else await deferIssueEdit(link, integration, ["title"]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.title).toBe("B"); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining({ title: "B" }), + ); + }, +); + +it.each(["uncertain", "pending"])( + "settles a matching %s write during repair while preserving active requests", + async (kind) => { + const { integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: outboundStamp(undefined, "A", undefined, { + intentId: "old", + uncertain: true, + }), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + m.write.mockImplementationOnce(async () => { + await updateExternalLink(link.id, { + outbound: { + field: "title", + value: "A", + intentId: "new", + pending: kind === "pending", + uncertain: kind === "uncertain", + }, + }); + return { updated_at: "2026-09-30T00:00:04Z" }; + }); + await replayDeferredIssueEdits(); + const entries = (await metadata(link.id)).lastSync.title.outbound; + expect( + entries.find((entry: { intentId: string }) => entry.intentId === "old"), + ).toMatchObject({ cancelled: true, uncertain: false, pending: false }); + expect( + entries.find((entry: { intentId: string }) => entry.intentId === "new"), + ).toMatchObject({ + pending: kind === "pending", + uncertain: false, + }); + expect( + entries.find((entry: { intentId: string }) => entry.intentId === "new") + .cancelled, + ).toBe(kind === "uncertain" ? true : undefined); + }, +); +it("retains every captured uncertain intent when the corrective provider write fails", async () => { + const { integration, link } = await seed(); + let stamp = outboundStamp(undefined, "A", undefined, { + intentId: "first", + uncertain: true, + }); + stamp = outboundStamp(stamp, "A", undefined, { + intentId: "second", + uncertain: true, + }); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { title: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + m.write.mockRejectedValueOnce(new Error("provider unavailable")); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + const saved = await metadata(link.id); + expect(saved.deferredIssueEdit).toBeDefined(); + for (const id of ["first", "second"]) { + const intent = saved.lastSync.title.outbound.find( + (entry: { intentId: string }) => entry.intentId === id, + ); + expect(intent.uncertain).toBe(true); + expect(intent.cancelled).not.toBe(true); + } +}); + +it.each( + providerFields.flatMap((entry) => + ["rebound", "disabled", "credentials"].map((change) => ({ + ...entry, + change, + })), + ), +)( + "guards the $provider $field confirmation after its integration is $change", + async ({ provider, field, change }) => { + const { task, integration, link } = await seed(provider); + const version = "2026-09-30T00:00:03Z"; + const before = field === "state" ? "closed" : "A"; + const after = field === "state" ? "open" : "B"; + const column = field === "state" ? "status" : field; + await db + .update(schema.taskTable) + .set({ [column]: field === "state" ? "done" : before }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + [field]: inboundStamp(undefined, before, provider, version), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockImplementationOnce(async () => { + const config = JSON.parse(integration.config); + await db + .update(schema.integrationTable) + .set( + change === "disabled" + ? { isActive: false } + : { + config: JSON.stringify({ + ...config, + ...(change === "rebound" + ? { repositoryName: "other" } + : { accessToken: "new-fake-token" }), + }), + }, + ) + .where(eq(schema.integrationTable.id, integration.id)); + return { + title: field === "title" ? after : "A", + body: field === "description" ? after : "A", + state: field === "state" ? after : "closed", + updated_at: version, + }; + }); + await deliverField(provider, field, after, version, integration.id); + const value = change === "credentials" ? after : before; + expect((await current(task.id))?.[column]).toBe( + field === "state" ? (value === "closed" ? "done" : "to-do") : value, + ); + expect(m.read).toHaveBeenCalledOnce(); + if (change !== "credentials") expect(publishEvent).not.toHaveBeenCalled(); + }, +); + +it.each( + providerFields.flatMap((entry) => + ["inbound", "queued"].map((mode) => ({ ...entry, mode })), + ), +)( + "retires successful $provider/$field repairs before a later field fails ($mode)", + async ({ provider, field, mode }) => { + const { task, integration, link } = await seed(provider); + const next = field === "title" ? "description" : "title"; + const before = field === "state" ? "closed" : "A"; + const stamps: Record = {}; + for (const f of [field, next]) { + const value = f === "state" ? "closed" : "A"; + stamps[f] = outboundStamp( + outboundStamp(undefined, value, undefined, { + intentId: `${f}-old-1`, + uncertain: true, + }), + value, + undefined, + { intentId: `${f}-old-2`, uncertain: true }, + ); + } + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: stamps }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + if (mode === "queued") + await deferTaskSync(link, integration, [field, next]); + else await deferIssueEdit(link, integration, [field, next]); + const issue: { + title: string; + body: string; + state: string; + updated_at: string; + } = { + title: "A", + body: "A", + state: "closed", + updated_at: "2026-09-30T00:00:03Z", + }; + m.read.mockImplementation(async () => ({ ...issue })); + m.write.mockImplementation(async (payload: Record) => { + if (m.write.mock.calls.length === 2) + throw new Error("second field failed"); + if (payload.title !== undefined) issue.title = payload.title; + if (payload.body !== undefined) issue.body = payload.body; + if (payload.state !== undefined) issue.state = payload.state; + issue.updated_at = "2026-09-30T00:00:05Z"; + return { updated_at: issue.updated_at }; + }); + expect(await replayDeferredIssueEdits()).toEqual({ degraded: true }); + const saved = await metadata(link.id); + expect(saved.deferredIssueEdit).toBeDefined(); + for (const id of [`${field}-old-1`, `${field}-old-2`]) + expect( + saved.lastSync[field].outbound.find( + (entry: { intentId: string }) => entry.intentId === id, + ), + ).toMatchObject({ cancelled: true, uncertain: false }); + for (const id of [`${next}-old-1`, `${next}-old-2`]) + expect( + saved.lastSync[next].outbound.find( + (entry: { intentId: string }) => entry.intentId === id, + ).cancelled, + ).not.toBe(true); + await replayDeferredIssueEdits(); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + if (field === "description") issue.body = before; + else issue[field] = before; + issue.updated_at = "2026-09-30T00:00:06Z"; + await deliverField( + provider, + field, + before, + issue.updated_at, + integration.id, + ); + expect( + field === "state" + ? (await current(task.id))?.status + : (await current(task.id))?.[field], + ).toBe(field === "state" ? "done" : before); + }, +); + +it.each(providerFields)( + "settles a $provider/$field write that becomes uncertain during correction before later genuine edits", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + const older = field === "state" ? "closed" : "A"; + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + [field]: outboundStamp(undefined, older, undefined, { + intentId: "old", + uncertain: true, + }), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, [field]); + m.write.mockImplementationOnce(async () => { + await updateExternalLink(link.id, { + outbound: { + field, + value: older, + intentId: "during-repair", + pending: false, + uncertain: true, + }, + }); + return { updated_at: "2026-09-30T00:00:04Z" }; + }); + await replayDeferredIssueEdits(); + const column = field === "state" ? "status" : field; + const later = field === "state" ? "open" : "C"; + await db + .update(schema.taskTable) + .set({ [column]: field === "state" ? "in-progress" : later }) + .where(eq(schema.taskTable.id, task.id)); + await syncLatestTaskValue( + task.id, + integration.projectId, + link, + field, + later, + async () => "2026-09-30T00:00:05Z", + ); + m.read.mockResolvedValue({ + title: older, + body: older, + state: "closed", + updated_at: "2026-09-30T00:00:06Z", + }); + await deliverField( + provider, + field, + older, + "2026-09-30T00:00:06Z", + integration.id, + ); + expect((await current(task.id))?.[column]).toBe( + field === "state" ? "done" : older, + ); + }, +); +it("queues an intent inserted after the repair receipt for a fresh correction", async () => { + const { task, integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: outboundStamp(undefined, "A", undefined, { + intentId: "old", + uncertain: true, + }), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, ["title"]); + const find = db.query.externalLinkTable.findMany.bind( + db.query.externalLinkTable, + ); + let inserted = false; + const spy = vi + .spyOn(db.query.externalLinkTable, "findMany") + .mockImplementation(async (options) => { + const rows = await find(options); + if ( + !inserted && + m.write.mock.calls.length && + rows.some( + (row) => + JSON.parse(row.metadata ?? "{}").lastSync?.title?.value === "B", + ) + ) { + inserted = true; + await updateExternalLink(link.id, { + outbound: { + field: "title", + value: "A", + intentId: "after-receipt", + pending: false, + uncertain: true, + }, + }); + return find(options); + } + return rows; + }); + try { + await replayDeferredIssueEdits(); + } finally { + spy.mockRestore(); + } + expect(inserted).toBe(true); + expect((await metadata(link.id)).deferredIssueEdit.repairFields).toEqual([ + "title", + ]); + await db + .update(schema.taskTable) + .set({ title: "C" }) + .where(eq(schema.taskTable.id, task.id)); + await syncLatestTaskValue( + task.id, + integration.projectId, + link, + "title", + "C", + async () => "2026-09-30T00:00:05Z", + ); + m.read.mockResolvedValue({ + title: "A", + body: "body", + state: "open", + updated_at: "2026-09-30T00:00:06Z", + }); + await deliverField( + "github", + "title", + "A", + "2026-09-30T00:00:06Z", + integration.id, + ); + expect((await current(task.id))?.title).toBe("A"); +}); +it("durably queues an uncertain standalone writer and later settles it", async () => { + const { task, integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await expect( + syncLatestTaskValue( + task.id, + integration.projectId, + link, + "title", + "A", + async () => { + throw new Error("provider unavailable"); + }, + ), + ).rejects.toThrow("provider unavailable"); + expect((await metadata(link.id)).deferredIssueEdit.repairFields).toEqual([ + "title", + ]); + await replayDeferredIssueEdits(); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + m.read.mockResolvedValue({ + title: "A", + body: "body", + state: "open", + updated_at: "2026-09-30T00:00:06Z", + }); + await deliverField( + "github", + "title", + "A", + "2026-09-30T00:00:06Z", + integration.id, + ); + expect((await current(task.id))?.title).toBe("A"); +}); + +it("skips provider reads and writes while another replica holds the worker lease", async () => { + const { link, integration } = await seed(); + await deferTaskSync(link, integration, ["title"]); + await db.execute(sql`INSERT INTO job_lease (name, owner, expires_at) + VALUES ('deferred-issue-edits', 'other-replica', now() + interval '10 minutes')`); + expect(await replayDeferredIssueEdits()).toEqual({}); + expect(m.read).not.toHaveBeenCalled(); + expect(m.write).not.toHaveBeenCalled(); + expect((await metadata(link.id)).deferredIssueEdit).toBeDefined(); +}); + +it("recovers a crashed replica lease and releases it after processing", async () => { + const { link, integration } = await seed(); + await updateExternalLink(link.id, { + outbound: { + field: "title", + value: "A", + intentId: "dead-writer", + cancelled: true, + }, + }); + await updateExternalLink(link.id, { + outbound: { field: "title", value: "B" }, + }); + await deferTaskSync(link, integration, ["title"]); + await db.execute(sql`INSERT INTO job_lease (name, owner, expires_at) + VALUES ('deferred-issue-edits', 'crashed-replica', now() - interval '1 minute')`); + await replayDeferredIssueEdits(); + expect(m.read).toHaveBeenCalledTimes(1); + expect(m.write).toHaveBeenCalledTimes(1); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect( + ( + await db.execute( + sql`SELECT name FROM job_lease WHERE name = 'deferred-issue-edits'`, + ) + ).rows, + ).toEqual([]); +}); + +it("uses the queued-link partial index without inspecting historical issue metadata", async () => { + const { link, integration } = await seed(); + await deferTaskSync(link, integration, ["title"]); + await db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL enable_seqscan = off`); + const result = await tx.execute<{ "QUERY PLAN": string }>(sql` + EXPLAIN SELECT id FROM external_link + WHERE resource_type = 'issue' AND metadata LIKE '%"deferredIssueEdit":%' + ORDER BY id LIMIT 20`); + expect(result.rows.map((row) => row["QUERY PLAN"]).join("\n")).toContain( + "external_link_deferred_issue_idx", + ); + }); +}); + +it.each(providerFields)( + "imports a newer deferred $provider $field webhook over an older queued local repair", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + const version = "2026-09-30T00:00:03Z"; + const initial = field === "state" ? "open" : "B"; + const latest = field === "state" ? "closed" : "C"; + await db + .update(schema.taskTable) + .set({ + [field === "state" ? "status" : field]: + field === "state" ? "to-do" : initial, + }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + [field]: inboundStamp(undefined, initial, provider, version), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferTaskSync(link, integration, [field]); + m.read.mockRejectedValueOnce( + new Error("confirmation temporarily unavailable"), + ); + await deliverField(provider, field, latest, version, integration.id); + expect((await metadata(link.id)).deferredIssueEdit.fields).toContain(field); + m.read.mockResolvedValue({ + title: "C", + body: "C", + state: "closed", + updated_at: version, + }); + await replayDeferredIssueEdits(); + const row = await current(task.id); + expect(field === "state" ? row?.status : row?.[field]).toBe( + field === "state" ? "done" : latest, + ); + expect(m.write).not.toHaveBeenCalled(); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + }, +); + +it.each(providerFields)( + "keeps a newer local $provider $field repair over an older deferred inbound job", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferIssueEdit(link, integration, [field]); + const latest = field === "state" ? "closed" : "C"; + await db + .update(schema.taskTable) + .set({ + [field === "state" ? "status" : field]: + field === "state" ? "done" : latest, + }) + .where(eq(schema.taskTable.id, task.id)); + await deferTaskSync(link, integration, [field]); + m.read.mockResolvedValue({ + title: "A", + body: "A", + state: "open", + updated_at: "2026-09-30T00:00:03Z", + }); + await replayDeferredIssueEdits(); + expect(m.write).toHaveBeenCalledWith( + expect.objectContaining({ + [field === "description" ? "body" : field]: + field === "description" ? expect.stringContaining("C") : latest, + }), + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + }, +); + +it("reconciles an existing overlapping inbound/repair job from the current provider value", async () => { + const { task, integration, link } = await seed(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + await deferIssueEdit(link, integration, ["title"]); + const queued = await metadata(link.id); + queued.deferredIssueEdit.repairFields = ["title"]; + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify(queued) }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: "C", + body: "body", + state: "open", + updated_at: "2026-09-30T00:00:03Z", + }); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.title).toBe("C"); + expect(m.write).not.toHaveBeenCalled(); +}); + +it.each( + providerFields.flatMap((entry) => + [false, true].map((failed) => ({ ...entry, failed })), + ), +)( + "retires an old $provider $field intent after repository reconfiguration (failed=$failed)", + async ({ provider, field, failed }) => { + const { task, integration, link } = await seed(provider); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + const older = field === "state" ? "closed" : "A"; + const fresh = field === "state" ? "open" : "new-repo-value"; + const write = vi.fn(async () => { + const config = JSON.stringify({ + ...JSON.parse(integration.config), + repositoryName: "new-repo", + }); + const [rebound] = await db + .update(schema.integrationTable) + .set({ config }) + .where(eq(schema.integrationTable.id, integration.id)) + .returning(); + m.integrations = [rebound]; + await updateExternalLink(link.id, { + outbound: { + field, + value: fresh, + intentId: "new-repo-intent", + pending: true, + }, + }); + if (failed) throw new Error("old repository request failed"); + return "2026-09-30T00:00:02Z"; + }); + const result = syncLatestTaskValue( + task.id, + integration.projectId, + link, + field, + older, + write, + undefined, + integration, + ); + if (failed) + await expect(result).rejects.toThrow("old repository request failed"); + else await result; + const stamp = (await metadata(link.id)).lastSync[field]; + const oldIntent = stamp.outbound.find( + (entry: { intentId: string }) => entry.intentId !== "new-repo-intent", + ); + expect(oldIntent).toMatchObject({ + pending: false, + uncertain: false, + cancelled: true, + }); + expect( + stamp.outbound.find( + (entry: { intentId: string }) => entry.intentId === "new-repo-intent", + ), + ).toMatchObject({ pending: true }); + await deliverField( + provider, + field, + older, + "2026-09-30T00:00:03Z", + integration.id, + "new-repo", + ); + const row = await current(task.id); + expect(field === "state" ? row?.status : row?.[field]).toBe( + field === "state" ? "done" : older, + ); + expect((await metadata(link.id)).deferredIssueEdit).toBeUndefined(); + expect(write).toHaveBeenCalledTimes(1); + expect(m.write).not.toHaveBeenCalled(); + }, +); + +it.each( + (["github", "gitea"] as const).flatMap((provider) => + (["title", "description"] as const).map((field) => ({ provider, field })), + ), +)( + "keeps the $provider $field webhook version when only the other field is confirmed", + async ({ provider, field }) => { + const { task, integration, link } = await seed(provider); + const confirmedField = field === "title" ? "description" : "title"; + const before = { title: "title-0", description: "body-0" }; + const first = { title: "title-1", description: "body-1" }; + const latest = { title: "title-2", description: "body-2" }; + const t0 = "2026-09-30T00:00:00Z"; + const t1 = "2026-09-30T00:00:01Z"; + const t2 = "2026-09-30T00:00:02Z"; + const t3 = "2026-09-30T00:00:03Z"; + await db + .update(schema.taskTable) + .set(before) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + [confirmedField]: inboundStamp( + undefined, + before[confirmedField], + provider, + t1, + ), + [field]: inboundStamp(undefined, before[field], provider, t0), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + // Confirmation reads the whole issue, including a newer edit to the other + // field. Its timestamp must not be assigned to that field's older payload. + m.read.mockResolvedValue({ + title: field === "title" ? latest.title : first.title, + body: field === "description" ? latest.description : first.description, + state: "open", + updated_at: t3, + }); + const payload = { + action: "edited", + issue: { + number: 1, + title: first.title, + body: first.description, + updated_at: t1, + html_url: link.url, + }, + changes: { + title: { from: before.title }, + body: { from: before.description }, + }, + repository: { + id: 20, + name: "repo", + full_name: "owner/repo", + owner: { login: "owner" }, + html_url: "https://git.example/owner/repo", + }, + }; + if (provider === "github") await handleIssueEdited(payload); + else await handleGiteaIssueEdited(payload, integration.id); + expect(m.read).toHaveBeenCalled(); + expect((await current(task.id))?.[field]).toBe(first[field]); + expect((await metadata(link.id)).lastSync[field].inboundUpdatedAt).toBe(t1); + await deliverField(provider, field, latest[field], t2, integration.id); + expect((await current(task.id))?.[field]).toBe(latest[field]); + }, +); + +it.each(["github", "gitea"])( + "keeps %s issues closed when orphaned replay finds a custom final workflow task", + async (provider) => { + const { task, integration, link } = await seed(provider); + const [finalColumn] = await db + .insert(schema.columnTable) + .values({ + projectId: task.projectId, + name: "Released", + slug: "released", + position: 10, + isFinal: true, + }) + .returning(); + await db.insert(schema.workflowRuleTable).values({ + projectId: task.projectId, + integrationType: provider, + eventType: "issue_closed", + columnId: finalColumn.id, + }); + await db + .update(schema.taskTable) + .set({ status: "released", columnId: finalColumn.id }) + .where(eq(schema.taskTable.id, task.id)); + // A genuine remote close has already been applied through the workflow. + // An older writer was orphaned; its stale delivery still needs replay. + const stamp = inboundStamp( + outboundStamp(undefined, "closed", undefined, { + intentId: "old-close", + pending: true, + }), + "closed", + provider, + "2026-09-30T00:00:03Z", + ); + const orphan = stamp.outbound?.find( + (entry) => entry.intentId === "old-close", + ); + if (!orphan) throw new Error("Missing orphaned close intent"); + orphan.timestamp = new Date(Date.now() - 300_001).toISOString(); + orphan.startedAt = orphan.timestamp; + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ lastSync: { state: stamp } }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.read.mockResolvedValue({ + title: "B", + body: "old body", + state: "closed", + updated_at: "2026-09-30T00:00:03Z", + }); + await deferIssueEdit(link, integration, ["state"]); + await replayDeferredIssueEdits(); + expect((await current(task.id))?.status).toBe("released"); + expect(m.write).not.toHaveBeenCalledWith( + expect.objectContaining({ state: "open" }), + ); + }, +); + +it.each( + (["github", "gitea"] as const).flatMap((provider) => + [true, false].map((isFinal) => ({ provider, isFinal })), + ), +)( + "repairs a late $provider close using the custom column's final flag ($isFinal)", + async ({ provider, isFinal }) => { + const { task, integration, link } = await seed(provider); + const [column] = await db + .insert(schema.columnTable) + .values({ + projectId: task.projectId, + name: "Custom", + slug: "custom", + position: 10, + isFinal, + }) + .returning(); + await db + .update(schema.externalLinkTable) + .set({ metadata: "{}" }) + .where(eq(schema.externalLinkTable.id, link.id)); + const write = vi.fn(async (_state: string) => { + if (write.mock.calls.length === 1) { + await db + .update(schema.taskTable) + .set({ status: column.slug, columnId: column.id }) + .where(eq(schema.taskTable.id, task.id)); + } + return "2026-09-30T00:00:04Z"; + }); + await syncLatestTaskValue( + task.id, + task.projectId, + link, + "state", + "closed", + write, + undefined, + integration, + ); + expect(write.mock.calls.map(([state]) => state)).toEqual( + isFinal ? ["closed"] : ["closed", "open"], + ); + expect((await metadata(link.id)).state).toBe(isFinal ? "closed" : "open"); + }, +); diff --git a/tests/api-integration/github-description-sync.test.ts b/tests/api-integration/github-description-sync.test.ts index f68333206a..3491337c1b 100644 --- a/tests/api-integration/github-description-sync.test.ts +++ b/tests/api-integration/github-description-sync.test.ts @@ -15,6 +15,7 @@ const m = vi.hoisted(() => ({ })); vi.mock("../../apps/api/src/plugins/github/services/link-manager", () => ({ findExternalLink: m.find, + lockExternalLink: m.find, updateExternalLink: m.update, })); diff --git a/tests/api-integration/inbound-state-echo.test.ts b/tests/api-integration/inbound-state-echo.test.ts new file mode 100644 index 0000000000..b021417a70 --- /dev/null +++ b/tests/api-integration/inbound-state-echo.test.ts @@ -0,0 +1,137 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import db, { schema } from "../../apps/api/src/database"; +import { handleIssueClosed } from "../../apps/api/src/plugins/github/webhooks/issue-closed"; +import { handleIssueReopened } from "../../apps/api/src/plugins/github/webhooks/issue-reopened"; +import { handleGiteaIssueClosed } from "../../apps/api/src/plugins/gitea/webhooks/issue-closed"; +import { handleGiteaIssueReopened } from "../../apps/api/src/plugins/gitea/webhooks/issue-reopened"; +import { outboundStamp } from "../../apps/api/src/plugins/github/utils/sync-echo"; +import { resetTestDatabase } from "./helpers/database"; +import { + createWorkspaceMember, + createProjectFixture, +} from "./helpers/fixtures"; +const m = vi.hoisted(() => ({ state: "open", integrations: [] as unknown[] })); +vi.mock("../../apps/api/src/events", () => ({ + publishEvent: vi.fn(async () => undefined), +})); +vi.mock( + "../../apps/api/src/plugins/github/services/task-service", + async (original) => ({ + ...(await original< + typeof import("../../apps/api/src/plugins/github/services/task-service") + >()), + findAllIntegrationsByRepo: async () => m.integrations, + }), +); +vi.mock("../../apps/api/src/plugins/github/utils/github-app", () => ({ + getVerifiedInstallationOctokit: async () => ({ + rest: { issues: { get: async () => ({ data: { state: m.state } }) } }, + }), +})); +vi.mock("../../apps/api/src/plugins/gitea/utils/gitea-api", () => ({ + createGiteaClient: () => ({ getIssue: async () => ({ state: m.state }) }), +})); +beforeEach(resetTestDatabase); +it.each( + ["github", "gitea"].flatMap((provider) => + ["closed", "open"].map((delayed) => ({ provider, delayed })), + ), +)( + "$provider ignores a delayed $delayed state echo and accepts a later genuine edit", + async ({ provider, delayed }) => { + const current = delayed === "closed" ? "open" : "closed"; + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "card", + status: current === "closed" ? "done" : "to-do", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: JSON.stringify({ + baseUrl: "https://git.example", + repositoryOwner: "owner", + repositoryName: "repo", + accessToken: "test-only", + }), + }) + .returning(); + m.integrations = [integration]; + m.state = current; + const stamp = outboundStamp( + outboundStamp(undefined, delayed, "2026-09-30T00:00:01Z"), + current, + "2026-09-30T00:00:02Z", + ); + await db.insert(schema.externalLinkTable).values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://git.example/owner/repo/issues/1", + metadata: JSON.stringify({ + createdFrom: provider, + state: current, + lastSync: { state: stamp }, + }), + }); + const payload = { + action: delayed === "closed" ? "closed" : "reopened", + issue: { + number: 1, + title: "card", + html_url: "https://git.example/owner/repo/issues/1", + state: delayed, + updated_at: "2026-09-30T00:00:01Z", + }, + repository: { + id: 20, + name: "repo", + owner: { login: "owner" }, + full_name: "owner/repo", + html_url: "https://git.example/owner/repo", + }, + }; + const apply = async () => { + if (provider === "github") + await (delayed === "closed" ? handleIssueClosed : handleIssueReopened)( + payload, + ); + else + await ( + delayed === "closed" + ? handleGiteaIssueClosed + : handleGiteaIssueReopened + )(payload, integration.id); + }; + await apply(); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }) + )?.status, + ).toBe(current === "closed" ? "done" : "to-do"); + m.state = delayed; + payload.issue.updated_at = "2026-09-30T00:00:03Z"; + await apply(); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }) + )?.status, + ).toBe(delayed === "closed" ? "done" : "to-do"); + }, +); diff --git a/tests/api-integration/integration-task-ownership.test.ts b/tests/api-integration/integration-task-ownership.test.ts index 0ce685d84e..04d7a6a95b 100644 --- a/tests/api-integration/integration-task-ownership.test.ts +++ b/tests/api-integration/integration-task-ownership.test.ts @@ -25,6 +25,7 @@ import { const m = vi.hoisted(() => ({ publish: vi.fn(async (_type: string, _data: unknown) => undefined), listIssues: vi.fn(), + getIssue: vi.fn(), listIssueComments: vi.fn(async () => []), listIssueNotes: vi.fn(async () => []), listPulls: vi.fn(async () => []), @@ -73,6 +74,7 @@ beforeEach(async () => { vi.clearAllMocks(); m.publish.mockReset().mockResolvedValue(undefined); m.listIssues.mockResolvedValue([remoteIssue]); + m.getIssue.mockReset().mockResolvedValue(remoteIssue); m.listIssueComments.mockResolvedValue([]); m.listIssueNotes.mockResolvedValue([]); }); @@ -420,6 +422,113 @@ it("preserves legacy links belonging to the destination integration when moving expect(await db.query.externalLinkTable.findMany()).toEqual([compatible]); }); +it("preserves outbound history and unrelated fields across concurrent sync completions", async () => { + const { updateExternalLink } = + await import("../../apps/api/src/plugins/github/services/link-manager"); + const { isOutboundEcho } = + await import("../../apps/api/src/plugins/github/utils/sync-echo"); + const { link } = await setup(); + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ remoteMarker: "keep" }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + await Promise.all( + Array.from({ length: 12 }, (_, i) => + updateExternalLink(link.id, { + outbound: { + field: i % 2 ? "description" : "title", + value: `edit-${i}`, + updatedAt: `time-${i}`, + }, + }), + ), + ); + const beforeStateWebhook = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + const latestTitleStamp = JSON.parse(beforeStateWebhook!.metadata!).lastSync + .title; + // A state webhook can hold a metadata snapshot predating every outbound write. + await updateExternalLink(link.id, { + metadata: { + remoteMarker: "keep", + state: "closed", + lastSync: { + title: { + source: "github", + value: "remote", + timestamp: "2000-01-01T00:00:00Z", + outbound: [], + }, + }, + }, + }); + const saved = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + const metadata = JSON.parse(saved!.metadata!); + expect(metadata.remoteMarker).toBe("keep"); + expect(metadata.lastSync.title).toEqual(latestTitleStamp); + for (let i = 0; i < 12; i++) + expect( + isOutboundEcho( + metadata.lastSync[i % 2 ? "description" : "title"], + `edit-${i}`, + `time-${i}`, + ), + ).toBe(true); +}); + +it("keeps outbound history committed during an inbound provider read", async () => { + const { updateExternalLink } = + await import("../../apps/api/src/plugins/github/services/link-manager"); + const { isOutboundEcho } = + await import("../../apps/api/src/plugins/github/utils/sync-echo"); + const { link } = await setup(); + const { outboundStamp } = + await import("../../apps/api/src/plugins/github/utils/sync-echo"); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { + title: outboundStamp( + outboundStamp(undefined, remoteIssue.title), + "Latest local", + ), + }, + }), + }) + .where(eq(schema.externalLinkTable.id, link.id)); + m.getIssue.mockImplementationOnce(async () => { + await updateExternalLink(link.id, { + outbound: { + field: "title", + value: "Local edit", + updatedAt: "local-time", + }, + }); + return remoteIssue; + }); + await handleGiteaIssueEdited({ + action: "edited", + repository, + issue: remoteIssue, + changes: { title: { from: "Old" } }, + }); + // The outbound metadata change invalidates the first provider confirmation. + expect(m.getIssue).toHaveBeenCalledTimes(2); + const saved = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + expect( + isOutboundEcho( + JSON.parse(saved!.metadata!).lastSync.title, + "Local edit", + "local-time", + ), + ).toBe(true); +}); it.each(["edit", "labels", "comment"])( "ignores a stale %s webhook after its link is removed during a move and return", async (kind) => { diff --git a/tests/api-integration/label-live-updates.test.ts b/tests/api-integration/label-live-updates.test.ts new file mode 100644 index 0000000000..676664a186 --- /dev/null +++ b/tests/api-integration/label-live-updates.test.ts @@ -0,0 +1,130 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import db, { schema } from "../../apps/api/src/database"; +import updateLabel from "../../apps/api/src/label/controllers/update-label"; +import { resetTestDatabase } from "./helpers/database"; +import { + createWorkspaceMember, + createProjectFixture, +} from "./helpers/fixtures"; +const publish = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../../apps/api/src/events", () => ({ publishEvent: publish })); +beforeEach(async () => { + await resetTestDatabase(); + publish.mockReset(); +}); +async function task(projectId: string, number: number) { + const [row] = await db + .insert(schema.taskTable) + .values({ projectId, number, title: `Task ${number}`, status: "to-do" }) + .returning(); + return row; +} +it("announces committed workspace label edits once per affected board", async () => { + const { workspace } = await createWorkspaceMember(); + const { project: first } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const { project: second } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const { workspace: otherWorkspace } = await createWorkspaceMember(); + const { project: foreign } = await createProjectFixture({ + workspaceId: otherWorkspace.id, + }); + const [a, b, c, privateTask] = await Promise.all([ + task(first.id, 1), + task(first.id, 2), + task(second.id, 1), + task(foreign.id, 1), + ]); + const [root] = await db + .insert(schema.labelTable) + .values({ workspaceId: workspace.id, name: "Old", color: "#000000" }) + .returning(); + await db.insert(schema.labelTable).values( + [a, b, c].map((row) => ({ + taskId: row.id, + workspaceId: workspace.id, + name: "Old", + color: "#000000", + })), + ); + await db.insert(schema.labelTable).values({ + taskId: privateTask.id, + workspaceId: otherWorkspace.id, + name: "Old", + color: "#000000", + }); + await updateLabel(root.id, "Renamed", "#123456"); + expect(publish.mock.calls.map((call) => call)).toEqual( + expect.arrayContaining([ + ["project.updated", { projectId: first.id }], + ["project.updated", { projectId: second.id }], + ]), + ); + expect(publish).toHaveBeenCalledTimes(2); + const labels = await db.query.labelTable.findMany(); + expect( + labels + .filter((label) => label.workspaceId === workspace.id) + .every((label) => label.name === "Renamed" && label.color === "#123456"), + ).toBe(true); + expect(labels.find((label) => label.taskId === privateTask.id)?.name).toBe( + "Old", + ); +}); +it("announces a direct task label recolor and emits nothing for a failed edit", async () => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const row = await task(project.id, 1); + const [label] = await db + .insert(schema.labelTable) + .values({ + taskId: row.id, + workspaceId: workspace.id, + name: "Bug", + color: "#000000", + }) + .returning(); + await updateLabel(label.id, "Bug", "#123456"); + expect(publish.mock.calls).toEqual([ + ["project.updated", { projectId: project.id }], + ]); + publish.mockClear(); + await expect(updateLabel("missing", "Name", "#000000")).rejects.toThrow( + "Label not found", + ); + expect(publish).not.toHaveBeenCalled(); +}); + +it("announces an unassigned workspace label edit to every local board's choices", async () => { + const { workspace } = await createWorkspaceMember(); + const { project: first } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const { project: second } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const { workspace: foreign } = await createWorkspaceMember(); + await createProjectFixture({ workspaceId: foreign.id }); + const [label] = await db + .insert(schema.labelTable) + .values({ workspaceId: workspace.id, name: "Unassigned", color: "red" }) + .returning(); + publish.mockImplementation(async () => { + expect( + await db.query.labelTable.findFirst({ + where: eq(schema.labelTable.id, label.id), + }), + ).toMatchObject({ name: "Renamed", color: "blue" }); + }); + await updateLabel(label.id, "Renamed", "blue"); + expect(publish.mock.calls).toEqual( + expect.arrayContaining([ + ["project.updated", { projectId: first.id }], + ["project.updated", { projectId: second.id }], + ]), + ); + expect(publish).toHaveBeenCalledTimes(2); +}); diff --git a/tests/api-integration/outbound-intent-correlation.test.ts b/tests/api-integration/outbound-intent-correlation.test.ts new file mode 100644 index 0000000000..8f531118dd --- /dev/null +++ b/tests/api-integration/outbound-intent-correlation.test.ts @@ -0,0 +1,439 @@ +import { eq } from "drizzle-orm"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import db, { schema } from "../../apps/api/src/database"; +import { syncLatestTaskValue } from "../../apps/api/src/plugins/github/services/sync-latest-task-value"; +import { withIntegrationLink } from "../../apps/api/src/plugins/github/services/with-integration-link"; +import { + inboundEcho, + withEchoConfirmation, +} from "../../apps/api/src/plugins/github/utils/inbound-echo"; +import { updateExternalLink } from "../../apps/api/src/plugins/github/services/link-manager"; +import { + inboundStamp, + type SyncStamp, +} from "../../apps/api/src/plugins/github/utils/sync-echo"; +import { resetTestDatabase } from "./helpers/database"; +import { + createWorkspaceMember, + createProjectFixture, +} from "./helpers/fixtures"; +vi.mock("../../apps/api/src/events", () => ({ + publishEvent: vi.fn(async () => undefined), +})); +beforeEach(resetTestDatabase); +it.each([ + { colliding: false, legitimate: true, timeout: false }, + { colliding: true, legitimate: true, timeout: false }, + { colliding: true, legitimate: false, timeout: false }, + { colliding: true, legitimate: true, timeout: true }, +])( + "correlates observed edits without losing intervening edits (collision=$colliding, legitimate=$legitimate, timeout=$timeout)", + async ({ colliding, legitimate, timeout }) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ projectId: project.id, number: 1, title: "B" }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ projectId: project.id, type: "github", config: "{}" }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/1", + }) + .returning(); + let release!: () => void; + const response = new Promise((resolve) => { + release = resolve; + }); + let remote = "A"; + const outbound = syncLatestTaskValue( + task.id, + project.id, + link, + "title", + "A", + async (value) => { + remote = value; + await response; + return "2026-09-30T00:00:01Z"; + }, + async () => remote, + ); + await vi.waitFor(async () => { + const current = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + expect( + JSON.parse(current?.metadata ?? "{}").lastSync?.title?.outbound?.[0] + .pending, + ).toBe(true); + }); + const pendingLink = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + const pending = JSON.parse(pendingLink?.metadata ?? "{}"); + await updateExternalLink(link.id, { + metadata: { + lastSync: { + title: inboundStamp(pending.lastSync.title, "B", "github"), + }, + }, + }); + remote = legitimate ? "A" : "B"; + const observedVersion = colliding + ? "2026-09-30T00:00:01Z" + : "2026-09-30T00:00:03Z"; + const inbound = withEchoConfirmation( + async () => remote, + (current) => + withIntegrationLink( + link, + integration, + async (tx, _afterCommit, locked) => { + const stamp = JSON.parse(locked.metadata ?? "{}").lastSync + ?.title as SyncStamp | undefined; + if ( + !inboundEcho(stamp, "A", observedVersion, current, { + linkId: link.id, + field: "title", + }) + ) + await tx + .update(schema.taskTable) + .set({ title: "A" }) + .where(eq(schema.taskTable.id, task.id)); + }, + ), + ); + const inboundResult = inbound.then( + () => null, + (error: unknown) => error, + ); + await vi.waitFor(async () => { + const current = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + expect( + JSON.parse(current?.metadata ?? "{}").lastSync?.title?.outbound?.[0] + .observedUpdatedAt, + ).toBe(observedVersion); + }); + if (timeout) expect(await inboundResult).toBeInstanceOf(Error); + release(); + await Promise.all([outbound, inboundResult]); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }) + )?.title, + ).toBe(legitimate ? "A" : "B"); + expect(remote).toBe(legitimate ? "A" : "B"); + }, + 15000, +); + +it("stops correction when a disconnect races the post-response task read", async () => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const [task] = await db + .insert(schema.taskTable) + .values({ projectId: project.id, number: 1, title: "B" }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ projectId: project.id, type: "github", config: "{}" }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/1", + }) + .returning(); + const findTask = db.query.taskTable.findFirst.bind(db.query.taskTable); + const read = vi + .spyOn(db.query.taskTable, "findFirst") + .mockImplementation(async (options) => { + await db + .delete(schema.externalLinkTable) + .where(eq(schema.externalLinkTable.id, link.id)); + return findTask(options); + }); + const write = vi.fn(async () => "2026-09-30T00:00:01Z"); + try { + await syncLatestTaskValue(task.id, project.id, link, "title", "A", write); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("A"); + } finally { + read.mockRestore(); + } +}); + +it.each( + (["title", "description", "state"] as const).flatMap((field) => + [false, true].map((aba) => ({ field, aba })), + ), +)( + "preserves a newer local $field edit during observed-value HTTP (ABA=$aba)", + async ({ field, aba }) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "B", + description: "B", + status: "to-do", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ projectId: project.id, type: "github", config: "{}" }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/1", + }) + .returning(); + const older = field === "state" ? "closed" : "A"; + const prior = field === "state" ? "open" : "B"; + let first = true; + let remote = older; + const write = vi.fn(async (value: string) => { + remote = value; + if (!first) return "2026-09-30T00:00:04Z"; + first = false; + const pending = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + const stamp = JSON.parse(pending?.metadata ?? "{}").lastSync[field]; + await updateExternalLink(link.id, { + metadata: { + lastSync: { + [field]: inboundStamp( + stamp, + prior, + "github", + "2026-09-30T00:00:02Z", + ), + }, + }, + observedOutbound: { + field, + intentId: stamp.outbound[0].intentId, + updatedAt: "2026-09-30T00:00:03Z", + }, + }); + return "2026-09-30T00:00:01Z"; + }); + await syncLatestTaskValue( + task.id, + project.id, + link, + field, + older, + write, + async () => { + const column = field === "state" ? "status" : field; + await db.transaction(async (tx) => { + await tx + .update(schema.taskTable) + .set({ + [column]: field === "state" ? "in-progress" : "C", + updatedAt: task.updatedAt, + }) + .where(eq(schema.taskTable.id, task.id)); + if (aba) + await tx + .update(schema.taskTable) + .set({ + [column]: field === "state" ? "to-do" : "B", + updatedAt: task.updatedAt, + }) + .where(eq(schema.taskTable.id, task.id)); + }); + return remote; + }, + ); + const current = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + expect(field === "state" ? current?.status : current?.[field]).toBe( + field === "state" ? (aba ? "to-do" : "in-progress") : aba ? "B" : "C", + ); + expect(remote).toBe(field === "state" ? "open" : aba ? "B" : "C"); + expect(write).toHaveBeenCalledTimes(2); + }, +); + +it.each( + (["github", "gitea"] as const).flatMap((provider) => + (["title", "description", "state"] as const).flatMap((field) => + ["retry", "read", "apply"].map((phase) => ({ provider, field, phase })), + ), + ), +)( + "stops $field sync against a reconfigured provider ($provider/$phase)", + async ({ provider, field, phase }) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + number: 1, + title: "B", + description: "B", + status: "to-do", + }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: JSON.stringify({ repositoryName: "old", accessToken: "fake" }), + }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/1", + }) + .returning(); + const older = field === "state" ? "closed" : "A"; + const reconfigure = async () => { + await db + .update(schema.integrationTable) + .set({ + config: JSON.stringify({ + repositoryName: "new", + accessToken: "fake", + }), + }) + .where(eq(schema.integrationTable.id, integration.id)); + }; + const write = vi.fn(async () => { + if (write.mock.calls.length === 1) { + if (phase !== "retry") { + const saved = await db.query.externalLinkTable.findFirst({ + where: eq(schema.externalLinkTable.id, link.id), + }); + const stamp = JSON.parse(saved!.metadata!).lastSync[field]; + await updateExternalLink(link.id, { + metadata: { + lastSync: { + [field]: inboundStamp( + stamp, + field === "state" ? "open" : "B", + provider, + "2026-09-30T00:00:02Z", + ), + }, + }, + observedOutbound: { + field, + intentId: stamp.outbound[0].intentId, + updatedAt: "2026-09-30T00:00:03Z", + }, + }); + } + if (phase !== "apply") await reconfigure(); + } + return "2026-09-30T00:00:01Z"; + }); + const read = vi.fn(async () => { + await reconfigure(); + return older; + }); + await syncLatestTaskValue( + task.id, + project.id, + link, + field, + older, + write, + read, + ); + expect(write).toHaveBeenCalledTimes(1); + expect(read).toHaveBeenCalledTimes(phase === "apply" ? 1 : 0); + const current = await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }); + expect(field === "state" ? current?.status : current?.[field]).toBe( + field === "state" ? "to-do" : "B", + ); + }, +); +it.each(["github", "gitea"])( + "rejects a stale provider context before the initial %s write", + async (provider) => { + const { workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const [task] = await db + .insert(schema.taskTable) + .values({ projectId: project.id, number: 1, title: "A" }) + .returning(); + const [integration] = await db + .insert(schema.integrationTable) + .values({ + projectId: project.id, + type: provider, + config: JSON.stringify({ repositoryName: "new" }), + }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + integrationId: integration.id, + resourceType: "issue", + externalId: "1", + url: "https://provider.example/1", + }) + .returning(); + const write = vi.fn(async () => "2026-09-30T00:00:01Z"); + await syncLatestTaskValue( + task.id, + project.id, + link, + "title", + "A", + write, + undefined, + { type: provider, config: JSON.stringify({ repositoryName: "old" }) }, + ); + expect(write).not.toHaveBeenCalled(); + }, +); diff --git a/tests/api-integration/provider-board-live-updates.test.ts b/tests/api-integration/provider-board-live-updates.test.ts index 053d620ed9..20189dc8eb 100644 --- a/tests/api-integration/provider-board-live-updates.test.ts +++ b/tests/api-integration/provider-board-live-updates.test.ts @@ -87,7 +87,12 @@ it.each([ .values({ projectId: project.id, type: provider, - config: "{}", + config: JSON.stringify({ + baseUrl: "https://gitea.example", + repositoryOwner: "owner", + repositoryName: "repo", + accessToken: "fake-test-token", + }), isActive: true, }) .returning(); diff --git a/tests/api-integration/subtask-counts.test.ts b/tests/api-integration/subtask-counts.test.ts index ce64265771..6c86d505cd 100644 --- a/tests/api-integration/subtask-counts.test.ts +++ b/tests/api-integration/subtask-counts.test.ts @@ -433,60 +433,65 @@ describe("API integration: subtask counters", () => { describe("subtask counter mutation paths", () => { beforeEach(resetTestDatabase); - it("refreshes a parent after moving a child into a nonfinal workflow", async () => { - const member = await createWorkspaceMember(); - const parentProject = await createProjectFixture({ - workspaceId: member.workspace.id, - }); - const source = await createProjectFixture({ - workspaceId: member.workspace.id, - }); - const destination = await createProjectFixture({ - workspaceId: member.workspace.id, - }); - await db - .update(schema.columnTable) - .set({ isFinal: false }) - .where(eq(schema.columnTable.id, destination.columns.done.id)); - const parent = await addTask(parentProject.project.id); - const child = await addTask(source.project.id, "done"); - await relate(parent.id, child.id); - expect(await countsFor(parentProject.project.id, parent.id)).toEqual({ - completed: 1, - total: 1, - }); - await initializeWebSocketAdapter(); - const send = vi.fn(); - const connection = addConnection( - parentProject.project.id, - { send } as never, - member.user.id, - "same-window", - member.workspace.id, - ); - try { - await eventContext.run({ initiatorId: "same-window" }, () => - moveTask({ - taskId: child.id, - destinationProjectId: destination.project.id, - currentUserId: member.user.id, - }), - ); - await vi.waitFor(() => expect(send).toHaveBeenCalled()); - expect(JSON.parse(send.mock.calls[0][0])).toEqual({ - type: "TASK_RELATION_UPDATED", - projectId: parentProject.project.id, - taskId: "", + it.each([false, true])( + "refreshes a parent after moving a child into a nonfinal workflow (sameSource=%s)", + async (sameSource) => { + const member = await createWorkspaceMember(); + const parentProject = await createProjectFixture({ + workspaceId: member.workspace.id, + }); + const source = sameSource + ? parentProject + : await createProjectFixture({ + workspaceId: member.workspace.id, + }); + const destination = await createProjectFixture({ + workspaceId: member.workspace.id, }); + await db + .update(schema.columnTable) + .set({ isFinal: false }) + .where(eq(schema.columnTable.id, destination.columns.done.id)); + const parent = await addTask(parentProject.project.id); + const child = await addTask(source.project.id, "done"); + await relate(parent.id, child.id); expect(await countsFor(parentProject.project.id, parent.id)).toEqual({ - completed: 0, + completed: 1, total: 1, }); - } finally { - removeConnection(parentProject.project.id, connection); - await shutdownWebSocketAdapter(); - } - }); + await initializeWebSocketAdapter(); + const send = vi.fn(); + const connection = addConnection( + parentProject.project.id, + { send } as never, + member.user.id, + "same-window", + member.workspace.id, + ); + try { + await eventContext.run({ initiatorId: "same-window" }, () => + moveTask({ + taskId: child.id, + destinationProjectId: destination.project.id, + currentUserId: member.user.id, + }), + ); + await vi.waitFor(() => expect(send).toHaveBeenCalled()); + expect(JSON.parse(send.mock.calls[0][0])).toEqual({ + type: "TASK_RELATION_UPDATED", + projectId: parentProject.project.id, + taskId: "", + }); + expect(await countsFor(parentProject.project.id, parent.id)).toEqual({ + completed: 0, + total: 1, + }); + } finally { + removeConnection(parentProject.project.id, connection); + await shutdownWebSocketAdapter(); + } + }, + ); it("refreshes same-project parents in other windows after child deletion", async () => { const member = await createWorkspaceMember(); const { project } = await createProjectFixture({ @@ -519,3 +524,31 @@ describe("subtask counter mutation paths", () => { } }); }); + +it("returns bounded board refresh descriptions and current parent progress", async () => { + await resetTestDatabase(); + const member = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: member.workspace.id, + }); + const parent = await addTask(project.id); + const child = await addTask(project.id, "done"); + await relate(parent.id, child.id); + await db + .update(schema.taskTable) + .set({ description: "😀".repeat(20000) }) + .where(eq(schema.taskTable.id, child.id)); + mockAuthenticatedSession(member.user); + const response = await createApp().app.request( + `/api/task/${child.id}?view=board`, + ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toMatchObject({ + description: null, + descriptionDeferred: true, + subtaskCounts: { completed: 0, total: 0 }, + parentSubtaskCounts: [{ taskId: parent.id, completed: 1, total: 1 }], + }); + expect(body).not.toHaveProperty("workspaceId"); +}); diff --git a/tests/api-integration/task-mutation-parity.test.ts b/tests/api-integration/task-mutation-parity.test.ts index f501c304b4..33c5b8e0ac 100644 --- a/tests/api-integration/task-mutation-parity.test.ts +++ b/tests/api-integration/task-mutation-parity.test.ts @@ -284,3 +284,107 @@ it("validates status and assignment without a second pool client inside the muta }), ).toMatchObject({ status: "done", userId: user.id }); }); + +it.each(["single", "full"])( + "%s status repairs notify clients without recording a status transition", + async (mode) => { + const { user, workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ + workspaceId: workspace.id, + }); + const column = await db.query.columnTable.findFirst({ + where: eq(schema.columnTable.slug, "to-do"), + }); + const [task] = await db + .insert(schema.taskTable) + .values({ + projectId: project.id, + title: "Legacy task", + status: "to-do", + columnId: null, + }) + .returning(); + if (mode === "single") + await updateTaskStatus({ + id: task.id, + status: task.status, + currentUserId: user.id, + }); + else + await updateTask( + task.id, + task.title, + task.status, + undefined, + undefined, + project.id, + undefined, + task.priority, + task.position, + undefined, + user.id, + ); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }) + )?.columnId, + ).toBe(column?.id); + expect(publish).toHaveBeenCalledWith( + "task.updated", + expect.objectContaining({ taskId: task.id, projectId: project.id }), + ); + expect(publish).toHaveBeenCalledWith( + "task-relation.refresh", + expect.objectContaining({ projectId: project.id }), + ); + expect( + publish.mock.calls.some(([type]) => type === "task.status_changed"), + ).toBe(false); + }, +); + +it("rejects a bulk due-date update after a task moves out of the authorized project", async () => { + const { user, workspace } = await createWorkspaceMember(); + const { project } = await createProjectFixture({ workspaceId: workspace.id }); + const other = await createWorkspaceMember(); + const { project: foreign } = await createProjectFixture({ + workspaceId: other.workspace.id, + }); + const dueDate = new Date("2030-01-01"); + const [task] = await db + .insert(schema.taskTable) + .values({ projectId: project.id, title: "Moved task", dueDate }) + .returning(); + await db + .insert(schema.taskReminderSentTable) + .values({ taskId: task.id, reminderType: "due_today" }); + const transaction = db.transaction.bind(db); + vi.spyOn(getDatabase(), "transaction").mockImplementationOnce( + async (apply, config) => { + await db + .update(schema.taskTable) + .set({ projectId: foreign.id }) + .where(eq(schema.taskTable.id, task.id)); + return transaction(apply, config); + }, + ); + await expect( + bulkUpdateTasks({ + taskIds: [task.id], + operation: "updateDueDate", + value: null, + userId: user.id, + }), + ).rejects.toMatchObject({ status: 409 }); + expect( + ( + await db.query.taskTable.findFirst({ + where: eq(schema.taskTable.id, task.id), + }) + )?.dueDate, + ).toEqual(dueDate); + expect(await db.query.taskReminderSentTable.findMany()).toHaveLength(1); + expect(publish).not.toHaveBeenCalled(); +}); diff --git a/tests/api-integration/task-pagination.test.ts b/tests/api-integration/task-pagination.test.ts index c032f2c7a3..d6f85a7b02 100644 --- a/tests/api-integration/task-pagination.test.ts +++ b/tests/api-integration/task-pagination.test.ts @@ -199,3 +199,150 @@ describe("bounded task pages", () => { } }); }); + +it.each(["create", "delete", "move", "reorder"])( + "changes the public pagination revision after a concurrent %s", + async (change) => { + const { project, app, tasks } = await fixture(237, true); + mockAnonymousSession(); + const first = await app.request( + `/api/public-project/${project.id}?limit=100`, + ); + const before = (await first.json()).pagination.revision; + expect(typeof before).toBe("string"); + const stable = await app.request( + `/api/public-project/${project.id}?page=2&limit=100`, + ); + expect((await stable.json()).pagination.revision).toBe(before); + if (change === "create") + await db.insert(schema.taskTable).values({ + id: "new-public-task", + projectId: project.id, + title: "new", + number: 999, + position: 1, + }); + else if (change === "delete") + await db + .delete(schema.taskTable) + .where(eq(schema.taskTable.id, tasks[0].id)); + else if (change === "move") { + const { project: other } = await createProjectFixture({ + workspaceId: project.workspaceId, + }); + await db + .update(schema.taskTable) + .set({ projectId: other.id }) + .where(eq(schema.taskTable.id, tasks[0].id)); + } else + await db + .update(schema.taskTable) + .set({ position: 200 }) + .where(eq(schema.taskTable.id, tasks[0].id)); + const later = await app.request( + `/api/public-project/${project.id}?page=2&limit=100`, + ); + expect((await later.json()).pagination.revision).not.toBe(before); + }, +); + +it("detects task edits between public pages regardless of sort order", async () => { + const { app, project, tasks } = await fixture(237, true); + mockAnonymousSession(); + const path = `/api/public-project/${project.id}?limit=100`; + const position = (await (await app.request(path)).json()).pagination.revision; + const title = (await (await app.request(path + "&sortBy=title")).json()) + .pagination.revision; + await db + .update(schema.taskTable) + .set({ title: "ZZZ new title" }) + .where(eq(schema.taskTable.id, tasks[0].id)); + expect( + (await (await app.request(path + "&page=2")).json()).pagination.revision, + ).not.toBe(position); + expect( + (await (await app.request(path + "&page=2&sortBy=title")).json()).pagination + .revision, + ).not.toBe(title); +}); + +it.each(["label", "link", "assignee", "project", "subtask"])( + "detects a %s edit to an earlier public page while loading a later page", + async (change) => { + const { app, project, tasks, member } = await fixture(2, true); + const task = tasks[0]; + await db + .update(schema.taskTable) + .set({ userId: member.user.id }) + .where(eq(schema.taskTable.id, task.id)); + const [label] = await db + .insert(schema.labelTable) + .values({ + taskId: task.id, + workspaceId: member.workspace.id, + name: "Before", + color: "red", + }) + .returning(); + const [link] = await db + .insert(schema.externalLinkTable) + .values({ + taskId: task.id, + resourceType: "pull_request", + externalId: "1", + url: "https://example.com/pr/1", + metadata: JSON.stringify({ merged: false }), + }) + .returning(); + const { project: childProject } = await createProjectFixture({ + workspaceId: member.workspace.id, + }); + await db + .update(schema.projectTable) + .set({ isPublic: true }) + .where(eq(schema.projectTable.id, childProject.id)); + const [child] = await db + .insert(schema.taskTable) + .values({ projectId: childProject.id, title: "Child", status: "to-do" }) + .returning(); + await db.insert(schema.taskRelationTable).values({ + sourceTaskId: task.id, + targetTaskId: child.id, + relationType: "subtask", + }); + mockAnonymousSession(); + const path = `/api/public-project/${project.id}?limit=1`; + const before = (await (await app.request(path)).json()).pagination.revision; + expect( + (await (await app.request(path + "&page=2")).json()).pagination.revision, + ).toBe(before); + if (change === "label") + await db + .update(schema.labelTable) + .set({ name: "After" }) + .where(eq(schema.labelTable.id, label.id)); + if (change === "link") + await db + .update(schema.externalLinkTable) + .set({ metadata: JSON.stringify({ merged: true }) }) + .where(eq(schema.externalLinkTable.id, link.id)); + if (change === "assignee") + await db + .update(schema.userTable) + .set({ name: "Renamed", image: "https://example.com/avatar.png" }) + .where(eq(schema.userTable.id, member.user.id)); + if (change === "project") + await db + .update(schema.projectTable) + .set({ name: "Renamed project" }) + .where(eq(schema.projectTable.id, project.id)); + if (change === "subtask") + await db + .update(schema.taskTable) + .set({ status: "done" }) + .where(eq(schema.taskTable.id, child.id)); + expect( + (await (await app.request(path + "&page=2")).json()).pagination.revision, + ).not.toBe(before); + }, +); diff --git a/tests/api-integration/task-related-pages.test.ts b/tests/api-integration/task-related-pages.test.ts index db63dd3fe5..4ef80445bb 100644 --- a/tests/api-integration/task-related-pages.test.ts +++ b/tests/api-integration/task-related-pages.test.ts @@ -188,3 +188,94 @@ describe("bounded board related pages", () => { } }); }); + +it.each( + ["labels", "links", "columns"].flatMap((collection) => + ["create", "delete"].map((change) => ({ collection, change })), + ), +)( + "detects $collection $change between public related pages", + async ({ collection, change }) => { + const { app, member, project, task } = await fixture(); + mockAnonymousSession(); + const path = `/api/public-project/${project.id}?limit=100`; + const first = await app.request(path); + const before = (await first.json()).pagination; + const stable = await app.request(path + "&relatedPage=2"); + expect((await stable.json()).pagination).toMatchObject({ + revision: before.revision, + relatedRevision: before.relatedRevision, + }); + if (collection === "labels") { + if (change === "delete") + await db + .delete(schema.labelTable) + .where(eq(schema.labelTable.id, "label-000")); + else + await db.insert(schema.labelTable).values({ + id: "label---new", + taskId: task.id, + workspaceId: member.workspace.id, + name: "New label", + color: "blue", + }); + } else if (collection === "links") { + if (change === "delete") + await db + .delete(schema.externalLinkTable) + .where(eq(schema.externalLinkTable.id, "link-000")); + else + await db.insert(schema.externalLinkTable).values({ + id: "link---new", + taskId: task.id, + resourceType: "issue", + externalId: "new", + url: "https://example.com/new", + }); + } else { + if (change === "delete") + await db + .delete(schema.columnTable) + .where(eq(schema.columnTable.id, "column-0")); + else + await db.insert(schema.columnTable).values({ + id: "column---new", + projectId: project.id, + slug: "new", + name: "New column", + position: 0, + }); + } + const later = await app.request(path + "&relatedPage=2"); + const after = (await later.json()).pagination; + if (collection === "columns") + expect(after.revision).not.toBe(before.revision); + else { + expect(after.revision).not.toBe(before.revision); + expect(after.relatedRevision).not.toBe(before.relatedRevision); + } + }, +); + +it("detects text and integration updates between public related pages", async () => { + const { app, project, task } = await fixture(); + mockAnonymousSession(); + const path = `/api/public-project/${project.id}?limit=100`; + const before = (await (await app.request(path)).json()).pagination; + await db + .update(schema.taskTable) + .set({ title: "Edited title", description: "Edited body" }) + .where(eq(schema.taskTable.id, task.id)); + await db + .update(schema.externalLinkTable) + .set({ + metadata: JSON.stringify({ + lastSync: { title: { source: "kaneo", value: "Edited title" } }, + }), + }) + .where(eq(schema.externalLinkTable.id, "link-000")); + const after = (await (await app.request(path + "&relatedPage=2")).json()) + .pagination; + expect(after.revision).not.toBe(before.revision); + expect(after.relatedRevision).not.toBe(before.relatedRevision); +}); diff --git a/tests/api/plugins/gitea/rapid-text-sync.test.ts b/tests/api/plugins/gitea/rapid-text-sync.test.ts new file mode 100644 index 0000000000..159f56bcdd --- /dev/null +++ b/tests/api/plugins/gitea/rapid-text-sync.test.ts @@ -0,0 +1,430 @@ +import { outboundStamp } from "../../../../apps/api/src/plugins/github/utils/sync-echo"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { handleGiteaIssueEdited } from "../../../../apps/api/src/plugins/gitea/webhooks/issue-edited"; +const m = vi.hoisted(() => ({ + metadata: "", + lockedMetadata: undefined as string | undefined, + getIssue: vi.fn(), + status: vi.fn(), + writes: vi.fn(), + linkWrites: vi.fn(), +})); +vi.mock("../../../../apps/api/src/database", () => ({ + default: { + query: { + columnTable: { findFirst: async () => undefined }, + externalLinkTable: { + findFirst: async () => ({ + id: "link", + taskId: "task", + metadata: m.metadata, + }), + }, + taskTable: { + findFirst: async () => ({ id: "task", projectId: "project" }), + }, + }, + update: () => ({ + set: m.writes.mockReturnValue({ where: async () => undefined }), + }), + }, +})); +vi.mock( + "../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (original) => ({ + ...(await original< + typeof import("../../../../apps/api/src/plugins/github/services/integration-task-scope") + >()), + withIntegrationTask: async ( + _id: string, + _scope: unknown, + apply: (database: unknown) => Promise, + ) => apply((await import("../../../../apps/api/src/database")).default), + }), +); +vi.mock( + "../../../../apps/api/src/plugins/github/services/link-manager", + () => ({ + findExternalLink: async () => ({ + id: "link", + taskId: "task", + metadata: m.metadata, + }), + updateExternalLink: m.linkWrites, + lockExternalLink: async () => ({ + id: "link", + metadata: m.lockedMetadata ?? m.metadata, + }), + }), +); +vi.mock( + "../../../../apps/api/src/plugins/gitea/services/integration-lookup", + () => ({ + findAllIntegrationsByGiteaRepo: async () => [ + { + id: "integration", + projectId: "project", + config: JSON.stringify({ + baseUrl: "https://gitea.example", + accessToken: "test", + repositoryOwner: "owner", + repositoryName: "repo", + }), + }, + ], + repoOwnerLogin: () => "owner", + }), +); +vi.mock("../../../../apps/api/src/plugins/gitea/utils/gitea-api", () => ({ + createGiteaClient: () => ({ getIssue: m.getIssue }), +})); +vi.mock( + "../../../../apps/api/src/plugins/github/services/with-integration-link", + () => ({ + withIntegrationLink: async ( + _link: unknown, + _scope: unknown, + apply: ( + database: unknown, + afterCommit: unknown, + link: unknown, + ) => Promise, + ) => + apply( + (await import("../../../../apps/api/src/database")).default, + () => {}, + { + id: "link", + taskId: "task", + metadata: m.lockedMetadata ?? m.metadata, + }, + ), + }), +); +const payload = { + action: "edited", + repository: { + owner: { login: "owner" }, + name: "repo", + html_url: "https://gitea.example/owner/repo", + }, + issue: { + number: 1, + title: "Remote correction", + body: "Remote description", + html_url: "https://gitea.example/owner/repo/issues/1", + }, + changes: { title: { from: "Old" }, body: { from: "Old body" } }, +}; +beforeEach(() => { + vi.clearAllMocks(); + m.lockedMetadata = undefined; + m.getIssue.mockResolvedValue({ title: "Newest title", body: "Newest body" }); + m.metadata = JSON.stringify({ + lastSync: { + title: { + source: "kaneo", + value: "Local title", + timestamp: new Date().toISOString(), + }, + description: { + source: "kaneo", + value: "Local description", + timestamp: new Date().toISOString(), + }, + }, + }); +}); +describe("rapid gitea text edits", () => { + it("applies a different title and description immediately after an outbound sync", async () => { + await handleGiteaIssueEdited(payload); + expect(m.writes).toHaveBeenCalledWith({ + title: "Remote correction", + description: "Remote description", + }); + }); + it("still ignores exact title and description echoes", async () => { + await handleGiteaIssueEdited({ + ...payload, + issue: { + ...payload.issue, + title: "Local title", + body: "Local description", + }, + }); + expect(m.writes).not.toHaveBeenCalled(); + }); +}); + +it("ignores a delayed first echo after a newer outbound edit", async () => { + m.metadata = JSON.stringify({ + lastSync: { + title: outboundStamp( + outboundStamp(undefined, "Earlier title", "2026-01-01T00:00:00Z"), + "Newest title", + "2026-01-01T00:00:01Z", + ), + description: outboundStamp( + outboundStamp(undefined, "Earlier body", "2026-01-01T00:00:00Z"), + "Newest body", + "2026-01-01T00:00:01Z", + ), + }, + }); + await handleGiteaIssueEdited({ + ...payload, + issue: { + ...payload.issue, + title: "Earlier title", + body: "Earlier body", + updated_at: "2026-01-01T00:00:00Z", + }, + }); + expect(m.writes).not.toHaveBeenCalled(); +}); +it("allows a new remote edit back to an older outbound value", async () => { + m.metadata = JSON.stringify({ + lastSync: { + title: outboundStamp( + outboundStamp(undefined, "Earlier title", "2026-01-01T00:00:00Z"), + "Newest title", + "2026-01-01T00:00:01Z", + ), + }, + }); + await handleGiteaIssueEdited({ + ...payload, + changes: { title: { from: "Newest title" } }, + issue: { + ...payload.issue, + title: "Earlier title", + updated_at: "2026-01-01T00:00:02Z", + }, + }); + expect(m.writes).toHaveBeenCalledWith({ title: "Earlier title" }); +}); + +vi.mock( + "../../../../apps/api/src/plugins/github/services/task-service", + async (original) => ({ + ...(await original< + typeof import("../../../../apps/api/src/plugins/github/services/task-service") + >()), + updateTaskStatus: m.status, + }), +); +vi.mock("../../../../apps/api/src/plugins/gitea/utils/resolve-column", () => ({ + resolveTargetStatus: async ( + _project: string, + _event: string, + fallback: string, + ) => fallback, +})); +it("ignores an older close echo after a newer outbound reopen", async () => { + const { handleGiteaIssueClosed } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-closed"); + m.metadata = JSON.stringify({ + state: "open", + lastSync: { + state: outboundStamp( + outboundStamp(undefined, "closed", "2026-01-01T00:00:00Z"), + "open", + "2026-01-01T00:00:01Z", + ), + }, + }); + m.getIssue.mockResolvedValue({ state: "open" }); + await handleGiteaIssueClosed({ + ...payload, + action: "closed", + issue: { + ...payload.issue, + state: "closed", + updated_at: "2026-01-01T00:00:00Z", + }, + }); + expect(m.status).not.toHaveBeenCalled(); +}); +it("ignores an older reopen echo after a newer outbound close", async () => { + const { handleGiteaIssueReopened } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-reopened"); + m.metadata = JSON.stringify({ + state: "closed", + lastSync: { + state: outboundStamp( + outboundStamp(undefined, "open", "2026-01-01T00:00:00Z"), + "closed", + "2026-01-01T00:00:01Z", + ), + }, + }); + m.getIssue.mockResolvedValue({ state: "closed" }); + await handleGiteaIssueReopened({ + ...payload, + action: "reopened", + issue: { + ...payload.issue, + state: "open", + updated_at: "2026-01-01T00:00:00Z", + }, + }); + expect(m.status).not.toHaveBeenCalled(); +}); + +it("accepts a legitimate remote edit matching a historical outbound value within the same timestamp", async () => { + const stamp = "2026-01-01T00:00:00Z"; + m.metadata = JSON.stringify({ + lastSync: { + title: outboundStamp( + outboundStamp(undefined, "Earlier title", stamp), + "Newest title", + stamp, + ), + description: outboundStamp( + outboundStamp(undefined, "Earlier body", stamp), + "Newest body", + stamp, + ), + }, + }); + m.getIssue.mockResolvedValue({ + title: "Earlier title", + body: "Earlier body", + }); + await handleGiteaIssueEdited({ + ...payload, + issue: { + ...payload.issue, + title: "Earlier title", + body: "Earlier body", + updated_at: stamp, + }, + }); + expect(m.writes).toHaveBeenCalledWith({ + title: "Earlier title", + description: "Earlier body", + }); + // Each ambiguous field needs a confirmation tied to its locked sync state. + expect(m.getIssue).toHaveBeenCalledTimes(2); +}); + +it("ignores the linked task footer when detecting a description echo", async () => { + await handleGiteaIssueEdited({ + ...payload, + changes: { body: { from: "Old" } }, + issue: { + ...payload.issue, + body: "Local description\n\n---\nTask: task", + }, + }); + expect(m.writes).not.toHaveBeenCalled(); +}); +it("accepts a same-timestamp remote close matching historical outbound state", async () => { + const { handleGiteaIssueClosed } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-closed"); + const stamp = "2026-01-01T00:00:00Z"; + m.metadata = JSON.stringify({ + state: "open", + lastSync: { + state: outboundStamp( + outboundStamp(undefined, "closed", stamp), + "open", + stamp, + ), + }, + }); + m.getIssue.mockResolvedValue({ state: "closed" }); + m.status.mockResolvedValue({ applied: false }); + await handleGiteaIssueClosed({ + ...payload, + action: "closed", + issue: { ...payload.issue, state: "closed", updated_at: stamp }, + }); + expect(m.status).toHaveBeenCalled(); +}); + +it("retains outbound stamps added after the initial inbound snapshot", async () => { + m.metadata = JSON.stringify({ lastSync: {} }); + const stamp = outboundStamp(undefined, "New local title", "new-time"); + m.lockedMetadata = JSON.stringify({ + marker: "preserve", + lastSync: { title: stamp }, + }); + await handleGiteaIssueEdited(payload); + expect( + m.linkWrites.mock.calls[0][1].metadata.lastSync.title.outbound, + ).toEqual(stamp.outbound); + expect(m.linkWrites.mock.calls[0][1].metadata.marker).toBe("preserve"); +}); + +it("recognizes a delayed echo whose stamp arrives before the locked reread", async () => { + m.metadata = JSON.stringify({ lastSync: {} }); + m.lockedMetadata = JSON.stringify({ + lastSync: { + title: outboundStamp(undefined, payload.issue.title, undefined), + }, + }); + await handleGiteaIssueEdited({ + ...payload, + changes: { title: payload.changes.title }, + }); + expect(m.writes).not.toHaveBeenCalled(); +}); + +it("applies an ordinary complete webhook without consulting an unavailable provider", async () => { + m.metadata = JSON.stringify({ lastSync: {} }); + m.getIssue.mockRejectedValue(new Error("provider unavailable")); + await handleGiteaIssueEdited(payload); + expect(m.getIssue).not.toHaveBeenCalled(); + expect(m.writes).toHaveBeenCalledWith( + expect.objectContaining({ + title: payload.issue.title, + description: payload.issue.body, + }), + ); +}); + +it("rechecks a close echo from the locked state snapshot", async () => { + m.metadata = JSON.stringify({ lastSync: {} }); + m.lockedMetadata = JSON.stringify({ + lastSync: { state: outboundStamp(undefined, "closed") }, + }); + const { handleGiteaIssueClosed } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-closed"); + await handleGiteaIssueClosed({ + action: "closed", + repository: payload.repository, + issue: { ...payload.issue, state: "closed" }, + }); + expect(m.status).not.toHaveBeenCalled(); +}); + +it("applies a close following an inbound reopen within the same provider timestamp", async () => { + const { handleGiteaIssueClosed } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-closed"); + const { handleGiteaIssueReopened } = + await import("../../../../apps/api/src/plugins/gitea/webhooks/issue-reopened"); + const stamp = "2026-01-01T00:00:00Z"; + m.metadata = JSON.stringify({ + state: "closed", + lastSync: { state: outboundStamp(undefined, "closed", stamp) }, + }); + m.status.mockResolvedValue({ applied: false }); + m.linkWrites.mockImplementation(async (_id, update) => { + m.metadata = JSON.stringify(update.metadata); + }); + await handleGiteaIssueReopened({ + ...payload, + action: "reopened", + issue: { ...payload.issue, state: "open", updated_at: stamp }, + }); + expect(JSON.parse(m.metadata).lastSync.state.value).toBe("open"); + m.getIssue.mockResolvedValue({ state: "closed" }); + await handleGiteaIssueClosed({ + ...payload, + action: "closed", + issue: { ...payload.issue, state: "closed", updated_at: stamp }, + }); + expect(m.status).toHaveBeenCalledTimes(2); + expect(JSON.parse(m.metadata).state).toBe("closed"); +}); diff --git a/tests/api/plugins/github/issue-reopened.test.ts b/tests/api/plugins/github/issue-reopened.test.ts index fde8fb3044..ec2aa89013 100644 --- a/tests/api/plugins/github/issue-reopened.test.ts +++ b/tests/api/plugins/github/issue-reopened.test.ts @@ -14,13 +14,17 @@ vi.mock("../../../../apps/api/src/database", () => ({ query: { externalLinkTable: { findFirst: m.links }, taskTable: { findFirst: m.tasks }, + columnTable: { findFirst: async () => undefined }, }, }, })); vi.mock("../../../../apps/api/src/events", () => ({ publishEvent: m.publish })); vi.mock( "../../../../apps/api/src/plugins/github/services/task-service", - () => ({ + async (original) => ({ + ...(await original< + typeof import("../../../../apps/api/src/plugins/github/services/task-service") + >()), findAllIntegrationsByRepo: m.find, updateTaskStatus: m.status, }), @@ -71,14 +75,14 @@ it.each(["null", "[]", "42", '"text"', "invalid JSON"])( expect(m.update).toHaveBeenCalledWith( "first", { - metadata: { state: "open" }, + metadata: expect.objectContaining({ state: "open" }), }, expect.anything(), ); expect(m.update).toHaveBeenCalledWith( "second", { - metadata: { custom: "keep", state: "open" }, + metadata: expect.objectContaining({ custom: "keep", state: "open" }), }, expect.anything(), ); diff --git a/tests/api/plugins/github/outbound-intent.test.ts b/tests/api/plugins/github/outbound-intent.test.ts new file mode 100644 index 0000000000..ae8b0e263e --- /dev/null +++ b/tests/api/plugins/github/outbound-intent.test.ts @@ -0,0 +1,505 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { syncLatestTaskValue } from "../../../../apps/api/src/plugins/github/services/sync-latest-task-value"; +import { + inboundEcho, + withEchoConfirmation, +} from "../../../../apps/api/src/plugins/github/utils/inbound-echo"; +import { mergeSyncMetadata } from "../../../../apps/api/src/plugins/github/utils/merge-sync-metadata"; +import { + inboundStamp, + inboundOccurredAfterIntent, + isPendingOutboundEcho, + outboundStamp, + type OutboundIntent, + type SyncStamp, +} from "../../../../apps/api/src/plugins/github/utils/sync-echo"; +const m = vi.hoisted(() => ({ + current: { title: "", description: "", status: "" }, + stamps: {} as Record, + save: vi.fn(), +})); +vi.mock("../../../../apps/api/src/database", () => ({ + default: { + query: { + taskTable: { findFirst: async () => m.current }, + columnTable: { findFirst: async () => undefined }, + }, + }, +})); +vi.mock( + "../../../../apps/api/src/plugins/github/services/link-manager", + () => ({ + findExternalLinksByTask: async () => [ + { + id: "link", + integrationId: "integration", + resourceType: "issue", + metadata: JSON.stringify({ lastSync: m.stamps }), + integration: { + id: "integration", + projectId: "project", + type: "github", + }, + }, + ], + updateExternalLink: m.save, + }), +); +vi.mock( + "../../../../apps/api/src/plugins/github/services/apply-observed-task-value", + () => ({ + applyObservedTaskValue: async ( + _link: unknown, + _integration: unknown, + field: "title" | "description" | "state", + value: string, + _intentId: string, + _updatedAt: string | undefined, + expectedRevision: string, + ) => { + if (JSON.stringify(m.current) !== expectedRevision) return false; + if (field === "state") + m.current.status = value === "closed" ? "done" : "to-do"; + else m.current[field] = value; + m.stamps[field] = inboundStamp(m.stamps[field], value, "github"); + return true; + }, + }), +); +vi.mock( + "../../../../apps/api/src/plugins/github/services/integration-task-scope", + async (original) => ({ + ...(await original< + typeof import("../../../../apps/api/src/plugins/github/services/integration-task-scope") + >()), + integrationTaskRevision: async () => JSON.stringify(m.current), + }), +); +const link = { id: "link", integrationId: "integration" }; +beforeEach(() => { + m.stamps = {}; + m.save.mockReset().mockImplementation( + async ( + _id: string, + { + outbound, + observedOutbound, + }: { + observedOutbound?: { + field: string; + intentId: string; + updatedAt: string; + }; + outbound?: OutboundIntent & { + field: string; + value: string; + updatedAt?: string; + }; + }, + ) => { + if (observedOutbound) { + const entry = m.stamps[observedOutbound.field]?.outbound?.find( + (entry) => entry.intentId === observedOutbound.intentId, + ); + if ( + entry && + (!entry.observedUpdatedAt || + observedOutbound.updatedAt > entry.observedUpdatedAt) + ) + entry.observedUpdatedAt = observedOutbound.updatedAt; + } + if (!outbound) return; + m.stamps[outbound.field] = outboundStamp( + m.stamps[outbound.field], + outbound.value, + outbound.updatedAt, + outbound, + ); + }, + ); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +it.each(["title", "description", "state"] as const)( + "preserves the newest %s when an old webhook arrives before its PATCH response", + async (field) => { + const a = field === "state" ? "closed" : "A"; + const b = field === "state" ? "open" : "B"; + m.current = { title: a, description: a, status: "done" }; + const started = deferred(); + const release = deferred(); + let first = true; + let remote = a; + const webhooks: Promise[] = []; + const write = async (value: string) => { + if (first) { + first = false; + started.resolve(); + await release.promise; + } + remote = value; + // The provider does not wait for webhook processing before responding. + webhooks.push( + withEchoConfirmation( + async () => remote, + (current) => { + if ( + !inboundEcho(m.stamps[field], value, `${value}-stamp`, current, { + linkId: "link", + field, + }) + ) { + if (field === "state") + m.current.status = value === "closed" ? "done" : "to-do"; + else m.current[field] = value; + } + return Promise.resolve(); + }, + ), + ); + return `${value}-stamp`; + }; + const older = syncLatestTaskValue("task", "project", link, field, a, write); + await started.promise; + m.current = { title: b, description: b, status: "to-do" }; + await syncLatestTaskValue("task", "project", link, field, b, write); + release.resolve(); + await older; + await Promise.all(webhooks); + expect(remote).toBe(b); + expect(field === "state" ? m.current.status : m.current[field]).toBe( + field === "state" ? "to-do" : b, + ); + expect(m.stamps[field].outbound?.some((entry) => entry.pending)).toBe( + false, + ); + expect(inboundEcho(m.stamps[field], a, "legitimate-later-stamp", a)).toBe( + false, + ); + }, +); +it("clears failed intents so a later legitimate provider edit is accepted", async () => { + await expect( + syncLatestTaskValue("task", "project", link, "title", "A", async () => { + throw Object.assign(new Error("provider failed"), { status: 422 }); + }), + ).rejects.toThrow("provider failed"); + expect(isPendingOutboundEcho(m.stamps.title, "A")).toBe(false); + expect(inboundEcho(m.stamps.title, "A", "later", "A")).toBe(false); +}); +it("does not resurrect a settled intent from stale metadata", () => { + const pending = outboundStamp(undefined, "A", undefined, { + intentId: "a", + pending: true, + }); + const completed = outboundStamp(pending, "A", "stamp", { + intentId: "a", + pending: false, + }); + const merged = mergeSyncMetadata( + { lastSync: { title: completed } }, + { lastSync: { title: pending } }, + ); + expect(merged.lastSync?.title.outbound).toHaveLength(1); + expect(isPendingOutboundEcho(merged.lastSync?.title, "A")).toBe(false); +}); +it("keeps an active intent while bounding completed rapid-write history", () => { + let stamp = outboundStamp(undefined, "A", undefined, { + intentId: "a", + pending: true, + }); + for (let index = 0; index < 50; index++) + stamp = outboundStamp(stamp, `B${index}`, `stamp-${index}`, { + intentId: `b${index}`, + pending: false, + }); + expect(stamp.outbound?.filter((entry) => !entry.pending)).toHaveLength(32); + expect(isPendingOutboundEcho(stamp, "A")).toBe(true); +}); + +it.each( + (["title", "description", "state"] as const).flatMap((field) => + [false, true].map((colliding) => ({ field, colliding })), + ), +)( + "preserves a later provider $field edit back to an in-flight value (collision=$colliding)", + async ({ field, colliding }) => { + const a = field === "state" ? "closed" : "A"; + const b = field === "state" ? "open" : "B"; + m.current = { title: a, description: a, status: "done" }; + const started = deferred(); + const release = deferred(); + let remote = a; + const firstVersion = "2026-09-30T00:00:01Z"; + const laterVersion = colliding ? firstVersion : "2026-09-30T00:00:03Z"; + const outbound = syncLatestTaskValue( + "task", + "project", + link, + field, + a, + async (value) => { + remote = value; + started.resolve(); + await release.promise; + return firstVersion; + }, + async () => remote, + ); + await started.promise; + const apply = (value: string, version: string) => + withEchoConfirmation( + async () => remote, + (current) => { + if ( + !inboundEcho(m.stamps[field], value, version, current, { + linkId: "link", + field, + }) + ) { + if (field === "state") + m.current.status = value === "closed" ? "done" : "to-do"; + else m.current[field] = value; + m.stamps[field] = inboundStamp(m.stamps[field], value, "github"); + } + return Promise.resolve(); + }, + ); + const original = apply(a, firstVersion); + remote = b; + await apply(b, "2026-09-30T00:00:02Z"); + remote = a; + const legitimate = apply(a, laterVersion); + await vi.waitFor(() => + expect(m.stamps[field].outbound?.[0].observedUpdatedAt).toBe( + laterVersion, + ), + ); + release.resolve(); + await Promise.all([outbound, original, legitimate]); + expect(remote).toBe(a); + expect(field === "state" ? m.current.status : m.current[field]).toBe( + field === "state" ? "done" : a, + ); + }, +); +it.each([ + new Error("lost response"), + Object.assign(new Error("timeout"), { status: 408 }), + Object.assign(new Error("gateway failure"), { status: 502 }), +])( + "recognizes a potentially applied failed write after a newer local edit (%s)", + async (error) => { + m.current = { title: "A", description: "", status: "to-do" }; + let remote = "A"; + await expect( + syncLatestTaskValue("task", "project", link, "title", "A", async () => { + throw error; + }), + ).rejects.toThrow(error.message); + m.current.title = "B"; + await syncLatestTaskValue( + "task", + "project", + link, + "title", + "B", + async (value) => { + remote = value; + return "2026-09-30T00:00:02Z"; + }, + ); + const echo = await withEchoConfirmation( + async () => remote, + (current) => + Promise.resolve( + inboundEcho(m.stamps.title, "A", "2026-09-30T00:00:01Z", current), + ), + ); + expect(echo).toBe(true); + expect(m.current.title).toBe("B"); + const defer = vi.fn(async () => undefined); + await withEchoConfirmation( + async () => "A", + async (current) => + inboundEcho(m.stamps.title, "A", "2026-09-30T00:00:03Z", current), + defer, + ); + expect(defer).toHaveBeenCalledOnce(); + expect(m.current.title).toBe("B"); + }, +); + +it("bounds abandoned intent polling and reports an unacknowledged delivery", async () => { + vi.useFakeTimers(); + m.stamps.title = outboundStamp(undefined, "A", undefined, { + intentId: "abandoned", + pending: true, + }); + let attempts = 0; + const run = withEchoConfirmation( + async () => "A", + async () => { + attempts++; + return inboundEcho(m.stamps.title, "A", "version"); + }, + ); + const rejected = expect(run).rejects.toThrow("retry this webhook delivery"); + await vi.advanceTimersByTimeAsync(5000); + await rejected; + expect(attempts).toBeLessThanOrEqual(11); + vi.useRealTimers(); +}); + +it.each([false, true])( + "acknowledges an abandoned writer only after durable deferral (persistFailure=%s)", + async (persistFailure) => { + vi.useFakeTimers(); + m.stamps.title = outboundStamp(undefined, "A", undefined, { + intentId: "orphan", + pending: true, + }); + const defer = vi.fn(async () => { + if (persistFailure) throw new Error("database unavailable"); + }); + const run = withEchoConfirmation( + async () => "A", + async () => inboundEcho(m.stamps.title, "A", "version"), + defer, + ); + const result = persistFailure + ? expect(run).rejects.toThrow( + "Could not persist deferred webhook delivery", + ) + : expect(run).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(5000); + await result; + expect(defer).toHaveBeenCalledOnce(); + vi.useRealTimers(); + }, +); + +it("does not PATCH when the link disappears before its pending intent commits", async () => { + m.current.title = "B"; + m.save.mockResolvedValueOnce(false); + const write = vi.fn(async () => "stamp"); + await syncLatestTaskValue("task", "project", link, "title", "B", write); + expect(write).not.toHaveBeenCalled(); +}); + +it("confirms a delayed different value sharing an outbound provider version", async () => { + const stamp = outboundStamp(undefined, "A", "shared-version", { + intentId: "completed", + }); + expect(inboundEcho(stamp, "A", "shared-version")).toBe(true); + const read = vi.fn(async () => "A"); + expect( + await withEchoConfirmation(read, async (current) => + inboundEcho(stamp, "B", "shared-version", current), + ), + ).toBe(true); + expect(read).toHaveBeenCalledOnce(); + expect(inboundEcho(stamp, "B", "shared-version", "B")).toBe(false); +}); +it("durably defers a colliding event when provider confirmation fails", async () => { + const stamp = outboundStamp(undefined, "A", "shared-version"); + const defer = vi.fn(async () => undefined); + await withEchoConfirmation( + async () => { + throw new Error("provider unavailable"); + }, + async (current) => inboundEcho(stamp, "B", "shared-version", current), + defer, + ); + expect(defer).toHaveBeenCalledOnce(); +}); + +it("distinguishes earlier and intervening inbound edits within one millisecond", () => { + vi.useFakeTimers(); + const prior = inboundStamp(undefined, "B", "github"); + const pending = outboundStamp(prior, "A", undefined, { + intentId: "pending", + pending: true, + }); + const intent = pending.outbound?.find( + (entry) => entry.intentId === "pending", + ); + expect(prior.inboundAt).toBe(intent?.startedAt); + expect(inboundOccurredAfterIntent(prior, intent)).toBe(false); + const later = inboundStamp(pending, "B", "github"); + expect(later.inboundAt).toBe(intent?.startedAt); + expect(inboundOccurredAfterIntent(later, intent)).toBe(true); + vi.useRealTimers(); +}); + +it("confirms different inbound values sharing a provider version", async () => { + const version = "2026-09-30T00:00:03Z"; + const stamp = inboundStamp(undefined, "B", "github", version); + expect(inboundEcho(stamp, "A", "2026-09-30T00:00:02Z")).toBe(true); + const read = vi.fn(async () => "B"); + expect( + await withEchoConfirmation(read, async (current) => + inboundEcho(stamp, "A", version, current), + ), + ).toBe(true); + expect(read).toHaveBeenCalledOnce(); + expect(inboundEcho(stamp, "A", version, "A")).toBe(false); +}); + +it("rereads a provider confirmation after the locked sync state changes", async () => { + const version = "2026-09-30T00:00:03Z"; + let stamp = inboundStamp(undefined, "C", "github", version); + const read = vi.fn(async () => { + if (read.mock.calls.length === 1) { + stamp = inboundStamp(stamp, "C", "github", version); + return "B"; + } + return "C"; + }); + const result = await withEchoConfirmation( + read, + async (current, confirmation) => + inboundEcho(stamp, "B", version, current, { + linkId: "link", + field: "title", + localValue: stamp.value, + confirmation, + }), + ); + expect(result).toBe(true); + expect(read).toHaveBeenCalledTimes(2); +}); +it("bounds confirmation retries and durably defers continuous sync changes", async () => { + vi.useFakeTimers(); + try { + const version = "2026-09-30T00:00:03Z"; + let stamp = inboundStamp(undefined, "C", "github", version); + const read = vi.fn(async () => { + stamp = inboundStamp(stamp, "C", "github", version); + return "B"; + }); + const defer = vi.fn(async () => undefined); + const run = withEchoConfirmation( + read, + async (current, confirmation) => + inboundEcho(stamp, "B", version, current, { + linkId: "link", + field: "title", + localValue: stamp.value, + confirmation, + }), + defer, + ); + await vi.advanceTimersByTimeAsync(5000); + await run; + expect(defer).toHaveBeenCalledOnce(); + expect(read.mock.calls.length).toBeLessThanOrEqual(11); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/api/plugins/github/rapid-state-sync.test.ts b/tests/api/plugins/github/rapid-state-sync.test.ts new file mode 100644 index 0000000000..5beb0f54b3 --- /dev/null +++ b/tests/api/plugins/github/rapid-state-sync.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, expect, it, vi } from "vite-plus/test"; +import { handleTaskStatusChanged as github } from "../../../../apps/api/src/plugins/github/events/task-status-changed"; +import { handleTaskStatusChanged as gitea } from "../../../../apps/api/src/plugins/gitea/events/task-status-changed"; +const m = vi.hoisted(() => ({ status: "", write: vi.fn(), save: vi.fn() })); +vi.mock("../../../../apps/api/src/database", () => ({ + default: { + query: { + taskTable: { findFirst: async () => ({ status: m.status }) }, + columnTable: { findFirst: async () => undefined }, + }, + }, +})); +vi.mock( + "../../../../apps/api/src/plugins/github/services/link-manager", + () => ({ + findExternalLinksByTask: async () => [ + { + id: "link", + taskId: "task", + integrationId: "integration", + resourceType: "issue", + externalId: "1", + }, + ], + updateExternalLink: m.save, + }), +); +vi.mock("../../../../apps/api/src/plugins/github/utils/github-app", () => ({ + getGithubApp: () => ({}), + getVerifiedInstallationOctokit: async () => ({ + rest: { + issues: { + update: async ({ state }: { state: string }) => ({ + data: await m.write(state), + }), + }, + }, + }), +})); +vi.mock("../../../../apps/api/src/plugins/gitea/utils/gitea-api", () => ({ + createGiteaClient: () => ({ + updateIssue: async ( + _owner: string, + _repo: string, + _number: number, + { state }: { state: string }, + ) => m.write(state), + }), +})); +vi.mock("../../../../apps/api/src/plugins/github/utils/labels", () => ({ + removeLabel: vi.fn(), + addLabelsToIssue: vi.fn(), +})); +vi.mock("../../../../apps/api/src/plugins/gitea/utils/labels", () => ({ + removeLabelGitea: vi.fn(), + addLabelsToIssueGitea: vi.fn(), +})); +const context = { + integrationId: "integration", + projectId: "project", + config: { + repositoryOwner: "owner", + repositoryName: "repo", + installationId: 1, + repositoryId: 2, + verifiedGithubAccountId: "3", + verifiedByUserId: "user", + baseUrl: "https://gitea.example", + accessToken: "test", + }, +}; +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +beforeEach(() => vi.resetAllMocks()); +it.each([ + ["github", "closed"], + ["github", "open"], + ["gitea", "closed"], + ["gitea", "open"], +])( + "repairs a late %s %s response using the current task state", + async (provider, firstState) => { + const handler = provider === "github" ? github : gitea; + const finalState = firstState === "closed" ? "open" : "closed"; + const started = deferred(); + const release = deferred(); + let first = true; + let remote = finalState; + m.write.mockImplementation(async (state: string) => { + if (first) { + first = false; + started.resolve(); + await release.promise; + } + remote = state; + return { updated_at: `${state}-stamp` }; + }); + m.status = firstState === "closed" ? "done" : "in-progress"; + const event = { + taskId: "task", + projectId: "project", + userId: "user", + oldStatus: firstState === "closed" ? "in-progress" : "done", + newStatus: m.status, + }; + const older = handler(event, context); + await started.promise; + m.status = finalState === "closed" ? "done" : "in-progress"; + await handler( + { ...event, oldStatus: event.newStatus, newStatus: m.status }, + context, + ); + expect(remote).toBe(finalState); + release.resolve(); + await older; + expect(remote).toBe(finalState); + expect(m.write.mock.calls.map(([state]) => state)).toEqual([ + firstState, + finalState, + finalState, + ]); + expect(m.save.mock.calls.at(-1)?.[1]).toMatchObject({ + outbound: { + field: "state", + value: finalState, + updatedAt: `${finalState}-stamp`, + }, + }); + }, +); diff --git a/tests/api/plugins/github/rapid-text-sync.test.ts b/tests/api/plugins/github/rapid-text-sync.test.ts new file mode 100644 index 0000000000..8349d86dab --- /dev/null +++ b/tests/api/plugins/github/rapid-text-sync.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const m = vi.hoisted(() => ({ + current: { title: "", description: "" }, + links: vi.fn(), + update: vi.fn(), + save: vi.fn(), +})); +vi.mock( + "../../../../apps/api/src/plugins/github/services/link-manager", + () => ({ findExternalLinksByTask: m.links, updateExternalLink: m.save }), +); +vi.mock("../../../../apps/api/src/plugins/github/utils/github-app", () => ({ + getGithubApp: () => ({}), + getVerifiedInstallationOctokit: async () => ({ + rest: { issues: { update: m.update } }, + }), +})); +vi.mock("../../../../apps/api/src/database", () => ({ + default: { query: { taskTable: { findFirst: async () => m.current } } }, +})); +const { handleTaskTitleChanged } = + await import("../../../../apps/api/src/plugins/github/events/task-title-changed"); +const { handleTaskDescriptionChanged } = + await import("../../../../apps/api/src/plugins/github/events/task-description-changed"); +const context = { + integrationId: "integration", + projectId: "project", + config: { + repositoryOwner: "owner", + repositoryName: "repo", + installationId: 1, + repositoryId: 2, + verifiedGithubAccountId: "3", + verifiedByUserId: "user", + }, +}; +beforeEach(() => vi.resetAllMocks()); +function link(field: string, source: string, value: string) { + return { + id: "link", + integrationId: "integration", + taskId: "task", + resourceType: "issue", + externalId: "1", + metadata: JSON.stringify({ + lastSync: { + [field]: { source, value, timestamp: new Date().toISOString() }, + }, + }), + }; +} +describe("rapid github text edits", () => { + it("sends a second local title even immediately after the first sync", async () => { + m.current.title = "Second title"; + m.links.mockResolvedValue([link("title", "kaneo", "First title")]); + await handleTaskTitleChanged( + { + taskId: "task", + projectId: "project", + userId: "user", + oldTitle: "First title", + newTitle: "Second title", + }, + context, + ); + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ title: "Second title" }), + ); + }); + it("sends a different title immediately after an incoming github title", async () => { + m.current.title = "Local correction"; + m.links.mockResolvedValue([link("title", "github", "Remote title")]); + await handleTaskTitleChanged( + { + taskId: "task", + projectId: "project", + userId: "user", + oldTitle: "Remote title", + newTitle: "Local correction", + }, + context, + ); + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ title: "Local correction" }), + ); + }); + it("keeps suppressing an exact incoming echo", async () => { + m.current.title = "Remote title"; + m.links.mockResolvedValue([link("title", "github", "Remote title")]); + await handleTaskTitleChanged( + { + taskId: "task", + projectId: "project", + userId: "user", + oldTitle: "Old", + newTitle: "Remote title", + }, + context, + ); + expect(m.update).not.toHaveBeenCalled(); + }); + it("sends a second local description immediately after the first sync", async () => { + m.current.description = "Second description"; + m.links.mockResolvedValue([ + link("description", "kaneo", "First description"), + ]); + await handleTaskDescriptionChanged( + { + taskId: "task", + projectId: "project", + userId: "user", + oldDescription: "First description", + newDescription: "Second description", + }, + context, + ); + expect(m.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("Second description"), + }), + ); + }); +}); + +it("does not send a queued title after a newer task edit", async () => { + m.current.title = "Newest"; + m.links.mockResolvedValue([link("title", "kaneo", "Old")]); + await handleTaskTitleChanged( + { + taskId: "task", + projectId: "project", + userId: "user", + oldTitle: "Old", + newTitle: "Queued", + }, + context, + ); + expect(m.update).not.toHaveBeenCalled(); +}); + +vi.mock("../../../../apps/api/src/plugins/gitea/utils/gitea-api", () => ({ + createGiteaClient: () => ({ + updateIssue: async ( + _owner: string, + _repo: string, + _number: number, + input: unknown, + ) => m.update(input), + }), +})); +const giteaTitle = ( + await import("../../../../apps/api/src/plugins/gitea/events/task-title-changed") +).handleTaskTitleChanged; +const giteaDescription = ( + await import("../../../../apps/api/src/plugins/gitea/events/task-description-changed") +).handleTaskDescriptionChanged; +it.each([ + ["github", "title"], + ["github", "description"], + ["gitea", "title"], + ["gitea", "description"], +])( + "repairs %s %s when an older request completes after the latest edit", + async (provider, field) => { + let finishOld!: () => void; + let oldStarted!: () => void; + const started = new Promise((resolve) => { + oldStarted = resolve; + }); + const delayed = new Promise((resolve) => { + finishOld = resolve; + }); + let remote = ""; + m.current.title = "A"; + m.current.description = "A"; + m.links.mockResolvedValue([link(field, "kaneo", "Before")]); + m.update.mockImplementation(async (input) => { + const value = + field === "title" ? input.title : input.body.split("\n\n---")[0]; + if (value === "A") { + oldStarted(); + await delayed; + } + remote = value; + return { + updated_at: "provider-time", + data: { updated_at: "provider-time" }, + }; + }); + const handler = + provider === "github" + ? field === "title" + ? handleTaskTitleChanged + : handleTaskDescriptionChanged + : field === "title" + ? giteaTitle + : giteaDescription; + const config = + provider === "github" + ? context + : { + ...context, + config: { + ...context.config, + baseUrl: "https://gitea.example", + accessToken: "test", + }, + }; + function event(value: string) { + return { + taskId: "task", + projectId: "project", + userId: "user", + oldTitle: "Before", + newTitle: value, + oldDescription: "Before", + newDescription: value, + }; + } + const first = handler(event("A"), config); + await started; + m.current.title = "B"; + m.current.description = "B"; + await handler(event("B"), config); + expect(remote).toBe("B"); + finishOld(); + await first; + expect(remote).toBe("B"); + expect(m.update).toHaveBeenCalledTimes(3); + expect(m.save.mock.calls.at(-1)?.[1].outbound.value).toBe("B"); + }, +);