@shipfox/api-integration-linear 2.0.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +89 -0
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/api/client.d.ts +29 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +189 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +22 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-tools-provider.d.ts +32 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +63 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +10 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +920 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +23 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +35 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +59 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +47 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +102 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +17 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +54 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +39 -0
- package/dist/core/tokens.d.ts.map +1 -0
- package/dist/core/tokens.js +117 -0
- package/dist/core/tokens.js.map +1 -0
- package/dist/core/webhook.d.ts +16 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +105 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +369 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +53 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +125 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +186 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +40 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +17 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +6 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
- package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-connection.js +64 -0
- package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +5 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +65 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +19 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +100 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +11 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +136 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +126 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +72 -0
- package/src/api/client.test.ts +366 -0
- package/src/api/client.ts +281 -0
- package/src/config.test.ts +34 -0
- package/src/config.ts +22 -0
- package/src/connection-external-url.test.ts +64 -0
- package/src/core/agent-tools-provider.test.ts +245 -0
- package/src/core/agent-tools-provider.ts +106 -0
- package/src/core/agent-tools.ts +918 -0
- package/src/core/disconnect.test.ts +45 -0
- package/src/core/disconnect.ts +27 -0
- package/src/core/errors.ts +71 -0
- package/src/core/install.ts +162 -0
- package/src/core/scopes.ts +18 -0
- package/src/core/state.test.ts +51 -0
- package/src/core/state.ts +85 -0
- package/src/core/tokens.test.ts +281 -0
- package/src/core/tokens.ts +219 -0
- package/src/core/webhook.test.ts +318 -0
- package/src/core/webhook.ts +146 -0
- package/src/db/db.ts +18 -0
- package/src/db/installations.test.ts +182 -0
- package/src/db/installations.ts +215 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +42 -0
- package/src/index.test.ts +20 -0
- package/src/index.ts +153 -0
- package/src/presentation/dto/integrations.ts +9 -0
- package/src/presentation/e2eRoutes/create-connection.ts +85 -0
- package/src/presentation/e2eRoutes/index.test.ts +193 -0
- package/src/presentation/e2eRoutes/index.ts +14 -0
- package/src/presentation/routes/errors.ts +60 -0
- package/src/presentation/routes/install.test.ts +460 -0
- package/src/presentation/routes/install.ts +132 -0
- package/src/presentation/routes/webhooks.test.ts +407 -0
- package/src/presentation/routes/webhooks.ts +154 -0
- package/test/api-secrets.d.ts +28 -0
- package/test/env.ts +13 -0
- package/test/factories/index.ts +1 -0
- package/test/globalSetup.ts +23 -0
- package/test/index.ts +1 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import {LinearIntegrationProviderError} from '#core/errors.js';
|
|
5
|
+
|
|
6
|
+
const LINEAR_OAUTH_TOKEN_URL = 'https://api.linear.app/oauth/token';
|
|
7
|
+
const LINEAR_OAUTH_REVOKE_URL = 'https://api.linear.app/oauth/revoke';
|
|
8
|
+
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
|
|
9
|
+
const LINEAR_API_TIMEOUT_MS = 10_000;
|
|
10
|
+
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
11
|
+
|
|
12
|
+
const IDENTITY_QUERY = `
|
|
13
|
+
query ShipfoxLinearIdentity {
|
|
14
|
+
viewer {
|
|
15
|
+
id
|
|
16
|
+
}
|
|
17
|
+
organization {
|
|
18
|
+
id
|
|
19
|
+
name
|
|
20
|
+
urlKey
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
export interface LinearAuthorization {
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string | undefined;
|
|
28
|
+
expiresAt?: Date | undefined;
|
|
29
|
+
scopes: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LinearIdentity {
|
|
33
|
+
appUserId: string;
|
|
34
|
+
organizationId: string;
|
|
35
|
+
organizationName: string;
|
|
36
|
+
organizationUrlKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LinearApiClient {
|
|
40
|
+
exchangeAuthorizationCode(input: {code: string}): Promise<LinearAuthorization>;
|
|
41
|
+
refreshAccessToken(input: {refreshToken: string}): Promise<LinearAuthorization>;
|
|
42
|
+
revokeToken(input: {
|
|
43
|
+
token: string;
|
|
44
|
+
tokenTypeHint: 'access_token' | 'refresh_token';
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
getIdentity(input: {accessToken: string}): Promise<LinearIdentity>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LinearTokenResponse {
|
|
50
|
+
access_token?: unknown;
|
|
51
|
+
refresh_token?: unknown;
|
|
52
|
+
expires_in?: unknown;
|
|
53
|
+
scope?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LinearGraphqlResponse<Data> {
|
|
57
|
+
data?: Data | null;
|
|
58
|
+
errors?: LinearGraphqlError[] | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface LinearGraphqlError {
|
|
62
|
+
extensions?: {type?: unknown} | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface LinearIdentityData {
|
|
66
|
+
viewer?: {id?: unknown} | null;
|
|
67
|
+
organization?: {id?: unknown; name?: unknown; urlKey?: unknown} | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface MapLinearErrorOptions {
|
|
71
|
+
classifyHttp4xx?(status: number): 'access-denied' | 'malformed-provider-response';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createLinearApiClient(): LinearApiClient {
|
|
75
|
+
return {
|
|
76
|
+
async exchangeAuthorizationCode(input) {
|
|
77
|
+
const body = await mapLinearError('exchange-authorization-code', () =>
|
|
78
|
+
ky
|
|
79
|
+
.post(LINEAR_OAUTH_TOKEN_URL, {
|
|
80
|
+
body: new URLSearchParams({
|
|
81
|
+
grant_type: 'authorization_code',
|
|
82
|
+
client_id: config.LINEAR_OAUTH_CLIENT_ID,
|
|
83
|
+
client_secret: config.LINEAR_OAUTH_CLIENT_SECRET,
|
|
84
|
+
code: input.code,
|
|
85
|
+
redirect_uri: config.LINEAR_OAUTH_REDIRECT_URL,
|
|
86
|
+
}),
|
|
87
|
+
timeout: LINEAR_API_TIMEOUT_MS,
|
|
88
|
+
})
|
|
89
|
+
.json<LinearTokenResponse>(),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return parseTokenResponse(body);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async refreshAccessToken(input) {
|
|
96
|
+
const body = await mapLinearError('refresh-access-token', () =>
|
|
97
|
+
ky
|
|
98
|
+
.post(LINEAR_OAUTH_TOKEN_URL, {
|
|
99
|
+
body: new URLSearchParams({
|
|
100
|
+
grant_type: 'refresh_token',
|
|
101
|
+
client_id: config.LINEAR_OAUTH_CLIENT_ID,
|
|
102
|
+
client_secret: config.LINEAR_OAUTH_CLIENT_SECRET,
|
|
103
|
+
refresh_token: input.refreshToken,
|
|
104
|
+
}),
|
|
105
|
+
timeout: LINEAR_API_TIMEOUT_MS,
|
|
106
|
+
})
|
|
107
|
+
.json<LinearTokenResponse>(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return parseTokenResponse(body);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async revokeToken(input) {
|
|
114
|
+
await mapLinearError('revoke-token', async () => {
|
|
115
|
+
await ky.post(LINEAR_OAUTH_REVOKE_URL, {
|
|
116
|
+
body: new URLSearchParams({
|
|
117
|
+
client_id: config.LINEAR_OAUTH_CLIENT_ID,
|
|
118
|
+
client_secret: config.LINEAR_OAUTH_CLIENT_SECRET,
|
|
119
|
+
token: input.token,
|
|
120
|
+
token_type_hint: input.tokenTypeHint,
|
|
121
|
+
}),
|
|
122
|
+
timeout: LINEAR_API_TIMEOUT_MS,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async getIdentity(input) {
|
|
128
|
+
const body = await mapLinearError(
|
|
129
|
+
'get-identity',
|
|
130
|
+
() =>
|
|
131
|
+
ky
|
|
132
|
+
.post(LINEAR_GRAPHQL_URL, {
|
|
133
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
134
|
+
json: {query: IDENTITY_QUERY},
|
|
135
|
+
timeout: LINEAR_API_TIMEOUT_MS,
|
|
136
|
+
})
|
|
137
|
+
.json<LinearGraphqlResponse<LinearIdentityData>>(),
|
|
138
|
+
{classifyHttp4xx: classifyGraphqlHttp4xx},
|
|
139
|
+
);
|
|
140
|
+
const data = graphqlData('get-identity', body);
|
|
141
|
+
const appUserId = data.viewer?.id;
|
|
142
|
+
const organizationId = data.organization?.id;
|
|
143
|
+
const organizationName = data.organization?.name;
|
|
144
|
+
const organizationUrlKey = data.organization?.urlKey;
|
|
145
|
+
|
|
146
|
+
if (
|
|
147
|
+
typeof appUserId !== 'string' ||
|
|
148
|
+
typeof organizationId !== 'string' ||
|
|
149
|
+
typeof organizationName !== 'string' ||
|
|
150
|
+
typeof organizationUrlKey !== 'string'
|
|
151
|
+
) {
|
|
152
|
+
throw new LinearIntegrationProviderError(
|
|
153
|
+
'malformed-provider-response',
|
|
154
|
+
'Linear identity response did not include the app user and organization',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {appUserId, organizationId, organizationName, organizationUrlKey};
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseTokenResponse(body: LinearTokenResponse): LinearAuthorization {
|
|
164
|
+
if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
|
|
165
|
+
throw new LinearIntegrationProviderError(
|
|
166
|
+
'malformed-provider-response',
|
|
167
|
+
'Linear authorization response did not include an access token',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const scopes = parseScopes(body.scope);
|
|
172
|
+
const expiresAt = parseExpiresAt(body.expires_in);
|
|
173
|
+
const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : undefined;
|
|
174
|
+
return {accessToken: body.access_token, refreshToken, expiresAt, scopes};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseScopes(scope: unknown): string[] {
|
|
178
|
+
if (typeof scope === 'string') {
|
|
179
|
+
return scope
|
|
180
|
+
.split(SCOPE_SEPARATOR_RE)
|
|
181
|
+
.map((value) => value.trim())
|
|
182
|
+
.filter(Boolean);
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(scope) && scope.every((value) => typeof value === 'string')) return scope;
|
|
185
|
+
if (scope === undefined) return [];
|
|
186
|
+
throw new LinearIntegrationProviderError(
|
|
187
|
+
'malformed-provider-response',
|
|
188
|
+
'Linear authorization response included malformed scopes',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseExpiresAt(expiresIn: unknown): Date | undefined {
|
|
193
|
+
if (expiresIn === undefined) return undefined;
|
|
194
|
+
if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
195
|
+
throw new LinearIntegrationProviderError(
|
|
196
|
+
'malformed-provider-response',
|
|
197
|
+
'Linear authorization response included a malformed expiry',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return new Date(Date.now() + expiresIn * 1000);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function graphqlData<Data>(operation: string, body: LinearGraphqlResponse<Data>): Data {
|
|
204
|
+
if (hasGraphqlErrors(body)) {
|
|
205
|
+
const reason = hasAuthGraphqlError(body.errors)
|
|
206
|
+
? 'access-denied'
|
|
207
|
+
: 'malformed-provider-response';
|
|
208
|
+
logger().warn({operation}, 'Linear GraphQL request returned errors');
|
|
209
|
+
throw new LinearIntegrationProviderError(reason, 'Linear GraphQL request failed');
|
|
210
|
+
}
|
|
211
|
+
if (!body.data) {
|
|
212
|
+
throw new LinearIntegrationProviderError(
|
|
213
|
+
'malformed-provider-response',
|
|
214
|
+
'Linear GraphQL response did not include data',
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return body.data;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasGraphqlErrors(body: LinearGraphqlResponse<unknown>): boolean {
|
|
221
|
+
return Array.isArray(body.errors) && body.errors.length > 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function hasAuthGraphqlError(errors: LinearGraphqlError[] | undefined): boolean {
|
|
225
|
+
return (
|
|
226
|
+
errors?.some((error) => {
|
|
227
|
+
const type = error.extensions?.type;
|
|
228
|
+
return (
|
|
229
|
+
typeof type === 'string' && ['authentication', 'authorization'].includes(type.toLowerCase())
|
|
230
|
+
);
|
|
231
|
+
}) ?? false
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function mapLinearError<T>(
|
|
236
|
+
operation: string,
|
|
237
|
+
request: () => Promise<T>,
|
|
238
|
+
options: MapLinearErrorOptions = {},
|
|
239
|
+
): Promise<T> {
|
|
240
|
+
try {
|
|
241
|
+
return await request();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof LinearIntegrationProviderError) throw error;
|
|
244
|
+
if (error instanceof HTTPError) {
|
|
245
|
+
const {status, statusText, headers} = error.response;
|
|
246
|
+
logger().warn({operation, status, statusText}, 'Linear API request rejected');
|
|
247
|
+
if (status === 429) {
|
|
248
|
+
throw new LinearIntegrationProviderError(
|
|
249
|
+
'rate-limited',
|
|
250
|
+
'Linear request was rate limited',
|
|
251
|
+
retryAfterSeconds(headers),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (status >= 500) {
|
|
255
|
+
throw new LinearIntegrationProviderError('provider-unavailable', 'Linear request failed');
|
|
256
|
+
}
|
|
257
|
+
const reason = options.classifyHttp4xx?.(status) ?? 'access-denied';
|
|
258
|
+
throw new LinearIntegrationProviderError(reason, 'Linear request was rejected');
|
|
259
|
+
}
|
|
260
|
+
if (error instanceof TimeoutError) {
|
|
261
|
+
logger().warn({operation}, 'Linear API request timed out');
|
|
262
|
+
throw new LinearIntegrationProviderError('timeout', 'Linear request timed out');
|
|
263
|
+
}
|
|
264
|
+
logger().warn(
|
|
265
|
+
{operation, errName: error instanceof Error ? error.name : typeof error},
|
|
266
|
+
'Linear API request failed',
|
|
267
|
+
);
|
|
268
|
+
throw new LinearIntegrationProviderError('provider-unavailable', 'Linear request failed');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function classifyGraphqlHttp4xx(status: number): 'access-denied' | 'malformed-provider-response' {
|
|
273
|
+
return status === 401 || status === 403 ? 'access-denied' : 'malformed-provider-response';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
277
|
+
const retryAfter = headers.get('retry-after');
|
|
278
|
+
if (!retryAfter) return undefined;
|
|
279
|
+
const parsed = Number.parseInt(retryAfter, 10);
|
|
280
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
281
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const linearEnvNames = [
|
|
2
|
+
'LINEAR_OAUTH_CLIENT_ID',
|
|
3
|
+
'LINEAR_OAUTH_CLIENT_SECRET',
|
|
4
|
+
'LINEAR_WEBHOOK_SIGNING_SECRET',
|
|
5
|
+
'LINEAR_OAUTH_REDIRECT_URL',
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
describe('linear config', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllEnvs();
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('exports validated Linear config from the package root', async () => {
|
|
15
|
+
vi.stubEnv('LINEAR_MCP_ENDPOINT', undefined);
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
|
|
18
|
+
const {config} = await import('#index.js');
|
|
19
|
+
|
|
20
|
+
for (const name of linearEnvNames) {
|
|
21
|
+
expect(config[name]).toBe(process.env[name]);
|
|
22
|
+
}
|
|
23
|
+
expect(config.LINEAR_MCP_ENDPOINT).toBe('https://mcp.linear.app/mcp');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('accepts a compatible Linear MCP endpoint override', async () => {
|
|
27
|
+
vi.stubEnv('LINEAR_MCP_ENDPOINT', 'https://linear-mcp.example.test/mcp');
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
|
|
30
|
+
const {config} = await import('#index.js');
|
|
31
|
+
|
|
32
|
+
expect(config.LINEAR_MCP_ENDPOINT).toBe('https://linear-mcp.example.test/mcp');
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {createConfig, str, url} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
const linearConfigSchema = {
|
|
4
|
+
LINEAR_OAUTH_CLIENT_ID: str({
|
|
5
|
+
desc: 'OAuth client ID of the Linear app, used to start and complete the Linear connect flow. Required.',
|
|
6
|
+
}),
|
|
7
|
+
LINEAR_OAUTH_CLIENT_SECRET: str({
|
|
8
|
+
desc: 'OAuth client secret of the Linear app, used to exchange authorization codes for workspace tokens. Required.',
|
|
9
|
+
}),
|
|
10
|
+
LINEAR_WEBHOOK_SIGNING_SECRET: str({
|
|
11
|
+
desc: 'Secret used to verify incoming Linear webhooks. Must match the signing secret configured on the Linear app. Required.',
|
|
12
|
+
}),
|
|
13
|
+
LINEAR_OAUTH_REDIRECT_URL: url({
|
|
14
|
+
desc: 'Public client callback URL Linear redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/linear/callback. Required.',
|
|
15
|
+
}),
|
|
16
|
+
LINEAR_MCP_ENDPOINT: url({
|
|
17
|
+
desc: 'Streamable HTTP endpoint used for Linear MCP tool calls. Set this only when routing Linear tools through a compatible proxy or test server.',
|
|
18
|
+
default: 'https://mcp.linear.app/mcp',
|
|
19
|
+
}),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const config = createConfig(linearConfigSchema);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {LinearInstallation} from '#db/installations.js';
|
|
2
|
+
import {createLinearIntegrationProvider} from '#index.js';
|
|
3
|
+
|
|
4
|
+
function installation(overrides: Partial<LinearInstallation> = {}): LinearInstallation {
|
|
5
|
+
return {
|
|
6
|
+
id: crypto.randomUUID(),
|
|
7
|
+
connectionId: crypto.randomUUID(),
|
|
8
|
+
organizationId: 'org-1',
|
|
9
|
+
organizationUrlKey: 'acme',
|
|
10
|
+
appUserId: 'app-user-1',
|
|
11
|
+
scopes: ['read', 'write'],
|
|
12
|
+
tokenExpiresAt: null,
|
|
13
|
+
status: 'installed',
|
|
14
|
+
createdAt: new Date(),
|
|
15
|
+
updatedAt: new Date(),
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('linear connectionExternalUrl', () => {
|
|
21
|
+
it('resolves the organization settings URL from the installation row', async () => {
|
|
22
|
+
const connectionId = crypto.randomUUID();
|
|
23
|
+
const provider = createLinearIntegrationProvider({
|
|
24
|
+
getLinearInstallationByConnectionId: () =>
|
|
25
|
+
Promise.resolve(installation({connectionId, organizationUrlKey: 'acme'})),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const url = await provider.connectionExternalUrl({id: connectionId});
|
|
29
|
+
|
|
30
|
+
expect(url).toBe('https://linear.app/acme/settings');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('URL-encodes the organization URL key', async () => {
|
|
34
|
+
const provider = createLinearIntegrationProvider({
|
|
35
|
+
getLinearInstallationByConnectionId: () =>
|
|
36
|
+
Promise.resolve(installation({organizationUrlKey: 'a/b c'})),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
40
|
+
|
|
41
|
+
expect(url).toBe('https://linear.app/a%2Fb%20c/settings');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns undefined when the installation row is missing', async () => {
|
|
45
|
+
const provider = createLinearIntegrationProvider({
|
|
46
|
+
getLinearInstallationByConnectionId: () => Promise.resolve(undefined),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
50
|
+
|
|
51
|
+
expect(url).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns undefined when the organization URL key is absent', async () => {
|
|
55
|
+
const provider = createLinearIntegrationProvider({
|
|
56
|
+
getLinearInstallationByConnectionId: () =>
|
|
57
|
+
Promise.resolve(installation({organizationUrlKey: ''})),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
61
|
+
|
|
62
|
+
expect(url).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {createServer, type IncomingMessage, type ServerResponse} from 'node:http';
|
|
2
|
+
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
4
|
+
import {linearAgentToolCatalog, linearAgentToolSelectionCatalog} from '#core/agent-tools.js';
|
|
5
|
+
import {LinearAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
6
|
+
import {LinearAccessTokenMissingError} from '#core/errors.js';
|
|
7
|
+
|
|
8
|
+
function linearConnection(
|
|
9
|
+
overrides: Partial<IntegrationConnection<'linear'>> = {},
|
|
10
|
+
): IntegrationConnection<'linear'> {
|
|
11
|
+
const now = new Date();
|
|
12
|
+
return {
|
|
13
|
+
id: 'linear-connection-1',
|
|
14
|
+
workspaceId: 'workspace-1',
|
|
15
|
+
provider: 'linear',
|
|
16
|
+
externalAccountId: 'linear-org-1',
|
|
17
|
+
slug: 'linear-main',
|
|
18
|
+
displayName: 'Linear',
|
|
19
|
+
lifecycleStatus: 'active',
|
|
20
|
+
createdAt: now,
|
|
21
|
+
updatedAt: now,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('LinearAgentToolsProvider', () => {
|
|
27
|
+
it('returns the Linear agent tools catalogs', () => {
|
|
28
|
+
const provider = new LinearAgentToolsProvider({
|
|
29
|
+
tokenStore: {getAccessToken: async () => 'linear-token'},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(provider.catalog()).toBe(linearAgentToolCatalog);
|
|
33
|
+
expect(provider.selectionCatalog()).toBe(linearAgentToolSelectionCatalog);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('reads the stored token for the connection id before opening the MCP client', async () => {
|
|
37
|
+
const getAccessToken = vi.fn().mockResolvedValue('linear-token');
|
|
38
|
+
const createClient = vi.fn().mockResolvedValue({
|
|
39
|
+
callTool: vi.fn().mockResolvedValue({content: []}),
|
|
40
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
});
|
|
42
|
+
const provider = new LinearAgentToolsProvider({
|
|
43
|
+
tokenStore: {getAccessToken},
|
|
44
|
+
createClient,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await provider.openSession({
|
|
48
|
+
connection: linearConnection({id: 'linear-connection-7'}),
|
|
49
|
+
tools: [],
|
|
50
|
+
scope: {provider: 'linear'},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(getAccessToken).toHaveBeenCalledWith({connectionId: 'linear-connection-7'});
|
|
54
|
+
expect(createClient).toHaveBeenCalledWith({
|
|
55
|
+
endpoint: new URL('https://mcp.linear.app/mcp'),
|
|
56
|
+
accessToken: 'linear-token',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sends bearer auth to the remote Streamable HTTP MCP server', async () => {
|
|
61
|
+
const authorizations: (string | undefined)[] = [];
|
|
62
|
+
const server = createServer((request, response) => {
|
|
63
|
+
authorizations.push(request.headers.authorization);
|
|
64
|
+
handleMcpRequest(request, response);
|
|
65
|
+
});
|
|
66
|
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
67
|
+
const address = server.address();
|
|
68
|
+
if (!address || typeof address === 'string') throw new Error('Expected TCP server address');
|
|
69
|
+
const provider = new LinearAgentToolsProvider({
|
|
70
|
+
tokenStore: {getAccessToken: async () => 'linear-token'},
|
|
71
|
+
endpoint: new URL(`http://127.0.0.1:${address.port}/mcp`),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const session = await provider.openSession({
|
|
76
|
+
connection: linearConnection(),
|
|
77
|
+
tools: [],
|
|
78
|
+
scope: {provider: 'linear'},
|
|
79
|
+
});
|
|
80
|
+
const result = await session.call({toolId: 'get_issue', arguments: {id: 'ENG-875'}});
|
|
81
|
+
await session.close?.();
|
|
82
|
+
|
|
83
|
+
expect(result.structuredContent).toEqual({tool: 'get_issue'});
|
|
84
|
+
expect(authorizations.every((authorization) => authorization === 'Bearer linear-token')).toBe(
|
|
85
|
+
true,
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
await new Promise<void>((resolve, reject) => {
|
|
89
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('proxies read and write tool calls through the MCP client with the configured timeout', async () => {
|
|
95
|
+
const callTool = vi
|
|
96
|
+
.fn()
|
|
97
|
+
.mockResolvedValueOnce({
|
|
98
|
+
content: [{type: 'text', text: 'issue'}],
|
|
99
|
+
structuredContent: {id: 'ENG-875'},
|
|
100
|
+
} satisfies CallToolResult)
|
|
101
|
+
.mockResolvedValueOnce({
|
|
102
|
+
content: [{type: 'text', text: 'comment saved'}],
|
|
103
|
+
structuredContent: {id: 'comment-1'},
|
|
104
|
+
} satisfies CallToolResult);
|
|
105
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
106
|
+
const provider = new LinearAgentToolsProvider({
|
|
107
|
+
tokenStore: {getAccessToken: async () => 'linear-token'},
|
|
108
|
+
callTimeoutMs: 1234,
|
|
109
|
+
createClient: async () => ({callTool, close}),
|
|
110
|
+
});
|
|
111
|
+
const session = await provider.openSession({
|
|
112
|
+
connection: linearConnection(),
|
|
113
|
+
tools: [],
|
|
114
|
+
scope: {provider: 'linear'},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const readResult = await session.call({
|
|
118
|
+
toolId: 'get_issue',
|
|
119
|
+
arguments: {id: 'ENG-875'},
|
|
120
|
+
});
|
|
121
|
+
const writeResult = await session.call({
|
|
122
|
+
toolId: 'save_comment',
|
|
123
|
+
arguments: {issueId: 'ENG-875', body: 'Looks good'},
|
|
124
|
+
});
|
|
125
|
+
await session.close?.();
|
|
126
|
+
|
|
127
|
+
expect(readResult.structuredContent).toEqual({id: 'ENG-875'});
|
|
128
|
+
expect(writeResult.structuredContent).toEqual({id: 'comment-1'});
|
|
129
|
+
expect(callTool).toHaveBeenNthCalledWith(
|
|
130
|
+
1,
|
|
131
|
+
{toolId: 'get_issue', arguments: {id: 'ENG-875'}},
|
|
132
|
+
1234,
|
|
133
|
+
);
|
|
134
|
+
expect(callTool).toHaveBeenNthCalledWith(
|
|
135
|
+
2,
|
|
136
|
+
{toolId: 'save_comment', arguments: {issueId: 'ENG-875', body: 'Looks good'}},
|
|
137
|
+
1234,
|
|
138
|
+
);
|
|
139
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not open an MCP client when the Linear token is missing', async () => {
|
|
143
|
+
const createClient = vi.fn();
|
|
144
|
+
const provider = new LinearAgentToolsProvider({
|
|
145
|
+
tokenStore: {
|
|
146
|
+
getAccessToken: () =>
|
|
147
|
+
Promise.reject(new LinearAccessTokenMissingError('linear-connection-1')),
|
|
148
|
+
},
|
|
149
|
+
createClient,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const result = provider.openSession({
|
|
153
|
+
connection: linearConnection(),
|
|
154
|
+
tools: [],
|
|
155
|
+
scope: {provider: 'linear'},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await expect(result).rejects.toBeInstanceOf(LinearAccessTokenMissingError);
|
|
159
|
+
expect(createClient).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('propagates remote provider errors and still exposes close for cleanup', async () => {
|
|
163
|
+
const remoteError = new Error('remote MCP rejected the request');
|
|
164
|
+
const close = vi.fn().mockResolvedValue(undefined);
|
|
165
|
+
const provider = new LinearAgentToolsProvider({
|
|
166
|
+
tokenStore: {getAccessToken: async () => 'linear-token'},
|
|
167
|
+
createClient: async () => ({
|
|
168
|
+
callTool: () => Promise.reject(remoteError),
|
|
169
|
+
close,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
const session = await provider.openSession({
|
|
173
|
+
connection: linearConnection(),
|
|
174
|
+
tools: [],
|
|
175
|
+
scope: {provider: 'linear'},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = session.call({toolId: 'get_issue', arguments: {id: 'ENG-875'}});
|
|
179
|
+
await session.close?.();
|
|
180
|
+
|
|
181
|
+
await expect(result).rejects.toBe(remoteError);
|
|
182
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
function handleMcpRequest(request: IncomingMessage, response: ServerResponse): void {
|
|
187
|
+
if (request.method !== 'POST') {
|
|
188
|
+
response.writeHead(405).end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let body = '';
|
|
193
|
+
request.setEncoding('utf8');
|
|
194
|
+
request.on('data', (chunk) => {
|
|
195
|
+
body += chunk;
|
|
196
|
+
});
|
|
197
|
+
request.on('end', () => {
|
|
198
|
+
const message = JSON.parse(body) as {
|
|
199
|
+
id?: string | number;
|
|
200
|
+
method?: string;
|
|
201
|
+
params?: {name?: string};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (message.method === 'notifications/initialized') {
|
|
205
|
+
response.writeHead(202).end();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (message.method === 'initialize') {
|
|
210
|
+
writeJson(response, {
|
|
211
|
+
jsonrpc: '2.0',
|
|
212
|
+
id: message.id,
|
|
213
|
+
result: {
|
|
214
|
+
protocolVersion: '2025-03-26',
|
|
215
|
+
capabilities: {tools: {}},
|
|
216
|
+
serverInfo: {name: 'mock-linear-mcp', version: '0.0.0'},
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (message.method === 'tools/call') {
|
|
223
|
+
writeJson(response, {
|
|
224
|
+
jsonrpc: '2.0',
|
|
225
|
+
id: message.id,
|
|
226
|
+
result: {
|
|
227
|
+
content: [{type: 'text', text: 'ok'}],
|
|
228
|
+
structuredContent: {tool: message.params?.name},
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
writeJson(response, {
|
|
235
|
+
jsonrpc: '2.0',
|
|
236
|
+
id: message.id,
|
|
237
|
+
error: {code: -32601, message: 'Method not found'},
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function writeJson(response: ServerResponse, body: unknown): void {
|
|
243
|
+
response.writeHead(200, {'content-type': 'application/json'});
|
|
244
|
+
response.end(JSON.stringify(body));
|
|
245
|
+
}
|