@shipfox/api-integration-core 13.1.0 → 14.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.
Files changed (89) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +31 -0
  3. package/dist/core/event-catalogs.d.ts +18 -0
  4. package/dist/core/event-catalogs.d.ts.map +1 -0
  5. package/dist/core/event-catalogs.js +27 -0
  6. package/dist/core/event-catalogs.js.map +1 -0
  7. package/dist/core/providers/registry.d.ts +1 -0
  8. package/dist/core/providers/registry.d.ts.map +1 -1
  9. package/dist/core/providers/registry.js +4 -1
  10. package/dist/core/providers/registry.js.map +1 -1
  11. package/dist/core/providers/source-control.d.ts +1 -1
  12. package/dist/core/providers/source-control.d.ts.map +1 -1
  13. package/dist/core/providers/source-control.js.map +1 -1
  14. package/dist/core/source-control-service.d.ts +5 -1
  15. package/dist/core/source-control-service.d.ts.map +1 -1
  16. package/dist/core/source-control-service.js +12 -0
  17. package/dist/core/source-control-service.js.map +1 -1
  18. package/dist/db/connections.d.ts +4 -1
  19. package/dist/db/connections.d.ts.map +1 -1
  20. package/dist/db/connections.js +15 -6
  21. package/dist/db/connections.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/metrics/instance.d.ts.map +1 -1
  26. package/dist/metrics/instance.js +2 -0
  27. package/dist/metrics/instance.js.map +1 -1
  28. package/dist/presentation/inter-module.d.ts.map +1 -1
  29. package/dist/presentation/inter-module.js +27 -5
  30. package/dist/presentation/inter-module.js.map +1 -1
  31. package/dist/presentation/routes/errors.d.ts.map +1 -1
  32. package/dist/presentation/routes/errors.js +1 -1
  33. package/dist/presentation/routes/errors.js.map +1 -1
  34. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  35. package/dist/presentation/routes/manage-connections.js +7 -3
  36. package/dist/presentation/routes/manage-connections.js.map +1 -1
  37. package/dist/providers/gitea.d.ts.map +1 -1
  38. package/dist/providers/gitea.js +5 -1
  39. package/dist/providers/gitea.js.map +1 -1
  40. package/dist/providers/github.d.ts.map +1 -1
  41. package/dist/providers/github.js +6 -5
  42. package/dist/providers/github.js.map +1 -1
  43. package/dist/providers/jira.d.ts.map +1 -1
  44. package/dist/providers/jira.js +7 -2
  45. package/dist/providers/jira.js.map +1 -1
  46. package/dist/providers/linear.d.ts.map +1 -1
  47. package/dist/providers/linear.js +6 -4
  48. package/dist/providers/linear.js.map +1 -1
  49. package/dist/providers/sentry.d.ts.map +1 -1
  50. package/dist/providers/sentry.js +3 -1
  51. package/dist/providers/sentry.js.map +1 -1
  52. package/dist/providers/slack.d.ts.map +1 -1
  53. package/dist/providers/slack.js +6 -4
  54. package/dist/providers/slack.js.map +1 -1
  55. package/dist/tsconfig.test.tsbuildinfo +1 -1
  56. package/package.json +12 -12
  57. package/src/core/agent-tool-selection.test.ts +4 -0
  58. package/src/core/event-catalogs.test.ts +16 -0
  59. package/src/core/event-catalogs.ts +34 -0
  60. package/src/core/providers/registry.test.ts +4 -0
  61. package/src/core/providers/registry.ts +9 -3
  62. package/src/core/providers/source-control.ts +2 -0
  63. package/src/core/secret-cleanup.test.ts +1 -0
  64. package/src/core/source-control-service.test.ts +48 -0
  65. package/src/core/source-control-service.ts +16 -0
  66. package/src/db/connections.test.ts +167 -0
  67. package/src/db/connections.ts +21 -4
  68. package/src/db/secret-cleanups.test.ts +1 -0
  69. package/src/index.ts +2 -0
  70. package/src/metrics/instance.ts +2 -0
  71. package/src/presentation/inter-module.test.ts +219 -0
  72. package/src/presentation/inter-module.ts +28 -3
  73. package/src/presentation/routes/errors.ts +3 -0
  74. package/src/presentation/routes/list-connections.test.ts +13 -1
  75. package/src/presentation/routes/list-repositories.test.ts +9 -0
  76. package/src/presentation/routes/manage-connections.test.ts +52 -0
  77. package/src/presentation/routes/manage-connections.ts +6 -2
  78. package/src/providers/gitea.ts +5 -0
  79. package/src/providers/github.ts +6 -1
  80. package/src/providers/jira.test.ts +3 -0
  81. package/src/providers/jira.ts +6 -0
  82. package/src/providers/linear.test.ts +3 -0
  83. package/src/providers/linear.ts +6 -1
  84. package/src/providers/modules.test.ts +64 -1
  85. package/src/providers/sentry.ts +3 -0
  86. package/src/providers/slack.test.ts +4 -0
  87. package/src/providers/slack.ts +6 -1
  88. package/test/route-utils.ts +4 -0
  89. package/tsconfig.build.tsbuildinfo +1 -1
@@ -2,6 +2,7 @@ import {
2
2
  CONNECTION_SLUG_MAX_LENGTH,
3
3
  INTEGRATION_CONNECTION_AVAILABLE,
4
4
  type IntegrationsEventMap,
5
+ RESERVED_CONNECTION_SLUGS,
5
6
  } from '@shipfox/api-integration-core-dto';
6
7
  import {ConnectionSlugConflictError} from '@shipfox/api-integration-spi';
7
8
  import {writeOutboxEvent} from '@shipfox/node-outbox';
@@ -10,7 +11,7 @@ import type {
10
11
  IntegrationConnection,
11
12
  IntegrationConnectionLifecycleStatus,
12
13
  } from '#core/entities/connection.js';
13
- import type {IntegrationProviderKind} from '#core/entities/provider.js';
14
+ import type {IntegrationCapability, IntegrationProviderKind} from '#core/entities/provider.js';
14
15
  import {IntegrationConnectionAlreadyExistsError} from '#core/errors.js';
15
16
  import {db} from './db.js';
16
17
  import {integrationConnections, toIntegrationConnection} from './schema/connections.js';
@@ -26,12 +27,14 @@ export interface UpsertIntegrationConnectionParams {
26
27
  slug: string;
27
28
  displayName: string;
28
29
  lifecycleStatus?: IntegrationConnectionLifecycleStatus | undefined;
30
+ capabilities?: IntegrationCapability[] | undefined;
29
31
  }
30
32
 
31
33
  export async function upsertIntegrationConnection(
32
34
  params: UpsertIntegrationConnectionParams,
33
35
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
34
36
  ): Promise<IntegrationConnection> {
37
+ assertConnectionSlugIsNotReserved(params.slug);
35
38
  if (options.tx === undefined) {
36
39
  return await db().transaction((tx) => upsertIntegrationConnection(params, {tx}));
37
40
  }
@@ -91,7 +94,7 @@ export async function upsertIntegrationConnection(
91
94
  if (!row) throw new Error('Integration connection upsert returned no rows');
92
95
  const connection = toIntegrationConnection(row);
93
96
  if (becameAvailable) {
94
- await writeConnectionAvailableEvent(executor, connection);
97
+ await writeConnectionAvailableEvent(executor, connection, params.capabilities);
95
98
  }
96
99
  return connection;
97
100
  }
@@ -103,6 +106,7 @@ export interface CreateIntegrationConnectionParams {
103
106
  slug: string;
104
107
  displayName: string;
105
108
  lifecycleStatus?: IntegrationConnectionLifecycleStatus | undefined;
109
+ capabilities?: IntegrationCapability[] | undefined;
106
110
  }
107
111
 
108
112
  const INTEGRATION_CONNECTION_EXTERNAL_UNIQUE_CONSTRAINT =
@@ -140,6 +144,7 @@ export async function createIntegrationConnection(
140
144
  params: CreateIntegrationConnectionParams,
141
145
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
142
146
  ): Promise<IntegrationConnection> {
147
+ assertConnectionSlugIsNotReserved(params.slug);
143
148
  if (options.tx === undefined) {
144
149
  return await db().transaction((tx) => createIntegrationConnection(params, {tx}));
145
150
  }
@@ -176,7 +181,7 @@ export async function createIntegrationConnection(
176
181
  if (!row) throw new Error('Integration connection insert returned no rows');
177
182
  const connection = toIntegrationConnection(row);
178
183
  if (connection.lifecycleStatus === 'active') {
179
- await writeConnectionAvailableEvent(executor, connection);
184
+ await writeConnectionAvailableEvent(executor, connection, params.capabilities);
180
185
  }
181
186
  return connection;
182
187
  }
@@ -192,6 +197,7 @@ export async function resolveUniqueConnectionSlug(
192
197
  params: ResolveUniqueConnectionSlugParams,
193
198
  options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
194
199
  ): Promise<string> {
200
+ assertConnectionSlugIsNotReserved(params.baseSlug);
195
201
  const executor = options.tx ?? db();
196
202
  const [existing] = await executor
197
203
  .select({slug: integrationConnections.slug})
@@ -220,6 +226,14 @@ export async function resolveUniqueConnectionSlug(
220
226
  }
221
227
  }
222
228
 
229
+ function assertConnectionSlugIsNotReserved(slug: string): void {
230
+ const normalizedSlug = slug.toLowerCase().replaceAll(/[_-]+$/g, '');
231
+ if (!(RESERVED_CONNECTION_SLUGS as readonly string[]).includes(normalizedSlug)) return;
232
+ throw new ConnectionSlugConflictError(
233
+ new Error(`Slug "${slug}" is reserved for a built-in trigger source`),
234
+ );
235
+ }
236
+
223
237
  export type CreateIntegrationConnectionFn = typeof createIntegrationConnection;
224
238
 
225
239
  export async function getIntegrationConnectionById(
@@ -267,6 +281,7 @@ export type GetIntegrationConnectionBySlugFn = typeof getIntegrationConnectionBy
267
281
  export interface UpdateIntegrationConnectionLifecycleStatusParams {
268
282
  id: string;
269
283
  lifecycleStatus: IntegrationConnectionLifecycleStatus;
284
+ capabilities?: IntegrationCapability[] | undefined;
270
285
  }
271
286
 
272
287
  export async function updateIntegrationConnectionLifecycleStatus(
@@ -294,7 +309,7 @@ export async function updateIntegrationConnectionLifecycleStatus(
294
309
  if (!row) return undefined;
295
310
  const connection = toIntegrationConnection(row);
296
311
  if (existing.lifecycleStatus !== 'active' && connection.lifecycleStatus === 'active') {
297
- await writeConnectionAvailableEvent(executor, connection);
312
+ await writeConnectionAvailableEvent(executor, connection, params.capabilities);
298
313
  }
299
314
  return connection;
300
315
  }
@@ -305,6 +320,7 @@ export type UpdateIntegrationConnectionLifecycleStatusFn =
305
320
  async function writeConnectionAvailableEvent(
306
321
  executor: IntegrationDb | IntegrationTx,
307
322
  connection: IntegrationConnection,
323
+ capabilities: IntegrationCapability[] | undefined,
308
324
  ): Promise<void> {
309
325
  await writeOutboxEvent<IntegrationsEventMap>(executor, integrationsOutbox, {
310
326
  type: INTEGRATION_CONNECTION_AVAILABLE,
@@ -313,6 +329,7 @@ async function writeConnectionAvailableEvent(
313
329
  workspaceId: connection.workspaceId,
314
330
  connectionId: connection.id,
315
331
  slug: connection.slug,
332
+ capabilities: capabilities ?? [],
316
333
  },
317
334
  });
318
335
  }
@@ -107,6 +107,7 @@ async function createCleanupConnection(overrides: {externalAccountId?: string} =
107
107
  externalAccountId: overrides.externalAccountId ?? crypto.randomUUID(),
108
108
  slug: `slack_${crypto.randomUUID()}`,
109
109
  displayName: 'Slack',
110
+ capabilities: ['agent_tools'],
110
111
  });
111
112
  await enqueueIntegrationSecretCleanup({connection});
112
113
  return connection;
package/src/index.ts CHANGED
@@ -112,6 +112,8 @@ export type {
112
112
  RepositoryPage,
113
113
  RepositorySnapshot,
114
114
  RepositoryVisibility,
115
+ ResolvedRef,
116
+ ResolveRefInput,
115
117
  ResolveRepositoryInput,
116
118
  SourceControlProvider,
117
119
  TriggerReference,
@@ -26,6 +26,8 @@ const integrationAgentToolCallErrorCodes = new Set<string>([
26
26
  'repository-not-found',
27
27
  'installation-not-found',
28
28
  'file-not-found',
29
+ 'ref-not-found',
30
+ 'ref-invalid',
29
31
  'access-denied',
30
32
  'rate-limited',
31
33
  'timeout',
@@ -0,0 +1,219 @@
1
+ import {integrationsInterModuleContract} from '@shipfox/api-integration-core-dto/inter-module';
2
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
3
+ import {createInMemoryInterModuleTransport} from '@shipfox/node-module/inter-module';
4
+ import {IntegrationProviderError} from '#core/errors.js';
5
+ import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
6
+ import type {SourceControlProvider} from '#core/providers/source-control.js';
7
+ import {createSourceControlIntegrationService} from '#core/source-control-service.js';
8
+ import {createIntegrationsInterModulePresentation} from './inter-module.js';
9
+
10
+ const workspaceId = crypto.randomUUID();
11
+ const connectionId = crypto.randomUUID();
12
+
13
+ function createClient(
14
+ resolveRef: (input: {ref: string}) => Promise<{ref: string; commit: string}>,
15
+ resolveRepository: SourceControlProvider['resolveRepository'] = () => {
16
+ throw new Error('not used');
17
+ },
18
+ ) {
19
+ const transport = createInMemoryInterModuleTransport();
20
+ const client = transport.createClient(integrationsInterModuleContract);
21
+
22
+ const sourceControl = createSourceControlIntegrationService({
23
+ registry: createIntegrationProviderRegistry([
24
+ {
25
+ provider: 'gitea',
26
+ displayName: 'Gitea',
27
+ adapters: {
28
+ source_control: {
29
+ listRepositories: vi.fn(),
30
+ resolveRepository: vi.fn(resolveRepository),
31
+ listFiles: vi.fn(),
32
+ fetchFile: vi.fn(),
33
+ resolveTriggerReference: () => null,
34
+ resolveRef: async (input) => await resolveRef(input),
35
+ },
36
+ },
37
+ },
38
+ ]),
39
+ getIntegrationConnectionById: async (id) => {
40
+ await Promise.resolve();
41
+ return id === connectionId
42
+ ? {
43
+ id: connectionId,
44
+ workspaceId,
45
+ provider: 'gitea',
46
+ externalAccountId: 'gitea-owner',
47
+ slug: 'gitea_owner',
48
+ displayName: 'Gitea',
49
+ lifecycleStatus: 'active' as const,
50
+ createdAt: new Date(),
51
+ updatedAt: new Date(),
52
+ }
53
+ : undefined;
54
+ },
55
+ });
56
+
57
+ transport.register(
58
+ createIntegrationsInterModulePresentation({
59
+ registry: createIntegrationProviderRegistry([]),
60
+ sourceControl,
61
+ }),
62
+ );
63
+ transport.seal();
64
+ return client;
65
+ }
66
+
67
+ describe('integrations inter-module presentation', () => {
68
+ const input = {
69
+ workspaceId,
70
+ connectionId,
71
+ externalRepositoryId: 'gitea:gitea-owner/platform',
72
+ };
73
+
74
+ it('resolves a source ref through the transport', async () => {
75
+ const client = createClient(async (input) => ({
76
+ ref: input.ref,
77
+ commit: 'a'.repeat(40),
78
+ }));
79
+
80
+ const result = await client.resolveSourceRef({...input, ref: 'refs/heads/main'});
81
+
82
+ expect(result).toEqual({ref: 'refs/heads/main', commit: 'a'.repeat(40)});
83
+ });
84
+
85
+ it('maps a missing ref to the ref-not-found known error', async () => {
86
+ const client = createClient(() => {
87
+ throw new IntegrationProviderError('ref-not-found', 'Ref not found');
88
+ });
89
+
90
+ const error = await client
91
+ .resolveSourceRef({...input, ref: 'refs/heads/missing'})
92
+ .catch((caught: unknown) => caught);
93
+
94
+ expect(
95
+ isInterModuleKnownError(integrationsInterModuleContract.methods.resolveSourceRef, error),
96
+ ).toBe(true);
97
+ if (isInterModuleKnownError(integrationsInterModuleContract.methods.resolveSourceRef, error)) {
98
+ expect(error.code).toBe('ref-not-found');
99
+ expect(error.details).toEqual({ref: 'refs/heads/missing'});
100
+ }
101
+ });
102
+
103
+ it('maps an invalid ref to the ref-invalid known error', async () => {
104
+ const client = createClient(() => {
105
+ throw new IntegrationProviderError('ref-invalid', 'Ref is not a branch or tag name');
106
+ });
107
+
108
+ const error = await client
109
+ .resolveSourceRef({...input, ref: 'a'.repeat(40)})
110
+ .catch((caught: unknown) => caught);
111
+
112
+ if (isInterModuleKnownError(integrationsInterModuleContract.methods.resolveSourceRef, error)) {
113
+ expect(error.code).toBe('ref-invalid');
114
+ expect(error.details).toEqual({ref: 'a'.repeat(40)});
115
+ } else {
116
+ throw error;
117
+ }
118
+ });
119
+
120
+ it('keeps other provider failures as provider-failure known errors', async () => {
121
+ const client = createClient(() => {
122
+ throw new IntegrationProviderError('rate-limited', 'Rate limited', 60);
123
+ });
124
+
125
+ const error = await client
126
+ .resolveSourceRef({...input, ref: 'refs/heads/main'})
127
+ .catch((caught: unknown) => caught);
128
+
129
+ if (isInterModuleKnownError(integrationsInterModuleContract.methods.resolveSourceRef, error)) {
130
+ expect(error.code).toBe('provider-failure');
131
+ expect(error.details).toEqual({reason: 'rate-limited', retryAfterSeconds: 60});
132
+ } else {
133
+ throw error;
134
+ }
135
+ });
136
+
137
+ it('keeps ref reasons generic for methods that do not declare ref errors', async () => {
138
+ const client = createClient(
139
+ async (input) => ({ref: input.ref, commit: 'a'.repeat(40)}),
140
+ () => {
141
+ throw new IntegrationProviderError('ref-not-found', 'Ref not found');
142
+ },
143
+ );
144
+
145
+ const error = await client.resolveSourceRepository(input).catch((caught: unknown) => caught);
146
+
147
+ if (
148
+ isInterModuleKnownError(
149
+ integrationsInterModuleContract.methods.resolveSourceRepository,
150
+ error,
151
+ )
152
+ ) {
153
+ expect(error.code).toBe('provider-failure');
154
+ expect(error.details).toEqual({reason: 'ref-not-found'});
155
+ } else {
156
+ throw error;
157
+ }
158
+ });
159
+
160
+ it('serves provider event catalogs and fixed event providers on the validation context', async () => {
161
+ const transport = createInMemoryInterModuleTransport();
162
+ const client = transport.createClient(integrationsInterModuleContract);
163
+
164
+ const sourceControl = createSourceControlIntegrationService({
165
+ registry: createIntegrationProviderRegistry([]),
166
+ getIntegrationConnectionById: async () => undefined,
167
+ });
168
+ transport.register(
169
+ createIntegrationsInterModulePresentation({
170
+ registry: createIntegrationProviderRegistry([
171
+ {
172
+ provider: 'github',
173
+ displayName: 'GitHub',
174
+ eventCatalog: {
175
+ provider: 'GitHub',
176
+ events: [
177
+ {
178
+ name: 'push',
179
+ summary: 'A push.',
180
+ emittedWhen: 'GitHub sends a push webhook.',
181
+ payloadKind: 'raw-provider',
182
+ },
183
+ ],
184
+ },
185
+ },
186
+ {
187
+ provider: 'webhook',
188
+ displayName: 'Webhook',
189
+ eventCatalog: {
190
+ provider: 'Custom webhook',
191
+ events: [
192
+ {
193
+ name: 'received',
194
+ summary: 'A webhook request is accepted.',
195
+ emittedWhen: 'Shipfox accepts a request.',
196
+ payloadKind: 'shipfox-normalized',
197
+ },
198
+ ],
199
+ },
200
+ },
201
+ {provider: 'gitea', displayName: 'Gitea'},
202
+ ]),
203
+ sourceControl,
204
+ }),
205
+ );
206
+ transport.seal();
207
+
208
+ const context = await client.getAgentToolsContext({
209
+ workspaceId,
210
+ defaultConnectionId: connectionId,
211
+ });
212
+
213
+ expect(context.eventCatalogs).toEqual([
214
+ {provider: 'github', events: ['push']},
215
+ {provider: 'webhook', events: ['received']},
216
+ ]);
217
+ expect(context.fixedEventProviders).toEqual(['webhook']);
218
+ });
219
+ });
@@ -19,6 +19,7 @@ import {
19
19
  IntegrationProviderError,
20
20
  IntegrationProviderUnavailableError,
21
21
  } from '#core/errors.js';
22
+ import {buildFixedEventProviders, buildProviderEventCatalogs} from '#core/event-catalogs.js';
22
23
  import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
23
24
  import type {IntegrationSourceControlService} from '#core/source-control-service.js';
24
25
  import {getIntegrationConnectionById, getIntegrationConnectionBySlug} from '#db/connections.js';
@@ -51,6 +52,12 @@ export function createIntegrationsInterModulePresentation(params: {
51
52
  input,
52
53
  async () => await params.sourceControl.resolveTriggerReference(input),
53
54
  ),
55
+ resolveSourceRef: async (input) =>
56
+ await known(
57
+ contract.methods.resolveSourceRef,
58
+ input,
59
+ async () => await params.sourceControl.resolveSourceRef(input),
60
+ ),
54
61
  listSourceFiles: async (input) =>
55
62
  await known(
56
63
  contract.methods.listSourceFiles,
@@ -105,6 +112,8 @@ export function createIntegrationsInterModulePresentation(params: {
105
112
  ...value,
106
113
  capabilities: [...value.capabilities],
107
114
  })),
115
+ eventCatalogs: buildProviderEventCatalogs(params.registry),
116
+ fixedEventProviders: buildFixedEventProviders(params.registry),
108
117
  defaultConnection: defaultConnection
109
118
  ? {
110
119
  id: defaultConnection.id,
@@ -119,7 +128,7 @@ export function createIntegrationsInterModulePresentation(params: {
119
128
 
120
129
  async function known<Output>(
121
130
  method: InterModuleMethodContract,
122
- input: {connectionId?: string; defaultConnectionId?: string},
131
+ input: {connectionId?: string; defaultConnectionId?: string; ref?: string | undefined},
123
132
  operation: () => Promise<Output>,
124
133
  ): Promise<Output> {
125
134
  try {
@@ -130,7 +139,7 @@ async function known<Output>(
130
139
  }
131
140
  function mapError(
132
141
  method: InterModuleMethodContract,
133
- input: {connectionId?: string; defaultConnectionId?: string},
142
+ input: {connectionId?: string; defaultConnectionId?: string; ref?: string | undefined},
134
143
  error: unknown,
135
144
  ): unknown {
136
145
  if (error instanceof IntegrationConnectionNotFoundError)
@@ -154,12 +163,28 @@ function mapError(
154
163
  });
155
164
  if (error instanceof IntegrationCheckoutUnsupportedError)
156
165
  return createInterModuleKnownError(method, 'checkout-unsupported', {provider: error.provider});
157
- if (error instanceof IntegrationProviderError)
166
+ if (error instanceof IntegrationProviderError) {
167
+ // Only methods that resolve refs declare these codes; other methods keep
168
+ // seeing the failure as a generic provider failure.
169
+ if (error.reason === 'ref-not-found' && 'ref-not-found' in method.errors) {
170
+ return createInterModuleKnownError(method, 'ref-not-found', refDetails(input));
171
+ }
172
+ if (error.reason === 'ref-invalid' && 'ref-invalid' in method.errors) {
173
+ return createInterModuleKnownError(method, 'ref-invalid', refDetails(input));
174
+ }
158
175
  return createInterModuleKnownError(method, 'provider-failure', {
159
176
  reason: error.reason,
160
177
  ...(error.retryAfterSeconds === undefined
161
178
  ? {}
162
179
  : {retryAfterSeconds: error.retryAfterSeconds}),
163
180
  });
181
+ }
164
182
  return error;
165
183
  }
184
+
185
+ function refDetails(input: {ref?: string | undefined}): {ref: string} {
186
+ if (input.ref === undefined) {
187
+ throw new Error('Ref error mapping requires a ref input');
188
+ }
189
+ return {ref: input.ref};
190
+ }
@@ -23,7 +23,10 @@ function isProviderError(error: unknown): error is IntegrationProviderError {
23
23
  'reason' in error &&
24
24
  typeof error.reason === 'string' &&
25
25
  (error.reason === 'repository-not-found' ||
26
+ error.reason === 'installation-not-found' ||
26
27
  error.reason === 'file-not-found' ||
28
+ error.reason === 'ref-not-found' ||
29
+ error.reason === 'ref-invalid' ||
27
30
  error.reason === 'access-denied' ||
28
31
  error.reason === 'rate-limited' ||
29
32
  error.reason === 'timeout' ||
@@ -13,6 +13,7 @@ describe('GET /integration-connections', () => {
13
13
  externalAccountId: 'debug-active',
14
14
  slug: 'debug_active',
15
15
  displayName: 'Gitea',
16
+ capabilities: ['source_control'],
16
17
  });
17
18
  await upsertIntegrationConnection({
18
19
  workspaceId: context.workspaceId,
@@ -20,6 +21,7 @@ describe('GET /integration-connections', () => {
20
21
  externalAccountId: 'debug-error',
21
22
  slug: 'debug_error',
22
23
  displayName: 'Gitea',
24
+ capabilities: ['source_control'],
23
25
  lifecycleStatus: 'error',
24
26
  });
25
27
 
@@ -38,13 +40,17 @@ describe('GET /integration-connections', () => {
38
40
  });
39
41
 
40
42
  it('drops connections whose provider misses the capability filter', async () => {
41
- const app = await createTestApp([sourceProvider()]);
43
+ const app = await createTestApp([
44
+ sourceProvider(),
45
+ {provider: 'github', displayName: 'GitHub', adapters: {}},
46
+ ]);
42
47
  await upsertIntegrationConnection({
43
48
  workspaceId: context.workspaceId,
44
49
  provider: 'gitea',
45
50
  externalAccountId: 'gitea-owner',
46
51
  slug: 'gitea_owner',
47
52
  displayName: 'Gitea',
53
+ capabilities: ['source_control'],
48
54
  });
49
55
  await upsertIntegrationConnection({
50
56
  workspaceId: context.workspaceId,
@@ -52,6 +58,7 @@ describe('GET /integration-connections', () => {
52
58
  externalAccountId: 'team-1',
53
59
  slug: 'github_team_1',
54
60
  displayName: 'GitHub',
61
+ capabilities: ['source_control', 'agent_tools'],
55
62
  });
56
63
 
57
64
  const res = await app.inject({
@@ -94,6 +101,7 @@ describe('GET /integration-connections', () => {
94
101
  externalAccountId: 'team-1',
95
102
  slug: 'github_team_1',
96
103
  displayName: 'GitHub',
104
+ capabilities: ['source_control', 'agent_tools'],
97
105
  });
98
106
 
99
107
  const res = await app.inject({
@@ -119,6 +127,7 @@ describe('GET /integration-connections', () => {
119
127
  externalAccountId: 'org-1',
120
128
  slug: 'linear_org_1',
121
129
  displayName: 'Linear Org',
130
+ capabilities: ['agent_tools'],
122
131
  });
123
132
 
124
133
  const res = await app.inject({
@@ -145,6 +154,7 @@ describe('GET /integration-connections', () => {
145
154
  externalAccountId: 'org-1',
146
155
  slug: 'linear_org_1',
147
156
  displayName: 'Linear Org',
157
+ capabilities: ['agent_tools'],
148
158
  });
149
159
 
150
160
  const res = await app.inject({
@@ -174,6 +184,7 @@ describe('GET /integration-connections', () => {
174
184
  externalAccountId: 'gitea-owner',
175
185
  slug: 'gitea_owner',
176
186
  displayName: 'Gitea',
187
+ capabilities: ['source_control'],
177
188
  });
178
189
 
179
190
  const res = await app.inject({
@@ -198,6 +209,7 @@ describe('GET /integration-connections', () => {
198
209
  externalAccountId: 'gitea-owner',
199
210
  slug: 'gitea_owner',
200
211
  displayName: 'Gitea',
212
+ capabilities: ['source_control'],
201
213
  });
202
214
 
203
215
  const res = await app.inject({
@@ -13,6 +13,7 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
13
13
  externalAccountId: 'gitea-owner',
14
14
  slug: 'gitea_owner',
15
15
  displayName: 'Gitea',
16
+ capabilities: ['source_control'],
16
17
  });
17
18
 
18
19
  const res = await app.inject({
@@ -46,6 +47,7 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
46
47
  externalAccountId: 'gitea-owner',
47
48
  slug: 'gitea_owner',
48
49
  displayName: 'Gitea',
50
+ capabilities: ['source_control'],
49
51
  lifecycleStatus: 'disabled',
50
52
  });
51
53
 
@@ -73,6 +75,7 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
73
75
  externalAccountId: 'team-1',
74
76
  slug: 'github_team_1',
75
77
  displayName: 'GitHub',
78
+ capabilities: ['source_control', 'agent_tools'],
76
79
  });
77
80
 
78
81
  const res = await app.inject({
@@ -93,6 +96,7 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
93
96
  externalAccountId: 'installation-1',
94
97
  slug: 'github_installation_1',
95
98
  displayName: 'GitHub',
99
+ capabilities: ['source_control', 'agent_tools'],
96
100
  });
97
101
 
98
102
  const res = await app.inject({
@@ -127,6 +131,10 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
127
131
  throw new Error('not used');
128
132
  },
129
133
  resolveTriggerReference: () => null,
134
+ resolveRef: async () => {
135
+ await Promise.resolve();
136
+ throw new Error('not used');
137
+ },
130
138
  },
131
139
  },
132
140
  }),
@@ -137,6 +145,7 @@ describe('GET /integration-connections/:connectionId/repositories', () => {
137
145
  externalAccountId: 'gitea-owner',
138
146
  slug: 'gitea_owner',
139
147
  displayName: 'Gitea',
148
+ capabilities: ['source_control'],
140
149
  });
141
150
 
142
151
  const res = await app.inject({