Skip to content

Commit 5718fe1

Browse files
khaliqgantclaude
andauthored
fix(linear,notion): resolve Nango sync model names in path-mapper (#34)
* fix(linear,notion): resolve Nango sync model names in path-mapper Nango emits sync records under PascalCase model names (LinearTeam, LinearUser, LinearIssue, …, NotionPage), but the path-mapper alias maps in adapter-linear and adapter-notion did not recognize those names — only canonical lowercase types and a single `linearproject` entry. Every Linear/Notion sync record was throwing "Unsupported Linear object type: LinearXxx" inside the cloud's `writeBatchToRelayfile`, where the rejection was swallowed by `Promise.allSettled` and only surfaced as an `errors: N` counter in CloudWatch with no message. Mirror the GitHub adapter's pattern: - Extend `OBJECT_TYPE_ALIASES` with the lowercased Linear model names. - Add `NANGO_MODEL_MAP` + `normalizeNangoLinearModel` / `normalizeNangoNotionModel` for explicit Nango model resolution (and `try*` variants for callers that want soft failure). - Cover every model emitted by the existing `linear-relay` and `notion-relay` Nango syncs in tests. The cloud's record-writer still needs a Notion dispatch arm — that's the follow-up PR in `cloud`. Once both land, Linear and Notion sync records will start populating the workspace tree. Bumps: adapter-linear 0.1.17 → 0.1.18, adapter-notion 0.1.15 → 0.1.16. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert: drop version bumps; document publish-phase ownership Versions in packages/*/package.json are bumped by the publish workflow (.github/workflows/publish.yml, workflow_dispatch), not by the feature PR that introduces the change. Document the convention in AGENTS.md so future contributors don't re-introduce the same drift between npm versions and what cloud pins to. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9095a73 commit 5718fe1

8 files changed

Lines changed: 248 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ packages/*/tests/**/*.d.ts
99
packages/*/tests/**/*.js.map
1010
packages/*/tests/**/*.d.ts.map
1111
.claude/settings.json
12+
.mcp.json
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"id": "traj_829pqp6emgql",
3+
"version": 1,
4+
"task": {
5+
"title": "Implement docs/PATH_SLUGIFICATION_SPEC.md"
6+
},
7+
"status": "active",
8+
"startedAt": "2026-05-04T11:59:04.694Z",
9+
"agents": [
10+
{
11+
"name": "default",
12+
"role": "lead",
13+
"joinedAt": "2026-05-04T12:01:14.228Z"
14+
}
15+
],
16+
"chapters": [
17+
{
18+
"id": "chap_jhjvz9f5x1u2",
19+
"title": "Work",
20+
"agentName": "default",
21+
"startedAt": "2026-05-04T12:01:14.228Z",
22+
"events": [
23+
{
24+
"ts": 1777896074229,
25+
"type": "decision",
26+
"content": "Implement slugification as optional path parameters with ID-compatible fallback: Implement slugification as optional path parameters with ID-compatible fallback",
27+
"raw": {
28+
"question": "Implement slugification as optional path parameters with ID-compatible fallback",
29+
"chosen": "Implement slugification as optional path parameters with ID-compatible fallback",
30+
"alternatives": [],
31+
"reasoning": "Existing adapter public APIs and tests depend on ID-only paths; optional title/name arguments let ingestion produce readable paths without breaking callers that only know IDs."
32+
},
33+
"significance": "high"
34+
}
35+
]
36+
}
37+
],
38+
"commits": [],
39+
"filesChanged": [],
40+
"projectId": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile-adapters",
41+
"tags": [],
42+
"_trace": {
43+
"startRef": "78b286656657cd64b8f8fe16d9a42fe6c87043da",
44+
"endRef": "78b286656657cd64b8f8fe16d9a42fe6c87043da"
45+
}
46+
}

.trajectories/index.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"version": 1,
3+
"lastUpdated": "2026-05-04T12:01:14.230Z",
4+
"trajectories": {
5+
"traj_829pqp6emgql": {
6+
"title": "Implement docs/PATH_SLUGIFICATION_SPEC.md",
7+
"status": "active",
8+
"startedAt": "2026-05-04T11:59:04.694Z",
9+
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile-adapters/.trajectories/active/traj_829pqp6emgql.json"
10+
}
11+
}
12+
}

AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ Your trajectory helps others understand:
150150

151151
Future agents can query past trajectories to learn from your decisions.
152152
<!-- prpm:snippet:end @agent-workforce/trail-snippet@1.1.0 -->
153+
154+
## Repository conventions
155+
156+
### Do not bump package versions in feature PRs
157+
158+
Versions in `packages/*/package.json` are bumped by the publish phase, not in the PR that introduces the change. The repo's pattern (see `chore(release): bump all (patch)` commits in history) is:
159+
160+
1. Open a feature PR with the source change only — leave `version` fields untouched.
161+
2. After merge, the publish workflow (`.github/workflows/publish.yml`, `workflow_dispatch`) handles the version bump and npm publish.
162+
163+
If you bump a version in a feature PR, downstream consumers (e.g. the `cloud` repo) may pin to a version that hasn't been published yet, breaking installs. Always leave version bumps to the release flow.
164+
153165
<!-- PRPM_MANIFEST_START -->
154166

155167
<skills_system priority="1">
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
4+
import {
5+
computeLinearPath,
6+
normalizeLinearObjectType,
7+
normalizeNangoLinearModel,
8+
tryNormalizeLinearObjectType,
9+
} from '../path-mapper.js';
10+
11+
describe('linear path-mapper', () => {
12+
describe('normalizeLinearObjectType', () => {
13+
it('accepts canonical types', () => {
14+
assert.equal(normalizeLinearObjectType('issue'), 'issue');
15+
assert.equal(normalizeLinearObjectType('TEAM'), 'team');
16+
});
17+
18+
it('accepts plural aliases', () => {
19+
assert.equal(normalizeLinearObjectType('issues'), 'issue');
20+
assert.equal(normalizeLinearObjectType('teams'), 'team');
21+
});
22+
23+
it('accepts Nango-style PascalCase model names', () => {
24+
assert.equal(normalizeLinearObjectType('LinearTeam'), 'team');
25+
assert.equal(normalizeLinearObjectType('LinearUser'), 'user');
26+
assert.equal(normalizeLinearObjectType('LinearIssue'), 'issue');
27+
assert.equal(normalizeLinearObjectType('LinearComment'), 'comment');
28+
assert.equal(normalizeLinearObjectType('LinearCycle'), 'cycle');
29+
assert.equal(normalizeLinearObjectType('LinearMilestone'), 'milestone');
30+
assert.equal(normalizeLinearObjectType('LinearProject'), 'project');
31+
assert.equal(normalizeLinearObjectType('LinearRoadmap'), 'roadmap');
32+
});
33+
34+
it('throws on unknown types', () => {
35+
assert.throws(() => normalizeLinearObjectType('flarb'));
36+
});
37+
});
38+
39+
describe('tryNormalizeLinearObjectType', () => {
40+
it('returns undefined on unknown types', () => {
41+
assert.equal(tryNormalizeLinearObjectType('flarb'), undefined);
42+
});
43+
44+
it('returns the resolved type for known input', () => {
45+
assert.equal(tryNormalizeLinearObjectType('LinearIssue'), 'issue');
46+
});
47+
});
48+
49+
describe('normalizeNangoLinearModel', () => {
50+
// Each Nango sync emits a single PascalCase model — see
51+
// cloud/nango-integrations/linear-relay/syncs/*.ts. This test pins those
52+
// contracts so any future sync rename surfaces here as a failure.
53+
it('maps every Nango linear-relay sync model', () => {
54+
assert.equal(normalizeNangoLinearModel('LinearComment'), 'comment');
55+
assert.equal(normalizeNangoLinearModel('LinearCycle'), 'cycle');
56+
assert.equal(normalizeNangoLinearModel('LinearIssue'), 'issue');
57+
assert.equal(normalizeNangoLinearModel('LinearMilestone'), 'milestone');
58+
assert.equal(normalizeNangoLinearModel('LinearProject'), 'project');
59+
assert.equal(normalizeNangoLinearModel('LinearRoadmap'), 'roadmap');
60+
assert.equal(normalizeNangoLinearModel('LinearTeam'), 'team');
61+
assert.equal(normalizeNangoLinearModel('LinearUser'), 'user');
62+
});
63+
64+
it('falls back to alias-map normalization for non-Nango input', () => {
65+
assert.equal(normalizeNangoLinearModel('issues'), 'issue');
66+
});
67+
});
68+
69+
describe('computeLinearPath', () => {
70+
it('produces Nango-driven paths from PascalCase model names', () => {
71+
assert.equal(
72+
computeLinearPath('LinearTeam', '50cf92f3-f53c-4ab6-bf05-ea76ebd21692'),
73+
'/linear/teams/50cf92f3-f53c-4ab6-bf05-ea76ebd21692.json',
74+
);
75+
assert.equal(
76+
computeLinearPath('LinearUser', 'usr_123'),
77+
'/linear/users/usr_123.json',
78+
);
79+
});
80+
});
81+
});

packages/linear/src/path-mapper.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,48 @@ export type LinearPathObjectType = (typeof LINEAR_OBJECT_TYPES)[number];
1616
const OBJECT_TYPE_ALIASES: Readonly<Record<string, LinearPathObjectType>> = {
1717
comment: 'comment',
1818
comments: 'comment',
19+
linearcomment: 'comment',
1920
cycle: 'cycle',
2021
cycles: 'cycle',
22+
linearcycle: 'cycle',
2123
issue: 'issue',
2224
issues: 'issue',
25+
linearissue: 'issue',
2326
milestone: 'milestone',
2427
milestones: 'milestone',
2528
projectmilestone: 'milestone',
2629
projectmilestones: 'milestone',
30+
linearmilestone: 'milestone',
2731
project: 'project',
2832
projects: 'project',
2933
linearproject: 'project',
3034
roadmap: 'roadmap',
3135
roadmaps: 'roadmap',
36+
linearroadmap: 'roadmap',
3237
team: 'team',
3338
teams: 'team',
39+
linearteam: 'team',
3440
user: 'user',
3541
users: 'user',
42+
linearuser: 'user',
43+
};
44+
45+
/**
46+
* Nango sync record `model` names → canonical Linear object types. The Nango
47+
* `linear-relay` integration emits records under these PascalCase model names
48+
* (see `cloud/nango-integrations/linear-relay/syncs/*.ts`). Resolving them
49+
* here lets the cloud's record-writer turn a Nango payload into a relayfile
50+
* path without hardcoding the mapping at the dispatch site.
51+
*/
52+
const NANGO_MODEL_MAP: Readonly<Record<string, LinearPathObjectType>> = {
53+
LinearComment: 'comment',
54+
LinearCycle: 'cycle',
55+
LinearIssue: 'issue',
56+
LinearMilestone: 'milestone',
57+
LinearProject: 'project',
58+
LinearRoadmap: 'roadmap',
59+
LinearTeam: 'team',
60+
LinearUser: 'user',
3661
};
3762

3863
function assertNonEmptySegment(value: string, label: string): string {
@@ -80,6 +105,20 @@ export function normalizeLinearObjectType(objectType: string): LinearPathObjectT
80105
return mapped;
81106
}
82107

108+
export function tryNormalizeLinearObjectType(objectType: string): LinearPathObjectType | undefined {
109+
try {
110+
return normalizeLinearObjectType(objectType);
111+
} catch {
112+
return undefined;
113+
}
114+
}
115+
116+
export function normalizeNangoLinearModel(model: string): LinearPathObjectType {
117+
const direct = NANGO_MODEL_MAP[model];
118+
if (direct) return direct;
119+
return normalizeLinearObjectType(model);
120+
}
121+
83122
export function linearIssuePath(issueId: string, title?: string): string {
84123
return `${LINEAR_PATH_ROOT}/issues/${titleSegmentWithId(title, issueId)}.json`;
85124
}

packages/notion/src/__tests__/path-mapper.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test';
22
import assert from 'node:assert/strict';
33
import {
44
computePath,
5+
normalizeNangoNotionModel,
56
notionDatabaseBlockPath,
67
notionDatabaseMetadataPath,
78
notionDatabasePageCommentsPath,
@@ -10,6 +11,7 @@ import {
1011
notionStandalonePageCommentsPath,
1112
notionStandalonePageContentPath,
1213
notionStandalonePagePath,
14+
tryNormalizeNangoNotionModel,
1315
} from '../path-mapper.js';
1416

1517
describe('path mapping', () => {
@@ -30,4 +32,30 @@ describe('path mapping', () => {
3032
assert.strictEqual(notionStandalonePageContentPath('page-1'), '/notion/pages/page-1/content.md');
3133
assert.strictEqual(notionStandalonePageCommentsPath('page-1'), '/notion/pages/page-1/comments.json');
3234
});
35+
36+
describe('normalizeNangoNotionModel', () => {
37+
// The Nango notion-relay `fetch-pages` sync emits records under the
38+
// `NotionPage` model — see
39+
// cloud/nango-integrations/notion-relay/syncs/fetch-pages.ts. The other
40+
// entries are forward-compatible for syncs we plan to add.
41+
it('maps NotionPage to the standalone page object type', () => {
42+
assert.strictEqual(normalizeNangoNotionModel('NotionPage'), 'page');
43+
});
44+
45+
it('maps richer notion models for forward compatibility', () => {
46+
assert.strictEqual(normalizeNangoNotionModel('NotionDatabase'), 'database');
47+
assert.strictEqual(normalizeNangoNotionModel('NotionDatabasePage'), 'database_page');
48+
assert.strictEqual(normalizeNangoNotionModel('NotionBlock'), 'block');
49+
assert.strictEqual(normalizeNangoNotionModel('NotionComment'), 'comment');
50+
});
51+
52+
it('throws on unknown models', () => {
53+
assert.throws(() => normalizeNangoNotionModel('NotionFlarb'));
54+
});
55+
56+
it('try-variant returns undefined on unknown models', () => {
57+
assert.strictEqual(tryNormalizeNangoNotionModel('NotionFlarb'), undefined);
58+
assert.strictEqual(tryNormalizeNangoNotionModel('NotionPage'), 'page');
59+
});
60+
});
3361
});

packages/notion/src/path-mapper.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,35 @@ export function notionDiscoveryManifestPath(): string {
120120
return `${NOTION_PATH_ROOT}/discovery/manifest.json`;
121121
}
122122

123+
/**
124+
* Nango sync record `model` names → canonical Notion path object types. The
125+
* Nango `notion-relay` integration's `fetch-pages` sync emits records under
126+
* the `NotionPage` model (see
127+
* `cloud/nango-integrations/notion-relay/syncs/fetch-pages.ts`). Future
128+
* notion syncs (databases, blocks, comments) extend this map.
129+
*/
130+
const NANGO_MODEL_MAP: Readonly<Record<string, NotionPathObjectType>> = {
131+
NotionPage: 'page',
132+
NotionDatabase: 'database',
133+
NotionDatabasePage: 'database_page',
134+
NotionBlock: 'block',
135+
NotionComment: 'comment',
136+
};
137+
138+
export function normalizeNangoNotionModel(model: string): NotionPathObjectType {
139+
const mapped = NANGO_MODEL_MAP[model];
140+
if (mapped) return mapped;
141+
throw new Error(`Unsupported Notion Nango model: ${model}`);
142+
}
143+
144+
export function tryNormalizeNangoNotionModel(model: string): NotionPathObjectType | undefined {
145+
try {
146+
return normalizeNangoNotionModel(model);
147+
} catch {
148+
return undefined;
149+
}
150+
}
151+
123152
export function computePath(input: ComputePathInput): string {
124153
switch (input.objectType) {
125154
case 'database':

0 commit comments

Comments
 (0)