@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
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@relayfile/adapter-linear",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Linear adapter bootstrap package for Relayfile",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
16
|
+
"test": "node --import tsx --test src/__tests__/*.test.ts",
|
|
17
|
+
"build": "tsc"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"relayfile",
|
|
21
|
+
"linear",
|
|
22
|
+
"adapter"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/AgentWorkforce/relayfile-adapters",
|
|
34
|
+
"directory": "packages/linear"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@agent-relay/sdk": "^3.2.22",
|
|
38
|
+
"@relayfile/sdk": "^0.1.6"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.6.0",
|
|
42
|
+
"tsx": "^4.20.6",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
LINEAR_SIGNATURE_HEADER,
|
|
7
|
+
LinearAdapter,
|
|
8
|
+
assertValidLinearWebhookSignature,
|
|
9
|
+
computeLinearPath,
|
|
10
|
+
linearCommentPath,
|
|
11
|
+
linearCyclePath,
|
|
12
|
+
linearIssuePath,
|
|
13
|
+
linearProjectPath,
|
|
14
|
+
normalizeLinearWebhook,
|
|
15
|
+
validateLinearWebhookSignature,
|
|
16
|
+
type ConnectionProvider,
|
|
17
|
+
type LinearAdapterConfig,
|
|
18
|
+
type ProxyRequest,
|
|
19
|
+
type ProxyResponse,
|
|
20
|
+
type RelayFileClientLike,
|
|
21
|
+
} from '../index.ts';
|
|
22
|
+
|
|
23
|
+
function createAdapter(config: LinearAdapterConfig = {}): LinearAdapter {
|
|
24
|
+
const client: RelayFileClientLike = {
|
|
25
|
+
async writeFile() {
|
|
26
|
+
return { created: true };
|
|
27
|
+
},
|
|
28
|
+
async deleteFile() {
|
|
29
|
+
return undefined;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const provider: ConnectionProvider = {
|
|
34
|
+
name: 'relayfile-test-provider',
|
|
35
|
+
async proxy<T = unknown>(_request: ProxyRequest): Promise<ProxyResponse<T>> {
|
|
36
|
+
return {
|
|
37
|
+
status: 200,
|
|
38
|
+
headers: {},
|
|
39
|
+
data: null as never,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
async healthCheck() {
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return new LinearAdapter(client, provider, config);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test('LinearAdapter exposes the provider name and supported Linear webhook events', () => {
|
|
50
|
+
const adapter = createAdapter();
|
|
51
|
+
|
|
52
|
+
assert.equal(adapter.name, 'linear');
|
|
53
|
+
assert.deepEqual(adapter.supportedEvents(), [
|
|
54
|
+
'comment.create',
|
|
55
|
+
'comment.update',
|
|
56
|
+
'comment.remove',
|
|
57
|
+
'cycle.create',
|
|
58
|
+
'cycle.update',
|
|
59
|
+
'cycle.remove',
|
|
60
|
+
'issue.create',
|
|
61
|
+
'issue.update',
|
|
62
|
+
'issue.remove',
|
|
63
|
+
'project.create',
|
|
64
|
+
'project.update',
|
|
65
|
+
'project.remove',
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('normalizeLinearWebhook normalizes issue callbacks and preserves connection metadata', () => {
|
|
70
|
+
const normalized = normalizeLinearWebhook(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
action: 'create',
|
|
73
|
+
type: 'Issue',
|
|
74
|
+
createdAt: '2026-03-28T10:00:00.000Z',
|
|
75
|
+
organizationId: 'org_123',
|
|
76
|
+
url: 'https://linear.app/acme/issue/ENG-123',
|
|
77
|
+
data: {
|
|
78
|
+
id: 'issue_123',
|
|
79
|
+
identifier: 'ENG-123',
|
|
80
|
+
title: 'Ship adapter tests',
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
{
|
|
84
|
+
'Linear-Delivery': 'delivery_123',
|
|
85
|
+
'X-Relay-Connection-Id': 'conn_linear_123',
|
|
86
|
+
'X-Relay-Provider-Config-Key': 'linear-primary',
|
|
87
|
+
'X-Request-Id': 'req_123',
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
assert.equal(normalized.provider, 'linear');
|
|
92
|
+
assert.equal(normalized.connectionId, 'conn_linear_123');
|
|
93
|
+
assert.equal(normalized.eventType, 'issue.create');
|
|
94
|
+
assert.equal(normalized.objectType, 'issue');
|
|
95
|
+
assert.equal(normalized.objectId, 'issue_123');
|
|
96
|
+
assert.deepEqual(normalized.payload._connection, {
|
|
97
|
+
connectionId: 'conn_linear_123',
|
|
98
|
+
deliveryId: 'delivery_123',
|
|
99
|
+
provider: 'linear',
|
|
100
|
+
providerConfigKey: 'linear-primary',
|
|
101
|
+
requestId: 'req_123',
|
|
102
|
+
});
|
|
103
|
+
assert.deepEqual(normalized.payload._webhook, {
|
|
104
|
+
action: 'create',
|
|
105
|
+
createdAt: '2026-03-28T10:00:00.000Z',
|
|
106
|
+
deliveryId: 'delivery_123',
|
|
107
|
+
eventType: 'issue.create',
|
|
108
|
+
objectId: 'issue_123',
|
|
109
|
+
objectType: 'issue',
|
|
110
|
+
organizationId: 'org_123',
|
|
111
|
+
url: 'https://linear.app/acme/issue/ENG-123',
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('normalizeLinearWebhook normalizes comment callbacks from payload metadata', () => {
|
|
116
|
+
const normalized = normalizeLinearWebhook({
|
|
117
|
+
action: 'update',
|
|
118
|
+
type: 'Comments',
|
|
119
|
+
createdAt: '2026-03-28T11:00:00.000Z',
|
|
120
|
+
metadata: {
|
|
121
|
+
provider: 'linear',
|
|
122
|
+
providerConfigKey: 'linear-secondary',
|
|
123
|
+
connectionId: 'conn_linear_456',
|
|
124
|
+
},
|
|
125
|
+
connection: {
|
|
126
|
+
id: 'conn_linear_ignored',
|
|
127
|
+
},
|
|
128
|
+
data: {
|
|
129
|
+
id: 'comment_123',
|
|
130
|
+
body: 'Looks good to me.',
|
|
131
|
+
issue: {
|
|
132
|
+
id: 'issue_456',
|
|
133
|
+
identifier: 'ENG-456',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
assert.equal(normalized.provider, 'linear');
|
|
139
|
+
assert.equal(normalized.connectionId, 'conn_linear_456');
|
|
140
|
+
assert.equal(normalized.eventType, 'comment.update');
|
|
141
|
+
assert.equal(normalized.objectType, 'comment');
|
|
142
|
+
assert.equal(normalized.objectId, 'comment_123');
|
|
143
|
+
assert.deepEqual(normalized.payload.data, {
|
|
144
|
+
id: 'comment_123',
|
|
145
|
+
body: 'Looks good to me.',
|
|
146
|
+
issue: {
|
|
147
|
+
id: 'issue_456',
|
|
148
|
+
identifier: 'ENG-456',
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(normalized.payload._connection, {
|
|
152
|
+
connectionId: 'conn_linear_456',
|
|
153
|
+
provider: 'linear',
|
|
154
|
+
providerConfigKey: 'linear-secondary',
|
|
155
|
+
});
|
|
156
|
+
assert.deepEqual(normalized.payload._webhook, {
|
|
157
|
+
action: 'update',
|
|
158
|
+
createdAt: '2026-03-28T11:00:00.000Z',
|
|
159
|
+
eventType: 'comment.update',
|
|
160
|
+
objectId: 'comment_123',
|
|
161
|
+
objectType: 'comment',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('signature rejection handling is deterministic for result and throwing helpers', () => {
|
|
166
|
+
const rawPayload = JSON.stringify({
|
|
167
|
+
action: 'create',
|
|
168
|
+
type: 'Issue',
|
|
169
|
+
data: { id: 'issue_123' },
|
|
170
|
+
});
|
|
171
|
+
const secret = 'linear-secret';
|
|
172
|
+
const validSignature = createHmac('sha256', secret).update(rawPayload).digest('hex');
|
|
173
|
+
|
|
174
|
+
const missing = validateLinearWebhookSignature(rawPayload, {}, secret);
|
|
175
|
+
assert.deepEqual(missing, { ok: false, reason: 'missing-signature' });
|
|
176
|
+
assert.throws(
|
|
177
|
+
() => assertValidLinearWebhookSignature(rawPayload, {}, secret),
|
|
178
|
+
/missing-signature/,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const malformed = validateLinearWebhookSignature(rawPayload, {
|
|
182
|
+
[LINEAR_SIGNATURE_HEADER]: 'not-hex',
|
|
183
|
+
}, secret);
|
|
184
|
+
assert.deepEqual(malformed, {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: 'malformed-signature',
|
|
187
|
+
receivedSignature: 'not-hex',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const invalid = validateLinearWebhookSignature(rawPayload, {
|
|
191
|
+
[LINEAR_SIGNATURE_HEADER]: `${validSignature.slice(0, -2)}00`,
|
|
192
|
+
}, secret);
|
|
193
|
+
assert.equal(invalid.ok, false);
|
|
194
|
+
assert.equal(invalid.reason, 'invalid-signature');
|
|
195
|
+
assert.equal(invalid.expectedSignature, validSignature);
|
|
196
|
+
assert.throws(
|
|
197
|
+
() =>
|
|
198
|
+
assertValidLinearWebhookSignature(rawPayload, {
|
|
199
|
+
[LINEAR_SIGNATURE_HEADER]: `${validSignature.slice(0, -2)}00`,
|
|
200
|
+
}, secret),
|
|
201
|
+
/invalid-signature/,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const missingSecret = validateLinearWebhookSignature(rawPayload, {
|
|
205
|
+
[LINEAR_SIGNATURE_HEADER]: validSignature,
|
|
206
|
+
}, ' ');
|
|
207
|
+
assert.deepEqual(missingSecret, { ok: false, reason: 'missing-secret' });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('path mapping stays deterministic for issue, comment, project, and cycle objects', () => {
|
|
211
|
+
const adapter = createAdapter();
|
|
212
|
+
|
|
213
|
+
assert.equal(linearIssuePath('issue 1/2'), '/linear/issues/issue%201%2F2.json');
|
|
214
|
+
assert.equal(linearCommentPath('comment:42'), '/linear/comments/comment%3A42.json');
|
|
215
|
+
assert.equal(linearProjectPath('project#7'), '/linear/projects/project%237.json');
|
|
216
|
+
assert.equal(linearCyclePath('cycle Q2'), '/linear/cycles/cycle%20Q2.json');
|
|
217
|
+
|
|
218
|
+
assert.equal(computeLinearPath('Issue', 'issue 1/2'), '/linear/issues/issue%201%2F2.json');
|
|
219
|
+
assert.equal(computeLinearPath('comments', 'comment:42'), '/linear/comments/comment%3A42.json');
|
|
220
|
+
assert.equal(computeLinearPath('project', 'project#7'), '/linear/projects/project%237.json');
|
|
221
|
+
assert.equal(computeLinearPath('Cycles', 'cycle Q2'), '/linear/cycles/cycle%20Q2.json');
|
|
222
|
+
|
|
223
|
+
assert.equal(adapter.computePath('issues', 'issue 1/2'), '/linear/issues/issue%201%2F2.json');
|
|
224
|
+
assert.equal(adapter.computePath('comment', 'comment:42'), '/linear/comments/comment%3A42.json');
|
|
225
|
+
assert.equal(adapter.computePath('projects', 'project#7'), '/linear/projects/project%237.json');
|
|
226
|
+
assert.equal(adapter.computePath('cycle', 'cycle Q2'), '/linear/cycles/cycle%20Q2.json');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('computeSemantics extracts issue priority, state, labels, and relations deterministically', () => {
|
|
230
|
+
const adapter = createAdapter();
|
|
231
|
+
|
|
232
|
+
const semantics = adapter.computeSemantics('Issue', 'issue_123', {
|
|
233
|
+
id: 'issue_123',
|
|
234
|
+
identifier: 'ENG-123',
|
|
235
|
+
title: 'Stabilize Linear adapter coverage',
|
|
236
|
+
priority: 2,
|
|
237
|
+
state: {
|
|
238
|
+
id: 'state_in_progress',
|
|
239
|
+
name: 'In Progress',
|
|
240
|
+
type: 'started',
|
|
241
|
+
color: '#f97316',
|
|
242
|
+
},
|
|
243
|
+
labels: [
|
|
244
|
+
{ id: 'label_bug', name: 'bug' },
|
|
245
|
+
{ id: 'label_ui', name: 'ui' },
|
|
246
|
+
{ id: 'label_backend', name: 'backend' },
|
|
247
|
+
{ id: 'label_blank', name: ' ' },
|
|
248
|
+
],
|
|
249
|
+
project: {
|
|
250
|
+
id: 'project_alpha',
|
|
251
|
+
name: 'Alpha',
|
|
252
|
+
state: 'started',
|
|
253
|
+
url: 'https://linear.app/acme/project/alpha',
|
|
254
|
+
},
|
|
255
|
+
cycle: {
|
|
256
|
+
id: 'cycle_2026_06',
|
|
257
|
+
number: 6,
|
|
258
|
+
name: 'Cycle 6',
|
|
259
|
+
},
|
|
260
|
+
parent: {
|
|
261
|
+
id: 'issue_parent',
|
|
262
|
+
},
|
|
263
|
+
children: [
|
|
264
|
+
{ id: 'issue_child_b' },
|
|
265
|
+
{ id: 'issue_child_a' },
|
|
266
|
+
],
|
|
267
|
+
relations: [
|
|
268
|
+
{ relatedIssueId: 'issue_related_z' },
|
|
269
|
+
{ relatedIssueId: 'issue_child_a' },
|
|
270
|
+
],
|
|
271
|
+
team: {
|
|
272
|
+
id: 'team_eng',
|
|
273
|
+
key: 'ENG',
|
|
274
|
+
name: 'Engineering',
|
|
275
|
+
},
|
|
276
|
+
url: 'https://linear.app/acme/issue/ENG-123',
|
|
277
|
+
_webhook: {
|
|
278
|
+
action: 'update',
|
|
279
|
+
createdAt: '2026-03-28T12:00:00.000Z',
|
|
280
|
+
organizationId: 'org_123',
|
|
281
|
+
url: 'https://linear.app/webhooks/issue_123',
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
assert.deepEqual(semantics.properties, {
|
|
286
|
+
provider: 'linear',
|
|
287
|
+
'provider.object_id': 'issue_123',
|
|
288
|
+
'provider.object_type': 'issue',
|
|
289
|
+
'linear.id': 'issue_123',
|
|
290
|
+
'linear.object_type': 'issue',
|
|
291
|
+
'linear.url': 'https://linear.app/acme/issue/ENG-123',
|
|
292
|
+
'linear.webhook.action': 'update',
|
|
293
|
+
'linear.webhook.created_at': '2026-03-28T12:00:00.000Z',
|
|
294
|
+
'linear.webhook.organization_id': 'org_123',
|
|
295
|
+
'linear.webhook.url': 'https://linear.app/webhooks/issue_123',
|
|
296
|
+
'linear.identifier': 'ENG-123',
|
|
297
|
+
'linear.title': 'Stabilize Linear adapter coverage',
|
|
298
|
+
'linear.priority': '2',
|
|
299
|
+
'linear.priority_label': 'high',
|
|
300
|
+
'linear.state_id': 'state_in_progress',
|
|
301
|
+
'linear.state_name': 'In Progress',
|
|
302
|
+
'linear.state_type': 'started',
|
|
303
|
+
'linear.state_color': '#f97316',
|
|
304
|
+
'linear.labels': 'backend, bug, ui',
|
|
305
|
+
'linear.label_count': '3',
|
|
306
|
+
'linear.project_id': 'project_alpha',
|
|
307
|
+
'linear.project_name': 'Alpha',
|
|
308
|
+
'linear.project_state': 'started',
|
|
309
|
+
'linear.project_url': 'https://linear.app/acme/project/alpha',
|
|
310
|
+
'linear.cycle_id': 'cycle_2026_06',
|
|
311
|
+
'linear.cycle_number': '6',
|
|
312
|
+
'linear.cycle_name': 'Cycle 6',
|
|
313
|
+
'linear.parent_id': 'issue_parent',
|
|
314
|
+
'linear.team_id': 'team_eng',
|
|
315
|
+
'linear.team_key': 'ENG',
|
|
316
|
+
'linear.team_name': 'Engineering',
|
|
317
|
+
});
|
|
318
|
+
assert.deepEqual(semantics.relations, [
|
|
319
|
+
'/linear/cycles/cycle_2026_06.json',
|
|
320
|
+
'/linear/issues/issue_child_a.json',
|
|
321
|
+
'/linear/issues/issue_child_b.json',
|
|
322
|
+
'/linear/issues/issue_parent.json',
|
|
323
|
+
'/linear/issues/issue_related_z.json',
|
|
324
|
+
'/linear/projects/project_alpha.json',
|
|
325
|
+
]);
|
|
326
|
+
assert.equal(semantics.comments, undefined);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('barrel exports import cleanly for runtime and type-checked usage', async () => {
|
|
330
|
+
const barrel = await import('../index.ts');
|
|
331
|
+
|
|
332
|
+
assert.equal(barrel.LinearAdapter, LinearAdapter);
|
|
333
|
+
assert.equal(barrel.computeLinearPath, computeLinearPath);
|
|
334
|
+
assert.equal(typeof barrel.normalizeLinearWebhook, 'function');
|
|
335
|
+
assert.equal(typeof barrel.validateLinearWebhookSignature, 'function');
|
|
336
|
+
|
|
337
|
+
const config: LinearAdapterConfig = {
|
|
338
|
+
connectionId: 'conn_linear_barrel',
|
|
339
|
+
provider: 'linear',
|
|
340
|
+
};
|
|
341
|
+
const adapter = createAdapter(config);
|
|
342
|
+
|
|
343
|
+
assert.equal(adapter.name, 'linear');
|
|
344
|
+
assert.equal(adapter.config.connectionId, 'conn_linear_barrel');
|
|
345
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
LINEAR_WEBHOOK_ACTIONS,
|
|
6
|
+
LINEAR_WEBHOOK_OBJECT_TYPES,
|
|
7
|
+
type LinearAdapterConfig,
|
|
8
|
+
} from '../index.ts';
|
|
9
|
+
|
|
10
|
+
test('exports supported Linear webhook object types', () => {
|
|
11
|
+
assert.deepEqual(LINEAR_WEBHOOK_OBJECT_TYPES, ['comment', 'cycle', 'issue', 'project']);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('exports supported Linear webhook actions', () => {
|
|
15
|
+
assert.deepEqual(LINEAR_WEBHOOK_ACTIONS, ['create', 'remove', 'update']);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('LinearAdapterConfig remains usable as a typed contract', () => {
|
|
19
|
+
const config = {
|
|
20
|
+
apiUrl: 'https://api.linear.app/graphql',
|
|
21
|
+
provider: 'nango',
|
|
22
|
+
webhookSecret: 'linear-secret',
|
|
23
|
+
} satisfies LinearAdapterConfig;
|
|
24
|
+
|
|
25
|
+
assert.equal(config.provider, 'nango');
|
|
26
|
+
assert.equal(config.webhookSecret, 'linear-secret');
|
|
27
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHmac } from 'node:crypto';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
LINEAR_DELIVERY_HEADER,
|
|
7
|
+
LINEAR_SIGNATURE_HEADER,
|
|
8
|
+
assertValidLinearWebhookSignature,
|
|
9
|
+
normalizeLinearWebhook,
|
|
10
|
+
validateLinearWebhookSignature,
|
|
11
|
+
validateLinearWebhookTimestamp,
|
|
12
|
+
} from '../index.ts';
|
|
13
|
+
|
|
14
|
+
const issuePayload = {
|
|
15
|
+
action: 'create',
|
|
16
|
+
type: 'Issue',
|
|
17
|
+
createdAt: '2026-03-28T10:00:00.000Z',
|
|
18
|
+
organizationId: 'org_123',
|
|
19
|
+
webhookTimestamp: 1_743_155_200_000,
|
|
20
|
+
webhookId: 'webhook_123',
|
|
21
|
+
data: {
|
|
22
|
+
id: 'issue_123',
|
|
23
|
+
identifier: 'ENG-123',
|
|
24
|
+
title: 'Ship webhook normalizer',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test('normalizeLinearWebhook extracts normalized event metadata and connection metadata', () => {
|
|
29
|
+
const normalized = normalizeLinearWebhook(issuePayload, {
|
|
30
|
+
[LINEAR_DELIVERY_HEADER]: 'delivery_123',
|
|
31
|
+
'Linear-Event': 'Issue',
|
|
32
|
+
'X-Relay-Connection-Id': 'conn_linear_123',
|
|
33
|
+
'X-Relay-Provider-Config-Key': 'linear',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
assert.equal(normalized.provider, 'linear');
|
|
37
|
+
assert.equal(normalized.connectionId, 'conn_linear_123');
|
|
38
|
+
assert.equal(normalized.eventType, 'issue.create');
|
|
39
|
+
assert.equal(normalized.objectType, 'issue');
|
|
40
|
+
assert.equal(normalized.objectId, 'issue_123');
|
|
41
|
+
assert.deepEqual(normalized.payload._connection, {
|
|
42
|
+
connectionId: 'conn_linear_123',
|
|
43
|
+
deliveryId: 'delivery_123',
|
|
44
|
+
provider: 'linear',
|
|
45
|
+
providerConfigKey: 'linear',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('validateLinearWebhookSignature accepts the expected HMAC and rejects invalid signatures', () => {
|
|
50
|
+
const rawPayload = JSON.stringify(issuePayload);
|
|
51
|
+
const secret = 'linear-secret';
|
|
52
|
+
const signature = createHmac('sha256', secret).update(rawPayload).digest('hex');
|
|
53
|
+
|
|
54
|
+
const valid = validateLinearWebhookSignature(rawPayload, {
|
|
55
|
+
[LINEAR_SIGNATURE_HEADER]: signature,
|
|
56
|
+
}, secret);
|
|
57
|
+
assert.equal(valid.ok, true);
|
|
58
|
+
|
|
59
|
+
const invalid = validateLinearWebhookSignature(rawPayload, {
|
|
60
|
+
[LINEAR_SIGNATURE_HEADER]: 'deadbeef',
|
|
61
|
+
}, secret);
|
|
62
|
+
assert.equal(invalid.ok, false);
|
|
63
|
+
assert.equal(invalid.reason, 'invalid-signature');
|
|
64
|
+
|
|
65
|
+
assert.doesNotThrow(() =>
|
|
66
|
+
assertValidLinearWebhookSignature(rawPayload, { [LINEAR_SIGNATURE_HEADER]: signature }, secret),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('validateLinearWebhookTimestamp enforces freshness', () => {
|
|
71
|
+
const fresh = validateLinearWebhookTimestamp(issuePayload, 60_000, 1_743_155_230_000);
|
|
72
|
+
assert.equal(fresh.ok, true);
|
|
73
|
+
|
|
74
|
+
const stale = validateLinearWebhookTimestamp(issuePayload, 60_000, 1_743_155_400_001);
|
|
75
|
+
assert.equal(stale.ok, false);
|
|
76
|
+
assert.equal(stale.reason, 'stale-timestamp');
|
|
77
|
+
});
|