Skip to content

Commit 251f7c9

Browse files
committed
fix(adapters): address PR review feedback
1 parent f274d19 commit 251f7c9

24 files changed

Lines changed: 373 additions & 195 deletions

.trajectories/active/traj_829pqp6emgql.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,38 @@
3131
"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."
3232
},
3333
"significance": "high"
34+
},
35+
{
36+
"ts": 1778168967407,
37+
"type": "decision",
38+
"content": "Address PR #40 comments in existing branch worktree: Address PR #40 comments in existing branch worktree",
39+
"raw": {
40+
"question": "Address PR #40 comments in existing branch worktree",
41+
"chosen": "Address PR #40 comments in existing branch worktree",
42+
"alternatives": [],
43+
"reasoning": "PR branch feat/tier1-adapters-batch-2 is already checked out in relayfile-adapters-verify2; using that worktree avoids disturbing the main checkout's untracked docs file."
44+
},
45+
"significance": "high"
46+
},
47+
{
48+
"ts": 1778169632657,
49+
"type": "reflection",
50+
"content": "Addressed PR #40 CodeRabbit feedback across Asana, ClickUp, Intercom, and Zendesk; focused package tests pass and typecheck is clean. Leaving new package version fields intact because they are newly introduced package manifests rather than feature-PR version bumps of existing packages.",
51+
"raw": {
52+
"focalPoints": [
53+
"review-comments",
54+
"validation",
55+
"package-versions"
56+
],
57+
"confidence": 0.86
58+
},
59+
"significance": "high",
60+
"tags": [
61+
"focal:review-comments",
62+
"focal:validation",
63+
"focal:package-versions",
64+
"confidence:0.86"
65+
]
3466
}
3567
]
3668
}

.trajectories/index.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"version": 1,
3-
"lastUpdated": "2026-05-04T12:01:14.230Z",
3+
"lastUpdated": "2026-05-07T16:00:32.659Z",
44
"trajectories": {
55
"traj_829pqp6emgql": {
66
"title": "Implement docs/PATH_SLUGIFICATION_SPEC.md",
@@ -9,4 +9,4 @@
99
"path": "/Users/khaliqgant/Projects/AgentWorkforce/relayfile-adapters/.trajectories/active/traj_829pqp6emgql.json"
1010
}
1111
}
12-
}
12+
}

packages/asana/src/__tests__/asana-adapter.test.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@ test('ingestWebhook writes task events with deterministic content and semantics'
9292
});
9393

9494
assert.equal(result.filesWritten, 1);
95-
assert.deepEqual(result.paths, ['/asana/tasks/ship-asana-adapter--12001.json']);
96-
assert.equal(writes[0]?.path, '/asana/tasks/ship-asana-adapter--12001.json');
95+
assert.deepEqual(result.paths, ['/asana/tasks/12001.json']);
96+
assert.equal(writes[0]?.path, '/asana/tasks/12001.json');
9797
assert.equal(writes[0]?.semantics?.properties?.['asana.assignee_name'], 'Ada');
9898
assert.deepEqual(writes[0]?.semantics?.relations, [
99-
'/asana/projects/adapters--project_1.json',
100-
'/asana/sections/in-progress--section_1.json',
101-
'/asana/workspaces/engineering--workspace_1.json',
99+
'/asana/projects/project_1.json',
100+
'/asana/sections/section_1.json',
101+
'/asana/workspaces/workspace_1.json',
102102
]);
103103
assert.deepEqual(writes[0]?.semantics?.comments, ['Implementation notes']);
104104
});
@@ -123,9 +123,9 @@ test('ingestWebhook writes project events and extracts project relations', async
123123
});
124124

125125
assert.equal(result.filesWritten, 1);
126-
assert.deepEqual(result.paths, ['/asana/projects/adapters--project_1.json']);
126+
assert.deepEqual(result.paths, ['/asana/projects/project_1.json']);
127127
assert.equal(writes[0]?.semantics?.properties?.['asana.status_title'], 'On track');
128-
assert.deepEqual(writes[0]?.semantics?.relations, ['/asana/workspaces/engineering--workspace_1.json']);
128+
assert.deepEqual(writes[0]?.semantics?.relations, ['/asana/workspaces/workspace_1.json']);
129129
});
130130

131131
test('ingestWebhook writes section events and extracts parent project relation', async () => {
@@ -145,8 +145,8 @@ test('ingestWebhook writes section events and extracts parent project relation',
145145
});
146146

147147
assert.equal(result.filesWritten, 1);
148-
assert.deepEqual(result.paths, ['/asana/sections/backlog--section_1.json']);
149-
assert.deepEqual(writes[0]?.semantics?.relations, ['/asana/projects/adapters--project_1.json']);
148+
assert.deepEqual(result.paths, ['/asana/sections/section_1.json']);
149+
assert.deepEqual(writes[0]?.semantics?.relations, ['/asana/projects/project_1.json']);
150150
});
151151

152152
test('ingestWebhook writes workspace events and extracts organization fields', async () => {
@@ -167,11 +167,34 @@ test('ingestWebhook writes workspace events and extracts organization fields', a
167167
});
168168

169169
assert.equal(result.filesWritten, 1);
170-
assert.deepEqual(result.paths, ['/asana/workspaces/engineering--workspace_1.json']);
170+
assert.deepEqual(result.paths, ['/asana/workspaces/workspace_1.json']);
171171
assert.equal(writes[0]?.semantics?.properties?.['asana.email_domain_count'], '2');
172172
assert.equal(writes[0]?.semantics?.properties?.['asana.is_organization'], 'true');
173173
});
174174

175+
test('ingestWebhook writes every event from raw Asana webhook batches', async () => {
176+
const writes: WriteFileInput[] = [];
177+
const adapter = createAdapter({ connectionId: 'conn_asana_123' }, writes);
178+
179+
const result = await adapter.ingestWebhook('ws_relay', {
180+
events: [
181+
{
182+
action: 'changed',
183+
resource: { gid: 'task_1', name: 'Task one', resource_type: 'task' },
184+
},
185+
{
186+
action: 'added',
187+
resource: { gid: 'project_1', name: 'Project one', resource_type: 'project' },
188+
},
189+
],
190+
});
191+
192+
assert.equal(result.filesWritten, 2);
193+
assert.deepEqual(result.paths, ['/asana/tasks/task_1.json', '/asana/projects/project_1.json']);
194+
assert.deepEqual(writes.map((write) => write.path), result.paths);
195+
assert.equal(JSON.parse(writes[0]?.content ?? '{}').connectionId, 'conn_asana_123');
196+
});
197+
175198
test('computeSemantics extracts task custom fields and path relations deterministically', () => {
176199
const adapter = createAdapter();
177200

@@ -201,7 +224,7 @@ test('computeSemantics extracts task custom fields and path relations determinis
201224
'/asana/projects/project_b.json',
202225
'/asana/sections/section_a.json',
203226
'/asana/sections/section_b.json',
204-
'/asana/tasks/launch--task_parent.json',
227+
'/asana/tasks/task_parent.json',
205228
]);
206229
});
207230

@@ -212,8 +235,8 @@ test('path mapper, read routes, and writeback routes cover primary Asana objects
212235
assert.equal(asanaProjectPath('project#7'), '/asana/projects/project%237.json');
213236
assert.equal(asanaSectionPath('section:42'), '/asana/sections/section%3A42.json');
214237
assert.equal(asanaWorkspacePath('workspace alpha'), '/asana/workspaces/workspace%20alpha.json');
215-
assert.equal(computeAsanaPath('Tasks', '12001', 'Ship adapter'), '/asana/tasks/ship-adapter--12001.json');
216-
assert.equal(adapter.computePath('projects', 'project_1', 'Adapters'), '/asana/projects/adapters--project_1.json');
238+
assert.equal(computeAsanaPath('Tasks', '12001', 'Ship adapter'), '/asana/tasks/12001.json');
239+
assert.equal(adapter.computePath('projects', 'project_1', 'Adapters'), '/asana/projects/project_1.json');
217240

218241
assert.deepEqual(resolveAsanaReadRequest('/asana/tasks/ship--12001.json').endpoint, '/api/1.0/tasks/12001');
219242
assert.deepEqual(resolveAsanaReadRequest('/asana/projects/adapters--project_1.json').endpoint, '/api/1.0/projects/project_1');

packages/asana/src/asana-adapter.ts

Lines changed: 101 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -136,55 +136,25 @@ export class AsanaAdapter extends IntegrationAdapter {
136136
event: NormalizedWebhook | AsanaWebhookPayload,
137137
): Promise<IngestResult> {
138138
try {
139-
const normalized = this.normalizeEvent(event);
140-
const name = readObjectName(normalized.payload);
141-
const path = computeAsanaPath(normalized.objectType, normalized.objectId, name);
142-
const semantics = this.computeSemantics(normalized.objectType, normalized.objectId, normalized.payload);
143-
144-
if (this.isDeleteEvent(normalized)) {
145-
if (this.client.deleteFile) {
146-
await this.client.deleteFile({ workspaceId, path });
147-
return {
148-
filesWritten: 0,
149-
filesUpdated: 0,
150-
filesDeleted: 1,
151-
paths: [path],
152-
errors: [],
153-
};
154-
}
155-
156-
const deleteResult = await this.client.writeFile({
157-
workspaceId,
158-
path,
159-
content: this.renderContent(workspaceId, normalized, true),
160-
contentType: JSON_CONTENT_TYPE,
161-
semantics,
162-
});
163-
const counts = inferWriteCounts(deleteResult, true);
164-
return {
165-
filesWritten: counts.filesWritten,
166-
filesUpdated: counts.filesUpdated,
167-
filesDeleted: counts.filesDeleted,
168-
paths: [path],
169-
errors: [],
170-
};
171-
}
172-
173-
const writeResult = await this.client.writeFile({
174-
workspaceId,
175-
path,
176-
content: this.renderContent(workspaceId, normalized, false),
177-
contentType: JSON_CONTENT_TYPE,
178-
semantics,
179-
});
180-
const counts = inferWriteCounts(writeResult, false);
181-
return {
182-
filesWritten: counts.filesWritten,
183-
filesUpdated: counts.filesUpdated,
139+
const normalizedEvents = this.normalizeEvents(event);
140+
const result: IngestResult = {
141+
filesWritten: 0,
142+
filesUpdated: 0,
184143
filesDeleted: 0,
185-
paths: [path],
144+
paths: [],
186145
errors: [],
187146
};
147+
148+
for (const normalized of normalizedEvents) {
149+
const eventResult = await this.ingestNormalizedEvent(workspaceId, normalized);
150+
result.filesWritten += eventResult.filesWritten;
151+
result.filesUpdated += eventResult.filesUpdated;
152+
result.filesDeleted += eventResult.filesDeleted;
153+
result.paths.push(...eventResult.paths);
154+
result.errors.push(...eventResult.errors);
155+
}
156+
157+
return result;
188158
} catch (error) {
189159
const fallbackPath = inferFallbackPath(event);
190160
return {
@@ -202,6 +172,57 @@ export class AsanaAdapter extends IntegrationAdapter {
202172
}
203173
}
204174

175+
private async ingestNormalizedEvent(workspaceId: string, normalized: NormalizedWebhook): Promise<IngestResult> {
176+
const name = readObjectName(normalized.payload);
177+
const path = computeAsanaPath(normalized.objectType, normalized.objectId, name);
178+
const semantics = this.computeSemantics(normalized.objectType, normalized.objectId, normalized.payload);
179+
180+
if (this.isDeleteEvent(normalized)) {
181+
if (this.client.deleteFile) {
182+
await this.client.deleteFile({ workspaceId, path });
183+
return {
184+
filesWritten: 0,
185+
filesUpdated: 0,
186+
filesDeleted: 1,
187+
paths: [path],
188+
errors: [],
189+
};
190+
}
191+
192+
const deleteResult = await this.client.writeFile({
193+
workspaceId,
194+
path,
195+
content: this.renderContent(workspaceId, normalized, true),
196+
contentType: JSON_CONTENT_TYPE,
197+
semantics,
198+
});
199+
const counts = inferWriteCounts(deleteResult, true);
200+
return {
201+
filesWritten: counts.filesWritten,
202+
filesUpdated: counts.filesUpdated,
203+
filesDeleted: counts.filesDeleted,
204+
paths: [path],
205+
errors: [],
206+
};
207+
}
208+
209+
const writeResult = await this.client.writeFile({
210+
workspaceId,
211+
path,
212+
content: this.renderContent(workspaceId, normalized, false),
213+
contentType: JSON_CONTENT_TYPE,
214+
semantics,
215+
});
216+
const counts = inferWriteCounts(writeResult, false);
217+
return {
218+
filesWritten: counts.filesWritten,
219+
filesUpdated: counts.filesUpdated,
220+
filesDeleted: 0,
221+
paths: [path],
222+
errors: [],
223+
};
224+
}
225+
205226
override computePath(objectType: string, objectId: string, name?: string): string {
206227
return computeAsanaPath(objectType, objectId, name);
207228
}
@@ -260,7 +281,7 @@ export class AsanaAdapter extends IntegrationAdapter {
260281
return compactSemantics(semantics);
261282
}
262283

263-
private normalizeEvent(event: NormalizedWebhook | AsanaWebhookPayload): NormalizedWebhook {
284+
private normalizeEvents(event: NormalizedWebhook | AsanaWebhookPayload): NormalizedWebhook[] {
264285
if (isNormalizedWebhook(event)) {
265286
const normalized: NormalizedWebhook = {
266287
provider: event.provider || this.config.provider || ASANA_PROVIDER_NAME,
@@ -273,38 +294,46 @@ export class AsanaAdapter extends IntegrationAdapter {
273294
if (connectionId) {
274295
normalized.connectionId = connectionId;
275296
}
276-
return normalized;
297+
return [normalized];
277298
}
278299

279-
const firstEvent = event.events.find(isRecord);
280-
if (!firstEvent) {
281-
throw new Error('Asana webhook payload is missing events[0]');
300+
const eventItems = event.events.filter(isRecord);
301+
if (eventItems.length === 0) {
302+
throw new Error('Asana webhook payload is missing events');
282303
}
283304

284-
const resource = getRecord(firstEvent.resource);
285-
const objectType = normalizeAsanaObjectType(
286-
asString(resource?.resource_type) ?? asString(firstEvent.type) ?? 'task',
287-
);
288-
const objectId = asString(resource?.gid) ?? asString(firstEvent.gid);
289-
if (!objectId) {
290-
throw new Error(`Asana ${objectType} webhook is missing resource.gid`);
305+
const normalizedEvents: NormalizedWebhook[] = [];
306+
for (const eventItem of eventItems) {
307+
const resource = getRecord(eventItem.resource);
308+
const objectType = normalizeAsanaObjectType(
309+
asString(resource?.resource_type) ?? asString(eventItem.type) ?? 'task',
310+
);
311+
const objectId = asString(resource?.gid) ?? asString(eventItem.gid);
312+
if (!objectId) {
313+
continue;
314+
}
315+
316+
const action = normalizeAction(
317+
asString(eventItem.action) ?? asString(getRecord(eventItem.change)?.action) ?? 'changed',
318+
);
319+
const payload = mergeAsanaPayload(event, eventItem, objectType, objectId, action);
320+
const normalized: NormalizedWebhook = {
321+
provider: this.config.provider || ASANA_PROVIDER_NAME,
322+
eventType: `${objectType}.${action}`,
323+
objectType,
324+
objectId,
325+
payload,
326+
};
327+
if (this.config.connectionId) {
328+
normalized.connectionId = this.config.connectionId;
329+
}
330+
normalizedEvents.push(normalized);
291331
}
292332

293-
const action = normalizeAction(
294-
asString(firstEvent.action) ?? asString(getRecord(firstEvent.change)?.action) ?? 'changed',
295-
);
296-
const payload = mergeAsanaPayload(event, firstEvent, objectType, objectId, action);
297-
const normalized: NormalizedWebhook = {
298-
provider: this.config.provider || ASANA_PROVIDER_NAME,
299-
eventType: `${objectType}.${action}`,
300-
objectType,
301-
objectId,
302-
payload,
303-
};
304-
if (this.config.connectionId) {
305-
normalized.connectionId = this.config.connectionId;
333+
if (normalizedEvents.length === 0) {
334+
throw new Error('Asana webhook payload is missing resource.gid');
306335
}
307-
return normalized;
336+
return normalizedEvents;
308337
}
309338

310339
private isDeleteEvent(event: NormalizedWebhook): boolean {

packages/asana/src/path-mapper.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,8 @@ export function encodeAsanaPathSegment(value: string): string {
4343
return encodeURIComponent(assertNonEmptySegment(value, 'path segment'));
4444
}
4545

46-
function slugify(value: string): string {
47-
return value
48-
.replace(/[{}]/g, '')
49-
.replace(/[^a-zA-Z0-9]+/g, '-')
50-
.replace(/^-+|-+$/g, '')
51-
.toLowerCase();
52-
}
53-
54-
function idSuffix(id: string): string {
55-
return id.replace(/-/g, '');
56-
}
57-
58-
function titleSegmentWithId(title: string | undefined, id: string): string {
59-
const slug = title ? slugify(title) : '';
60-
return slug ? `${slug}--${idSuffix(id)}` : encodeAsanaPathSegment(id);
46+
function titleSegmentWithId(_title: string | undefined, id: string): string {
47+
return encodeAsanaPathSegment(id);
6148
}
6249

6350
export function normalizeAsanaObjectType(objectType: string): AsanaPathObjectType {

0 commit comments

Comments
 (0)