@shipfox/api-integration-core 13.0.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 +44 -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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-core",
3
3
  "license": "MIT",
4
- "version": "13.0.0",
4
+ "version": "14.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,19 +24,19 @@
24
24
  "ajv": "^8.20.0",
25
25
  "drizzle-orm": "^0.45.2",
26
26
  "zod": "^4.4.3",
27
- "@shipfox/api-agent-dto": "12.2.0",
27
+ "@shipfox/api-agent-dto": "14.0.0",
28
28
  "@shipfox/api-auth-context": "12.2.0",
29
- "@shipfox/api-workflows-dto": "13.0.0",
30
- "@shipfox/api-integration-core-dto": "12.2.0",
31
- "@shipfox/api-integration-spi": "1.1.1",
29
+ "@shipfox/api-workflows-dto": "14.0.0",
30
+ "@shipfox/api-integration-core-dto": "14.0.0",
31
+ "@shipfox/api-integration-spi": "2.0.0",
32
32
  "@shipfox/api-workspaces-dto": "12.0.0",
33
- "@shipfox/api-integration-gitea": "12.5.0",
34
- "@shipfox/api-integration-github": "12.7.0",
35
- "@shipfox/api-integration-jira": "12.5.0",
36
- "@shipfox/api-integration-linear": "12.5.0",
37
- "@shipfox/api-integration-sentry": "12.5.0",
38
- "@shipfox/api-integration-slack": "12.5.0",
39
- "@shipfox/api-integration-webhook": "12.5.0",
33
+ "@shipfox/api-integration-gitea": "14.0.0",
34
+ "@shipfox/api-integration-github": "14.0.0",
35
+ "@shipfox/api-integration-jira": "14.0.0",
36
+ "@shipfox/api-integration-linear": "14.0.0",
37
+ "@shipfox/api-integration-sentry": "14.0.0",
38
+ "@shipfox/api-integration-slack": "14.0.0",
39
+ "@shipfox/api-integration-webhook": "14.0.0",
40
40
  "@shipfox/config": "1.2.4",
41
41
  "@shipfox/inter-module": "0.2.3",
42
42
  "@shipfox/node-drizzle": "0.3.5",
@@ -110,6 +110,10 @@ function sourceProvider(provider: string) {
110
110
  throw new Error('not used');
111
111
  },
112
112
  resolveTriggerReference: () => null,
113
+ resolveRef: async () => {
114
+ await Promise.resolve();
115
+ throw new Error('not used');
116
+ },
113
117
  },
114
118
  },
115
119
  };
@@ -0,0 +1,16 @@
1
+ import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
2
+ import {buildFixedEventProviders} from './event-catalogs.js';
3
+
4
+ describe('buildFixedEventProviders', () => {
5
+ it('omits fixed providers that are not registered', () => {
6
+ expect(buildFixedEventProviders(createIntegrationProviderRegistry([]))).toEqual([]);
7
+ });
8
+
9
+ it('keeps a registered fixed provider in the context metadata', () => {
10
+ expect(
11
+ buildFixedEventProviders(
12
+ createIntegrationProviderRegistry([{provider: 'webhook', displayName: 'Webhook'}]),
13
+ ),
14
+ ).toEqual(['webhook']);
15
+ });
16
+ });
@@ -0,0 +1,34 @@
1
+ import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
2
+
3
+ /**
4
+ * Providers whose single event name is minted by Shipfox. An explicit event on
5
+ * one of these providers is provably wrong unless it is that one name; every
6
+ * other provider's event names are provider-minted and never treated as a
7
+ * closed set. `manual` and `cron` are registered built-in trigger sources;
8
+ * sync classifies them by literal, before slug resolution.
9
+ */
10
+ export const FIXED_EVENT_PROVIDERS = ['webhook'] as const;
11
+
12
+ export interface ProviderEventCatalog {
13
+ provider: string;
14
+ events: string[];
15
+ }
16
+
17
+ /** Keeps fixed-provider metadata aligned with the enabled provider registry. */
18
+ export function buildFixedEventProviders(
19
+ registry: IntegrationProviderRegistry,
20
+ ): Array<(typeof FIXED_EVENT_PROVIDERS)[number]> {
21
+ const registeredProviders = new Set(registry.list().map(({provider}) => provider));
22
+ return FIXED_EVENT_PROVIDERS.filter((provider) => registeredProviders.has(provider));
23
+ }
24
+
25
+ /** Collects the event names each registered provider documents. */
26
+ export function buildProviderEventCatalogs(
27
+ registry: IntegrationProviderRegistry,
28
+ ): ProviderEventCatalog[] {
29
+ return registry.list().flatMap((provider) => {
30
+ const catalog = provider.eventCatalog;
31
+ if (catalog === undefined) return [];
32
+ return [{provider: provider.provider, events: catalog.events.map((event) => event.name)}];
33
+ });
34
+ }
@@ -23,6 +23,10 @@ function sourceControlAdapter() {
23
23
  throw new Error('not used');
24
24
  },
25
25
  resolveTriggerReference: () => null,
26
+ resolveRef: async () => {
27
+ await Promise.resolve();
28
+ throw new Error('not used');
29
+ },
26
30
  };
27
31
  }
28
32
 
@@ -83,8 +83,14 @@ function normalizeProvider(provider: IntegrationProvider): RegisteredIntegration
83
83
  return {
84
84
  ...provider,
85
85
  adapters,
86
- capabilities: Object.entries(adapters)
87
- .filter(([, adapter]) => Boolean(adapter))
88
- .map(([capability]) => capability as IntegrationCapability),
86
+ capabilities: getIntegrationProviderCapabilities(adapters),
89
87
  };
90
88
  }
89
+
90
+ export function getIntegrationProviderCapabilities(
91
+ adapters: IntegrationProvider['adapters'],
92
+ ): IntegrationCapability[] {
93
+ return Object.entries(adapters ?? {})
94
+ .filter(([, adapter]) => Boolean(adapter))
95
+ .map(([capability]) => capability as IntegrationCapability);
96
+ }
@@ -12,6 +12,8 @@ export type {
12
12
  RepositoryPage,
13
13
  RepositorySnapshot,
14
14
  RepositoryVisibility,
15
+ ResolvedRef,
16
+ ResolveRefInput,
15
17
  ResolveRepositoryInput,
16
18
  SourceControlProvider,
17
19
  TriggerReference,
@@ -119,6 +119,7 @@ async function createCleanupConnection(
119
119
  externalAccountId: overrides.externalAccountId ?? crypto.randomUUID(),
120
120
  slug: `${overrides.provider ?? 'slack'}_${crypto.randomUUID()}`,
121
121
  displayName: overrides.provider ?? 'Slack',
122
+ capabilities: overrides.provider === 'gitea' ? ['source_control'] : ['agent_tools'],
122
123
  });
123
124
  await enqueueIntegrationSecretCleanup({connection});
124
125
  return connection;
@@ -52,6 +52,10 @@ describe('integration source-control service', () => {
52
52
  await Promise.resolve();
53
53
  return {path: '.shipfox/workflows/ci.yml', ref: 'main', content: 'name: CI'};
54
54
  },
55
+ resolveRef: async (input) => {
56
+ await Promise.resolve();
57
+ return {ref: input.ref, commit: 'a'.repeat(40)};
58
+ },
55
59
  resolveTriggerReference: () => null,
56
60
  createCheckoutSpec: async (input) => {
57
61
  await Promise.resolve();
@@ -116,6 +120,50 @@ describe('integration source-control service', () => {
116
120
  expect(resolveTriggerReference).toHaveBeenCalledWith({ref: 'refs/heads/main'});
117
121
  });
118
122
 
123
+ it('resolves a ref to a pinned commit through an active source-control connection', async () => {
124
+ const service = createService();
125
+
126
+ const result = await service.resolveSourceRef({
127
+ workspaceId,
128
+ connectionId: connection.id,
129
+ externalRepositoryId: 'gitea:gitea-owner/platform',
130
+ ref: 'refs/heads/main',
131
+ });
132
+
133
+ expect(result).toEqual({ref: 'refs/heads/main', commit: 'a'.repeat(40)});
134
+ });
135
+
136
+ it('surfaces provider ref failures', async () => {
137
+ const service = createService({
138
+ resolveRef: async () => {
139
+ await Promise.resolve();
140
+ throw new IntegrationProviderError('ref-not-found', 'Ref not found');
141
+ },
142
+ });
143
+
144
+ const result = service.resolveSourceRef({
145
+ workspaceId,
146
+ connectionId: connection.id,
147
+ externalRepositoryId: 'gitea:gitea-owner/platform',
148
+ ref: 'refs/heads/missing',
149
+ });
150
+
151
+ await expect(result).rejects.toMatchObject({reason: 'ref-not-found'});
152
+ });
153
+
154
+ it('rejects source ref resolution for a connection in another workspace', async () => {
155
+ const service = createService();
156
+
157
+ const result = service.resolveSourceRef({
158
+ workspaceId: crypto.randomUUID(),
159
+ connectionId: connection.id,
160
+ externalRepositoryId: 'gitea:gitea-owner/platform',
161
+ ref: 'refs/heads/main',
162
+ });
163
+
164
+ await expect(result).rejects.toThrow('requested workspace');
165
+ });
166
+
119
167
  it('rejects a missing connection', async () => {
120
168
  const service = createService();
121
169
 
@@ -13,6 +13,7 @@ import type {
13
13
  FileSnapshot,
14
14
  RepositoryPage,
15
15
  RepositorySnapshot,
16
+ ResolvedRef,
16
17
  TriggerReference,
17
18
  } from './providers/source-control.js';
18
19
 
@@ -21,6 +22,7 @@ export interface IntegrationSourceControlService {
21
22
  listRepositories(input: ListSourceRepositoriesInput): Promise<RepositoryPage>;
22
23
  resolveRepository(input: ResolveSourceRepositoryInput): Promise<ResolvedSourceRepository>;
23
24
  resolveTriggerReference(input: ResolveTriggerReferenceInput): Promise<TriggerReference | null>;
25
+ resolveSourceRef(input: ResolveSourceRefInput): Promise<ResolvedRef>;
24
26
  listFiles(input: ListSourceFilesInput): Promise<FilePage>;
25
27
  fetchFile(input: FetchSourceFileInput): Promise<FileSnapshot>;
26
28
  createCheckoutSpec(input: CreateSourceCheckoutSpecInput): Promise<CheckoutSpec>;
@@ -45,6 +47,10 @@ export interface ResolveTriggerReferenceInput {
45
47
  payload: unknown;
46
48
  }
47
49
 
50
+ export interface ResolveSourceRefInput extends ResolveSourceRepositoryInput {
51
+ ref: string;
52
+ }
53
+
48
54
  export interface ListSourceFilesInput extends ResolveSourceRepositoryInput {
49
55
  ref: string;
50
56
  prefix: string;
@@ -129,6 +135,16 @@ export function createSourceControlIntegrationService({
129
135
  return sourceControl?.resolveTriggerReference(payload) ?? null;
130
136
  },
131
137
 
138
+ async resolveSourceRef({workspaceId, connectionId, externalRepositoryId, ref}) {
139
+ const connection = await getConnection(connectionId);
140
+ if (connection.workspaceId !== workspaceId) {
141
+ throw new IntegrationConnectionWorkspaceMismatchError(connectionId);
142
+ }
143
+ const sourceControl = registry.getAdapter(connection.provider, 'source_control');
144
+
145
+ return await sourceControl.resolveRef({connection, externalRepositoryId, ref});
146
+ },
147
+
132
148
  async listFiles({workspaceId, connectionId, externalRepositoryId, ref, prefix, limit, cursor}) {
133
149
  const connection = await getConnection(connectionId);
134
150
  if (connection.workspaceId !== workspaceId) {
@@ -40,6 +40,7 @@ describe('integration connection queries', () => {
40
40
  externalAccountId: 'gitea-owner',
41
41
  slug: 'gitea_owner',
42
42
  displayName: 'Gitea',
43
+ capabilities: ['source_control'],
43
44
  });
44
45
 
45
46
  const second = await upsertIntegrationConnection({
@@ -48,6 +49,7 @@ describe('integration connection queries', () => {
48
49
  externalAccountId: 'gitea-owner',
49
50
  slug: 'debug_renamed',
50
51
  displayName: 'Renamed Debug',
52
+ capabilities: ['source_control'],
51
53
  });
52
54
 
53
55
  expect(second.id).toBe(first.id);
@@ -64,6 +66,7 @@ describe('integration connection queries', () => {
64
66
  slug: 'linear_acme',
65
67
  displayName: 'Linear Acme',
66
68
  lifecycleStatus: 'disabled',
69
+ capabilities: ['agent_tools'],
67
70
  });
68
71
 
69
72
  expect(await connectionEvents(connection.id)).toHaveLength(0);
@@ -75,6 +78,7 @@ describe('integration connection queries', () => {
75
78
  slug: 'linear_acme',
76
79
  displayName: 'Linear Acme',
77
80
  lifecycleStatus: 'active',
81
+ capabilities: ['agent_tools'],
78
82
  });
79
83
 
80
84
  expect(await connectionEvents(connection.id)).toHaveLength(1);
@@ -87,6 +91,7 @@ describe('integration connection queries', () => {
87
91
  externalAccountId: 'debug-1',
88
92
  slug: 'debug_1',
89
93
  displayName: 'Debug One',
94
+ capabilities: ['source_control'],
90
95
  });
91
96
  await upsertIntegrationConnection({
92
97
  workspaceId,
@@ -94,6 +99,7 @@ describe('integration connection queries', () => {
94
99
  externalAccountId: 'debug-2',
95
100
  slug: 'debug_2',
96
101
  displayName: 'Debug Two',
102
+ capabilities: ['source_control'],
97
103
  });
98
104
 
99
105
  const result = await listIntegrationConnections({workspaceId});
@@ -108,6 +114,7 @@ describe('integration connection queries', () => {
108
114
  externalAccountId: 'debug-1',
109
115
  slug: 'github_main',
110
116
  displayName: 'GitHub',
117
+ capabilities: ['source_control', 'agent_tools'],
111
118
  });
112
119
 
113
120
  await expect(
@@ -125,6 +132,7 @@ describe('integration connection queries', () => {
125
132
  externalAccountId: 'debug-1',
126
133
  slug: 'gitea_owner',
127
134
  displayName: 'Debug One',
135
+ capabilities: ['source_control'],
128
136
  });
129
137
  await upsertIntegrationConnection({
130
138
  workspaceId,
@@ -132,6 +140,7 @@ describe('integration connection queries', () => {
132
140
  externalAccountId: 'debug-2',
133
141
  slug: 'debug_2',
134
142
  displayName: 'Debug Two',
143
+ capabilities: ['source_control', 'agent_tools'],
135
144
  });
136
145
 
137
146
  const result = await resolveUniqueConnectionSlug({
@@ -144,6 +153,83 @@ describe('integration connection queries', () => {
144
153
  expect(result).toBe('debug');
145
154
  });
146
155
 
156
+ it.each([
157
+ 'github',
158
+ 'gitea',
159
+ 'sentry',
160
+ 'slack',
161
+ 'jira',
162
+ 'linear',
163
+ 'webhook',
164
+ ] as const)('refuses the reserved source slugs for provider %s', async (provider) => {
165
+ // Sync classifies a trigger source by literal: `manual` and `cron` are
166
+ // built-in sources, never connection slugs, so every provider must refuse
167
+ // to allocate them.
168
+ for (const baseSlug of ['manual', 'cron'] as const) {
169
+ await expect(
170
+ resolveUniqueConnectionSlug({
171
+ workspaceId,
172
+ provider,
173
+ externalAccountId: 'external-account',
174
+ baseSlug,
175
+ }),
176
+ ).rejects.toBeInstanceOf(ConnectionSlugConflictError);
177
+ }
178
+ });
179
+
180
+ it('still allocates provider-prefixed slugs derived from reserved words', async () => {
181
+ const result = await resolveUniqueConnectionSlug({
182
+ workspaceId,
183
+ provider: 'github',
184
+ externalAccountId: 'external-account',
185
+ baseSlug: 'github_manual',
186
+ });
187
+
188
+ expect(result).toBe('github_manual');
189
+ });
190
+
191
+ it.each([
192
+ 'manual_',
193
+ 'MANUAL',
194
+ 'cron-',
195
+ ] as const)('refuses slugs that normalize to a reserved source slug: %s', async (baseSlug) => {
196
+ await expect(
197
+ resolveUniqueConnectionSlug({
198
+ workspaceId,
199
+ provider: 'github',
200
+ externalAccountId: `normalized-${baseSlug}`,
201
+ baseSlug,
202
+ }),
203
+ ).rejects.toBeInstanceOf(ConnectionSlugConflictError);
204
+ });
205
+
206
+ it.each([
207
+ 'manual',
208
+ 'cron',
209
+ ] as const)('refuses direct writes for reserved slug %s', async (slug) => {
210
+ await expect(
211
+ createIntegrationConnection({
212
+ workspaceId,
213
+ provider: 'github',
214
+ externalAccountId: `create-${slug}`,
215
+ slug,
216
+ displayName: 'GitHub',
217
+ capabilities: ['source_control'],
218
+ }),
219
+ ).rejects.toBeInstanceOf(ConnectionSlugConflictError);
220
+
221
+ await expect(
222
+ upsertIntegrationConnection({
223
+ workspaceId,
224
+ provider: 'gitea',
225
+ externalAccountId: `upsert-${slug}`,
226
+ slug,
227
+ displayName: 'Gitea',
228
+ capabilities: ['source_control'],
229
+ }),
230
+ ).rejects.toBeInstanceOf(ConnectionSlugConflictError);
231
+ });
232
+
147
233
  it('keeps the existing slug when resolving a reconnect', async () => {
148
234
  await upsertIntegrationConnection({
149
235
  workspaceId,
@@ -151,6 +237,7 @@ describe('integration connection queries', () => {
151
237
  externalAccountId: 'install-uuid',
152
238
  slug: 'sentry_prod',
153
239
  displayName: 'Sentry',
240
+ capabilities: [],
154
241
  });
155
242
 
156
243
  const result = await resolveUniqueConnectionSlug({
@@ -170,6 +257,7 @@ describe('integration connection queries', () => {
170
257
  externalAccountId: 'stripe',
171
258
  slug: 'stripe',
172
259
  displayName: 'Stripe',
260
+ capabilities: [],
173
261
  });
174
262
 
175
263
  const result = createIntegrationConnection({
@@ -178,6 +266,7 @@ describe('integration connection queries', () => {
178
266
  externalAccountId: 'stripe',
179
267
  slug: 'stripe_renamed',
180
268
  displayName: 'Renamed Stripe',
269
+ capabilities: [],
181
270
  });
182
271
 
183
272
  await expect(result).rejects.toBeInstanceOf(IntegrationConnectionAlreadyExistsError);
@@ -195,6 +284,7 @@ describe('integration connection queries', () => {
195
284
  externalAccountId: 'stripe',
196
285
  slug: 'stripe',
197
286
  displayName: 'Stripe',
287
+ capabilities: [],
198
288
  });
199
289
 
200
290
  const result = createIntegrationConnection({
@@ -203,6 +293,7 @@ describe('integration connection queries', () => {
203
293
  externalAccountId: 'stripe-prod',
204
294
  slug: 'stripe',
205
295
  displayName: 'Stripe prod',
296
+ capabilities: [],
206
297
  });
207
298
 
208
299
  await expect(result).rejects.toBeInstanceOf(ConnectionSlugConflictError);
@@ -215,6 +306,7 @@ describe('integration connection queries', () => {
215
306
  externalAccountId: 'gitea-owner',
216
307
  slug: 'gitea_owner',
217
308
  displayName: 'Gitea',
309
+ capabilities: ['source_control'],
218
310
  });
219
311
  await upsertIntegrationConnection({
220
312
  workspaceId,
@@ -222,6 +314,7 @@ describe('integration connection queries', () => {
222
314
  externalAccountId: 'team-1',
223
315
  slug: 'github_team_1',
224
316
  displayName: 'GitHub',
317
+ capabilities: ['source_control', 'agent_tools'],
225
318
  });
226
319
  await upsertIntegrationConnection({
227
320
  workspaceId,
@@ -230,6 +323,7 @@ describe('integration connection queries', () => {
230
323
  slug: 'github_installation_1',
231
324
  displayName: 'GitHub',
232
325
  lifecycleStatus: 'disabled',
326
+ capabilities: ['source_control', 'agent_tools'],
233
327
  });
234
328
 
235
329
  const result = await listIntegrationConnections({workspaceId});
@@ -249,6 +343,7 @@ describe('integration connection queries', () => {
249
343
  externalAccountId: 'gitea-owner',
250
344
  slug: 'gitea_owner',
251
345
  displayName: 'Gitea',
346
+ capabilities: ['source_control'],
252
347
  });
253
348
  const debugB = await upsertIntegrationConnection({
254
349
  workspaceId: otherWorkspaceId,
@@ -256,6 +351,7 @@ describe('integration connection queries', () => {
256
351
  externalAccountId: 'gitea-owner',
257
352
  slug: 'gitea_owner',
258
353
  displayName: 'Gitea',
354
+ capabilities: ['source_control'],
259
355
  });
260
356
  const github = await upsertIntegrationConnection({
261
357
  workspaceId,
@@ -263,6 +359,7 @@ describe('integration connection queries', () => {
263
359
  externalAccountId: 'gh-1',
264
360
  slug: 'github_gh_1',
265
361
  displayName: 'GitHub',
362
+ capabilities: ['source_control', 'agent_tools'],
266
363
  });
267
364
 
268
365
  const result = await listIntegrationConnectionsByProvider({provider: 'gitea'});
@@ -280,11 +377,13 @@ describe('integration connection queries', () => {
280
377
  externalAccountId: 'install-uuid',
281
378
  slug: 'sentry_install_uuid',
282
379
  displayName: 'Sentry acme',
380
+ capabilities: [],
283
381
  });
284
382
 
285
383
  const updated = await updateIntegrationConnectionLifecycleStatus({
286
384
  id: connection.id,
287
385
  lifecycleStatus: 'disabled',
386
+ capabilities: [],
288
387
  });
289
388
 
290
389
  expect(updated?.lifecycleStatus).toBe('disabled');
@@ -301,6 +400,7 @@ describe('integration connection queries', () => {
301
400
  slug: 'linear_acme',
302
401
  displayName: 'Linear Acme',
303
402
  lifecycleStatus: 'disabled',
403
+ capabilities: ['agent_tools'],
304
404
  });
305
405
 
306
406
  expect(await connectionEvents(connection.id)).toHaveLength(0);
@@ -308,6 +408,7 @@ describe('integration connection queries', () => {
308
408
  await updateIntegrationConnectionLifecycleStatus({
309
409
  id: connection.id,
310
410
  lifecycleStatus: 'active',
411
+ capabilities: ['agent_tools'],
311
412
  });
312
413
 
313
414
  const events = await connectionEvents(connection.id);
@@ -317,6 +418,7 @@ describe('integration connection queries', () => {
317
418
  workspaceId,
318
419
  connectionId: connection.id,
319
420
  slug: 'linear_acme',
421
+ capabilities: ['agent_tools'],
320
422
  });
321
423
  });
322
424
 
@@ -327,11 +429,13 @@ describe('integration connection queries', () => {
327
429
  externalAccountId: 'linear-acme',
328
430
  slug: 'linear_acme',
329
431
  displayName: 'Linear Acme',
432
+ capabilities: ['agent_tools'],
330
433
  });
331
434
 
332
435
  await updateIntegrationConnectionLifecycleStatus({
333
436
  id: connection.id,
334
437
  lifecycleStatus: 'active',
438
+ capabilities: ['agent_tools'],
335
439
  });
336
440
 
337
441
  expect(await connectionEvents(connection.id)).toHaveLength(1);
@@ -341,6 +445,7 @@ describe('integration connection queries', () => {
341
445
  const result = await updateIntegrationConnectionLifecycleStatus({
342
446
  id: crypto.randomUUID(),
343
447
  lifecycleStatus: 'disabled',
448
+ capabilities: [],
344
449
  });
345
450
 
346
451
  expect(result).toBeUndefined();
@@ -353,6 +458,7 @@ describe('integration connection queries', () => {
353
458
  externalAccountId: 'stripe',
354
459
  slug: 'stripe',
355
460
  displayName: 'Stripe',
461
+ capabilities: [],
356
462
  });
357
463
 
358
464
  const deleted = await deleteIntegrationConnection({id: connection.id});
@@ -372,6 +478,7 @@ describe('integration connection queries', () => {
372
478
  externalAccountId: '123',
373
479
  slug: 'github_123',
374
480
  displayName: 'GitHub shipfox',
481
+ capabilities: ['source_control', 'agent_tools'],
375
482
  },
376
483
  {tx},
377
484
  );
@@ -401,4 +508,64 @@ describe('integration connection queries', () => {
401
508
  );
402
509
  expect(events).toHaveLength(0);
403
510
  });
511
+
512
+ it('publishes source-control and tool capabilities for a GitHub connection', async () => {
513
+ const connection = await upsertIntegrationConnection({
514
+ workspaceId,
515
+ provider: 'github',
516
+ externalAccountId: 'gh-capabilities',
517
+ slug: 'github_capabilities',
518
+ displayName: 'GitHub',
519
+ capabilities: ['source_control', 'agent_tools'],
520
+ });
521
+
522
+ const [event] = await connectionEvents(connection.id);
523
+ expect(event?.payload).toEqual({
524
+ provider: 'github',
525
+ workspaceId,
526
+ connectionId: connection.id,
527
+ slug: 'github_capabilities',
528
+ capabilities: ['source_control', 'agent_tools'],
529
+ });
530
+ });
531
+
532
+ it('publishes agent-tool capabilities for a Linear connection', async () => {
533
+ const connection = await upsertIntegrationConnection({
534
+ workspaceId,
535
+ provider: 'linear',
536
+ externalAccountId: 'linear-capabilities',
537
+ slug: 'linear_capabilities',
538
+ displayName: 'Linear',
539
+ capabilities: ['agent_tools'],
540
+ });
541
+
542
+ const [event] = await connectionEvents(connection.id);
543
+ expect(event?.payload).toEqual({
544
+ provider: 'linear',
545
+ workspaceId,
546
+ connectionId: connection.id,
547
+ slug: 'linear_capabilities',
548
+ capabilities: ['agent_tools'],
549
+ });
550
+ });
551
+
552
+ it('publishes an empty capabilities array for a webhook connection', async () => {
553
+ const connection = await createIntegrationConnection({
554
+ workspaceId,
555
+ provider: 'webhook',
556
+ externalAccountId: 'stripe-capabilities',
557
+ slug: 'stripe_capabilities',
558
+ displayName: 'Stripe',
559
+ capabilities: [],
560
+ });
561
+
562
+ const [event] = await connectionEvents(connection.id);
563
+ expect(event?.payload).toEqual({
564
+ provider: 'webhook',
565
+ workspaceId,
566
+ connectionId: connection.id,
567
+ slug: 'stripe_capabilities',
568
+ capabilities: [],
569
+ });
570
+ });
404
571
  });