From 6d27a3602e262e5868a5dd734b59111388c21ae5 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Sun, 12 Jul 2026 01:47:32 +0200 Subject: [PATCH 1/2] feat(linear): add Worker-safe planner contract --- CHANGELOG.md | 1 + package-lock.json | 1 + packages/linear/package.json | 6 + .../src/__tests__/planner-contract.test.ts | 56 +++++++++ packages/linear/src/path-mapper.ts | 107 +++-------------- packages/linear/src/planner-contract.ts | 112 ++++++++++++++++++ 6 files changed, 190 insertions(+), 93 deletions(-) create mode 100644 packages/linear/src/__tests__/planner-contract.test.ts create mode 100644 packages/linear/src/planner-contract.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 393604e4..9d226009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,5 +19,6 @@ published version with a date and open a fresh empty `[Unreleased]` above it. ### Added +- Linear now exports a dependency-free `@relayfile/adapter-linear/planner-contract` subpath for Worker-safe Nango model normalization and workflow-state path planning. - Added the Telegram adapter with typed path helpers, rich Bot API writeback resources for messages, reactions, callback answers, inline answers, commands, and menu buttons, plus optional event-sourced conversation history layout and discovery metadata. - `normalizeWritebackStatus(result, entry?)` + `NormalizedWritebackState` (incl. `'no_receipt'`) and `NormalizedWritebackStatus` in `@relayfile/adapter-core` (and re-exported from the `vfs-client` subpath). Bridges high-level `WritebackResult` (receipt present/absent) with low-level `WritebackStatusEntry` outcomes. First-class support for agent debuggability (writeback no-receipt, W6) so runtime wrappers and terminal status taxonomies share a stable enum without per-adapter code. See updated `WritebackOutcome` and docs. diff --git a/package-lock.json b/package-lock.json index b80585d9..cf8d9cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6451,6 +6451,7 @@ "@agent-relay/sdk": "^6.0.7", "@relayfile/sdk": "^0.6.0", "@types/node": "^24.6.0", + "esbuild": "^0.27.7", "tsx": "^4.20.6", "typescript": "^5.9.3" }, diff --git a/packages/linear/package.json b/packages/linear/package.json index 2a7bf561..d25c45e1 100644 --- a/packages/linear/package.json +++ b/packages/linear/package.json @@ -16,6 +16,11 @@ "import": "./dist/path-mapper.js", "default": "./dist/path-mapper.js" }, + "./planner-contract": { + "types": "./dist/planner-contract.d.ts", + "import": "./dist/planner-contract.js", + "default": "./dist/planner-contract.js" + }, "./writeback": { "types": "./dist/writeback.d.ts", "import": "./dist/writeback.js", @@ -64,6 +69,7 @@ "@agent-relay/sdk": "^6.0.7", "@relayfile/sdk": "^0.6.0", "@types/node": "^24.6.0", + "esbuild": "^0.27.7", "tsx": "^4.20.6", "typescript": "^5.9.3" } diff --git a/packages/linear/src/__tests__/planner-contract.test.ts b/packages/linear/src/__tests__/planner-contract.test.ts new file mode 100644 index 00000000..e1def2a0 --- /dev/null +++ b/packages/linear/src/__tests__/planner-contract.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { builtinModules } from 'node:module'; +import test from 'node:test'; +import { build } from 'esbuild'; + +import * as plannerContract from '../planner-contract.js'; + +test('planner-contract exposes only the Worker planning surface', () => { + assert.deepEqual(Object.keys(plannerContract).sort(), [ + 'LINEAR_OBJECT_TYPES', + 'linearStatePath', + 'linearStatesIndexPath', + 'normalizeNangoLinearModel', + ]); + + assert.equal(plannerContract.normalizeNangoLinearModel('LinearState'), 'state'); + for (const inheritedKey of ['constructor', 'toString', '__proto__', 'hasOwnProperty']) { + assert.throws( + () => plannerContract.normalizeNangoLinearModel(inheritedKey), + new RegExp(`Unsupported Linear object type: ${inheritedKey}`, 'u'), + ); + } + assert.equal(plannerContract.linearStatePath(' state/id '), '/linear/states/state%2Fid.json'); + assert.equal(plannerContract.linearStatesIndexPath(), '/linear/states/_index.json'); +}); + +test('planner-contract public subpath has a pure Worker transitive import graph', async () => { + const result = await build({ + absWorkingDir: new URL('../..', import.meta.url).pathname, + bundle: true, + conditions: ['worker', 'browser', 'import'], + format: 'esm', + logLevel: 'silent', + metafile: true, + platform: 'browser', + stdin: { + contents: `export * from '@relayfile/adapter-linear/planner-contract';`, + loader: 'js', + resolveDir: new URL('../..', import.meta.url).pathname, + sourcefile: 'worker-entry.js', + }, + write: false, + }); + + const runtimeInputs = Object.keys(result.metafile.inputs) + .filter((input) => input !== 'worker-entry.js') + .map((input) => input.replaceAll('\\\\', '/')); + + assert.deepEqual(runtimeInputs, ['dist/planner-contract.js']); + + const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + const importedPaths = Object.values(result.metafile.inputs) + .flatMap((input) => input.imports) + .map((entry) => entry.path); + assert.deepEqual(importedPaths.filter((path) => nodeBuiltins.has(path)), []); +}); diff --git a/packages/linear/src/path-mapper.ts b/packages/linear/src/path-mapper.ts index ae74e5f8..db152323 100644 --- a/packages/linear/src/path-mapper.ts +++ b/packages/linear/src/path-mapper.ts @@ -1,7 +1,20 @@ import { createHash } from 'node:crypto'; import { aliasCollisionSuffix, slugifyAlias } from './alias-slug.js'; +import { + linearStatePath, + normalizeNangoLinearModel, +} from './planner-contract.js'; +import type { LinearPathObjectType } from './planner-contract.js'; import { LINEAR_AGENT_WEBHOOK_EVENTS } from './types.js'; +export { + LINEAR_OBJECT_TYPES, + linearStatePath, + linearStatesIndexPath, + normalizeNangoLinearModel, +} from './planner-contract.js'; +export type { LinearPathObjectType } from './planner-contract.js'; + export const LINEAR_PATH_ROOT = '/linear'; export const LINEAR_CANONICAL_STATES = ['Todo', 'In Progress', 'Done', 'Backlog', 'Canceled'] as const; export const LINEAR_AGENT_WEBHOOK_PATH_ROOTS = { @@ -11,20 +24,6 @@ export const LINEAR_AGENT_WEBHOOK_PATH_ROOTS = { OAuthApp: `${LINEAR_PATH_ROOT}/oauth-app`, } as const; -export const LINEAR_OBJECT_TYPES = [ - 'comment', - 'cycle', - 'issue', - 'label', - 'milestone', - 'project', - 'roadmap', - 'state', - 'team', - 'user', -] as const; - -export type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number]; export type LinearAgentWebhookCategory = keyof typeof LINEAR_AGENT_WEBHOOK_PATH_ROOTS; export interface NameWithIdOptions { @@ -39,65 +38,6 @@ export interface ParseNameWithIdResult { ext: string | null; } -const OBJECT_TYPE_ALIASES: Readonly> = { - comment: 'comment', - comments: 'comment', - linearcomment: 'comment', - cycle: 'cycle', - cycles: 'cycle', - linearcycle: 'cycle', - issue: 'issue', - issues: 'issue', - issue_label: 'label', - issuelabel: 'label', - linearissue: 'issue', - linearissuelabel: 'label', - label: 'label', - labels: 'label', - linearlabel: 'label', - milestone: 'milestone', - milestones: 'milestone', - projectmilestone: 'milestone', - projectmilestones: 'milestone', - linearmilestone: 'milestone', - project: 'project', - projects: 'project', - linearproject: 'project', - roadmap: 'roadmap', - roadmaps: 'roadmap', - linearroadmap: 'roadmap', - state: 'state', - states: 'state', - linearstate: 'state', - team: 'team', - teams: 'team', - linearteam: 'team', - user: 'user', - users: 'user', - linearuser: 'user', -}; - -/** - * Nango sync record `model` names → canonical Linear object types. The Nango - * `linear-relay` integration emits records under these PascalCase model names - * (see `cloud/nango-integrations/linear-relay/syncs/*.ts`). Resolving them - * here lets the cloud's record-writer turn a Nango payload into a relayfile - * path without hardcoding the mapping at the dispatch site. - */ -const NANGO_MODEL_MAP: Readonly> = { - LinearComment: 'comment', - LinearCycle: 'cycle', - LinearIssue: 'issue', - LinearIssueLabel: 'label', - LinearLabel: 'label', - LinearMilestone: 'milestone', - LinearProject: 'project', - LinearRoadmap: 'roadmap', - LinearState: 'state', - LinearTeam: 'team', - LinearUser: 'user', -}; - const LINEAR_PUBLIC_IDENTIFIER_PATTERN = /^[A-Z][A-Z0-9]+-\d+$/u; const MAX_HUMAN_READABLE_LENGTH = 80; const CANONICAL_STATE_SLUGS: Readonly> = { @@ -232,12 +172,7 @@ export function slugifyStateName(stateName: string): string { } export function normalizeLinearObjectType(objectType: string): LinearPathObjectType { - const normalized = objectType.trim().toLowerCase(); - const mapped = OBJECT_TYPE_ALIASES[normalized]; - if (!mapped) { - throw new Error(`Unsupported Linear object type: ${objectType}`); - } - return mapped; + return normalizeNangoLinearModel(objectType); } export function tryNormalizeLinearObjectType(objectType: string): LinearPathObjectType | undefined { @@ -248,12 +183,6 @@ export function tryNormalizeLinearObjectType(objectType: string): LinearPathObje } } -export function normalizeNangoLinearModel(model: string): LinearPathObjectType { - const direct = NANGO_MODEL_MAP[model]; - if (direct) return direct; - return normalizeLinearObjectType(model); -} - export function linearAgentWebhookCategory(eventType: string): LinearAgentWebhookCategory | null { const category = eventType.trim().split('.')[0] ?? ''; return category in LINEAR_AGENT_WEBHOOK_PATH_ROOTS @@ -437,14 +366,6 @@ export function linearLabelByTeamPath(teamId: string, labelId: string): string { return `${LINEAR_PATH_ROOT}/labels/by-team/${encodeLinearPathSegment(teamId)}/${encodeLinearPathSegment(labelId)}.json`; } -export function linearStatePath(stateId: string): string { - return `${LINEAR_PATH_ROOT}/states/${encodeLinearPathSegment(stateId)}.json`; -} - -export function linearStatesIndexPath(): string { - return `${LINEAR_PATH_ROOT}/states/_index.json`; -} - export function linearCyclePath(cycleId: string): string { return `${LINEAR_PATH_ROOT}/cycles/${encodeLinearPathSegment(cycleId)}.json`; } diff --git a/packages/linear/src/planner-contract.ts b/packages/linear/src/planner-contract.ts new file mode 100644 index 00000000..4f2883f7 --- /dev/null +++ b/packages/linear/src/planner-contract.ts @@ -0,0 +1,112 @@ +/** + * Minimal Linear record-planning contract for Worker/browser consumers. + * + * Keep this module dependency-free: the `./planner-contract` package subpath is + * deliberately safe to include in strict Worker bundles. + */ + +export const LINEAR_OBJECT_TYPES = [ + 'comment', + 'cycle', + 'issue', + 'label', + 'milestone', + 'project', + 'roadmap', + 'state', + 'team', + 'user', +] as const; + +export type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number]; + +const LINEAR_PATH_ROOT = '/linear'; + +const OBJECT_TYPE_ALIASES: Readonly> = { + comment: 'comment', + comments: 'comment', + linearcomment: 'comment', + cycle: 'cycle', + cycles: 'cycle', + linearcycle: 'cycle', + issue: 'issue', + issues: 'issue', + issue_label: 'label', + issuelabel: 'label', + linearissue: 'issue', + linearissuelabel: 'label', + label: 'label', + labels: 'label', + linearlabel: 'label', + milestone: 'milestone', + milestones: 'milestone', + projectmilestone: 'milestone', + projectmilestones: 'milestone', + linearmilestone: 'milestone', + project: 'project', + projects: 'project', + linearproject: 'project', + roadmap: 'roadmap', + roadmaps: 'roadmap', + linearroadmap: 'roadmap', + state: 'state', + states: 'state', + linearstate: 'state', + team: 'team', + teams: 'team', + linearteam: 'team', + user: 'user', + users: 'user', + linearuser: 'user', +}; + +/** Nango Linear model names that require an explicit canonical mapping. */ +const NANGO_MODEL_MAP: Readonly> = { + LinearComment: 'comment', + LinearCycle: 'cycle', + LinearIssue: 'issue', + LinearIssueLabel: 'label', + LinearLabel: 'label', + LinearMilestone: 'milestone', + LinearProject: 'project', + LinearRoadmap: 'roadmap', + LinearState: 'state', + LinearTeam: 'team', + LinearUser: 'user', +}; + +function assertNonEmptySegment(value: string, label: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`Linear ${label} must be a non-empty string`); + } + return trimmed; +} + +/** Normalize a Nango model name or supported Linear object-type alias. */ +export function normalizeNangoLinearModel(model: string): LinearPathObjectType { + const direct = Object.hasOwn(NANGO_MODEL_MAP, model) + ? NANGO_MODEL_MAP[model] + : undefined; + if (direct) return direct; + + const normalized = model.trim().toLowerCase(); + const mapped = Object.hasOwn(OBJECT_TYPE_ALIASES, normalized) + ? OBJECT_TYPE_ALIASES[normalized] + : undefined; + if (!mapped) { + throw new Error(`Unsupported Linear object type: ${model}`); + } + return mapped; +} + +/** Canonical flat-record path for a Linear workflow state. */ +export function linearStatePath(stateId: string): string { + const encodedStateId = encodeURIComponent(assertNonEmptySegment(stateId, 'path segment')); + return `${LINEAR_PATH_ROOT}/states/${encodedStateId}.json`; +} + +/** Canonical resource index path for Linear workflow states. */ +export function linearStatesIndexPath(): string { + return `${LINEAR_PATH_ROOT}/states/_index.json`; +} From eb7f3779e83d76c53a11e009db36800e5e659a26 Mon Sep 17 00:00:00 2001 From: Hubspot Adapter Bot Date: Sun, 12 Jul 2026 01:53:50 +0200 Subject: [PATCH 2/2] test(linear): harden planner contract portability --- packages/linear/src/__tests__/planner-contract.test.ts | 9 +++++++-- packages/linear/src/path-mapper.ts | 5 +++-- packages/linear/src/planner-contract.ts | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/linear/src/__tests__/planner-contract.test.ts b/packages/linear/src/__tests__/planner-contract.test.ts index e1def2a0..74b4b29f 100644 --- a/packages/linear/src/__tests__/planner-contract.test.ts +++ b/packages/linear/src/__tests__/planner-contract.test.ts @@ -1,10 +1,14 @@ import assert from 'node:assert/strict'; import { builtinModules } from 'node:module'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { build } from 'esbuild'; import * as plannerContract from '../planner-contract.js'; +// @ts-expect-error The planner subpath deliberately has no fifth public type export. +type NoPlannerContractTypeExport = import('../planner-contract.js').LinearPathObjectType; + test('planner-contract exposes only the Worker planning surface', () => { assert.deepEqual(Object.keys(plannerContract).sort(), [ 'LINEAR_OBJECT_TYPES', @@ -25,8 +29,9 @@ test('planner-contract exposes only the Worker planning surface', () => { }); test('planner-contract public subpath has a pure Worker transitive import graph', async () => { + const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); const result = await build({ - absWorkingDir: new URL('../..', import.meta.url).pathname, + absWorkingDir: packageRoot, bundle: true, conditions: ['worker', 'browser', 'import'], format: 'esm', @@ -36,7 +41,7 @@ test('planner-contract public subpath has a pure Worker transitive import graph' stdin: { contents: `export * from '@relayfile/adapter-linear/planner-contract';`, loader: 'js', - resolveDir: new URL('../..', import.meta.url).pathname, + resolveDir: packageRoot, sourcefile: 'worker-entry.js', }, write: false, diff --git a/packages/linear/src/path-mapper.ts b/packages/linear/src/path-mapper.ts index db152323..b4dd3ff4 100644 --- a/packages/linear/src/path-mapper.ts +++ b/packages/linear/src/path-mapper.ts @@ -1,10 +1,10 @@ import { createHash } from 'node:crypto'; import { aliasCollisionSuffix, slugifyAlias } from './alias-slug.js'; import { + LINEAR_OBJECT_TYPES, linearStatePath, normalizeNangoLinearModel, } from './planner-contract.js'; -import type { LinearPathObjectType } from './planner-contract.js'; import { LINEAR_AGENT_WEBHOOK_EVENTS } from './types.js'; export { @@ -13,7 +13,8 @@ export { linearStatesIndexPath, normalizeNangoLinearModel, } from './planner-contract.js'; -export type { LinearPathObjectType } from './planner-contract.js'; + +export type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number]; export const LINEAR_PATH_ROOT = '/linear'; export const LINEAR_CANONICAL_STATES = ['Todo', 'In Progress', 'Done', 'Backlog', 'Canceled'] as const; diff --git a/packages/linear/src/planner-contract.ts b/packages/linear/src/planner-contract.ts index 4f2883f7..a178b276 100644 --- a/packages/linear/src/planner-contract.ts +++ b/packages/linear/src/planner-contract.ts @@ -18,7 +18,7 @@ export const LINEAR_OBJECT_TYPES = [ 'user', ] as const; -export type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number]; +type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number]; const LINEAR_PATH_ROOT = '/linear';