diff --git a/docs/writeback-spec-coverage.md b/docs/writeback-spec-coverage.md index 48c31d69..2b4e3d02 100644 --- a/docs/writeback-spec-coverage.md +++ b/docs/writeback-spec-coverage.md @@ -36,6 +36,7 @@ Contract-backed means the endpoint uses `contractEndpoint(...)`, loads its reque | reddit | None | 0 | 2 | Inline JS schemas. | | s3 | None | 0 | 2 | Inline JS schemas. | | salesforce | None | 0 | 5 | Inline JS schemas. | +| shortcut | None | 0 | 11 | Inline JS schemas for Shortcut categories, custom fields, epics, groups, iterations, labels, members, milestones, projects, stories, and workflows. | | sharepoint | None | 0 | 2 | Inline JS schemas. | | slack | None | 0 | 4 | Inline JS schemas. | | teams | None | 0 | 3 | Inline JS schemas. | diff --git a/package-lock.json b/package-lock.json index 4e8983e7..9e5f8033 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1200,6 +1200,10 @@ "resolved": "packages/shopify", "link": true }, + "node_modules/@relayfile/adapter-shortcut": { + "resolved": "packages/shortcut", + "link": true + }, "node_modules/@relayfile/adapter-slack": { "resolved": "packages/slack", "link": true @@ -4083,10 +4087,10 @@ }, "packages/airtable": { "name": "@relayfile/adapter-airtable", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4259,10 +4263,10 @@ }, "packages/asana": { "name": "@relayfile/adapter-asana", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4429,10 +4433,10 @@ }, "packages/azure-blob": { "name": "@relayfile/azure-blob", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4445,10 +4449,10 @@ }, "packages/box": { "name": "@relayfile/box", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4461,10 +4465,10 @@ }, "packages/calendly": { "name": "@relayfile/adapter-calendly", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4637,10 +4641,10 @@ }, "packages/clickup": { "name": "@relayfile/adapter-clickup", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -4807,10 +4811,10 @@ }, "packages/cloudflare": { "name": "@relayfile/adapter-cloudflare", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -4826,10 +4830,10 @@ }, "packages/confluence": { "name": "@relayfile/adapter-confluence", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5011,7 +5015,7 @@ }, "packages/core": { "name": "@relayfile/adapter-core", - "version": "0.5.12", + "version": "0.5.14", "license": "Apache-2.0", "dependencies": { "@scalar/postman-to-openapi": "^0.6.0", @@ -5038,10 +5042,10 @@ }, "packages/daytona": { "name": "@relayfile/adapter-daytona", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5057,10 +5061,10 @@ }, "packages/docker-hub": { "name": "@relayfile/adapter-docker-hub", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5272,10 +5276,10 @@ }, "packages/dropbox": { "name": "@relayfile/adapter-dropbox", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5288,10 +5292,10 @@ }, "packages/fathom": { "name": "@relayfile/adapter-fathom", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5307,10 +5311,10 @@ }, "packages/gcp": { "name": "@relayfile/adapter-gcp", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5326,10 +5330,10 @@ }, "packages/gcs": { "name": "@relayfile/gcs", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5342,10 +5346,10 @@ }, "packages/github": { "name": "@relayfile/adapter-github", - "version": "0.5.4", + "version": "0.5.5", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -5362,10 +5366,10 @@ }, "packages/gitlab": { "name": "@relayfile/adapter-gitlab", - "version": "0.4.1", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5500,10 +5504,10 @@ }, "packages/gmail": { "name": "@relayfile/gmail", - "version": "0.3.1", + "version": "0.3.3", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5516,10 +5520,10 @@ }, "packages/google-calendar": { "name": "@relayfile/adapter-google-calendar", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5693,10 +5697,10 @@ }, "packages/google-drive": { "name": "@relayfile/google-drive", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -5709,10 +5713,10 @@ }, "packages/granola": { "name": "@relayfile/adapter-granola", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -5924,10 +5928,10 @@ }, "packages/hubspot": { "name": "@relayfile/adapter-hubspot", - "version": "0.4.1", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6094,10 +6098,10 @@ }, "packages/intercom": { "name": "@relayfile/adapter-intercom", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6264,10 +6268,10 @@ }, "packages/jira": { "name": "@relayfile/adapter-jira", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6434,10 +6438,10 @@ }, "packages/linear": { "name": "@relayfile/adapter-linear", - "version": "0.4.9", + "version": "0.4.10", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6573,10 +6577,10 @@ }, "packages/mailgun": { "name": "@relayfile/adapter-mailgun", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6743,10 +6747,10 @@ }, "packages/mixpanel": { "name": "@relayfile/adapter-mixpanel", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -6919,10 +6923,10 @@ }, "packages/neon": { "name": "@relayfile/adapter-neon", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -6938,10 +6942,10 @@ }, "packages/notion": { "name": "@relayfile/adapter-notion", - "version": "0.4.1", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7076,10 +7080,10 @@ }, "packages/onedrive": { "name": "@relayfile/onedrive", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7092,10 +7096,10 @@ }, "packages/pipedrive": { "name": "@relayfile/adapter-pipedrive", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7262,10 +7266,10 @@ }, "packages/postgres": { "name": "@relayfile/postgres", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@electric-sql/pglite": "^0.4.5", @@ -7279,10 +7283,10 @@ }, "packages/posthog": { "name": "@relayfile/adapter-posthog", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7299,10 +7303,10 @@ }, "packages/recall": { "name": "@relayfile/adapter-recall", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7514,10 +7518,10 @@ }, "packages/reddit": { "name": "@relayfile/adapter-reddit", - "version": "0.2.7", + "version": "0.2.8", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7729,10 +7733,10 @@ }, "packages/redis": { "name": "@relayfile/redis", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7745,20 +7749,20 @@ }, "packages/relay-helpers": { "name": "@relayfile/relay-helpers", - "version": "0.4.9", + "version": "0.4.10", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12", - "@relayfile/adapter-linear": "^0.4.9", - "@relayfile/adapter-reddit": "^0.2.7" + "@relayfile/adapter-core": "^0.5.14", + "@relayfile/adapter-linear": "^0.4.10", + "@relayfile/adapter-reddit": "^0.2.8" } }, "packages/s3": { "name": "@relayfile/s3", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -7771,10 +7775,10 @@ }, "packages/salesforce": { "name": "@relayfile/adapter-salesforce", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -7941,10 +7945,10 @@ }, "packages/segment": { "name": "@relayfile/adapter-segment", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8117,10 +8121,10 @@ }, "packages/sendgrid": { "name": "@relayfile/adapter-sendgrid", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8287,10 +8291,10 @@ }, "packages/sharepoint": { "name": "@relayfile/sharepoint", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@types/node": "^24.6.0", @@ -8303,10 +8307,10 @@ }, "packages/shopify": { "name": "@relayfile/adapter-shopify", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8471,12 +8475,31 @@ "version": "6.0.9", "dev": true }, + "packages/shortcut": { + "name": "@relayfile/adapter-shortcut", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@relayfile/adapter-core": "^0.5.14" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + } + }, "packages/slack": { "name": "@relayfile/adapter-slack", - "version": "0.4.1", + "version": "0.4.2", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8612,10 +8635,10 @@ }, "packages/stripe": { "name": "@relayfile/adapter-stripe", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8782,10 +8805,10 @@ }, "packages/teams": { "name": "@relayfile/adapter-teams", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -8933,10 +8956,10 @@ }, "packages/telegram": { "name": "@relayfile/adapter-telegram", - "version": "0.1.2", + "version": "0.1.3", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", @@ -8953,7 +8976,7 @@ }, "packages/webhook-server": { "name": "@relayfile/webhook-server", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { "@hono/node-server": "^1.19.11", @@ -8974,10 +8997,10 @@ }, "packages/x": { "name": "@relayfile/adapter-x", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@agent-relay/sdk": "^6.0.7", @@ -9118,10 +9141,10 @@ }, "packages/zendesk": { "name": "@relayfile/adapter-zendesk", - "version": "0.4.0", + "version": "0.4.1", "license": "Apache-2.0", "dependencies": { - "@relayfile/adapter-core": "^0.5.12" + "@relayfile/adapter-core": "^0.5.14" }, "devDependencies": { "@relayfile/sdk": "^0.6.0", diff --git a/packages/core/src/inbound/catalog.generated.json b/packages/core/src/inbound/catalog.generated.json index 917e9cbc..661cf064 100644 --- a/packages/core/src/inbound/catalog.generated.json +++ b/packages/core/src/inbound/catalog.generated.json @@ -1,6 +1,6 @@ { "schema": "relayfile.inbound-capability-catalog/1", - "catalogVersion": "sha256:edcb9170384faeebebe1a4c535552d6c6e2e96c69df68373d588322080fd96b0", + "catalogVersion": "sha256:4ff718ab3ab703411833b0a59c2ae21966b6d793f952974c8302dcea9ed7d680", "capabilities": [ { "schema": "relayfile.inbound-capability/1", @@ -237,6 +237,38 @@ "semanticPayload": "canonical-json-v1" } }, + { + "schema": "relayfile.inbound-capability/1", + "id": "shortcut.nango", + "providerId": "shortcut", + "pathRoot": "/shortcut", + "providerConfigAliases": [ + "shortcut", + "shortcut-relay" + ], + "source": "nango", + "eventKinds": [ + "forward", + "sync", + "webhook" + ], + "logicalKey": { + "version": "1", + "strategies": [ + "provider-delivery-id", + "nango-sync-page", + "semantic-payload" + ], + "providerDeliveryIdHeaders": [ + "x-nango-delivery-id", + "x-nango-webhook-id", + "x-nango-id", + "webhook-id" + ], + "unknownEventFallback": "raw-body", + "semanticPayload": "canonical-json-v1" + } + }, { "schema": "relayfile.inbound-capability/1", "id": "slack.nango", diff --git a/packages/core/src/inbound/catalog.generated.ts b/packages/core/src/inbound/catalog.generated.ts index 5ffac230..d29a7c48 100644 --- a/packages/core/src/inbound/catalog.generated.ts +++ b/packages/core/src/inbound/catalog.generated.ts @@ -242,6 +242,38 @@ export const INBOUND_CAPABILITY_CATALOG: readonly InboundCapabilityDeclaration[] "semanticPayload": "canonical-json-v1" } }, + { + "schema": "relayfile.inbound-capability/1", + "id": "shortcut.nango", + "providerId": "shortcut", + "pathRoot": "/shortcut", + "providerConfigAliases": [ + "shortcut", + "shortcut-relay" + ], + "source": "nango", + "eventKinds": [ + "forward", + "sync", + "webhook" + ], + "logicalKey": { + "version": "1", + "strategies": [ + "provider-delivery-id", + "nango-sync-page", + "semantic-payload" + ], + "providerDeliveryIdHeaders": [ + "x-nango-delivery-id", + "x-nango-webhook-id", + "x-nango-id", + "webhook-id" + ], + "unknownEventFallback": "raw-body", + "semanticPayload": "canonical-json-v1" + } + }, { "schema": "relayfile.inbound-capability/1", "id": "slack.nango", @@ -277,4 +309,4 @@ export const INBOUND_CAPABILITY_CATALOG: readonly InboundCapabilityDeclaration[] } } ] as const; -export const INBOUND_CAPABILITY_CATALOG_VERSION = "sha256:edcb9170384faeebebe1a4c535552d6c6e2e96c69df68373d588322080fd96b0" as const; +export const INBOUND_CAPABILITY_CATALOG_VERSION = "sha256:4ff718ab3ab703411833b0a59c2ae21966b6d793f952974c8302dcea9ed7d680" as const; diff --git a/packages/core/src/runtime/file-native-router.test.ts b/packages/core/src/runtime/file-native-router.test.ts index 494c9faa..8bb7923a 100644 --- a/packages/core/src/runtime/file-native-router.test.ts +++ b/packages/core/src/runtime/file-native-router.test.ts @@ -63,6 +63,24 @@ const resources: readonly AdapterResourceConfig[] = [ const issueId = "11111111-1111-1111-1111-111111111111"; +const shortcutUpdateOnlyResource: AdapterResourceConfig = { + name: "custom-fields", + path: "/shortcut/custom-fields", + pathPattern: /^\/shortcut\/custom-fields(?:\/[^/]+(?:\.json)?)?$/, + idPattern: /^\d+$/, + schema: "discovery/shortcut/custom-fields/.schema.json", + operations: ["update", "delete"], +}; + +const shortcutReadOnlyResource: AdapterResourceConfig = { + name: "members", + path: "/shortcut/members", + pathPattern: /^\/shortcut\/members(?:\/[^/]+(?:\.json)?)?$/, + idPattern: /^\d+$/, + schema: "discovery/shortcut/members/.schema.json", + operations: [], +}; + test("classifyWrite maps canonical ids to patch and drafts to create", () => { const patch = classifyWrite(`/linear/issues/${issueId}.json`, resources); assert.equal(patch?.kind, "patch"); @@ -77,6 +95,14 @@ test("classifyWrite maps canonical ids to patch and drafts to create", () => { assert.equal(classifyWrite("/linear/issues", resources), null); }); +test("classifyWrite honors resource operation capabilities", () => { + assert.equal(classifyWrite("/shortcut/custom-fields/123.json", [shortcutUpdateOnlyResource])?.kind, "patch"); + assert.equal(classifyWrite("/shortcut/custom-fields/draft.json", [shortcutUpdateOnlyResource]), null); + assert.equal(classifyWrite("/shortcut/custom-fields/123.json", [shortcutUpdateOnlyResource], { fsEvent: "delete" })?.kind, "delete"); + assert.equal(classifyWrite("/shortcut/members/123.json", [shortcutReadOnlyResource]), null); + assert.equal(classifyWrite("/shortcut/members/123.json", [shortcutReadOnlyResource], { fsEvent: "delete" }), null); +}); + test("classifyWrite maps canonical delete events to delete", () => { const deleted = classifyWrite(`/linear/issues/${issueId}.json`, resources, { fsEvent: "delete", @@ -157,6 +183,27 @@ test("validatePayload enforces create required fields", () => { }); }); +test("validatePayload enforces exactly-one object-level constraints", () => { + const schema: JsonSchema = { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + workflow_state_id: { type: "integer" }, + project_id: { type: "integer" }, + }, + oneOf: [ + { required: ["workflow_state_id"] }, + { required: ["project_id"] }, + ], + additionalProperties: false, + }; + assert.equal(validatePayload({ name: "Story", workflow_state_id: 1 }, schema, "create").ok, true); + assert.equal(validatePayload({ name: "Story", project_id: 2 }, schema, "create").ok, true); + assert.equal(validatePayload({ name: "Story" }, schema, "create").ok, false); + assert.equal(validatePayload({ name: "Story", workflow_state_id: 1, project_id: 2 }, schema, "create").ok, false); +}); + test("validatePayload enforces additionalProperties false", () => { const result = validatePayload( { title: "Issue", unexpected: true }, diff --git a/packages/core/src/runtime/file-native-router.ts b/packages/core/src/runtime/file-native-router.ts index a98df918..63607915 100644 --- a/packages/core/src/runtime/file-native-router.ts +++ b/packages/core/src/runtime/file-native-router.ts @@ -15,7 +15,7 @@ export interface AdapterResourceConfig { readonly pathPattern: RegExp; readonly idPattern: RegExp; readonly schema: string; - readonly createExample: string; + readonly createExample?: string; readonly operations?: readonly AdapterResourceOperation[]; } @@ -110,6 +110,7 @@ export interface JsonSchema { readonly readOnly?: boolean; readonly enum?: readonly unknown[]; readonly items?: JsonSchema; + readonly oneOf?: readonly JsonSchema[]; } export type WritebackValidationReason = @@ -117,7 +118,8 @@ export type WritebackValidationReason = | "additionalProperties" | "readOnly" | "type" - | "enum"; + | "enum" + | "oneOf"; export class ReadOnlyFieldError extends Error { readonly field: string; @@ -160,7 +162,7 @@ export function classifyWrite( const canonical = testResourceId(resource.idPattern, id); if (event === "delete") { - if (!canonical) { + if (!canonical || (resource.operations && !resource.operations.includes("delete"))) { continue; } return { @@ -175,8 +177,14 @@ export function classifyWrite( continue; } + const kind = canonical ? "patch" : "create"; + const operation = kind === "patch" ? "update" : "create"; + if (resource.operations && !resource.operations.includes(operation)) { + continue; + } + return { - kind: canonical ? "patch" : "create", + kind, resource, id, canonical, @@ -214,6 +222,20 @@ export function validatePayload( ); } } + + if (schema.oneOf) { + const matchingBranches = schema.oneOf.filter((branch) => + (branch.required ?? []).every((field) => payload[field] !== undefined), + ).length; + if (matchingBranches !== 1) { + errors.push( + new WritebackValidationError({ + reason: "oneOf", + message: "Payload must satisfy exactly one allowed schema shape", + }), + ); + } + } } for (const [field, value] of Object.entries(payload)) { @@ -761,6 +783,8 @@ function defaultValidationMessage( return `${label} is required`; case "type": return `${label} does not match the schema type`; + case "oneOf": + return `${label} must satisfy exactly one allowed schema shape`; } } diff --git a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json index 993d0e9d..af2c6fa1 100644 --- a/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json +++ b/packages/core/src/scope-keys/adapters-without-known-scope-keys.generated.json @@ -203,6 +203,12 @@ "provider": "shopify", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, + { + "packageName": "@relayfile/adapter-shortcut", + "packagePath": "packages/shortcut", + "provider": "shortcut", + "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" + }, { "packageName": "@relayfile/adapter-slack", "packagePath": "packages/slack", diff --git a/packages/core/src/scope-keys/catalog.generated.ts b/packages/core/src/scope-keys/catalog.generated.ts index 94ed7255..f1f5e5dd 100644 --- a/packages/core/src/scope-keys/catalog.generated.ts +++ b/packages/core/src/scope-keys/catalog.generated.ts @@ -249,6 +249,12 @@ export const ADAPTERS_WITHOUT_KNOWN_SCOPE_KEYS = [ "provider": "shopify", "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" }, + { + "packageName": "@relayfile/adapter-shortcut", + "packagePath": "packages/shortcut", + "provider": "shortcut", + "reason": "No supportedScopeKeys() implementation and no mapping.yaml scopeKeys block" + }, { "packageName": "@relayfile/adapter-slack", "packagePath": "packages/slack", diff --git a/packages/core/src/triggers/catalog.generated.json b/packages/core/src/triggers/catalog.generated.json index cfefbba3..a600cd6f 100644 --- a/packages/core/src/triggers/catalog.generated.json +++ b/packages/core/src/triggers/catalog.generated.json @@ -487,6 +487,14 @@ "product.paid", "product.update" ], + "shortcut": [ + "epic.create", + "epic.delete", + "epic.update", + "story.create", + "story.delete", + "story.update" + ], "slack": [ "channel.archived", "channel.created", diff --git a/packages/core/src/triggers/catalog.generated.ts b/packages/core/src/triggers/catalog.generated.ts index ea09fa31..ce9079d0 100644 --- a/packages/core/src/triggers/catalog.generated.ts +++ b/packages/core/src/triggers/catalog.generated.ts @@ -495,6 +495,14 @@ export const KNOWN_TRIGGER_CATALOG = { "product.paid", "product.update" ], + "shortcut": [ + "epic.create", + "epic.delete", + "epic.update", + "story.create", + "story.delete", + "story.update" + ], "slack": [ "channel.archived", "channel.created", diff --git a/packages/core/src/writeback-paths/catalog.generated.json b/packages/core/src/writeback-paths/catalog.generated.json index a29f4e66..2ee0a1f2 100644 --- a/packages/core/src/writeback-paths/catalog.generated.json +++ b/packages/core/src/writeback-paths/catalog.generated.json @@ -852,6 +852,74 @@ } ] }, + "shortcut": { + "categories": [ + { + "path": "/shortcut/categories", + "params": [] + } + ], + "custom-fields": [ + { + "path": "/shortcut/custom-fields", + "params": [] + } + ], + "epics": [ + { + "path": "/shortcut/epics", + "params": [] + } + ], + "groups": [ + { + "path": "/shortcut/groups", + "params": [] + } + ], + "iterations": [ + { + "path": "/shortcut/iterations", + "params": [] + } + ], + "labels": [ + { + "path": "/shortcut/labels", + "params": [] + } + ], + "members": [ + { + "path": "/shortcut/members", + "params": [] + } + ], + "milestones": [ + { + "path": "/shortcut/milestones", + "params": [] + } + ], + "projects": [ + { + "path": "/shortcut/projects", + "params": [] + } + ], + "stories": [ + { + "path": "/shortcut/stories", + "params": [] + } + ], + "workflows": [ + { + "path": "/shortcut/workflows", + "params": [] + } + ] + }, "slack": { "direct-messages": [ { diff --git a/packages/core/src/writeback-paths/catalog.generated.ts b/packages/core/src/writeback-paths/catalog.generated.ts index 5a1398d5..41829ff0 100644 --- a/packages/core/src/writeback-paths/catalog.generated.ts +++ b/packages/core/src/writeback-paths/catalog.generated.ts @@ -861,6 +861,74 @@ export const WRITEBACK_PATH_CATALOG = { } ] }, + "shortcut": { + "categories": [ + { + "path": "/shortcut/categories", + "params": [] + } + ], + "custom-fields": [ + { + "path": "/shortcut/custom-fields", + "params": [] + } + ], + "epics": [ + { + "path": "/shortcut/epics", + "params": [] + } + ], + "groups": [ + { + "path": "/shortcut/groups", + "params": [] + } + ], + "iterations": [ + { + "path": "/shortcut/iterations", + "params": [] + } + ], + "labels": [ + { + "path": "/shortcut/labels", + "params": [] + } + ], + "members": [ + { + "path": "/shortcut/members", + "params": [] + } + ], + "milestones": [ + { + "path": "/shortcut/milestones", + "params": [] + } + ], + "projects": [ + { + "path": "/shortcut/projects", + "params": [] + } + ], + "stories": [ + { + "path": "/shortcut/stories", + "params": [] + } + ], + "workflows": [ + { + "path": "/shortcut/workflows", + "params": [] + } + ] + }, "slack": { "direct-messages": [ { diff --git a/packages/core/tests/inbound/catalog-generator.test.ts b/packages/core/tests/inbound/catalog-generator.test.ts index 7d3bcea9..2e411be7 100644 --- a/packages/core/tests/inbound/catalog-generator.test.ts +++ b/packages/core/tests/inbound/catalog-generator.test.ts @@ -35,7 +35,7 @@ test("generated inbound capability catalog is in sync with adapter declarations" assert.deepEqual( generation.sources.map((source) => source.providerId), - ["github", "gitlab", "gmail", "hubspot", "linear", "notion", "slack"], + ["github", "gitlab", "gmail", "hubspot", "linear", "notion", "shortcut", "slack"], ); }); diff --git a/packages/relay-helpers/src/generated/clients.ts b/packages/relay-helpers/src/generated/clients.ts index 439d98f1..f2dd68d2 100644 --- a/packages/relay-helpers/src/generated/clients.ts +++ b/packages/relay-helpers/src/generated/clients.ts @@ -98,6 +98,9 @@ export const sendgridClient = (opts?: RelayClientOptions): ProviderClient<'sendg export const sharepointClient = (opts?: RelayClientOptions): ProviderClient<'sharepoint'> => providerClient('sharepoint', opts); +export const shortcutClient = (opts?: RelayClientOptions): ProviderClient<'shortcut'> => + providerClient('shortcut', opts); + export const teamsClient = (opts?: RelayClientOptions): ProviderClient<'teams'> => providerClient('teams', opts); diff --git a/packages/shortcut/discovery/shortcut/.adapter.md b/packages/shortcut/discovery/shortcut/.adapter.md new file mode 100644 index 00000000..9aeb2cb6 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/.adapter.md @@ -0,0 +1,233 @@ +# Shortcut adapter + +The Shortcut adapter exposes categories, custom fields, epics, groups, iterations, labels, members, milestones, projects, stories, and workflows under `/shortcut`, with file-native discovery contracts for the corresponding Nango actions and canonical sync records. + +Canonical mounts: +- `/shortcut/categories/.json` - Category records. +- `/shortcut/custom-fields/.json` - Custom field records. +- `/shortcut/epics/.json` - Epic records. +- `/shortcut/groups/.json` - Group records. +- `/shortcut/iterations/.json` - Iteration records. +- `/shortcut/labels/.json` - Label records. +- `/shortcut/members/.json` - Member records. +- `/shortcut/milestones/.json` - Milestone records. +- `/shortcut/projects/.json` - Project records. +- `/shortcut/stories/.json` - Story records. +- `/shortcut/workflows/.json` - Workflow records. + +Resources: + +| Resource | Schema | Create example | ID pattern | What it does | +|---|---|---|---|---| +| `/shortcut/categories/.json` | `/shortcut/categories/.schema.json` | `/shortcut/categories/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut category. | +| `/shortcut/custom-fields/.json` | `/shortcut/custom-fields/.schema.json` | — | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Updates or deletes an existing Shortcut custom field. Shortcut does not expose a REST create endpoint for custom fields. | +| `/shortcut/epics/.json` | `/shortcut/epics/.schema.json` | `/shortcut/epics/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut epic. | +| `/shortcut/groups/.json` | `/shortcut/groups/.schema.json` | `/shortcut/groups/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut group. | +| `/shortcut/iterations/.json` | `/shortcut/iterations/.schema.json` | `/shortcut/iterations/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut iteration. | +| `/shortcut/labels/.json` | `/shortcut/labels/.schema.json` | `/shortcut/labels/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut label. | +| `/shortcut/members/.json` | `/shortcut/members/.schema.json` | — | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Reads Shortcut members. The REST API does not expose a member create operation. | +| `/shortcut/milestones/.json` | `/shortcut/milestones/.schema.json` | `/shortcut/milestones/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut milestone. | +| `/shortcut/projects/.json` | `/shortcut/projects/.schema.json` | `/shortcut/projects/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut project. | +| `/shortcut/stories/.json` | `/shortcut/stories/.schema.json` | `/shortcut/stories/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut story. Supply exactly one of `workflow_state_id` or `project_id`. | +| `/shortcut/workflows/.json` | `/shortcut/workflows/.schema.json` | `/shortcut/workflows/.create.example.json` | `^(?!_index$)(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}\|[A-Za-z0-9_.~-]+__(?:[0-9]+\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\|[0-9a-f]{32}))$` | Creates a Shortcut workflow record contract for discovery. | + +## Operations + +| To... | Do... | +|---|---| +| Read | `cat ` after listing the resource directory or following an alias when one is available. Use the resource table and ID patterns below to determine whether a resource uses a bare id, an adapter-specific slug/id filename, or an exact sidecar path such as `content.md`. | +| Edit | Write the resource update payload to the canonical resource path. For JSON resources, included mutable fields PATCH; fields marked `readOnly` in `.schema.json` are rejected. | +| Create | For resources with a create example, write JSON to any non-canonical filename such as `create request.json`. The adapter creates the record at its canonical resource path and rewrites the draft as `{ "created": "", "path": "", "url": "" }`. | +| Ignore | Editor scratch files named `partial.json`, `.tmp.json`, `.partial.json`, `*.tmp.json`, or `*.partial.json` are ignored and never treated as create drafts. | +| Delete | `rm ` for canonical records. | + +## ID Patterns +- `/shortcut/categories/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/custom-fields/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Non-canonical filenames are not writeback routes for this resource. +- `/shortcut/epics/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/groups/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/iterations/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/labels/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/members/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Non-canonical filenames are not writeback routes for this resource. +- `/shortcut/milestones/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/projects/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/stories/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. +- `/shortcut/workflows/.json`: `^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$`. Filenames that do not match this pattern are treated as create drafts. + +## Write field contracts + +### Create Shortcut category + +Resource: `/shortcut/categories/.json` +Schema: `/shortcut/categories/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/categories/.create.example.json` +Required fields: `name`. +Optional fields: none. + +Fields: + +- `name` (required, string) - Category name. + +### Update Shortcut custom field + +Resource: `/shortcut/custom-fields/.json` +Schema: `/shortcut/custom-fields/.schema.json` +Operations: `update`, `delete`. +Create example: none; use the provider UI or another supported operation. +Required fields: none at the top level. +Optional fields: `name`, `field_type`, `values`, `description`, `enabled`, `icon_set_identifier`. + +Fields: + +- `name` (optional, string) - Custom field name. +- `field_type` (optional, enum) - Custom field type. Allowed values: `text`, `textarea`, `date`, `url`, `single_select`, `multi_select`, `person`, `file`, `number`. +- `values` (optional, array) - Optional enum value updates. Omit a value to delete it. +- `values[].id` (optional, string) - Existing enum value id. +- `values[].value` (optional, string) - Enum value text. +- `values[].color_key` (optional, string) - Shortcut color key. +- `values[].enabled` (optional, boolean) - Whether the enum value is enabled. +- `description` (optional, string) - Custom field description. +- `enabled` (optional, boolean) - Whether the custom field is enabled. +- `icon_set_identifier` (optional, string) - Shortcut icon set identifier. + +### Create Shortcut epic + +Resource: `/shortcut/epics/.json` +Schema: `/shortcut/epics/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/epics/.create.example.json` +Required fields: `name`. +Optional fields: `description`, `group_id`. + +Fields: + +- `name` (required, string) - Epic name. +- `description` (optional, string) - Epic description. +- `group_id` (optional, string) - Shortcut group id. + +### Create Shortcut group + +Resource: `/shortcut/groups/.json` +Schema: `/shortcut/groups/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/groups/.create.example.json` +Required fields: `mention_name`, `name`. +Optional fields: `description`. + +Fields: + +- `mention_name` (required, string) - Group mention name. +- `name` (required, string) - Group name. +- `description` (optional, string) - Group description. + +### Create Shortcut iteration + +Resource: `/shortcut/iterations/.json` +Schema: `/shortcut/iterations/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/iterations/.create.example.json` +Required fields: `name`, `start_date`, `end_date`. +Optional fields: none. + +Fields: + +- `name` (required, string) - Iteration name. +- `start_date` (required, string, date) - Iteration start date. +- `end_date` (required, string, date) - Iteration end date. + +### Create Shortcut label + +Resource: `/shortcut/labels/.json` +Schema: `/shortcut/labels/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/labels/.create.example.json` +Required fields: `name`. +Optional fields: `color`. + +Fields: + +- `name` (required, string) - Label name. +- `color` (optional, string) - Label color. + +### Read Shortcut member + +Resource: `/shortcut/members/.json` +Schema: `/shortcut/members/.schema.json` +Operations: read-only. +Create example: none; use the provider UI or another supported operation. +Required fields: none at the top level. +Optional fields: `name`, `email`. + +Fields: + +- `name` (optional, string) - Member display name. +- `email` (optional, string, email) - Member email address. + +### Create Shortcut milestone + +Resource: `/shortcut/milestones/.json` +Schema: `/shortcut/milestones/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/milestones/.create.example.json` +Required fields: `name`. +Optional fields: `description`, `state`, `started_at_override`, `completed_at_override`, `categories`. + +Fields: + +- `name` (required, string) - Milestone name. +- `description` (optional, string) - Milestone description. +- `state` (optional, string) - Milestone state. +- `started_at_override` (optional, string, date-time) - Milestone start override. +- `completed_at_override` (optional, string, date-time) - Milestone completion override. +- `categories` (optional, array) - Category ids associated with the milestone. + +### Create Shortcut project + +Resource: `/shortcut/projects/.json` +Schema: `/shortcut/projects/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/projects/.create.example.json` +Required fields: `name`, `team_id`. +Optional fields: `description`. + +Fields: + +- `name` (required, string) - Project name. +- `team_id` (required, integer) - Shortcut team id. +- `description` (optional, string) - Project description. + +### Create Shortcut story + +Resource: `/shortcut/stories/.json` +Schema: `/shortcut/stories/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/stories/.create.example.json` +Required fields: `name`. +Optional fields: `description`, `priority`, `workflow_state_id`, `project_id`. +Validation: satisfy exactly one of 2 allowed payload shapes. + +Fields: + +- `name` (required, string) - Story title. +- `description` (optional, string) - Story description. +- `priority` (optional, integer) - Shortcut story priority. +- `workflow_state_id` (optional, integer) - Workflow state id. +- `project_id` (optional, integer) - Project id. + +### Create Shortcut workflow + +Resource: `/shortcut/workflows/.json` +Schema: `/shortcut/workflows/.schema.json` +Operations: `create`, `update`, `delete`. +Create example: `/shortcut/workflows/.create.example.json` +Required fields: `name`. +Optional fields: `description`. + +Fields: + +- `name` (required, string) - Workflow name. +- `description` (optional, string) - Workflow description. + +## Create Examples +Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document when the resource advertises one. The example intentionally omits read-only fields. diff --git a/packages/shortcut/discovery/shortcut/categories/.create.example.json b/packages/shortcut/discovery/shortcut/categories/.create.example.json new file mode 100644 index 00000000..61c50589 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/categories/.create.example.json @@ -0,0 +1,3 @@ +{ + "name": "Product" +} diff --git a/packages/shortcut/discovery/shortcut/categories/.schema.json b/packages/shortcut/discovery/shortcut/categories/.schema.json new file mode 100644 index 00000000..f8924b2b --- /dev/null +++ b/packages/shortcut/discovery/shortcut/categories/.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut category", + "type": "object", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Category name." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/custom-fields/.schema.json b/packages/shortcut/discovery/shortcut/custom-fields/.schema.json new file mode 100644 index 00000000..3449dc1a --- /dev/null +++ b/packages/shortcut/discovery/shortcut/custom-fields/.schema.json @@ -0,0 +1,133 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Update Shortcut custom field", + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Custom field name." + }, + "field_type": { + "enum": [ + "text", + "textarea", + "date", + "url", + "single_select", + "multi_select", + "person", + "file", + "number" + ], + "description": "Custom field type.", + "readOnly": true + }, + "values": { + "type": "array", + "description": "Optional enum value updates. Omit a value to delete it.", + "items": { + "type": "object", + "description": "Custom field enum value.", + "properties": { + "id": { + "type": "string", + "description": "Existing enum value id." + }, + "value": { + "type": "string", + "description": "Enum value text." + }, + "color_key": { + "type": "string", + "description": "Shortcut color key." + }, + "enabled": { + "type": "boolean", + "description": "Whether the enum value is enabled." + } + }, + "additionalProperties": true + } + }, + "description": { + "type": "string", + "description": "Custom field description." + }, + "enabled": { + "type": "boolean", + "description": "Whether the custom field is enabled." + }, + "icon_set_identifier": { + "type": "string", + "description": "Shortcut icon set identifier." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/epics/.create.example.json b/packages/shortcut/discovery/shortcut/epics/.create.example.json new file mode 100644 index 00000000..5ef9bd8d --- /dev/null +++ b/packages/shortcut/discovery/shortcut/epics/.create.example.json @@ -0,0 +1,3 @@ +{ + "name": "Q4 onboarding" +} diff --git a/packages/shortcut/discovery/shortcut/epics/.schema.json b/packages/shortcut/discovery/shortcut/epics/.schema.json new file mode 100644 index 00000000..ae1290c0 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/epics/.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut epic", + "type": "object", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Epic name." + }, + "description": { + "type": "string", + "description": "Epic description." + }, + "group_id": { + "type": "string", + "description": "Shortcut group id." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/groups/.create.example.json b/packages/shortcut/discovery/shortcut/groups/.create.example.json new file mode 100644 index 00000000..d79a8c98 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/groups/.create.example.json @@ -0,0 +1,4 @@ +{ + "mention_name": "product-eng", + "name": "Product engineering" +} diff --git a/packages/shortcut/discovery/shortcut/groups/.schema.json b/packages/shortcut/discovery/shortcut/groups/.schema.json new file mode 100644 index 00000000..61be6e60 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/groups/.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut group", + "type": "object", + "required": [ + "mention_name", + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "mention_name": { + "type": "string", + "description": "Group mention name." + }, + "name": { + "type": "string", + "description": "Group name." + }, + "description": { + "type": "string", + "description": "Group description." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/iterations/.create.example.json b/packages/shortcut/discovery/shortcut/iterations/.create.example.json new file mode 100644 index 00000000..84428e2c --- /dev/null +++ b/packages/shortcut/discovery/shortcut/iterations/.create.example.json @@ -0,0 +1,5 @@ +{ + "name": "Iteration 1", + "start_date": "2026-01-01", + "end_date": "2026-01-14" +} diff --git a/packages/shortcut/discovery/shortcut/iterations/.schema.json b/packages/shortcut/discovery/shortcut/iterations/.schema.json new file mode 100644 index 00000000..03342917 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/iterations/.schema.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut iteration", + "type": "object", + "required": [ + "name", + "start_date", + "end_date" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Iteration name." + }, + "start_date": { + "type": "string", + "format": "date", + "description": "Iteration start date." + }, + "end_date": { + "type": "string", + "format": "date", + "description": "Iteration end date." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/labels/.create.example.json b/packages/shortcut/discovery/shortcut/labels/.create.example.json new file mode 100644 index 00000000..0e3f7343 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/labels/.create.example.json @@ -0,0 +1,4 @@ +{ + "name": "Bug", + "color": "#d73a4a" +} diff --git a/packages/shortcut/discovery/shortcut/labels/.schema.json b/packages/shortcut/discovery/shortcut/labels/.schema.json new file mode 100644 index 00000000..87098520 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/labels/.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut label", + "type": "object", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Label name." + }, + "color": { + "type": "string", + "description": "Label color." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/members/.schema.json b/packages/shortcut/discovery/shortcut/members/.schema.json new file mode 100644 index 00000000..4dde6ad7 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/members/.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Read Shortcut member", + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Member display name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Member email address." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/milestones/.create.example.json b/packages/shortcut/discovery/shortcut/milestones/.create.example.json new file mode 100644 index 00000000..adb5f76c --- /dev/null +++ b/packages/shortcut/discovery/shortcut/milestones/.create.example.json @@ -0,0 +1,4 @@ +{ + "name": "Public beta", + "state": "Unstarted" +} diff --git a/packages/shortcut/discovery/shortcut/milestones/.schema.json b/packages/shortcut/discovery/shortcut/milestones/.schema.json new file mode 100644 index 00000000..93bcf016 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/milestones/.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut milestone", + "type": "object", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Milestone name." + }, + "description": { + "type": "string", + "description": "Milestone description." + }, + "state": { + "type": "string", + "description": "Milestone state." + }, + "started_at_override": { + "type": "string", + "format": "date-time", + "description": "Milestone start override." + }, + "completed_at_override": { + "type": "string", + "format": "date-time", + "description": "Milestone completion override." + }, + "categories": { + "type": "array", + "description": "Category ids associated with the milestone.", + "items": { + "type": "string", + "description": "Category id." + } + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/projects/.create.example.json b/packages/shortcut/discovery/shortcut/projects/.create.example.json new file mode 100644 index 00000000..cfd4e574 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/projects/.create.example.json @@ -0,0 +1,4 @@ +{ + "name": "Mobile refresh", + "team_id": 123 +} diff --git a/packages/shortcut/discovery/shortcut/projects/.schema.json b/packages/shortcut/discovery/shortcut/projects/.schema.json new file mode 100644 index 00000000..943b91ab --- /dev/null +++ b/packages/shortcut/discovery/shortcut/projects/.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut project", + "type": "object", + "required": [ + "name", + "team_id" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Project name." + }, + "team_id": { + "type": "integer", + "description": "Shortcut team id." + }, + "description": { + "type": "string", + "description": "Project description." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/stories/.create.example.json b/packages/shortcut/discovery/shortcut/stories/.create.example.json new file mode 100644 index 00000000..5beba35a --- /dev/null +++ b/packages/shortcut/discovery/shortcut/stories/.create.example.json @@ -0,0 +1,6 @@ +{ + "name": "Replace example story title", + "description": "Optional markdown body.", + "priority": 3, + "workflow_state_id": 500000001 +} diff --git a/packages/shortcut/discovery/shortcut/stories/.schema.json b/packages/shortcut/discovery/shortcut/stories/.schema.json new file mode 100644 index 00000000..46d2ee0d --- /dev/null +++ b/packages/shortcut/discovery/shortcut/stories/.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut story", + "type": "object", + "required": [ + "name" + ], + "oneOf": [ + { + "required": [ + "workflow_state_id" + ] + }, + { + "required": [ + "project_id" + ] + } + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Story title." + }, + "description": { + "type": "string", + "description": "Story description." + }, + "priority": { + "type": "integer", + "description": "Shortcut story priority." + }, + "workflow_state_id": { + "type": "integer", + "description": "Workflow state id." + }, + "project_id": { + "type": "integer", + "description": "Project id." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/discovery/shortcut/workflows/.create.example.json b/packages/shortcut/discovery/shortcut/workflows/.create.example.json new file mode 100644 index 00000000..333b24d1 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/workflows/.create.example.json @@ -0,0 +1,3 @@ +{ + "name": "Engineering workflow" +} diff --git a/packages/shortcut/discovery/shortcut/workflows/.schema.json b/packages/shortcut/discovery/shortcut/workflows/.schema.json new file mode 100644 index 00000000..4e9d1537 --- /dev/null +++ b/packages/shortcut/discovery/shortcut/workflows/.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Shortcut workflow", + "type": "object", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Provider canonical record id.", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "Provider creation timestamp.", + "readOnly": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "Provider last update timestamp.", + "readOnly": true + }, + "url": { + "type": "string", + "format": "uri", + "description": "Provider URL for the record.", + "readOnly": true + }, + "identifier": { + "type": "string", + "description": "Provider human-readable identifier or key.", + "readOnly": true + }, + "provider": { + "type": "string", + "description": "Relayfile provider name.", + "readOnly": true + }, + "objectType": { + "type": "string", + "description": "Relayfile object type.", + "readOnly": true + }, + "objectId": { + "type": "string", + "description": "Relayfile object id.", + "readOnly": true + }, + "workspaceId": { + "type": "string", + "description": "Relayfile workspace id.", + "readOnly": true + }, + "connectionId": { + "type": "string", + "description": "Relayfile connection id.", + "readOnly": true + }, + "_webhook": { + "type": "object", + "description": "Provider webhook metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "_connection": { + "type": "object", + "description": "Relayfile connection metadata captured during sync.", + "readOnly": true, + "additionalProperties": true + }, + "name": { + "type": "string", + "description": "Workflow name." + }, + "description": { + "type": "string", + "description": "Workflow description." + } + }, + "additionalProperties": false, + "description": "Full resource record schema. Fields marked readOnly are synced from the provider and cannot be written by agents." +} diff --git a/packages/shortcut/package.json b/packages/shortcut/package.json new file mode 100644 index 00000000..0d0e1c5f --- /dev/null +++ b/packages/shortcut/package.json @@ -0,0 +1,61 @@ +{ + "name": "@relayfile/adapter-shortcut", + "version": "0.1.0", + "description": "Shortcut adapter package for Relayfile", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./digest": { + "types": "./dist/digest.d.ts", + "import": "./dist/digest.js", + "default": "./dist/digest.js" + }, + "./path-mapper": { + "types": "./dist/path-mapper.d.ts", + "import": "./dist/path-mapper.js", + "default": "./dist/path-mapper.js" + }, + "./webhook": { + "types": "./dist/webhook-normalizer.d.ts", + "import": "./dist/webhook-normalizer.js", + "default": "./dist/webhook-normalizer.js" + }, + "./inbound": { + "types": "./dist/inbound.d.ts", + "import": "./dist/inbound.js", + "default": "./dist/inbound.js" + } + }, + "files": ["dist", "discovery"], + "scripts": { + "build": "tsc", + "test": "node --import tsx --test 'src/**/*.test.ts'", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@relayfile/adapter-core": "^0.5.14" + }, + "peerDependencies": { + "@relayfile/sdk": ">=0.6.0 <1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "tsx": "^4.20.6", + "typescript": "^5.9.3" + }, + "engines": { "node": ">=18.18" }, + "license": "Apache-2.0", + "publishConfig": { "access": "public" }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/relayfile-adapters", + "directory": "packages/shortcut" + } +} diff --git a/packages/shortcut/shortcut.mapping.yaml b/packages/shortcut/shortcut.mapping.yaml new file mode 100644 index 00000000..67fc08fa --- /dev/null +++ b/packages/shortcut/shortcut.mapping.yaml @@ -0,0 +1,7 @@ +webhooks: + story.create: {} + story.update: {} + story.delete: {} + epic.create: {} + epic.update: {} + epic.delete: {} diff --git a/packages/shortcut/src/digest.ts b/packages/shortcut/src/digest.ts new file mode 100644 index 00000000..5187c194 --- /dev/null +++ b/packages/shortcut/src/digest.ts @@ -0,0 +1,56 @@ +import { + createDigestHandler, + type DigestBullet, + type DigestChangeEvent, + type DigestContext, + type DigestHandler, + type DigestSection, + type DigestWindow, +} from "@relayfile/adapter-core/digest"; + +export type { DigestBullet, DigestChangeEvent, DigestContext, DigestHandler, DigestSection, DigestWindow }; + +export const digest: DigestHandler = createDigestHandler({ + provider: "shortcut", + identify: (path) => path.split("/").filter(Boolean).at(-1)?.replace(/\.json$/u, "") ?? path, + actionRules: [ + { verbs: "create|created|add|added|open|opened", pastTense: "was created" }, + { verbs: "delete|deleted|remove|removed", pastTense: "was deleted" }, + { verbs: "close|closed", pastTense: "was closed" }, + { verbs: "resolve|resolved", pastTense: "was resolved" }, + { verbs: "complete|completed|done", pastTense: "was completed" }, + { verbs: "archive|archived", pastTense: "was archived" }, + { verbs: "cancel|canceled|cancelled", pastTense: "was canceled" }, + { verbs: "update|updated|change|changed|edit|edited", pastTense: "was updated" }, + ], + alias: { mode: "any" }, + classify: (event) => terminalStateVerb(event), +}); + +function terminalStateVerb(event: DigestChangeEvent): string | null { + const content = asRecord(event.content); + const payload = asRecord(content?.payload) ?? content; + if (!payload) return null; + + const state = readLower(payload.state) ?? readLower(asRecord(payload.workflow_state)?.name) ?? readLower(asRecord(payload.epic_state)?.name); + const archived = payload.archived === true; + const completed = payload.completed === true || payload.completed_at != null || payload.completed_at_override != null; + const canceled = payload.canceled === true || payload.cancelled === true || payload.canceled_at != null || payload.cancelled_at != null; + + if (archived) return "was archived"; + if (canceled || state === "canceled" || state === "cancelled") return "was canceled"; + if (completed || state === "completed" || state === "done") return "was completed"; + if (state === "closed") return "was closed"; + if (state === "resolved") return "was resolved"; + return null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : null; +} + +function readLower(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined; +} diff --git a/packages/shortcut/src/emit-auxiliary-files.ts b/packages/shortcut/src/emit-auxiliary-files.ts new file mode 100644 index 00000000..f43e9b05 --- /dev/null +++ b/packages/shortcut/src/emit-auxiliary-files.ts @@ -0,0 +1,369 @@ +import { + EMIT_AUXILIARY_JSON_CONTENT_TYPE, + IndexFileReconciler, + PriorAliasReader, + runEmitBatch, + slugifyAlias, + type AuxiliaryEmitterClient, + type EmitDelete, + type EmitAuxiliaryFilesResult, + type EmitPlan, +} from "@relayfile/adapter-core"; +import { + computeShortcutPath, + computeShortcutRecordPath, + shortcutByAssigneeAliasPath, + shortcutByCreatorAliasPath, + shortcutByIdAliasPath, + shortcutByPriorityAliasPath, + shortcutByStateAliasPath, + shortcutByTitleAliasPath, + shortcutCollectionPath, + shortcutIndexPath, + shortcutLegacyByIdAliasPath, + shortcutRootIndexPath, +} from "./path-mapper.js"; +import type { ShortcutPathObjectType, ShortcutRecord } from "./types.js"; + +export interface EmitShortcutAuxiliaryFilesInput { + workspaceId: string; + categories?: readonly ShortcutRecord[]; + customFields?: readonly ShortcutRecord[]; + groups?: readonly ShortcutRecord[]; + iterations?: readonly ShortcutRecord[]; + labels?: readonly ShortcutRecord[]; + members?: readonly ShortcutRecord[]; + milestones?: readonly ShortcutRecord[]; + projects?: readonly ShortcutRecord[]; + stories?: readonly ShortcutRecord[]; + epics?: readonly ShortcutRecord[]; + workflows?: readonly ShortcutRecord[]; +} + +interface ShortcutIndexRow { + id: string; + title: string; + updated: string; + canonicalPath: string; + aliasKeys?: readonly string[]; +} + +type BucketKey = keyof EmitShortcutAuxiliaryFilesInput; + +const BUCKETS: readonly [ShortcutPathObjectType, BucketKey, string][] = [ + ["category", "categories", "categories"], + ["custom-field", "customFields", "custom-fields"], + ["epic", "epics", "epics"], + ["group", "groups", "groups"], + ["iteration", "iterations", "iterations"], + ["label", "labels", "labels"], + ["member", "members", "members"], + ["milestone", "milestones", "milestones"], + ["project", "projects", "projects"], + ["story", "stories", "stories"], + ["workflow", "workflows", "workflows"], +]; + +const JSON_CONTENT_TYPE = EMIT_AUXILIARY_JSON_CONTENT_TYPE; + +export async function emitShortcutAuxiliaryFiles( + client: AuxiliaryEmitterClient, + input: EmitShortcutAuxiliaryFilesInput, +): Promise { + const result: EmitAuxiliaryFilesResult = { written: 0, deleted: 0, errors: [] }; + + await writeJson( + client, + input.workspaceId, + shortcutRootIndexPath(), + BUCKETS.map(([objectType, , collection]) => ({ + id: objectType, + title: collection, + canonicalPath: shortcutIndexPath(objectType), + })), + result, + ); + + for (const [objectType, bucketKey] of BUCKETS) { + if (!Object.prototype.hasOwnProperty.call(input, bucketKey)) continue; + const records = (input[bucketKey] as readonly ShortcutRecord[] | undefined) ?? []; + const reconciler = new IndexFileReconciler({ + client, + workspaceId: input.workspaceId, + path: shortcutIndexPath(objectType), + builder: (rows) => ({ + path: shortcutIndexPath(objectType), + content: `${JSON.stringify([...rows].sort(compareIndexRows), null, 2)}\n`, + contentType: JSON_CONTENT_TYPE, + }), + }); + const priorReader = new PriorAliasReader(client, input.workspaceId); + const persistedRows = await readIndexRows(client, input.workspaceId, shortcutIndexPath(objectType)); + const previousCollidingAliases = findCollidingAliases(objectType, [], persistedRows); + const collidingAliases = findCollidingAliases(objectType, records, persistedRows); + const recordsToRebalance = await readRecordsToRebalance( + client, + input.workspaceId, + objectType, + records, + persistedRows, + previousCollidingAliases, + collidingAliases, + ); + + const fanOut = await runEmitBatch(client, input.workspaceId, [...records, ...recordsToRebalance], async (record) => { + const id = readRecordId(record); + if (!id) throw new Error(`Shortcut ${objectType} record is missing a valid id`); + + const prior = await priorReader.read(shortcutByIdAliasPath(objectType, id)); + const previous = prior ?? record; + if (record._deleted === true) { + reconciler.remove(id); + return { + deletes: uniquePaths([ + computeShortcutPath(objectType, id), + computeShortcutRecordPath(objectType, previous), + shortcutLegacyByIdAliasPath(objectType, id), + ...aliasPathsFor(objectType, previous, id, true), + ]).map((path): EmitDelete => ({ path })), + }; + } + + const nextAliases = aliasPathsFor(objectType, record, id, collidingAliases); + const nextCanonicalPath = computeShortcutRecordPath(objectType, record); + const previousCanonicalPath = computeShortcutRecordPath(objectType, previous); + const staleAliases = prior + ? aliasPathsFor(objectType, previous, id, true).filter((path) => !nextAliases.includes(path)) + : [shortcutLegacyByIdAliasPath(objectType, id)]; + reconciler.upsert(indexRow(objectType, record, id)); + + return { + deletes: uniquePaths([ + ...staleAliases, + ...(computeShortcutPath(objectType, id) !== nextCanonicalPath ? [computeShortcutPath(objectType, id)] : []), + ...(previousCanonicalPath !== nextCanonicalPath ? [previousCanonicalPath] : []), + ]).map((path): EmitDelete => ({ path })), + writes: uniquePaths([nextCanonicalPath, ...nextAliases]).map((path) => ({ + path, + content: `${JSON.stringify(record, null, 2)}\n`, + contentType: JSON_CONTENT_TYPE, + })), + } satisfies EmitPlan; + }); + + result.written += fanOut.written; + result.deleted += fanOut.deleted; + result.errors.push(...fanOut.errors); + + const indexResult = await reconciler.flush(); + result.written += indexResult.written; + result.errors.push(...indexResult.errors); + } + + return result; +} + +function indexRow(objectType: ShortcutPathObjectType, record: ShortcutRecord, id: string): ShortcutIndexRow { + return { + id, + title: readString(record, "name", "title", "description") ?? id, + updated: readString(record, "updated_at", "updatedAt") ?? "", + canonicalPath: computeShortcutRecordPath(objectType, record), + aliasKeys: aliasKeysFor(objectType, record), + }; +} + +function aliasPathsFor( + objectType: ShortcutPathObjectType, + record: ShortcutRecord, + id: string, + collisionState: boolean | ReadonlySet, +): string[] { + const paths = [shortcutByIdAliasPath(objectType, id)]; + if (objectType !== "story" && objectType !== "epic") return paths; + + const addAlias = ( + kind: string, + value: string, + build: (colliding: boolean) => string, + ) => { + const base = build(false); + const collision = build(true); + if (collisionState === true) { + paths.push(base, collision); + } else { + paths.push(isColliding(collisionState, objectType, kind, value) ? collision : base); + } + }; + + const title = readString(record, "name", "title"); + if (title) addAlias("title", title, (colliding) => shortcutByTitleAliasPath(objectType, title, id, colliding)); + + const state = readState(record, objectType); + if (state) addAlias("state", state, (colliding) => shortcutByStateAliasPath(objectType, state, id, colliding)); + + for (const assigneeId of readAssigneeIds(record)) { + addAlias("assignee", assigneeId, (colliding) => shortcutByAssigneeAliasPath(objectType, assigneeId, id, colliding)); + } + + const creatorId = readString(record, "requested_by_id", "creator_id", "creatorId"); + if (creatorId) addAlias("creator", creatorId, (colliding) => shortcutByCreatorAliasPath(objectType, creatorId, id, colliding)); + + const priority = readString(record, "priority", "priority_id", "priorityId"); + if (priority) addAlias("priority", priority, (colliding) => shortcutByPriorityAliasPath(objectType, priority, id, colliding)); + + return paths; +} + +function findCollidingAliases( + objectType: ShortcutPathObjectType, + records: readonly ShortcutRecord[], + persistedRows: readonly ShortcutIndexRow[], +): ReadonlySet { + const counts = new Map(); + const incomingIds = new Set(records.map(readRecordId).filter((id): id is string => Boolean(id))); + for (const row of persistedRows) { + if (incomingIds.has(row.id)) continue; + for (const key of row.aliasKeys ?? (row.title ? [`${objectType}:title:${slugifyAlias(row.title)}`] : [])) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + for (const record of records) { + if (record._deleted === true) continue; + for (const key of aliasKeysFor(objectType, record)) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return new Set([...counts.entries()].filter(([, count]) => count > 1).map(([key]) => key)); +} + +function isColliding(state: boolean | ReadonlySet, objectType: ShortcutPathObjectType, kind: string, value: string): boolean { + return state === true || (state instanceof Set && state.has(`${objectType}:${kind}:${slugifyAlias(value)}`)); +} + +function aliasKeysFor(objectType: ShortcutPathObjectType, record: ShortcutRecord): string[] { + if (objectType !== "story" && objectType !== "epic") return []; + const keys: string[] = []; + const add = (kind: string, value: string | undefined) => { + if (value) keys.push(`${objectType}:${kind}:${slugifyAlias(value)}`); + }; + add("title", readString(record, "name", "title")); + add("state", readState(record, objectType)); + add("creator", readString(record, "requested_by_id", "creator_id", "creatorId")); + add("priority", readString(record, "priority", "priority_id", "priorityId")); + for (const assigneeId of readAssigneeIds(record)) add("assignee", assigneeId); + return keys; +} + +async function readIndexRows( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, +): Promise { + if (!client.readFile) return []; + try { + const result = await client.readFile({ workspaceId, path }); + if (!result?.content) return []; + const parsed: unknown = JSON.parse(result.content); + return Array.isArray(parsed) ? parsed.filter(isRecord) as unknown as ShortcutIndexRow[] : []; + } catch { + return []; + } +} + +async function readRecordsToRebalance( + client: AuxiliaryEmitterClient, + workspaceId: string, + objectType: ShortcutPathObjectType, + incomingRecords: readonly ShortcutRecord[], + persistedRows: readonly ShortcutIndexRow[], + previousCollidingAliases: ReadonlySet, + currentCollidingAliases: ReadonlySet, +): Promise { + if (!client.readFile || previousCollidingAliases.size === 0) return []; + + const incomingIds = new Set(incomingRecords.map(readRecordId).filter((id): id is string => Boolean(id))); + const survivors = persistedRows.filter((row) => { + if (incomingIds.has(row.id)) return false; + const keys = row.aliasKeys ?? (row.title ? [`${objectType}:title:${slugifyAlias(row.title)}`] : []); + return keys.some((key) => previousCollidingAliases.has(key) && !currentCollidingAliases.has(key)); + }); + const records: ShortcutRecord[] = []; + + for (const row of survivors) { + try { + const result = await client.readFile({ workspaceId, path: shortcutByIdAliasPath(objectType, row.id) }); + if (!result?.content) continue; + const parsed: unknown = JSON.parse(result.content); + if (isRecord(parsed) && readRecordId(parsed as ShortcutRecord) === row.id) { + records.push(parsed as ShortcutRecord); + } + } catch { + // A missing survivor cannot be safely re-emitted; the normal batch remains best-effort. + } + } + + return records; +} + +function readState(record: ShortcutRecord, objectType: ShortcutPathObjectType): string | undefined { + const nested = record.workflow_state ?? record.epic_state; + if (isRecord(nested)) return readString(nested, "name", "id"); + return readString(record, objectType === "story" ? "workflow_state_id" : "epic_state_id", "state", "state_id"); +} + +function readAssigneeIds(record: ShortcutRecord): string[] { + const owners = record.owner_ids ?? record.ownerIds; + if (Array.isArray(owners)) return owners.map(String).filter(Boolean); + const owner = readString(record, "owner_id", "ownerId"); + return owner ? [owner] : []; +} + +function readRecordId(record: ShortcutRecord): string | undefined { + const raw = record.id; + if (raw === undefined || raw === null) return undefined; + const id = String(raw).trim(); + return id && id !== "_index" ? id : undefined; +} + +function readString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function compareIndexRows(left: ShortcutIndexRow, right: ShortcutIndexRow): number { + const updated = right.updated.localeCompare(left.updated); + return updated || left.id.localeCompare(right.id); +} + +function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths)]; +} + +async function writeJson( + client: AuxiliaryEmitterClient, + workspaceId: string, + path: string, + value: unknown, + result: EmitAuxiliaryFilesResult, +): Promise { + try { + await client.writeFile({ + workspaceId, + path, + content: `${JSON.stringify(value, null, 2)}\n`, + contentType: JSON_CONTENT_TYPE, + }); + result.written += 1; + } catch (error) { + result.errors.push({ path, error: error instanceof Error ? error.message : String(error) }); + } +} diff --git a/packages/shortcut/src/inbound.ts b/packages/shortcut/src/inbound.ts new file mode 100644 index 00000000..b163e284 --- /dev/null +++ b/packages/shortcut/src/inbound.ts @@ -0,0 +1,13 @@ +import { + defineInboundCapabilities, + defineNangoInboundCapability, +} from "@relayfile/adapter-core/inbound"; + +export const inboundCapabilities = defineInboundCapabilities([ + defineNangoInboundCapability({ + id: "shortcut.nango", + providerId: "shortcut", + pathRoot: "/shortcut", + providerConfigAliases: ["shortcut", "shortcut-relay"], + }), +]); diff --git a/packages/shortcut/src/index.ts b/packages/shortcut/src/index.ts new file mode 100644 index 00000000..8fcf117f --- /dev/null +++ b/packages/shortcut/src/index.ts @@ -0,0 +1,9 @@ +export * from "./digest.js"; +export * from "./emit-auxiliary-files.js"; +export * from "./layout-prompt.js"; +export * from "./inbound.js"; +export * from "./path-mapper.js"; +export * from "./resources.js"; +export * from "./sync-bucketing.js"; +export * from "./types.js"; +export * from "./webhook-normalizer.js"; diff --git a/packages/shortcut/src/layout-prompt.ts b/packages/shortcut/src/layout-prompt.ts new file mode 100644 index 00000000..3bf20bf0 --- /dev/null +++ b/packages/shortcut/src/layout-prompt.ts @@ -0,0 +1,39 @@ +import { SHORTCUT_PATH_ROOT } from "./types.js"; + +export const SHORTCUT_LAYOUT_PROMPT = `# Shortcut Mount Layout + +Shortcut records are stored under ${SHORTCUT_PATH_ROOT}. Flat canonical records use \`__.json\` when a human-readable name is available; older id-only canonical paths remain readable for compatibility. Use the indexes or aliases to discover records instead of guessing names. + +## Tree + +- /shortcut/categories/__.json — category records +- /shortcut/custom-fields/__.json — custom-field records +- /shortcut/epics/__.json — epic records +- /shortcut/groups/__.json — group records +- /shortcut/iterations/__.json — iteration records +- /shortcut/labels/__.json — label records +- /shortcut/members/__.json — member records +- /shortcut/milestones/__.json — milestone records +- /shortcut/projects/__.json — project records +- /shortcut/stories/__.json — story records +- /shortcut/workflows/__.json — workflow records +- Each collection has _index.json and materialized aliases whose bodies are byte-identical canonical record mirrors. +- Story and Epic records additionally expose by-title, by-state, by-assignee, by-creator, and by-priority aliases when the corresponding provider fields are present. +- Alias filenames use deterministic slug__id segments; collision variants add a stable id-derived suffix. +- Index rows are sorted by updated timestamp descending, then by stable id. Use \`jq '.[].canonicalPath' /shortcut/_index.json\` to inspect collection roots and \`jq '.[].id' /shortcut/stories/_index.json\` to inspect story ids. +- Examples: \`ls /shortcut/stories\`, \`cat /shortcut/stories/_index.json\`, and \`cat /shortcut/stories/by-title/roadmap-q3__35.json\`. + +Writable discovery contracts: + +- discovery/shortcut//.schema.json and .create.example.json for each writable collection + +Terminal lifecycle fields such as archived, completed, completed_at, epic_state_id, and workflow_state_id remain on canonical records. Only an explicit Shortcut delete action removes a record. +`; + +export function layoutPromptFile() { + return { + path: `${SHORTCUT_PATH_ROOT}/LAYOUT.md`, + contentType: "text/markdown; charset=utf-8" as const, + content: SHORTCUT_LAYOUT_PROMPT.endsWith("\n") ? SHORTCUT_LAYOUT_PROMPT : `${SHORTCUT_LAYOUT_PROMPT}\n`, + }; +} diff --git a/packages/shortcut/src/path-mapper.ts b/packages/shortcut/src/path-mapper.ts new file mode 100644 index 00000000..6bbd1b48 --- /dev/null +++ b/packages/shortcut/src/path-mapper.ts @@ -0,0 +1,241 @@ +import { aliasCollisionSuffix, slugifyAlias } from "@relayfile/adapter-core"; +import { SHORTCUT_OBJECT_TYPES, SHORTCUT_PATH_ROOT, type ShortcutPathObjectType } from "./types.js"; + +const COLLECTIONS: Record = { + category: "categories", + "custom-field": "custom-fields", + epic: "epics", + group: "groups", + iteration: "iterations", + label: "labels", + member: "members", + milestone: "milestones", + project: "projects", + story: "stories", + workflow: "workflows", +}; + +const NANGO_MODEL_MAP: Record = { + Category: "category", + CustomField: "custom-field", + Epic: "epic", + Group: "group", + Iteration: "iteration", + Label: "label", + Member: "member", + Milestone: "milestone", + Project: "project", + Story: "story", + Workflow: "workflow", +}; + +const RESERVED_CANONICAL_IDS = new Set(["_index"]); + +function required(value: string | number, label: string): string { + const normalized = String(value).trim(); + if (!normalized) throw new Error(`Shortcut ${label} must be non-empty`); + return normalized; +} + +function requiredRecordId(value: string | number): string { + const normalized = required(value, "record id"); + if (RESERVED_CANONICAL_IDS.has(normalized)) { + throw new Error(`Shortcut record id is reserved: ${normalized}`); + } + return normalized; +} + +export function encodeShortcutPathSegment(value: string | number): string { + return encodeURIComponent(required(value, "path segment")); +} + +export function normalizeShortcutObjectType(value: string): ShortcutPathObjectType { + const normalized = value.trim().toLowerCase().replace(/[_-]/g, ""); + const aliases: Record = { + category: "category", + categories: "category", + customfield: "custom-field", + customfields: "custom-field", + epic: "epic", + epics: "epic", + group: "group", + groups: "group", + iteration: "iteration", + iterations: "iteration", + label: "label", + labels: "label", + member: "member", + members: "member", + milestone: "milestone", + milestones: "milestone", + project: "project", + projects: "project", + story: "story", + stories: "story", + workflow: "workflow", + workflows: "workflow", + }; + const result = aliases[normalized]; + if (!result) throw new Error(`Unsupported Shortcut object type: ${value}`); + return result; +} + +export function tryNormalizeShortcutObjectType(value: string): ShortcutPathObjectType | null { + try { + return normalizeShortcutObjectType(value); + } catch { + return null; + } +} + +export function normalizeNangoShortcutModel(model: string): ShortcutPathObjectType | null { + return NANGO_MODEL_MAP[model] ?? tryNormalizeShortcutObjectType(model); +} + +export function shortcutCollectionPath(objectType: ShortcutPathObjectType): string { + return `${SHORTCUT_PATH_ROOT}/${COLLECTIONS[objectType]}`; +} + +export function shortcutIndexPath(objectType: ShortcutPathObjectType): string { + return `${shortcutCollectionPath(objectType)}/_index.json`; +} + +export function computeShortcutPath(objectType: ShortcutPathObjectType | string, id: string | number): string { + const normalized = normalizeShortcutObjectType(objectType); + return `${shortcutCollectionPath(normalized)}/${encodeShortcutPathSegment(requiredRecordId(id))}.json`; +} + +/** Compose the human-readable flat canonical path used for titled records. */ +export function computeShortcutRecordPath( + objectType: ShortcutPathObjectType | string, + record: Record, +): string { + const idValue = record.id; + if (typeof idValue !== "string" && typeof idValue !== "number") { + throw new Error("Shortcut record must include a string or numeric id"); + } + const id = requiredRecordId(idValue); + const title = typeof record.name === "string" + ? record.name + : typeof record.title === "string" + ? record.title + : ""; + if (!title.trim()) return computeShortcutPath(objectType, id); + const normalized = normalizeShortcutObjectType(objectType); + return `${shortcutCollectionPath(normalized)}/${encodeShortcutPathSegment(`${slugifyAlias(title)}__${id}`)}.json`; +} + +export function shortcutByIdAliasPath( + objectType: ShortcutPathObjectType | string, + id: string | number, + colliding = false, +): string { + const normalized = normalizeShortcutObjectType(objectType); + const normalizedId = requiredRecordId(id); + const slug = slugifyAlias(normalizedId); + const collisionSuffix = colliding ? `-${aliasCollisionSuffix(normalizedId)}` : ""; + return `${shortcutCollectionPath(normalized)}/by-id/${encodeShortcutPathSegment(`${slug}${collisionSuffix}__${normalizedId}`)}.json`; +} + +export function shortcutLegacyByIdAliasPath(objectType: ShortcutPathObjectType | string, id: string | number): string { + const normalized = normalizeShortcutObjectType(objectType); + return `${shortcutCollectionPath(normalized)}/by-id/${encodeShortcutPathSegment(requiredRecordId(id))}.json`; +} + +export function shortcutByTitleAliasPath( + objectType: ShortcutPathObjectType | string, + title: string, + id: string | number, + colliding = false, +): string { + return shortcutNaturalAliasPath(objectType, "by-title", title, id, colliding); +} + +export function shortcutByStateAliasPath( + objectType: ShortcutPathObjectType | string, + state: string | number, + id: string | number, + colliding = false, +): string { + return shortcutNaturalAliasPath(objectType, "by-state", state, id, colliding); +} + +export function shortcutByAssigneeAliasPath( + objectType: ShortcutPathObjectType | string, + assigneeId: string | number, + id: string | number, + colliding = false, +): string { + return shortcutNaturalAliasPath(objectType, "by-assignee", assigneeId, id, colliding); +} + +export function shortcutByCreatorAliasPath( + objectType: ShortcutPathObjectType | string, + creatorId: string | number, + id: string | number, + colliding = false, +): string { + return shortcutNaturalAliasPath(objectType, "by-creator", creatorId, id, colliding); +} + +export function shortcutByPriorityAliasPath( + objectType: ShortcutPathObjectType | string, + priority: string | number, + id: string | number, + colliding = false, +): string { + return shortcutNaturalAliasPath(objectType, "by-priority", priority, id, colliding); +} + +function shortcutNaturalAliasPath( + objectType: ShortcutPathObjectType | string, + aliasKind: "by-title" | "by-state" | "by-assignee" | "by-creator" | "by-priority", + value: string | number, + id: string | number, + colliding: boolean, +): string { + const normalized = normalizeShortcutObjectType(objectType); + const normalizedId = requiredRecordId(id); + const slug = slugifyAlias(required(value, "alias value")); + const collisionSuffix = colliding ? `-${aliasCollisionSuffix(normalizedId)}` : ""; + return `${shortcutCollectionPath(normalized)}/${aliasKind}/${encodeShortcutPathSegment(`${slug}${collisionSuffix}__${normalizedId}`)}.json`; +} + +export function shortcutRootIndexPath(): string { + return `${SHORTCUT_PATH_ROOT}/_index.json`; +} + +export interface ParsedShortcutPath { + objectType: ShortcutPathObjectType; + id: string; + alias: "canonical" | "by-id" | "by-title" | "by-state" | "by-assignee" | "by-creator" | "by-priority"; +} + +export function parseShortcutPath(path: string): ParsedShortcutPath | null { + try { + const segments = path.replace(/^\/+|\/+$/gu, "").split("/"); + if (segments.length !== 3 && segments.length !== 4) return null; + if (segments[0] !== SHORTCUT_PATH_ROOT.slice(1)) return null; + const collection = segments[1]; + const objectType = Object.entries(COLLECTIONS).find(([, value]) => value === collection)?.[0] as ShortcutPathObjectType | undefined; + if (!objectType) return null; + const leaf = segments.at(-1) ?? ""; + if (!leaf.endsWith(".json")) return null; + const filename = decodeURIComponent(leaf.slice(0, -5)); + if (segments.length === 3) { + if (filename === "_index") return null; + const separator = filename.lastIndexOf("__"); + const id = separator > 0 ? filename.slice(separator + 2) : filename; + return { objectType, id: requiredRecordId(id), alias: "canonical" }; + } + const alias = segments[2] as ParsedShortcutPath["alias"]; + if (!["by-id", "by-title", "by-state", "by-assignee", "by-creator", "by-priority"].includes(alias)) return null; + const separator = filename.lastIndexOf("__"); + if (separator <= 0 || separator === filename.length - 2) return null; + return { objectType, id: requiredRecordId(filename.slice(separator + 2)), alias }; + } catch { + return null; + } +} + +export { COLLECTIONS, SHORTCUT_OBJECT_TYPES }; diff --git a/packages/shortcut/src/resources.ts b/packages/shortcut/src/resources.ts new file mode 100644 index 00000000..8c884f54 --- /dev/null +++ b/packages/shortcut/src/resources.ts @@ -0,0 +1,107 @@ +export interface AdapterResourceConfig { + readonly name: string; + readonly path: string; + readonly pathPattern: RegExp; + readonly idPattern: RegExp; + readonly schema: string; + readonly createExample?: string; + readonly operations?: readonly AdapterResourceOperation[]; +} + +export type AdapterResourceOperation = "create" | "update" | "delete"; + +export const resources = [ + { + name: "categories", + path: "/shortcut/categories", + pathPattern: /^\/shortcut\/categories(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/categories/.schema.json", + createExample: "discovery/shortcut/categories/.create.example.json", + }, + { + name: "custom-fields", + path: "/shortcut/custom-fields", + pathPattern: /^\/shortcut\/custom-fields(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/custom-fields/.schema.json", + operations: ["update","delete"], + }, + { + name: "epics", + path: "/shortcut/epics", + pathPattern: /^\/shortcut\/epics(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/epics/.schema.json", + createExample: "discovery/shortcut/epics/.create.example.json", + }, + { + name: "groups", + path: "/shortcut/groups", + pathPattern: /^\/shortcut\/groups(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/groups/.schema.json", + createExample: "discovery/shortcut/groups/.create.example.json", + }, + { + name: "iterations", + path: "/shortcut/iterations", + pathPattern: /^\/shortcut\/iterations(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/iterations/.schema.json", + createExample: "discovery/shortcut/iterations/.create.example.json", + }, + { + name: "labels", + path: "/shortcut/labels", + pathPattern: /^\/shortcut\/labels(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/labels/.schema.json", + createExample: "discovery/shortcut/labels/.create.example.json", + }, + { + name: "members", + path: "/shortcut/members", + pathPattern: /^\/shortcut\/members(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/members/.schema.json", + operations: [], + }, + { + name: "milestones", + path: "/shortcut/milestones", + pathPattern: /^\/shortcut\/milestones(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/milestones/.schema.json", + createExample: "discovery/shortcut/milestones/.create.example.json", + }, + { + name: "projects", + path: "/shortcut/projects", + pathPattern: /^\/shortcut\/projects(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/projects/.schema.json", + createExample: "discovery/shortcut/projects/.create.example.json", + }, + { + name: "stories", + path: "/shortcut/stories", + pathPattern: /^\/shortcut\/stories(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/stories/.schema.json", + createExample: "discovery/shortcut/stories/.create.example.json", + }, + { + name: "workflows", + path: "/shortcut/workflows", + pathPattern: /^\/shortcut\/workflows(?:\/(?!_index\.json$)[^\/]+(?:\.json)?)?$/, + idPattern: /^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$/i, + schema: "discovery/shortcut/workflows/.schema.json", + createExample: "discovery/shortcut/workflows/.create.example.json", + }, +] as const satisfies readonly AdapterResourceConfig[]; + +export function findResourceByPath(path: string): AdapterResourceConfig | undefined { + const normalizedPath = path.endsWith(".json") ? path : path.replace(/\/$/, ""); + return resources.find((resource) => resource.pathPattern.test(normalizedPath)); +} diff --git a/packages/shortcut/src/shortcut.test.ts b/packages/shortcut/src/shortcut.test.ts new file mode 100644 index 00000000..b64d7000 --- /dev/null +++ b/packages/shortcut/src/shortcut.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { emitShortcutAuxiliaryFiles } from "./emit-auxiliary-files.js"; +import { digest } from "./digest.js"; +import { layoutPromptFile } from "./layout-prompt.js"; +import { + computeShortcutPath, + computeShortcutRecordPath, + normalizeShortcutObjectType, + parseShortcutPath, + shortcutByAssigneeAliasPath, + shortcutByIdAliasPath, + shortcutByStateAliasPath, + shortcutByTitleAliasPath, + shortcutIndexPath, + shortcutRootIndexPath, +} from "./path-mapper.js"; +import { resources } from "./resources.js"; +import { normalizeShortcutWebhook } from "./webhook-normalizer.js"; + +function client() { + const files = new Map(); + return { + files, + async writeFile(input: { path: string; content: string }) { files.set(input.path, input.content); }, + async deleteFile(input: { path: string }) { files.delete(input.path); }, + async readFile(input: { path: string }) { + const content = files.get(input.path); + return content === undefined ? null : { content }; + }, + }; +} + +describe("Shortcut adapter", () => { + it("normalizes supported direct and nested actions while keeping trigger events catalogued", () => { + const body = JSON.stringify({ + id: "event-1", + actions: [ + { id: 35, entity_type: "story", action: "update" }, + { id: 16, entity_type: "epic", action: "delete" }, + { id: 17, entity_type: "story-comment", action: "create" }, + { id: 18, entity_type: "story-task", action: "update" }, + ], + }); + const normalized = normalizeShortcutWebhook(JSON.parse(body), {}, { connectionId: "conn" }); + assert.deepEqual(normalized.actions.map((action) => action.eventType), [ + "story.update", + "epic.delete", + "story.update", + "story.update", + ]); + }); + + it("rejects malformed and unsupported webhook actions consistently", () => { + assert.throws(() => normalizeShortcutWebhook({ action: { id: 1, entity_type: "story", action: "archive" } })); + assert.throws(() => normalizeShortcutWebhook({ action: { id: 1, entity_type: "unknown", action: "update" } })); + assert.throws(() => normalizeShortcutWebhook({ actions: [{ id: 1, entity_type: "story" }] })); + }); + + it("round-trips canonical, by-id, and Story alias paths", () => { + for (const [objectType, id] of [["story", "story/with spaces"] as const, ["epic", "epic/with spaces"] as const]) { + const canonical = computeShortcutRecordPath(objectType, { id, name: "Roadmap / Q3" }); + const byId = shortcutByIdAliasPath(objectType, id); + const byTitle = shortcutByTitleAliasPath(objectType, "Roadmap / Q3", id); + const byState = shortcutByStateAliasPath(objectType, "In Progress", id); + const byAssignee = shortcutByAssigneeAliasPath(objectType, "member-1", id); + assert.deepEqual(parseShortcutPath(canonical), { objectType, id, alias: "canonical" }); + assert.deepEqual(parseShortcutPath(byId), { objectType, id, alias: "by-id" }); + assert.deepEqual(parseShortcutPath(byTitle), { objectType, id, alias: "by-title" }); + assert.deepEqual(parseShortcutPath(byState), { objectType, id, alias: "by-state" }); + assert.deepEqual(parseShortcutPath(byAssignee), { objectType, id, alias: "by-assignee" }); + } + assert.throws(() => computeShortcutPath("story", "_index")); + assert.throws(() => shortcutByIdAliasPath("story", "_index")); + }); + + it("emits a navigable layout manifest", () => { + const layout = layoutPromptFile(); + assert.ok(layout.content.length >= 1000); + assert.match(layout.content, /by-state/); + assert.match(layout.content, /slug__id/); + assert.match(layout.content, /jq/); + assert.match(layout.content, /_index\.json/); + }); + + it("emits every writable resource with canonical records, indexes, and by-id aliases", async () => { + for (const resource of resources) { + assert.equal(resource.pathPattern.test(`${resource.path}/_index.json`), false, `${resource.name} index must not be writable`); + } + const relay = client(); + const result = await emitShortcutAuxiliaryFiles(relay, { + workspaceId: "workspace", + categories: [{ id: "category-1", name: "Engineering" }], + customFields: [{ id: "custom-field-1", name: "Priority" }], + epics: [{ id: 16, name: "Archived epic", archived: true }], + groups: [{ id: "group-1", name: "Product" }], + iterations: [{ id: "iteration-1", name: "Sprint 1" }], + labels: [{ id: "label-1", name: "Important" }], + members: [{ id: "member-1", name: "Design partner" }], + milestones: [{ id: "milestone-1", name: "Launch" }], + projects: [{ id: "project-1", name: "Relayfile" }], + stories: [{ id: 35, name: "Completed", completed: true, archived: true, workflow_state_id: 500000009 }], + workflows: [{ id: "workflow-1", name: "Default" }], + }); + assert.equal(result.errors.length, 0); + assert.ok(relay.files.has(shortcutRootIndexPath())); + assert.ok(relay.files.has(computeShortcutRecordPath("story", { id: 35, name: "Completed" }))); + assert.ok(relay.files.has(shortcutByIdAliasPath("story", 35))); + assert.ok(relay.files.has(shortcutByStateAliasPath("story", 500000009, 35))); + assert.match(relay.files.get(computeShortcutRecordPath("story", { id: 35, name: "Completed" })) ?? "", /"completed": true/); + assert.equal( + relay.files.get(computeShortcutRecordPath("story", { id: 35, name: "Completed" })), + relay.files.get(shortcutByIdAliasPath("story", 35)), + ); + const expectedIds: Record = { + categories: "category-1", + "custom-fields": "custom-field-1", + epics: "16", + groups: "group-1", + iterations: "iteration-1", + labels: "label-1", + members: "member-1", + milestones: "milestone-1", + projects: "project-1", + stories: "35", + workflows: "workflow-1", + }; + const expectedNames: Record = { + categories: "Engineering", + "custom-fields": "Priority", + epics: "Archived epic", + groups: "Product", + iterations: "Sprint 1", + labels: "Important", + members: "Design partner", + milestones: "Launch", + projects: "Relayfile", + stories: "Completed", + workflows: "Default", + }; + for (const resource of resources) { + const objectType = normalizeShortcutObjectType(resource.name); + const id = expectedIds[resource.name] ?? `${objectType}-1`; + assert.ok(relay.files.has(shortcutIndexPath(objectType)), `${resource.name} index missing`); + assert.ok(relay.files.has(computeShortcutRecordPath(objectType, { id, name: expectedNames[resource.name] })), `${resource.name} canonical missing`); + assert.ok(relay.files.has(shortcutByIdAliasPath(objectType, id)), `${resource.name} alias missing`); + } + }); + + it("keeps index rows and aliases collision-safe across webhook-sized updates", async () => { + const relay = client(); + await emitShortcutAuxiliaryFiles(relay, { + workspaceId: "workspace", + stories: [ + { id: 35, name: "Roadmap / Q3", workflow_state_id: 500000009, owner_ids: ["member-1"], updated_at: "2026-08-01T00:00:00.000Z" }, + { id: 36, name: "Roadmap Q3", workflow_state_id: 500000009, owner_ids: ["member-1"], updated_at: "2026-08-02T00:00:00.000Z" }, + ], + }); + const firstIndex = JSON.parse(relay.files.get(shortcutIndexPath("story")) ?? "[]") as Array<{ id: string }>; + assert.deepEqual(firstIndex.map((row) => row.id), ["36", "35"]); + assert.ok(relay.files.has(shortcutByAssigneeAliasPath("story", "member-1", 35, true))); + assert.ok(relay.files.has(shortcutByTitleAliasPath("story", "Roadmap / Q3", 35, true))); + assert.ok(relay.files.has(shortcutByStateAliasPath("story", 500000009, 35, true))); + assert.ok(relay.files.has(shortcutByTitleAliasPath("story", "Roadmap Q3", 36, true))); + assert.ok(relay.files.has(shortcutByStateAliasPath("story", 500000009, 36, true))); + const stableAlias = shortcutByTitleAliasPath("story", "Roadmap / Q3", 35, true); + await emitShortcutAuxiliaryFiles(relay, { + workspaceId: "workspace", + stories: [{ id: 35, name: "Roadmap / Q3", workflow_state_id: 500000009, owner_ids: ["member-1"], updated_at: "2026-08-02T12:00:00.000Z" }], + }); + assert.ok(relay.files.has(stableAlias), "collision suffix changed during partial re-emission"); + await emitShortcutAuxiliaryFiles(relay, { + workspaceId: "workspace", + stories: [{ id: 35, name: "Renamed", workflow_state_id: 500000010, owner_ids: ["member-2"], updated_at: "2026-08-03T00:00:00.000Z" }], + }); + const secondIndex = JSON.parse(relay.files.get(shortcutIndexPath("story")) ?? "[]") as Array<{ id: string }>; + assert.deepEqual(secondIndex.map((row) => row.id), ["35", "36"]); + assert.ok(!relay.files.has(shortcutByTitleAliasPath("story", "Roadmap / Q3", 35, true))); + assert.ok(relay.files.has(shortcutByAssigneeAliasPath("story", "member-2", 35))); + + const uniqueRelay = client(); + await emitShortcutAuxiliaryFiles(uniqueRelay, { + workspaceId: "workspace", + stories: [{ id: 40, name: "Unique", workflow_state_id: 500000011, owner_ids: ["member-4"] }], + }); + await emitShortcutAuxiliaryFiles(uniqueRelay, { + workspaceId: "workspace", + stories: [{ id: 41, name: "Unique", workflow_state_id: 500000011, owner_ids: ["member-4"] }, { id: 40, name: "Unique", workflow_state_id: 500000011, owner_ids: ["member-4"] }], + }); + assert.ok(uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 40, true))); + assert.ok(!uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 40))); + await emitShortcutAuxiliaryFiles(uniqueRelay, { + workspaceId: "workspace", + stories: [{ id: 40, _deleted: true }], + }); + assert.ok(uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 41))); + assert.ok(uniqueRelay.files.has(shortcutByStateAliasPath("story", 500000011, 41))); + assert.ok(uniqueRelay.files.has(shortcutByAssigneeAliasPath("story", "member-4", 41))); + assert.ok(!uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 41, true))); + assert.ok(!uniqueRelay.files.has(shortcutByStateAliasPath("story", 500000011, 41, true))); + assert.ok(!uniqueRelay.files.has(shortcutByAssigneeAliasPath("story", "member-4", 41, true))); + assert.ok(!uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 40))); + assert.ok(!uniqueRelay.files.has(shortcutByTitleAliasPath("story", "Unique", 40, true))); + }); + + it("reports delete failures when the client cannot delete files", async () => { + const result = await emitShortcutAuxiliaryFiles({ + async writeFile() {}, + }, { + workspaceId: "workspace", + stories: [{ id: 35, _deleted: true }], + }); + assert.ok(result.errors.some((error) => error.path.includes("stories/35.json"))); + }); + + it("keeps Shortcut terminal mutations visible to the digest handler", async () => { + const section = await digest({ + provider: "shortcut", + window: { + from: "2026-08-10T00:00:00.000Z", + to: "2026-08-11T00:00:00.000Z", + }, + async changeEvents() { + return [ + { + id: "story-35", + timestamp: "2026-08-10T12:00:00.000Z", + action: "completed", + canonicalPath: "/shortcut/stories/35.json", + }, + { + id: "epic-16", + timestamp: "2026-08-10T12:01:00.000Z", + action: "archived", + canonicalPath: "/shortcut/epics/16.json", + }, + { + id: "story-36", + timestamp: "2026-08-10T12:02:00.000Z", + action: "update", + canonicalPath: "/shortcut/stories/36.json", + content: { completed: true }, + }, + { + id: "story-37", + timestamp: "2026-08-10T12:03:00.000Z", + action: "update", + canonicalPath: "/shortcut/stories/37.json", + content: { state: "canceled" }, + }, + ]; + }, + }); + + assert.deepEqual(section?.bullets.map((bullet) => bullet.text), [ + "35 was completed", + "16 was archived", + "36 was completed", + "37 was canceled", + ]); + }); +}); diff --git a/packages/shortcut/src/sync-bucketing.ts b/packages/shortcut/src/sync-bucketing.ts new file mode 100644 index 00000000..08a6a0cd --- /dev/null +++ b/packages/shortcut/src/sync-bucketing.ts @@ -0,0 +1,19 @@ +import { modelBucket, safeNormalize } from "@relayfile/adapter-core/sync-bucketing"; +import { normalizeNangoShortcutModel } from "./path-mapper.js"; + +export const syncRecordBucketing = modelBucket({ + normalizeModel: safeNormalize(normalizeNangoShortcutModel), + buckets: { + category: "categories", + "custom-field": "custom-fields", + group: "groups", + iteration: "iterations", + label: "labels", + member: "members", + milestone: "milestones", + project: "projects", + story: "stories", + epic: "epics", + workflow: "workflows", + }, +}); diff --git a/packages/shortcut/src/types.ts b/packages/shortcut/src/types.ts new file mode 100644 index 00000000..e0d5135c --- /dev/null +++ b/packages/shortcut/src/types.ts @@ -0,0 +1,30 @@ +export const SHORTCUT_PATH_ROOT = "/shortcut" as const; + +export const SHORTCUT_OBJECT_TYPES = [ + "category", + "custom-field", + "epic", + "group", + "iteration", + "label", + "member", + "milestone", + "project", + "story", + "workflow", +] as const; +export type ShortcutPathObjectType = (typeof SHORTCUT_OBJECT_TYPES)[number]; + +export type ShortcutRecord = Record & { + id?: string | number; + _deleted?: true; +}; + +export type ShortcutWebhookAction = { + id: string | number; + entity_type: string; + action: string; + name?: string; + changes?: unknown; + [key: string]: unknown; +}; diff --git a/packages/shortcut/src/webhook-normalizer.ts b/packages/shortcut/src/webhook-normalizer.ts new file mode 100644 index 00000000..0a23588a --- /dev/null +++ b/packages/shortcut/src/webhook-normalizer.ts @@ -0,0 +1,136 @@ +import type { ShortcutWebhookAction } from "./types.js"; + +export const SHORTCUT_PROVIDER = "shortcut"; + +export const SHORTCUT_SUPPORTED_EVENTS = [ + "story.create", + "story.update", + "story.delete", + "epic.create", + "epic.update", + "epic.delete", +] as const; + +const NESTED_PARENT_TYPES: Readonly> = { + "story-comment": "story", + "story-link": "story", + "story-task": "story", + "epic-comment": "epic", +}; + +export interface ShortcutWebhookHeaders { + [key: string]: string | number | boolean | readonly string[] | null | undefined; +} + +export interface ShortcutNormalizedWebhookAction { + provider: typeof SHORTCUT_PROVIDER; + eventType: string; + action: "create" | "update" | "delete" | string; + objectType: string; + objectId: string; + payload: Record; + connectionId?: string; + deliveryId?: string; +} + +export interface ShortcutNormalizedWebhook { + provider: typeof SHORTCUT_PROVIDER; + eventId?: string; + changedAt?: string; + actions: ShortcutNormalizedWebhookAction[]; + headers: Record; + connectionId?: string; + deliveryId?: string; +} + +export function normalizeShortcutWebhook( + rawPayload: unknown, + headers: ShortcutWebhookHeaders = {}, + options: { connectionId?: string; deliveryId?: string } = {}, +): ShortcutNormalizedWebhook { + const payload = asRecord(rawPayload); + if (!payload) throw new Error("Shortcut webhook payload must be a JSON object"); + + const normalizedHeaders = normalizeHeaders(headers); + const actions = readActions(payload).map((action) => { + const objectType = String(action.entity_type ?? "").trim().toLowerCase(); + const verb = String(action.action ?? "").trim().toLowerCase(); + const objectId = String(action.id ?? "").trim(); + if (!objectType || !verb || !objectId) { + throw new Error("Shortcut webhook action must include entity_type, action, and id"); + } + const eventType = normalizeEventType(objectType, verb); + return { + provider: SHORTCUT_PROVIDER as typeof SHORTCUT_PROVIDER, + eventType, + action: verb, + objectType, + objectId, + payload: { ...payload, action: { ...action } }, + ...(options.connectionId ? { connectionId: options.connectionId } : {}), + ...(options.deliveryId ? { deliveryId: options.deliveryId } : {}), + }; + }); + + return { + provider: SHORTCUT_PROVIDER, + eventId: readString(payload, "id"), + changedAt: readString(payload, "changed_at"), + actions, + headers: normalizedHeaders, + ...(options.connectionId ? { connectionId: options.connectionId } : {}), + ...(options.deliveryId ? { deliveryId: options.deliveryId } : {}), + }; +} + +function readActions(payload: Record): ShortcutWebhookAction[] { + const values = Array.isArray(payload.actions) + ? payload.actions + : payload.action === undefined + ? [] + : [payload.action]; + return values.map((value) => { + const record = asRecord(value); + if (!record || !isWebhookAction(record)) { + throw new Error("Shortcut webhook action must include id, entity_type, and action"); + } + return record as ShortcutWebhookAction; + }); +} + +function normalizeEventType(objectType: string, verb: string): string { + const direct = `${objectType}.${verb}`; + if ((SHORTCUT_SUPPORTED_EVENTS as readonly string[]).includes(direct)) return direct; + const parentType = NESTED_PARENT_TYPES[objectType]; + if (parentType && ["create", "update", "delete"].includes(verb)) { + return `${parentType}.update`; + } + throw new Error(`Unsupported Shortcut webhook event: ${direct}`); +} + +function isWebhookAction(value: Record): boolean { + return ( + (typeof value.id === "string" || typeof value.id === "number") && + typeof value.entity_type === "string" && + value.entity_type.trim().length > 0 && + typeof value.action === "string" && + value.action.trim().length > 0 + ); +} + +function normalizeHeaders(headers: ShortcutWebhookHeaders): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") result[key.toLowerCase()] = value; + else if (typeof value === "number" || typeof value === "boolean") result[key.toLowerCase()] = String(value); + } + return result; +} + +function readString(record: Record, key: string): string | undefined { + return typeof record[key] === "string" ? record[key] as string : undefined; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; +} diff --git a/packages/shortcut/tsconfig.json b/packages/shortcut/tsconfig.json new file mode 100644 index 00000000..c99ec7b7 --- /dev/null +++ b/packages/shortcut/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/generate-writeback-discovery.mjs b/scripts/generate-writeback-discovery.mjs index 38229eab..4aeeedac 100644 --- a/scripts/generate-writeback-discovery.mjs +++ b/scripts/generate-writeback-discovery.mjs @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, unlink, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -23,10 +23,14 @@ for (const adapter of normalizedAdapters) { join(root, 'packages', adapter.slug, 'discovery', resource.schemaPath.slice(1)), `${JSON.stringify(fullRecordSchema(endpoint.schema), null, 2)}\n`, ); - await writeDiscoveryFile( - join(root, 'packages', adapter.slug, 'discovery', resource.examplePath.slice(1)), - `${JSON.stringify(endpoint.example, null, 2)}\n`, - ); + if (resource.examplePath && endpoint.example !== undefined) { + await writeDiscoveryFile( + join(root, 'packages', adapter.slug, 'discovery', resource.examplePath.slice(1)), + `${JSON.stringify(endpoint.example, null, 2)}\n`, + ); + } else { + await removeDiscoveryFile(join(root, 'packages', adapter.slug, 'discovery', `${resource.resourcePath}/.create.example.json`.replace(/^\//, ''))); + } } } @@ -35,6 +39,14 @@ async function writeDiscoveryFile(path, content) { await writeFile(path, content); } +async function removeDiscoveryFile(path) { + try { + await unlink(path); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } +} + function renderAdapterReadme(adapter) { const resources = adapter.resources; const lines = [ @@ -42,14 +54,14 @@ function renderAdapterReadme(adapter) { '', adapter.overview, '', - 'Read-only mounts:', + adapter.mountLabel ?? 'Read-only mounts:', ...adapter.readPaths.map(([path, description]) => `- \`${path}\` - ${description}`), '', 'Resources:', '', '| Resource | Schema | Create example | ID pattern | What it does |', '|---|---|---|---|---|', - ...resources.map((resource) => `| \`${resourceWritePath(resource)}\` | \`${resource.schemaPath}\` | \`${resource.examplePath}\` | ${resourceIdPatternCell(resource)} | ${resource.description} |`), + ...resources.map((resource) => `| \`${resourceWritePath(resource)}\` | \`${resource.schemaPath}\` | ${resource.examplePath ? `\`${resource.examplePath}\`` : '—'} | ${resourceIdPatternCell(resource)} | ${resource.description} |`), '', '## Operations', '', @@ -57,7 +69,7 @@ function renderAdapterReadme(adapter) { '|---|---|', '| Read | `cat ` after listing the resource directory or following an alias when one is available. Use the resource table and ID patterns below to determine whether a resource uses a bare id, an adapter-specific slug/id filename, or an exact sidecar path such as `content.md`. |', '| Edit | Write the resource update payload to the canonical resource path. For JSON resources, included mutable fields PATCH; fields marked `readOnly` in `.schema.json` are rejected. |', - '| Create | Write JSON to any non-canonical filename such as `create request.json`. The adapter creates the record at its canonical resource path and rewrites the draft as `{ "created": "", "path": "", "url": "" }`. |', + '| Create | For resources with a create example, write JSON to any non-canonical filename such as `create request.json`. The adapter creates the record at its canonical resource path and rewrites the draft as `{ "created": "", "path": "", "url": "" }`. |', '| Ignore | Editor scratch files named `partial.json`, `.tmp.json`, `.partial.json`, `*.tmp.json`, or `*.partial.json` are ignored and never treated as create drafts. |', '| Delete | `rm ` for canonical records. |', '', @@ -68,7 +80,7 @@ function renderAdapterReadme(adapter) { '', ...adapter.endpoints.flatMap((endpoint) => renderEndpointContract(endpoint)), '## Create Examples', - 'Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document. The example intentionally omits read-only fields.', + 'Read the resource `.schema.json` first, then use the sibling `.create.example.json` as a minimal create document when the resource advertises one. The example intentionally omits read-only fields.', '', ]; @@ -80,12 +92,14 @@ function renderEndpointContract(endpoint) { const required = new Set(endpoint.schema.required ?? []); const fieldNames = Object.keys(endpoint.schema.properties ?? {}); const optional = fieldNames.filter((fieldName) => !required.has(fieldName)); + const operations = endpoint.operations ?? ['create', 'update', 'delete']; const lines = [ `### ${endpoint.schema.title}`, '', `Resource: \`${resourceWritePath(resource)}\``, `Schema: \`${resource.schemaPath}\``, - `Create example: \`${resource.examplePath}\``, + `Operations: ${operations.length > 0 ? operations.map((operation) => `\`${operation}\``).join(', ') : 'read-only'}.`, + ...(resource.examplePath ? [`Create example: \`${resource.examplePath}\``] : ['Create example: none; use the provider UI or another supported operation.']), `Required fields: ${required.size > 0 ? [...required].map((fieldName) => `\`${fieldName}\``).join(', ') : 'none at the top level'}.`, `Optional fields: ${optional.length > 0 ? optional.map((fieldName) => `\`${fieldName}\``).join(', ') : 'none'}.`, ...renderValidationNotes(endpoint.schema), @@ -110,7 +124,9 @@ function renderIdPattern(resource) { if (writePath === resource.resourcePath) { return `- \`${writePath}\`: exact file path.`; } - return `- \`${writePath}\`: \`${resource.idPatternSource}\`. Filenames that do not match this pattern are treated as create drafts.`; + return resource.operations && !resource.operations.includes('create') + ? `- \`${writePath}\`: \`${resource.idPatternSource}\`. Non-canonical filenames are not writeback routes for this resource.` + : `- \`${writePath}\`: \`${resource.idPatternSource}\`. Filenames that do not match this pattern are treated as create drafts.`; } function resourceIdPatternCell(resource) { @@ -209,9 +225,12 @@ function renderResourcesTs(adapter) { ' readonly pathPattern: RegExp;', ' readonly idPattern: RegExp;', ' readonly schema: string;', - ' readonly createExample: string;', + ' readonly createExample?: string;', + ' readonly operations?: readonly AdapterResourceOperation[];', '}', '', + 'export type AdapterResourceOperation = "create" | "update" | "delete";', + '', 'export const resources = [', ...resources.map((resource) => [ ' {', @@ -220,7 +239,8 @@ function renderResourcesTs(adapter) { ` pathPattern: ${resource.pathPatternLiteral},`, ` idPattern: ${resource.idPatternLiteral},`, ` schema: ${JSON.stringify(`discovery${resource.schemaPath}`)},`, - ` createExample: ${JSON.stringify(`discovery${resource.examplePath}`)},`, + ...(resource.examplePath ? [` createExample: ${JSON.stringify(`discovery${resource.examplePath}`)},`] : []), + ...(resource.operations ? [` operations: ${JSON.stringify(resource.operations)},`] : []), ' },', ].join('\n')), '] as const satisfies readonly AdapterResourceConfig[];', diff --git a/scripts/resolve-publish-targets.mjs b/scripts/resolve-publish-targets.mjs index 50e4614b..d5d23bb6 100644 --- a/scripts/resolve-publish-targets.mjs +++ b/scripts/resolve-publish-targets.mjs @@ -26,7 +26,7 @@ const GROUPS = { calendar: ['google-calendar'], devtools: ['github', 'gitlab'], crm: ['hubspot', 'salesforce', 'pipedrive'], - pm: ['asana', 'clickup', 'jira', 'linear', 'notion'], + pm: ['asana', 'clickup', 'jira', 'linear', 'notion', 'shortcut'], support: ['intercom', 'zendesk'], analytics: ['mixpanel', 'segment'], email: ['mailgun', 'sendgrid'], diff --git a/scripts/verify-writeback-discovery.mjs b/scripts/verify-writeback-discovery.mjs index 42cc20dd..4ec22efd 100644 --- a/scripts/verify-writeback-discovery.mjs +++ b/scripts/verify-writeback-discovery.mjs @@ -41,21 +41,22 @@ for (const adapter of adapters) { } const schemaFile = join(root, 'packages', adapter.slug, 'discovery', schemaPath.slice(1)); - const exampleFile = join(root, 'packages', adapter.slug, 'discovery', examplePath.slice(1)); const hasSchema = await assertFile(schemaFile, schemaPath); - const hasExample = await assertFile(exampleFile, examplePath); - if (!hasSchema || !hasExample) { + const hasExample = endpoint.example !== undefined; + const exampleFile = join(root, 'packages', adapter.slug, 'discovery', examplePath.slice(1)); + const hasExampleFile = hasExample ? await assertFile(exampleFile, examplePath) : true; + if (!hasSchema || !hasExampleFile) { continue; } const schema = await readJson(schemaFile, schemaPath); - const example = await readJson(exampleFile, examplePath); - if (!schema || !example) { + const example = hasExample ? await readJson(exampleFile, examplePath) : undefined; + if (!schema || (hasExample && !example)) { continue; } validateSchema(adapter.slug, schemaPath, schema); - validateExample(adapter.slug, examplePath, schema, example); + if (hasExample) validateExample(adapter.slug, examplePath, schema, example); if (!adapterMd.includes(`\`${schemaPath}\``) || !adapterMd.includes('## Operations') || !adapterMd.includes('## ID Patterns')) { failures.push(`${adapter.slug}: .adapter.md must list ${schemaPath} plus Operations and ID Patterns sections`); diff --git a/scripts/writeback-discovery-data.mjs b/scripts/writeback-discovery-data.mjs index c782c2f4..02bc3768 100644 --- a/scripts/writeback-discovery-data.mjs +++ b/scripts/writeback-discovery-data.mjs @@ -505,6 +505,51 @@ export const adapters = [ salesforceEndpoint('/salesforce/cases/new.json', 'Create Salesforce case', 'Creates a Salesforce Case.', { Subject: 'Replace example case subject' }, ['Subject'], salesforceCaseProps()), ], }, + { + slug: 'shortcut', + title: 'Shortcut adapter', + overview: + 'The Shortcut adapter exposes categories, custom fields, epics, groups, iterations, labels, members, milestones, projects, stories, and workflows under `/shortcut`, with file-native discovery contracts for the corresponding Nango actions and canonical sync records.', + mountLabel: 'Canonical mounts:', + readPaths: [ + ['/shortcut/categories/.json', 'Category records.'], + ['/shortcut/custom-fields/.json', 'Custom field records.'], + ['/shortcut/epics/.json', 'Epic records.'], + ['/shortcut/groups/.json', 'Group records.'], + ['/shortcut/iterations/.json', 'Iteration records.'], + ['/shortcut/labels/.json', 'Label records.'], + ['/shortcut/members/.json', 'Member records.'], + ['/shortcut/milestones/.json', 'Milestone records.'], + ['/shortcut/projects/.json', 'Project records.'], + ['/shortcut/stories/.json', 'Story records.'], + ['/shortcut/workflows/.json', 'Workflow records.'], + ], + endpoints: [ + endpoint('/shortcut/categories', 'Create Shortcut category', 'Creates a Shortcut category.', ['name'], { name: str('Category name.') }, { name: 'Product' }), + endpoint('/shortcut/custom-fields', 'Update Shortcut custom field', 'Updates or deletes an existing Shortcut custom field. Shortcut does not expose a REST create endpoint for custom fields.', [], { + name: str('Custom field name.'), + field_type: { ...en(['text', 'textarea', 'date', 'url', 'single_select', 'multi_select', 'person', 'file', 'number'], 'Custom field type.'), readOnly: true }, + values: arr(obj('Custom field enum value.', { + id: str('Existing enum value id.'), + value: str('Enum value text.'), + color_key: str('Shortcut color key.'), + enabled: bool('Whether the enum value is enabled.'), + }), 'Optional enum value updates. Omit a value to delete it.'), + description: str('Custom field description.'), + enabled: bool('Whether the custom field is enabled.'), + icon_set_identifier: str('Shortcut icon set identifier.'), + }, undefined, { operations: ['update', 'delete'], createSupported: false }), + endpoint('/shortcut/epics', 'Create Shortcut epic', 'Creates a Shortcut epic.', ['name'], { name: str('Epic name.'), description: str('Epic description.'), group_id: str('Shortcut group id.') }, { name: 'Q4 onboarding' }), + endpoint('/shortcut/groups', 'Create Shortcut group', 'Creates a Shortcut group.', ['mention_name', 'name'], { mention_name: str('Group mention name.'), name: str('Group name.'), description: str('Group description.') }, { mention_name: 'product-eng', name: 'Product engineering' }), + endpoint('/shortcut/iterations', 'Create Shortcut iteration', 'Creates a Shortcut iteration.', ['name', 'start_date', 'end_date'], { name: str('Iteration name.'), start_date: str('Iteration start date.', 'date'), end_date: str('Iteration end date.', 'date') }, { name: 'Iteration 1', start_date: '2026-01-01', end_date: '2026-01-14' }), + endpoint('/shortcut/labels', 'Create Shortcut label', 'Creates a Shortcut label.', ['name'], { name: str('Label name.'), color: str('Label color.') }, { name: 'Bug', color: '#d73a4a' }), + endpoint('/shortcut/members', 'Read Shortcut member', 'Reads Shortcut members. The REST API does not expose a member create operation.', [], { name: str('Member display name.'), email: str('Member email address.', 'email') }, undefined, { operations: [], createSupported: false }), + endpoint('/shortcut/milestones', 'Create Shortcut milestone', 'Creates a Shortcut milestone.', ['name'], { name: str('Milestone name.'), description: str('Milestone description.'), state: str('Milestone state.'), started_at_override: str('Milestone start override.', 'date-time'), completed_at_override: str('Milestone completion override.', 'date-time'), categories: arr(str('Category id.'), 'Category ids associated with the milestone.') }, { name: 'Public beta', state: 'Unstarted' }), + endpoint('/shortcut/projects', 'Create Shortcut project', 'Creates a Shortcut project.', ['name', 'team_id'], { name: str('Project name.'), team_id: int('Shortcut team id.'), description: str('Project description.') }, { name: 'Mobile refresh', team_id: 123 }), + endpoint('/shortcut/stories', 'Create Shortcut story', 'Creates a Shortcut story. Supply exactly one of `workflow_state_id` or `project_id`.', ['name'], { name: str('Story title.'), description: str('Story description.'), priority: int('Shortcut story priority.'), workflow_state_id: int('Workflow state id.'), project_id: int('Project id.') }, { name: 'Replace example story title', description: 'Optional markdown body.', priority: 3, workflow_state_id: 500000001 }, { oneOf: [{ required: ['workflow_state_id'] }, { required: ['project_id'] }] }), + endpoint('/shortcut/workflows', 'Create Shortcut workflow', 'Creates a Shortcut workflow record contract for discovery.', ['name'], { name: str('Workflow name.'), description: str('Workflow description.') }, { name: 'Engineering workflow' }), + ], + }, { slug: 'slack', title: 'Slack adapter', @@ -726,17 +771,20 @@ export const adapters = [ ]; function endpoint(path, title, description, required, properties, example, schemaExtra = {}) { + const { operations, createSupported, ...schemaOverrides } = schemaExtra; return { path, schemaPath: path.replace(/new\.json$/, 'new.schema.json'), - examplePath: path.replace(/new\.json$/, 'new.example.json'), + ...(createSupported === false || example === undefined ? { createSupported: false } : {}), + ...(operations ? { operations } : {}), + ...(example === undefined ? {} : { examplePath: path.replace(/new\.json$/, 'new.example.json') }), description, schema: { $schema: 'https://json-schema.org/draft/2020-12/schema', title, type: 'object', required, - ...schemaExtra, + ...schemaOverrides, properties, additionalProperties: false, }, diff --git a/scripts/writeback-discovery-normalizer.mjs b/scripts/writeback-discovery-normalizer.mjs index bf681370..aa25e30f 100644 --- a/scripts/writeback-discovery-normalizer.mjs +++ b/scripts/writeback-discovery-normalizer.mjs @@ -34,6 +34,7 @@ export function normalizeWritebackDiscoveryAdapter(adapter, options = {}) { slug: adapter.slug, title: adapter.title, overview: adapter.overview, + ...(adapter.mountLabel ? { mountLabel: adapter.mountLabel } : {}), readPaths: adapter.readPaths.map(([path, description]) => [path, description]), ...(layoutManifest ? { layoutManifest } : {}), endpoints, @@ -97,11 +98,14 @@ export function normalizeWritebackEndpointResource(adapterSlug, endpoint, layout name: resourceNameFor(adapterSlug, resourcePath), resourcePath, schemaPath: `${resourcePath}/.schema.json`, - examplePath: `${resourcePath}/.create.example.json`, + ...(endpoint.createSupported === false || endpoint.example === undefined + ? {} + : { examplePath: `${resourcePath}/.create.example.json` }), description: endpoint.description, pathPatternSource: pathPatternSourceFor(adapterSlug, resourcePath), pathPatternLiteral: patternLiteral(pathPatternSourceFor(adapterSlug, resourcePath)), ...idPatternFor(adapterSlug, resourcePath), + ...(Array.isArray(endpoint.operations) ? { operations: [...endpoint.operations] } : {}), ...(layoutMatch ? { layoutResource: layoutMatch.resource, @@ -244,6 +248,9 @@ function resourceNameFor(adapterSlug, resourcePath) { } function pathPatternSourceFor(adapterSlug, resourcePath) { + if (adapterSlug === 'shortcut') { + return `^${escapeRegex(resourcePath)}(?:/(?!_index\\.json$)[^/]+(?:\\.json)?)?$`; + } if (adapterSlug === 'dropbox') { if (resourcePath === '/dropbox/files' || resourcePath === '/dropbox/folders') { return `^${escapeRegex(resourcePath)}/(?!_index\\.json$)(?!by-(?:id|path)/)[^/]+(?:\\.json)?$`; @@ -290,6 +297,9 @@ function pathPatternSourceFor(adapterSlug, resourcePath) { } function idPatternFor(adapterSlug, resourcePath) { + if (adapterSlug === 'shortcut') { + return pattern('^(?!_index$)(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}|[A-Za-z0-9_.~-]+__(?:[0-9]+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32}))$', 'i'); + } if (adapterSlug === 'linear') { if (resourcePath.includes('/agent-sessions/') && resourcePath.endsWith('/activities')) { return pattern('^(?:activity_[A-Za-z0-9_-]+|[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$', 'i'); diff --git a/turbo.json b/turbo.json index b490df2e..29d42fe9 100644 --- a/turbo.json +++ b/turbo.json @@ -83,6 +83,7 @@ "@relayfile/adapter-sendgrid#build", "@relayfile/adapter-shopify#build", "@relayfile/adapter-slack#build", + "@relayfile/adapter-shortcut#build", "@relayfile/adapter-stripe#build", "@relayfile/adapter-teams#build", "@relayfile/adapter-telegram#build",