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
-





+




## 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 && (
)}
+ {boardError && (
+
+ {t("tasks:calendar.loadError")}{" "}
+ void retryBoard()}
+ >
+ {t("tasks:descriptionRetry")}
+
+
+ )}
) : (
)
- ) : (
+ ) : 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;
+ }) => (
+ {
+ const task = project.columns[0]?.tasks[0];
+ if (task) onTaskClick(task);
+ }}
+ >
+ Open task
+
+ ),
+}));
+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 ? (
+
+ ) : 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");
+ },
+);