@relayfile/adapter-linear 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +45 -0
- package/src/__tests__/linear-adapter.test.ts +345 -0
- package/src/__tests__/types.test.ts +27 -0
- package/src/__tests__/webhook-normalizer.test.ts +77 -0
- package/src/index.ts +5 -0
- package/src/linear-adapter.ts +680 -0
- package/src/path-mapper.ts +69 -0
- package/src/types.ts +164 -0
- package/src/webhook-normalizer.ts +721 -0
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
import type { ConnectionProvider } from '@relayfile/sdk';
|
|
2
|
+
export type { ConnectionProvider, ProxyRequest, ProxyResponse } from '@relayfile/sdk';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
computeLinearPath,
|
|
6
|
+
linearCyclePath,
|
|
7
|
+
linearIssuePath,
|
|
8
|
+
linearProjectPath,
|
|
9
|
+
normalizeLinearObjectType,
|
|
10
|
+
} from './path-mapper.ts';
|
|
11
|
+
import type {
|
|
12
|
+
LinearAdapterConfig,
|
|
13
|
+
LinearComment,
|
|
14
|
+
LinearCycle,
|
|
15
|
+
LinearIssue,
|
|
16
|
+
LinearLabel,
|
|
17
|
+
LinearProject,
|
|
18
|
+
LinearRelation,
|
|
19
|
+
LinearState,
|
|
20
|
+
LinearUser,
|
|
21
|
+
LinearWebhookPayload,
|
|
22
|
+
} from './types.ts';
|
|
23
|
+
|
|
24
|
+
export interface FileSemantics {
|
|
25
|
+
properties?: Record<string, string>;
|
|
26
|
+
relations?: string[];
|
|
27
|
+
permissions?: string[];
|
|
28
|
+
comments?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IngestError {
|
|
32
|
+
path: string;
|
|
33
|
+
error: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface IngestResult {
|
|
37
|
+
filesWritten: number;
|
|
38
|
+
filesUpdated: number;
|
|
39
|
+
filesDeleted: number;
|
|
40
|
+
paths: string[];
|
|
41
|
+
errors: IngestError[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface NormalizedWebhook {
|
|
45
|
+
provider: string;
|
|
46
|
+
connectionId?: string;
|
|
47
|
+
eventType: string;
|
|
48
|
+
objectType: string;
|
|
49
|
+
objectId: string;
|
|
50
|
+
payload: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WriteFileInput {
|
|
54
|
+
workspaceId: string;
|
|
55
|
+
path: string;
|
|
56
|
+
content: string;
|
|
57
|
+
contentType?: string;
|
|
58
|
+
semantics?: FileSemantics;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface WriteFileResult {
|
|
62
|
+
created?: boolean;
|
|
63
|
+
updated?: boolean;
|
|
64
|
+
status?: 'created' | 'updated' | 'queued' | 'pending';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DeleteFileInput {
|
|
68
|
+
workspaceId: string;
|
|
69
|
+
path: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RelayFileClientLike {
|
|
73
|
+
writeFile(input: WriteFileInput): Promise<WriteFileResult | void>;
|
|
74
|
+
deleteFile?(input: DeleteFileInput): Promise<void> | void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export abstract class IntegrationAdapter {
|
|
78
|
+
protected readonly client: RelayFileClientLike;
|
|
79
|
+
protected readonly provider: ConnectionProvider;
|
|
80
|
+
|
|
81
|
+
abstract readonly name: string;
|
|
82
|
+
abstract readonly version: string;
|
|
83
|
+
|
|
84
|
+
constructor(client: RelayFileClientLike, provider: ConnectionProvider) {
|
|
85
|
+
this.client = client;
|
|
86
|
+
this.provider = provider;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
abstract ingestWebhook(workspaceId: string, event: NormalizedWebhook | LinearWebhookPayload): Promise<IngestResult>;
|
|
90
|
+
|
|
91
|
+
abstract computePath(objectType: string, objectId: string): string;
|
|
92
|
+
|
|
93
|
+
abstract computeSemantics(
|
|
94
|
+
objectType: string,
|
|
95
|
+
objectId: string,
|
|
96
|
+
payload: Record<string, unknown>
|
|
97
|
+
): FileSemantics;
|
|
98
|
+
|
|
99
|
+
supportedEvents?(): string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type LinearRecord = Record<string, unknown>;
|
|
103
|
+
type LinearWebhookEnvelope = Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
|
106
|
+
const SUPPORTED_EVENTS = ['comment', 'cycle', 'issue', 'project'] as const;
|
|
107
|
+
const LINEAR_PROVIDER_NAME = 'linear';
|
|
108
|
+
|
|
109
|
+
export class LinearAdapter extends IntegrationAdapter {
|
|
110
|
+
override readonly name = LINEAR_PROVIDER_NAME;
|
|
111
|
+
override readonly version = '0.1.0';
|
|
112
|
+
|
|
113
|
+
readonly config: LinearAdapterConfig;
|
|
114
|
+
|
|
115
|
+
constructor(
|
|
116
|
+
client: RelayFileClientLike,
|
|
117
|
+
provider: ConnectionProvider,
|
|
118
|
+
config: LinearAdapterConfig = {}
|
|
119
|
+
) {
|
|
120
|
+
super(client, provider);
|
|
121
|
+
this.config = config;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
override supportedEvents(): string[] {
|
|
125
|
+
return SUPPORTED_EVENTS.flatMap((objectType) => [
|
|
126
|
+
`${objectType}.create`,
|
|
127
|
+
`${objectType}.update`,
|
|
128
|
+
`${objectType}.remove`,
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override async ingestWebhook(
|
|
133
|
+
workspaceId: string,
|
|
134
|
+
event: NormalizedWebhook | LinearWebhookPayload
|
|
135
|
+
): Promise<IngestResult> {
|
|
136
|
+
try {
|
|
137
|
+
const normalized = this.normalizeEvent(event);
|
|
138
|
+
const path = this.computePath(normalized.objectType, normalized.objectId);
|
|
139
|
+
|
|
140
|
+
if (this.isRemoveEvent(normalized)) {
|
|
141
|
+
if (this.client.deleteFile) {
|
|
142
|
+
await this.client.deleteFile({ workspaceId, path });
|
|
143
|
+
return {
|
|
144
|
+
filesWritten: 0,
|
|
145
|
+
filesUpdated: 0,
|
|
146
|
+
filesDeleted: 1,
|
|
147
|
+
paths: [path],
|
|
148
|
+
errors: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const deleteResult = await this.client.writeFile({
|
|
153
|
+
workspaceId,
|
|
154
|
+
path,
|
|
155
|
+
content: this.renderContent(workspaceId, normalized, true),
|
|
156
|
+
contentType: JSON_CONTENT_TYPE,
|
|
157
|
+
semantics: this.computeSemantics(normalized.objectType, normalized.objectId, normalized.payload),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const counts = inferWriteCounts(normalized, deleteResult, true);
|
|
161
|
+
return {
|
|
162
|
+
filesWritten: counts.filesWritten,
|
|
163
|
+
filesUpdated: counts.filesUpdated,
|
|
164
|
+
filesDeleted: counts.filesDeleted,
|
|
165
|
+
paths: [path],
|
|
166
|
+
errors: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const writeResult = await this.client.writeFile({
|
|
171
|
+
workspaceId,
|
|
172
|
+
path,
|
|
173
|
+
content: this.renderContent(workspaceId, normalized, false),
|
|
174
|
+
contentType: JSON_CONTENT_TYPE,
|
|
175
|
+
semantics: this.computeSemantics(normalized.objectType, normalized.objectId, normalized.payload),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const counts = inferWriteCounts(normalized, writeResult, false);
|
|
179
|
+
return {
|
|
180
|
+
filesWritten: counts.filesWritten,
|
|
181
|
+
filesUpdated: counts.filesUpdated,
|
|
182
|
+
filesDeleted: 0,
|
|
183
|
+
paths: [path],
|
|
184
|
+
errors: [],
|
|
185
|
+
};
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const fallbackPath = inferFallbackPath(event);
|
|
188
|
+
return {
|
|
189
|
+
filesWritten: 0,
|
|
190
|
+
filesUpdated: 0,
|
|
191
|
+
filesDeleted: 0,
|
|
192
|
+
paths: fallbackPath ? [fallbackPath] : [],
|
|
193
|
+
errors: [
|
|
194
|
+
{
|
|
195
|
+
path: fallbackPath,
|
|
196
|
+
error: toErrorMessage(error),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
override computePath(objectType: string, objectId: string): string {
|
|
204
|
+
return computeLinearPath(objectType, objectId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
override computeSemantics(
|
|
208
|
+
objectType: string,
|
|
209
|
+
objectId: string,
|
|
210
|
+
payload: Record<string, unknown>
|
|
211
|
+
): FileSemantics {
|
|
212
|
+
const normalizedType = normalizeLinearObjectType(objectType);
|
|
213
|
+
const properties: Record<string, string> = {
|
|
214
|
+
provider: LINEAR_PROVIDER_NAME,
|
|
215
|
+
'provider.object_id': objectId,
|
|
216
|
+
'provider.object_type': normalizedType,
|
|
217
|
+
'linear.id': objectId,
|
|
218
|
+
'linear.object_type': normalizedType,
|
|
219
|
+
};
|
|
220
|
+
const relations = new Set<string>();
|
|
221
|
+
const comments: string[] = [];
|
|
222
|
+
|
|
223
|
+
addStringProperty(properties, 'linear.url', payload.url);
|
|
224
|
+
|
|
225
|
+
const webhook = getRecord(payload._webhook);
|
|
226
|
+
if (webhook) {
|
|
227
|
+
addStringProperty(properties, 'linear.webhook.action', webhook.action);
|
|
228
|
+
addStringProperty(properties, 'linear.webhook.created_at', webhook.createdAt);
|
|
229
|
+
addStringProperty(properties, 'linear.webhook.organization_id', webhook.organizationId);
|
|
230
|
+
addStringProperty(properties, 'linear.webhook.url', webhook.url);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
switch (normalizedType) {
|
|
234
|
+
case 'issue':
|
|
235
|
+
applyIssueSemantics(properties, relations, payload as LinearRecord);
|
|
236
|
+
break;
|
|
237
|
+
case 'comment':
|
|
238
|
+
applyCommentSemantics(properties, relations, comments, payload as LinearRecord);
|
|
239
|
+
break;
|
|
240
|
+
case 'project':
|
|
241
|
+
applyProjectSemantics(properties, payload as LinearRecord);
|
|
242
|
+
break;
|
|
243
|
+
case 'cycle':
|
|
244
|
+
applyCycleSemantics(properties, payload as LinearRecord);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const semantics: FileSemantics = {
|
|
249
|
+
properties,
|
|
250
|
+
relations: sortStrings(relations),
|
|
251
|
+
};
|
|
252
|
+
if (comments.length > 0) {
|
|
253
|
+
semantics.comments = comments;
|
|
254
|
+
}
|
|
255
|
+
return compactSemantics(semantics);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private normalizeEvent(event: NormalizedWebhook | LinearWebhookPayload): NormalizedWebhook {
|
|
259
|
+
if (isNormalizedWebhook(event)) {
|
|
260
|
+
const normalized: NormalizedWebhook = {
|
|
261
|
+
provider: event.provider || this.config.provider || LINEAR_PROVIDER_NAME,
|
|
262
|
+
eventType: event.eventType,
|
|
263
|
+
objectType: normalizeLinearObjectType(event.objectType),
|
|
264
|
+
objectId: event.objectId.trim(),
|
|
265
|
+
payload: event.payload,
|
|
266
|
+
};
|
|
267
|
+
const connectionId = event.connectionId || this.config.connectionId;
|
|
268
|
+
if (connectionId) {
|
|
269
|
+
normalized.connectionId = connectionId;
|
|
270
|
+
}
|
|
271
|
+
return normalized;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const objectType = normalizeLinearObjectType(event.type);
|
|
275
|
+
const objectId = extractPayloadId(event.data);
|
|
276
|
+
if (!objectId) {
|
|
277
|
+
throw new Error(`Linear ${objectType} webhook is missing data.id`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const payload = mergeLinearPayload(event);
|
|
281
|
+
|
|
282
|
+
const normalized: NormalizedWebhook = {
|
|
283
|
+
provider: this.config.provider || LINEAR_PROVIDER_NAME,
|
|
284
|
+
eventType: `${objectType}.${String(event.action)}`,
|
|
285
|
+
objectType,
|
|
286
|
+
objectId,
|
|
287
|
+
payload,
|
|
288
|
+
};
|
|
289
|
+
if (this.config.connectionId) {
|
|
290
|
+
normalized.connectionId = this.config.connectionId;
|
|
291
|
+
}
|
|
292
|
+
return normalized;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private isRemoveEvent(event: NormalizedWebhook): boolean {
|
|
296
|
+
const action = getWebhookAction(event.payload) ?? getEventAction(event.eventType);
|
|
297
|
+
return action === 'remove';
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private renderContent(workspaceId: string, event: NormalizedWebhook, deleted: boolean): string {
|
|
301
|
+
return stableJson({
|
|
302
|
+
provider: event.provider,
|
|
303
|
+
connectionId: event.connectionId ?? null,
|
|
304
|
+
workspaceId,
|
|
305
|
+
eventType: event.eventType,
|
|
306
|
+
objectType: normalizeLinearObjectType(event.objectType),
|
|
307
|
+
objectId: event.objectId,
|
|
308
|
+
deleted,
|
|
309
|
+
payload: event.payload,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function applyIssueSemantics(
|
|
315
|
+
properties: Record<string, string>,
|
|
316
|
+
relations: Set<string>,
|
|
317
|
+
payload: LinearRecord
|
|
318
|
+
): void {
|
|
319
|
+
const issue = payload as Partial<LinearIssue> & LinearRecord;
|
|
320
|
+
|
|
321
|
+
addStringProperty(properties, 'linear.identifier', issue.identifier);
|
|
322
|
+
addStringProperty(properties, 'linear.title', issue.title);
|
|
323
|
+
addStringProperty(properties, 'linear.branch_name', issue.branchName);
|
|
324
|
+
addStringProperty(properties, 'linear.due_date', issue.dueDate);
|
|
325
|
+
addStringProperty(properties, 'linear.created_at', issue.createdAt);
|
|
326
|
+
addStringProperty(properties, 'linear.updated_at', issue.updatedAt);
|
|
327
|
+
addStringProperty(properties, 'linear.completed_at', issue.completedAt);
|
|
328
|
+
addStringProperty(properties, 'linear.canceled_at', issue.canceledAt);
|
|
329
|
+
addNumberProperty(properties, 'linear.estimate', issue.estimate);
|
|
330
|
+
|
|
331
|
+
const priority = asNumber(issue.priority);
|
|
332
|
+
if (priority !== undefined) {
|
|
333
|
+
properties['linear.priority'] = String(priority);
|
|
334
|
+
properties['linear.priority_label'] = mapPriorityLabel(priority);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const state = issue.state as LinearState | null | undefined;
|
|
338
|
+
if (state) {
|
|
339
|
+
addStringProperty(properties, 'linear.state_id', state.id);
|
|
340
|
+
addStringProperty(properties, 'linear.state_name', state.name);
|
|
341
|
+
addStringProperty(properties, 'linear.state_type', state.type);
|
|
342
|
+
addStringProperty(properties, 'linear.state_color', state.color);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const assignee = issue.assignee as LinearUser | null | undefined;
|
|
346
|
+
if (assignee) {
|
|
347
|
+
addStringProperty(properties, 'linear.assignee_id', assignee.id);
|
|
348
|
+
addStringProperty(properties, 'linear.assignee_name', assignee.displayName ?? assignee.name);
|
|
349
|
+
addStringProperty(properties, 'linear.assignee_email', assignee.email);
|
|
350
|
+
addStringProperty(properties, 'linear.assignee_url', assignee.url);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const creator = issue.creator as LinearUser | null | undefined;
|
|
354
|
+
if (creator) {
|
|
355
|
+
addStringProperty(properties, 'linear.creator_id', creator.id);
|
|
356
|
+
addStringProperty(properties, 'linear.creator_name', creator.displayName ?? creator.name);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const labels = asLabels(issue.labels);
|
|
360
|
+
if (labels.length > 0) {
|
|
361
|
+
const labelNames = labels
|
|
362
|
+
.map((label) => label.name.trim())
|
|
363
|
+
.filter((name) => name.length > 0)
|
|
364
|
+
.sort((left, right) => left.localeCompare(right));
|
|
365
|
+
if (labelNames.length > 0) {
|
|
366
|
+
properties['linear.labels'] = labelNames.join(', ');
|
|
367
|
+
properties['linear.label_count'] = String(labelNames.length);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (issue.project?.id) {
|
|
372
|
+
relations.add(linearProjectPath(issue.project.id));
|
|
373
|
+
addStringProperty(properties, 'linear.project_id', issue.project.id);
|
|
374
|
+
addStringProperty(properties, 'linear.project_name', issue.project.name);
|
|
375
|
+
addStringProperty(properties, 'linear.project_state', issue.project.state);
|
|
376
|
+
addStringProperty(properties, 'linear.project_url', issue.project.url);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (issue.cycle?.id) {
|
|
380
|
+
relations.add(linearCyclePath(issue.cycle.id));
|
|
381
|
+
addStringProperty(properties, 'linear.cycle_id', issue.cycle.id);
|
|
382
|
+
addNumberProperty(properties, 'linear.cycle_number', issue.cycle.number);
|
|
383
|
+
addStringProperty(properties, 'linear.cycle_name', issue.cycle.name);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (issue.parent?.id) {
|
|
387
|
+
relations.add(linearIssuePath(issue.parent.id));
|
|
388
|
+
addStringProperty(properties, 'linear.parent_id', issue.parent.id);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const child of issue.children ?? []) {
|
|
392
|
+
if (child.id) {
|
|
393
|
+
relations.add(linearIssuePath(child.id));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const relation of asRelations(issue.relations)) {
|
|
398
|
+
if (relation.relatedIssueId) {
|
|
399
|
+
relations.add(linearIssuePath(relation.relatedIssueId));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (issue.team?.id) {
|
|
404
|
+
addStringProperty(properties, 'linear.team_id', issue.team.id);
|
|
405
|
+
addStringProperty(properties, 'linear.team_key', issue.team.key);
|
|
406
|
+
addStringProperty(properties, 'linear.team_name', issue.team.name);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function applyCommentSemantics(
|
|
411
|
+
properties: Record<string, string>,
|
|
412
|
+
relations: Set<string>,
|
|
413
|
+
comments: string[],
|
|
414
|
+
payload: LinearRecord
|
|
415
|
+
): void {
|
|
416
|
+
const comment = payload as Partial<LinearComment> & LinearRecord;
|
|
417
|
+
|
|
418
|
+
addStringProperty(properties, 'linear.created_at', comment.createdAt);
|
|
419
|
+
addStringProperty(properties, 'linear.updated_at', comment.updatedAt);
|
|
420
|
+
|
|
421
|
+
const author = comment.user as LinearUser | null | undefined;
|
|
422
|
+
if (author) {
|
|
423
|
+
addStringProperty(properties, 'linear.author_id', author.id);
|
|
424
|
+
addStringProperty(properties, 'linear.author_name', author.displayName ?? author.name);
|
|
425
|
+
addStringProperty(properties, 'linear.author_email', author.email);
|
|
426
|
+
addStringProperty(properties, 'linear.author_url', author.url);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (comment.issue?.id) {
|
|
430
|
+
relations.add(linearIssuePath(comment.issue.id));
|
|
431
|
+
addStringProperty(properties, 'linear.issue_id', comment.issue.id);
|
|
432
|
+
addStringProperty(properties, 'linear.issue_identifier', comment.issue.identifier);
|
|
433
|
+
addStringProperty(properties, 'linear.issue_title', comment.issue.title);
|
|
434
|
+
addStringProperty(properties, 'linear.issue_url', comment.issue.url);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const body = asString(comment.body);
|
|
438
|
+
if (body) {
|
|
439
|
+
comments.push(body);
|
|
440
|
+
properties['linear.comment_length'] = String(body.length);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function applyProjectSemantics(properties: Record<string, string>, payload: LinearRecord): void {
|
|
445
|
+
const project = payload as Partial<LinearProject> & LinearRecord;
|
|
446
|
+
|
|
447
|
+
addStringProperty(properties, 'linear.name', project.name);
|
|
448
|
+
addStringProperty(properties, 'linear.state', project.state);
|
|
449
|
+
addStringProperty(properties, 'linear.target_date', project.targetDate);
|
|
450
|
+
addStringProperty(properties, 'linear.started_at', project.startedAt);
|
|
451
|
+
addStringProperty(properties, 'linear.completed_at', project.completedAt);
|
|
452
|
+
|
|
453
|
+
const progress = asNumber(project.progress);
|
|
454
|
+
if (progress !== undefined) {
|
|
455
|
+
properties['linear.progress'] = String(progress);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function applyCycleSemantics(properties: Record<string, string>, payload: LinearRecord): void {
|
|
460
|
+
const cycle = payload as Partial<LinearCycle> & LinearRecord;
|
|
461
|
+
|
|
462
|
+
addNumberProperty(properties, 'linear.number', cycle.number);
|
|
463
|
+
addStringProperty(properties, 'linear.name', cycle.name);
|
|
464
|
+
addStringProperty(properties, 'linear.starts_at', cycle.startsAt);
|
|
465
|
+
addStringProperty(properties, 'linear.ends_at', cycle.endsAt);
|
|
466
|
+
addStringProperty(properties, 'linear.completed_at', cycle.completedAt);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function asLabels(labels: LinearIssue['labels']): LinearLabel[] {
|
|
470
|
+
return Array.isArray(labels) ? labels.filter((label): label is LinearLabel => Boolean(label?.name)) : [];
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function asRelations(relations: LinearIssue['relations']): LinearRelation[] {
|
|
474
|
+
return Array.isArray(relations)
|
|
475
|
+
? relations.filter((relation): relation is LinearRelation => Boolean(relation?.relatedIssueId))
|
|
476
|
+
: [];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function mergeLinearPayload(event: LinearWebhookPayload): Record<string, unknown> {
|
|
480
|
+
const data = getRecord(event.data) ?? {};
|
|
481
|
+
return {
|
|
482
|
+
...data,
|
|
483
|
+
_webhook: compactObject<LinearWebhookEnvelope>({
|
|
484
|
+
action: asString(event.action),
|
|
485
|
+
actionBy: event.actionBy ?? undefined,
|
|
486
|
+
createdAt: asString(event.createdAt),
|
|
487
|
+
organization: event.organization ?? undefined,
|
|
488
|
+
organizationId: asString(event.organizationId),
|
|
489
|
+
previousData: event.previousData ?? undefined,
|
|
490
|
+
type: asString(event.type),
|
|
491
|
+
url: asString(event.url),
|
|
492
|
+
}),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function inferWriteCounts(
|
|
497
|
+
event: NormalizedWebhook,
|
|
498
|
+
writeResult: WriteFileResult | void,
|
|
499
|
+
deleted: boolean
|
|
500
|
+
): Pick<IngestResult, 'filesDeleted' | 'filesUpdated' | 'filesWritten'> {
|
|
501
|
+
if (deleted) {
|
|
502
|
+
if (writeResult?.status === 'created' || writeResult?.created) {
|
|
503
|
+
return { filesWritten: 1, filesUpdated: 0, filesDeleted: 0 };
|
|
504
|
+
}
|
|
505
|
+
return { filesWritten: 0, filesUpdated: 1, filesDeleted: 0 };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (writeResult?.created || writeResult?.status === 'created') {
|
|
509
|
+
return { filesWritten: 1, filesUpdated: 0, filesDeleted: 0 };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (writeResult?.updated || writeResult?.status === 'updated') {
|
|
513
|
+
return { filesWritten: 0, filesUpdated: 1, filesDeleted: 0 };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const action = getWebhookAction(event.payload) ?? getEventAction(event.eventType);
|
|
517
|
+
if (action === 'create') {
|
|
518
|
+
return { filesWritten: 1, filesUpdated: 0, filesDeleted: 0 };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return { filesWritten: 0, filesUpdated: 1, filesDeleted: 0 };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function getWebhookAction(payload: Record<string, unknown>): string | undefined {
|
|
525
|
+
return asString(getRecord(payload._webhook)?.action)?.toLowerCase();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getEventAction(eventType: string): string | undefined {
|
|
529
|
+
const separatorIndex = eventType.lastIndexOf('.');
|
|
530
|
+
if (separatorIndex === -1 || separatorIndex === eventType.length - 1) {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
return eventType.slice(separatorIndex + 1).toLowerCase();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function inferFallbackPath(event: NormalizedWebhook | LinearWebhookPayload): string {
|
|
537
|
+
try {
|
|
538
|
+
if (isNormalizedWebhook(event)) {
|
|
539
|
+
return computeLinearPath(event.objectType, event.objectId);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const objectId = extractPayloadId(event.data);
|
|
543
|
+
if (!objectId) {
|
|
544
|
+
return '';
|
|
545
|
+
}
|
|
546
|
+
return computeLinearPath(event.type, objectId);
|
|
547
|
+
} catch {
|
|
548
|
+
return '';
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function extractPayloadId(value: unknown): string | undefined {
|
|
553
|
+
const record = getRecord(value);
|
|
554
|
+
return asString(record?.id);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function isNormalizedWebhook(event: NormalizedWebhook | LinearWebhookPayload): event is NormalizedWebhook {
|
|
558
|
+
return (
|
|
559
|
+
isRecord(event) &&
|
|
560
|
+
typeof event.eventType === 'string' &&
|
|
561
|
+
typeof event.objectType === 'string' &&
|
|
562
|
+
typeof event.objectId === 'string' &&
|
|
563
|
+
isRecord(event.payload)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function addStringProperty(properties: Record<string, string>, key: string, value: unknown): void {
|
|
568
|
+
const normalized = asString(value);
|
|
569
|
+
if (normalized) {
|
|
570
|
+
properties[key] = normalized;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function addNumberProperty(properties: Record<string, string>, key: string, value: unknown): void {
|
|
575
|
+
const normalized = asNumber(value);
|
|
576
|
+
if (normalized !== undefined) {
|
|
577
|
+
properties[key] = String(normalized);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function compactSemantics(semantics: FileSemantics): FileSemantics {
|
|
582
|
+
const compacted: FileSemantics = {};
|
|
583
|
+
|
|
584
|
+
if (semantics.properties && Object.keys(semantics.properties).length > 0) {
|
|
585
|
+
compacted.properties = semantics.properties;
|
|
586
|
+
}
|
|
587
|
+
if (semantics.relations && semantics.relations.length > 0) {
|
|
588
|
+
compacted.relations = semantics.relations;
|
|
589
|
+
}
|
|
590
|
+
if (semantics.comments && semantics.comments.length > 0) {
|
|
591
|
+
compacted.comments = semantics.comments;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return compacted;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function compactObject<T extends Record<string, unknown>>(value: T): T {
|
|
598
|
+
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
|
|
599
|
+
return Object.fromEntries(entries) as T;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function sortStrings(values: Set<string>): string[] {
|
|
603
|
+
return Array.from(values).sort((left, right) => left.localeCompare(right));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function stableJson(value: unknown): string {
|
|
607
|
+
return `${JSON.stringify(sortJson(value), null, 2)}\n`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function sortJson(value: unknown): unknown {
|
|
611
|
+
if (Array.isArray(value)) {
|
|
612
|
+
return value.map(sortJson);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (isRecord(value)) {
|
|
616
|
+
const sortedEntries = Object.entries(value)
|
|
617
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
618
|
+
.map(([key, entry]) => [key, sortJson(entry)] as const);
|
|
619
|
+
return Object.fromEntries(sortedEntries);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return value;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getRecord(value: unknown): Record<string, unknown> | undefined {
|
|
626
|
+
return isRecord(value) ? value : undefined;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
630
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function asString(value: unknown): string | undefined {
|
|
634
|
+
if (typeof value !== 'string') {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
const trimmed = value.trim();
|
|
638
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function asNumber(value: unknown): number | undefined {
|
|
642
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
643
|
+
return value;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (typeof value === 'string') {
|
|
647
|
+
const trimmed = value.trim();
|
|
648
|
+
if (!trimmed) {
|
|
649
|
+
return undefined;
|
|
650
|
+
}
|
|
651
|
+
const parsed = Number(trimmed);
|
|
652
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function mapPriorityLabel(priority: number): string {
|
|
659
|
+
switch (priority) {
|
|
660
|
+
case 0:
|
|
661
|
+
return 'none';
|
|
662
|
+
case 1:
|
|
663
|
+
return 'urgent';
|
|
664
|
+
case 2:
|
|
665
|
+
return 'high';
|
|
666
|
+
case 3:
|
|
667
|
+
return 'normal';
|
|
668
|
+
case 4:
|
|
669
|
+
return 'low';
|
|
670
|
+
default:
|
|
671
|
+
return 'custom';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function toErrorMessage(error: unknown): string {
|
|
676
|
+
if (error instanceof Error) {
|
|
677
|
+
return error.message;
|
|
678
|
+
}
|
|
679
|
+
return String(error);
|
|
680
|
+
}
|