Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/linear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
Expand Down
61 changes: 61 additions & 0 deletions packages/linear/src/__tests__/planner-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure cross-platform compatibility (especially on Windows), we should use fileURLToPath from node:url instead of accessing .pathname on a new URL(...) object. On Windows, .pathname can return paths starting with a leading slash (e.g., /C:/path/to/project), which can cause issues with esbuild and other path resolution tools.

Suggested change
import { build } from 'esbuild';
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',
'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 packageRoot = fileURLToPath(new URL('../..', import.meta.url));
const result = await build({
absWorkingDir: packageRoot,
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: packageRoot,
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)), []);
});
108 changes: 15 additions & 93 deletions packages/linear/src/path-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { createHash } from 'node:crypto';
import { aliasCollisionSuffix, slugifyAlias } from './alias-slug.js';
import {
LINEAR_OBJECT_TYPES,
linearStatePath,
normalizeNangoLinearModel,
} 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 = (typeof LINEAR_OBJECT_TYPES)[number];

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 = {
Expand All @@ -11,20 +25,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 {
Expand All @@ -39,65 +39,6 @@ export interface ParseNameWithIdResult {
ext: string | null;
}

const OBJECT_TYPE_ALIASES: Readonly<Record<string, LinearPathObjectType>> = {
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<Record<string, LinearPathObjectType>> = {
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<Record<(typeof LINEAR_CANONICAL_STATES)[number], string>> = {
Expand Down Expand Up @@ -232,12 +173,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 {
Expand All @@ -248,12 +184,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
Expand Down Expand Up @@ -437,14 +367,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`;
}
Expand Down
112 changes: 112 additions & 0 deletions packages/linear/src/planner-contract.ts
Original file line number Diff line number Diff line change
@@ -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;

type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number];

const LINEAR_PATH_ROOT = '/linear';

const OBJECT_TYPE_ALIASES: Readonly<Record<string, LinearPathObjectType>> = {
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<Record<string, LinearPathObjectType>> = {
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`;
}
Loading