@shipfox/client-integrations 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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/dist/application/complete-integration-callback.d.ts +11 -0
  4. package/dist/application/complete-integration-callback.d.ts.map +1 -1
  5. package/dist/application/complete-integration-callback.js +38 -8
  6. package/dist/application/complete-integration-callback.js.map +1 -1
  7. package/dist/components/callback-status-shell.d.ts +1 -1
  8. package/dist/components/callback-status-shell.d.ts.map +1 -1
  9. package/dist/components/callback-status-shell.js.map +1 -1
  10. package/dist/core/models.d.ts +6 -0
  11. package/dist/core/models.d.ts.map +1 -1
  12. package/dist/core/models.js +12 -0
  13. package/dist/core/models.js.map +1 -1
  14. package/dist/feature.d.ts +8 -0
  15. package/dist/feature.d.ts.map +1 -1
  16. package/dist/feature.js +10 -0
  17. package/dist/feature.js.map +1 -1
  18. package/dist/hooks/api/integration-mapper.d.ts +3 -1
  19. package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
  20. package/dist/hooks/api/integration-mapper.js +8 -0
  21. package/dist/hooks/api/integration-mapper.js.map +1 -1
  22. package/dist/hooks/api/integrations.d.ts +25 -1
  23. package/dist/hooks/api/integrations.d.ts.map +1 -1
  24. package/dist/hooks/api/integrations.js +44 -1
  25. package/dist/hooks/api/integrations.js.map +1 -1
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/jira-callback-state.d.ts +9 -0
  31. package/dist/jira-callback-state.d.ts.map +1 -0
  32. package/dist/jira-callback-state.js +16 -0
  33. package/dist/jira-callback-state.js.map +1 -0
  34. package/dist/jira-callback.d.ts +11 -0
  35. package/dist/jira-callback.d.ts.map +1 -0
  36. package/dist/jira-callback.js +59 -0
  37. package/dist/jira-callback.js.map +1 -0
  38. package/dist/jira-form-errors.d.ts +8 -0
  39. package/dist/jira-form-errors.d.ts.map +1 -0
  40. package/dist/jira-form-errors.js +55 -0
  41. package/dist/jira-form-errors.js.map +1 -0
  42. package/dist/pages/jira-callback-page.d.ts +2 -0
  43. package/dist/pages/jira-callback-page.d.ts.map +1 -0
  44. package/dist/pages/jira-callback-page.js +341 -0
  45. package/dist/pages/jira-callback-page.js.map +1 -0
  46. package/dist/pages/jira-install-page.d.ts +2 -0
  47. package/dist/pages/jira-install-page.d.ts.map +1 -0
  48. package/dist/pages/jira-install-page.js +26 -0
  49. package/dist/pages/jira-install-page.js.map +1 -0
  50. package/dist/provider-catalog.d.ts +1 -1
  51. package/dist/provider-catalog.d.ts.map +1 -1
  52. package/dist/provider-catalog.js +5 -0
  53. package/dist/provider-catalog.js.map +1 -1
  54. package/dist/routes/jira-callback.d.ts +8 -0
  55. package/dist/routes/jira-callback.d.ts.map +1 -0
  56. package/dist/routes/jira-callback.js +9 -0
  57. package/dist/routes/jira-callback.js.map +1 -0
  58. package/dist/routes/jira.d.ts +6 -0
  59. package/dist/routes/jira.d.ts.map +1 -0
  60. package/dist/routes/jira.js +7 -0
  61. package/dist/routes/jira.js.map +1 -0
  62. package/dist/tsconfig.test.tsbuildinfo +1 -1
  63. package/package.json +9 -8
  64. package/src/application/complete-integration-callback.test.ts +44 -1
  65. package/src/application/complete-integration-callback.ts +61 -8
  66. package/src/components/callback-status-shell.tsx +4 -1
  67. package/src/components/integration-usage-events.test.ts +13 -0
  68. package/src/core/models.ts +18 -0
  69. package/src/feature.ts +10 -0
  70. package/src/hooks/api/integration-mapper.ts +6 -0
  71. package/src/hooks/api/integrations.test.ts +92 -0
  72. package/src/hooks/api/integrations.ts +71 -0
  73. package/src/index.ts +4 -0
  74. package/src/jira-callback-state.ts +19 -0
  75. package/src/jira-callback.test.ts +76 -0
  76. package/src/jira-callback.ts +67 -0
  77. package/src/jira-form-errors.test.ts +57 -0
  78. package/src/jira-form-errors.ts +94 -0
  79. package/src/pages/jira-callback-page.test.tsx +250 -0
  80. package/src/pages/jira-callback-page.tsx +366 -0
  81. package/src/pages/jira-install-page.tsx +28 -0
  82. package/src/provider-catalog.test.ts +6 -0
  83. package/src/provider-catalog.ts +7 -1
  84. package/src/routes/jira-callback.tsx +8 -0
  85. package/src/routes/jira.tsx +4 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-integrations",
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",
@@ -28,18 +28,19 @@
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "@tanstack/react-form": "^1.32.0",
30
30
  "@shipfox/api-integration-core-dto": "12.0.0",
31
+ "@shipfox/api-integration-gitea-dto": "12.0.0",
31
32
  "@shipfox/api-integration-github-dto": "12.0.0",
33
+ "@shipfox/api-integration-jira-dto": "12.0.0",
32
34
  "@shipfox/api-integration-linear-dto": "12.0.0",
33
- "@shipfox/api-integration-gitea-dto": "12.0.0",
34
35
  "@shipfox/api-integration-slack-dto": "12.0.0",
36
+ "@shipfox/api-integration-sentry-dto": "12.0.0",
35
37
  "@shipfox/api-integration-webhook-dto": "12.0.0",
36
38
  "@shipfox/client-api": "6.0.1",
37
- "@shipfox/client-shell": "13.0.0",
38
- "@shipfox/integration-icons": "0.2.3",
39
- "@shipfox/api-integration-sentry-dto": "12.0.0",
40
- "@shipfox/client-ui": "13.0.0",
41
- "@shipfox/client-auth": "13.0.0",
42
- "@shipfox/react-ui": "0.4.0"
39
+ "@shipfox/client-auth": "14.0.0",
40
+ "@shipfox/client-shell": "14.0.0",
41
+ "@shipfox/client-ui": "14.0.0",
42
+ "@shipfox/integration-icons": "0.3.0",
43
+ "@shipfox/react-ui": "0.5.0"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "@tanstack/react-query": "^5.101.0",
@@ -1,7 +1,10 @@
1
1
  import {QueryClient} from '@tanstack/react-query';
2
2
  import type {IntegrationConnection} from '#core/models.js';
3
3
  import {integrationsQueryKeys} from '#hooks/api/integrations.js';
4
- import {completeIntegrationCallback} from './complete-integration-callback.js';
4
+ import {
5
+ completeIntegrationCallback,
6
+ completeIntegrationCallbackResult,
7
+ } from './complete-integration-callback.js';
5
8
 
6
9
  const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
7
10
 
@@ -38,4 +41,44 @@ describe('completeIntegrationCallback', () => {
38
41
  queryKey: integrationsQueryKeys.connectionsByWorkspace(WORKSPACE_ID),
39
42
  });
40
43
  });
44
+
45
+ test('does not invalidate a connection view for a result without a connection', async () => {
46
+ const queryClient = new QueryClient();
47
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
48
+ const refreshAuth = vi.fn().mockResolvedValue({accessToken: 'access-token'});
49
+ const complete = vi.fn().mockResolvedValue({sites: []});
50
+
51
+ const result = await completeIntegrationCallbackResult({
52
+ input: {code: 'grant-code'},
53
+ refreshAuth,
54
+ complete,
55
+ getConnection: () => undefined,
56
+ queryClient,
57
+ });
58
+
59
+ expect(result).toEqual({sites: []});
60
+ expect(complete).toHaveBeenCalledWith({code: 'grant-code'}, 'access-token');
61
+ expect(invalidateQueries).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test('invalidates a connection view for a result with a connection', async () => {
65
+ const queryClient = new QueryClient();
66
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
67
+ const refreshAuth = vi.fn().mockResolvedValue({accessToken: 'access-token'});
68
+ const complete = vi.fn(async () => connection);
69
+
70
+ const result = await completeIntegrationCallbackResult({
71
+ input: {code: 'grant-code'},
72
+ refreshAuth,
73
+ complete,
74
+ getConnection: (value) => value,
75
+ queryClient,
76
+ });
77
+
78
+ expect(result).toBe(connection);
79
+ expect(complete).toHaveBeenCalledWith({code: 'grant-code'}, 'access-token');
80
+ expect(invalidateQueries).toHaveBeenCalledWith({
81
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(WORKSPACE_ID),
82
+ });
83
+ });
41
84
  });
@@ -10,22 +10,65 @@ export interface CompleteIntegrationCallbackOptions<TInput> {
10
10
  queryClient: QueryClient;
11
11
  }
12
12
 
13
+ export interface CompleteIntegrationCallbackResultOptions<TInput, TResult> {
14
+ input: TInput;
15
+ refreshAuth: () => Promise<{accessToken: string}>;
16
+ complete: (input: TInput, accessToken: string) => Promise<TResult>;
17
+ getConnection: (result: TResult) => IntegrationConnection | undefined;
18
+ queryClient: QueryClient;
19
+ }
20
+
13
21
  export async function completeIntegrationCallback<TInput>({
14
22
  input,
15
23
  refreshAuth,
16
24
  complete,
17
25
  queryClient,
18
26
  }: CompleteIntegrationCallbackOptions<TInput>): Promise<IntegrationConnection> {
27
+ return await completeIntegrationCallbackResultInternal({
28
+ input,
29
+ refreshAuth,
30
+ complete,
31
+ getConnection: (result) => result,
32
+ queryClient,
33
+ });
34
+ }
35
+
36
+ export async function completeIntegrationCallbackResult<TInput, TResult>({
37
+ input,
38
+ refreshAuth,
39
+ complete,
40
+ getConnection,
41
+ queryClient,
42
+ }: CompleteIntegrationCallbackResultOptions<TInput, TResult>): Promise<TResult> {
43
+ return await completeIntegrationCallbackResultInternal({
44
+ input,
45
+ refreshAuth,
46
+ complete,
47
+ getConnection,
48
+ queryClient,
49
+ });
50
+ }
51
+
52
+ async function completeIntegrationCallbackResultInternal<TInput, TResult>({
53
+ input,
54
+ refreshAuth,
55
+ complete,
56
+ getConnection,
57
+ queryClient,
58
+ }: CompleteIntegrationCallbackResultOptions<TInput, TResult>): Promise<TResult> {
19
59
  const session = await refreshAuth();
20
- const connection = await complete(input, session.accessToken);
21
- try {
22
- await queryClient.invalidateQueries({
23
- queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspaceId),
24
- });
25
- } catch {
26
- // Cache refresh is best effort: the successful callback is already committed server-side.
60
+ const result = await complete(input, session.accessToken);
61
+ const connection = getConnection(result);
62
+ if (connection) {
63
+ try {
64
+ await queryClient.invalidateQueries({
65
+ queryKey: integrationsQueryKeys.connectionsByWorkspace(connection.workspaceId),
66
+ });
67
+ } catch {
68
+ // Cache refresh is best effort: the callback is already committed server-side.
69
+ }
27
70
  }
28
- return connection;
71
+ return result;
29
72
  }
30
73
 
31
74
  export function useCompleteIntegrationCallback() {
@@ -37,3 +80,13 @@ export function useCompleteIntegrationCallback() {
37
80
  [queryClient],
38
81
  );
39
82
  }
83
+
84
+ export function useCompleteIntegrationCallbackResult() {
85
+ const queryClient = useQueryClient();
86
+ return useCallback(
87
+ async <TInput, TResult>(
88
+ options: Omit<CompleteIntegrationCallbackResultOptions<TInput, TResult>, 'queryClient'>,
89
+ ): Promise<TResult> => completeIntegrationCallbackResult({...options, queryClient}),
90
+ [queryClient],
91
+ );
92
+ }
@@ -19,7 +19,10 @@ export function CallbackStatusShell({
19
19
  startOver?: boolean;
20
20
  switchAccount?: boolean;
21
21
  workspaceSlug?: string | undefined;
22
- installPath: '/w/$workspaceSlug/integrations/linear' | '/w/$workspaceSlug/integrations/slack';
22
+ installPath:
23
+ | '/w/$workspaceSlug/integrations/linear'
24
+ | '/w/$workspaceSlug/integrations/slack'
25
+ | '/w/$workspaceSlug/integrations/jira';
23
26
  }) {
24
27
  const headingRef = useRef<HTMLHeadingElement>(null);
25
28
  useEffect(() => headingRef.current?.focus(), []);
@@ -1,4 +1,5 @@
1
1
  import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
+ import {jiraWebhookEventNames} from '@shipfox/api-integration-jira-dto';
2
3
  import {linearWebhookEventNames} from '@shipfox/api-integration-linear-dto';
3
4
  import {WEBHOOK_RECEIVED_EVENT} from '@shipfox/api-integration-webhook-dto';
4
5
  import {usageEventsForConnection} from './integration-usage-events.js';
@@ -67,6 +68,18 @@ describe('usageEventsForConnection', () => {
67
68
  expect(events.map((event) => event.value)).toEqual(linearWebhookEventNames);
68
69
  });
69
70
 
71
+ it('uses Jira webhook names directly', () => {
72
+ const connection = {
73
+ ...baseConnection,
74
+ provider: 'jira',
75
+ capabilities: ['agent_tools'],
76
+ } satisfies IntegrationConnectionDto;
77
+
78
+ const events = usageEventsForConnection(connection);
79
+
80
+ expect(events.map((event) => event.value)).toEqual(jiraWebhookEventNames);
81
+ });
82
+
70
83
  it('falls back to a generic received event for uncataloged providers', () => {
71
84
  const connection = {
72
85
  ...baseConnection,
@@ -7,6 +7,13 @@ export interface IntegrationProvider {
7
7
  capabilities: IntegrationCapability[];
8
8
  }
9
9
 
10
+ export interface JiraSite {
11
+ cloudId: string;
12
+ name: string;
13
+ url: string;
14
+ scopes: string[];
15
+ }
16
+
10
17
  export interface IntegrationConnection {
11
18
  id: string;
12
19
  workspaceId: string;
@@ -163,6 +170,15 @@ const linearUsageEvents = [
163
170
 
164
171
  const sentryIssueActions = ['created', 'resolved', 'assigned', 'archived', 'unresolved'] as const;
165
172
 
173
+ const jiraUsageEvents = [
174
+ 'jira:issue_created',
175
+ 'jira:issue_updated',
176
+ 'jira:issue_deleted',
177
+ 'comment_created',
178
+ 'comment_updated',
179
+ 'comment_deleted',
180
+ ] as const;
181
+
166
182
  export function usageEventsForConnection(
167
183
  connection: Pick<IntegrationConnection, 'provider' | 'capabilities'>,
168
184
  ): IntegrationUsageEvent[] {
@@ -174,6 +190,8 @@ export function usageEventsForConnection(
174
190
  value: `issue.${action}`,
175
191
  label: `issue.${action}`,
176
192
  }));
193
+ if (connection.provider === 'jira')
194
+ return jiraUsageEvents.map((value) => ({value, label: value}));
177
195
  if (connection.provider === 'linear')
178
196
  return linearUsageEvents.map((value) => ({value, label: value}));
179
197
  if (connection.capabilities.includes('source_control')) return [{value: 'push', label: 'push'}];
package/src/feature.ts CHANGED
@@ -23,6 +23,11 @@ export const integrationsFeature = defineClientFeature({
23
23
  parent: 'root',
24
24
  impl: '@shipfox/client-integrations/routes/linear-callback',
25
25
  },
26
+ {
27
+ path: '/integrations/jira/callback',
28
+ parent: 'root',
29
+ impl: '@shipfox/client-integrations/routes/jira-callback',
30
+ },
26
31
  {
27
32
  path: '/integrations/sentry/callback',
28
33
  parent: 'root',
@@ -53,6 +58,11 @@ export const integrationsFeature = defineClientFeature({
53
58
  parent: 'workspaceLayout',
54
59
  impl: '@shipfox/client-integrations/routes/linear',
55
60
  },
61
+ {
62
+ path: '/w/$workspaceSlug/integrations/jira',
63
+ parent: 'workspaceLayout',
64
+ impl: '@shipfox/client-integrations/routes/jira',
65
+ },
56
66
  {
57
67
  path: '/w/$workspaceSlug/integrations/sentry',
58
68
  parent: 'workspaceLayout',
@@ -3,11 +3,13 @@ import type {
3
3
  IntegrationProviderDto,
4
4
  RepositoryDto,
5
5
  } from '@shipfox/api-integration-core-dto';
6
+ import type {JiraAccessibleResourceDto} from '@shipfox/api-integration-jira-dto';
6
7
  import type {WebhookConnectionDto} from '@shipfox/api-integration-webhook-dto';
7
8
  import type {
8
9
  InstallRedirect,
9
10
  IntegrationConnection,
10
11
  IntegrationProvider,
12
+ JiraSite,
11
13
  Repository,
12
14
  WebhookConnection,
13
15
  } from '#core/models.js';
@@ -20,6 +22,10 @@ export function toInstallRedirect(dto: {install_url: string}): InstallRedirect {
20
22
  return {installUrl: dto.install_url};
21
23
  }
22
24
 
25
+ export function toJiraSite(dto: JiraAccessibleResourceDto): JiraSite {
26
+ return {cloudId: dto.cloud_id, name: dto.name, url: dto.url, scopes: dto.scopes};
27
+ }
28
+
23
29
  export function toIntegrationConnection(dto: IntegrationConnectionDto): IntegrationConnection {
24
30
  return {
25
31
  id: dto.id,
@@ -1,8 +1,11 @@
1
1
  import type {IntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
2
2
  import {configureApiClient} from '@shipfox/client-api';
3
3
  import {
4
+ completeJiraCallback,
5
+ completeJiraSiteSelection,
4
6
  completeLinearCallback,
5
7
  completeSlackCallback,
8
+ createJiraInstall,
6
9
  createLinearInstall,
7
10
  createSlackInstall,
8
11
  listSourceConnections,
@@ -155,3 +158,92 @@ describe('Linear transport', () => {
155
158
  );
156
159
  });
157
160
  });
161
+
162
+ describe('Jira transport', () => {
163
+ it('posts the install workspace, maps multi-site callbacks, and completes a site selection', async () => {
164
+ const requests: Request[] = [];
165
+ const connectionBody = {
166
+ id: '33333333-3333-4333-8333-333333333333',
167
+ workspace_id: '11111111-1111-4111-8111-111111111111',
168
+ provider: 'jira',
169
+ external_account_id: 'cloud-1',
170
+ slug: 'jira_acme',
171
+ display_name: 'Jira Acme',
172
+ lifecycle_status: 'active',
173
+ capabilities: ['agent_tools'],
174
+ created_at: '2026-01-01T00:00:00.000Z',
175
+ updated_at: '2026-01-01T00:00:00.000Z',
176
+ };
177
+ configureApiClient({
178
+ baseUrl: 'https://api.example.test',
179
+ fetchImpl: vi.fn((input, init) => {
180
+ const request = new Request(input, init);
181
+ requests.push(request);
182
+ if (request.url.endsWith('/install'))
183
+ return Promise.resolve(jsonResponse({install_url: 'https://jira.example.test/install'}));
184
+ if (request.url.endsWith('/callback/site'))
185
+ return Promise.resolve(jsonResponse(connectionBody));
186
+ return Promise.resolve(
187
+ jsonResponse({
188
+ sites: [
189
+ {
190
+ cloud_id: 'cloud-1',
191
+ name: 'Acme',
192
+ url: 'https://acme.atlassian.net',
193
+ scopes: ['read:jira-work'],
194
+ },
195
+ {
196
+ cloud_id: 'cloud-2',
197
+ name: 'Beta',
198
+ url: 'https://beta.atlassian.net',
199
+ scopes: ['read:jira-work'],
200
+ },
201
+ ],
202
+ }),
203
+ );
204
+ }),
205
+ });
206
+
207
+ const install = await createJiraInstall({
208
+ workspace_id: '11111111-1111-4111-8111-111111111111',
209
+ });
210
+ const sites = await completeJiraCallback({
211
+ query: {code: 'grant code', state: 'signed state'},
212
+ token: 'session-token',
213
+ });
214
+ const connection = await completeJiraSiteSelection({
215
+ body: {cloud_id: 'cloud-1', state: 'signed state'},
216
+ token: 'session-token',
217
+ });
218
+
219
+ expect(install).toEqual({installUrl: 'https://jira.example.test/install'});
220
+ expect(sites).toEqual({
221
+ sites: [
222
+ {
223
+ cloudId: 'cloud-1',
224
+ name: 'Acme',
225
+ url: 'https://acme.atlassian.net',
226
+ scopes: ['read:jira-work'],
227
+ },
228
+ {
229
+ cloudId: 'cloud-2',
230
+ name: 'Beta',
231
+ url: 'https://beta.atlassian.net',
232
+ scopes: ['read:jira-work'],
233
+ },
234
+ ],
235
+ });
236
+ expect(connection.provider).toBe('jira');
237
+ expect(requests[0]?.url).toBe('https://api.example.test/integrations/jira/install');
238
+ expect(await requests[0]?.json()).toEqual({
239
+ workspace_id: '11111111-1111-4111-8111-111111111111',
240
+ });
241
+ expect(requests[1]?.url).toBe(
242
+ 'https://api.example.test/integrations/jira/callback/api?code=grant+code&state=signed+state',
243
+ );
244
+ expect(requests[1]?.headers.get('authorization')).toBe('Bearer session-token');
245
+ expect(requests[2]?.url).toBe('https://api.example.test/integrations/jira/callback/site');
246
+ expect(await requests[2]?.json()).toEqual({cloud_id: 'cloud-1', state: 'signed state'});
247
+ expect(requests[2]?.headers.get('authorization')).toBe('Bearer session-token');
248
+ });
249
+ });
@@ -15,6 +15,15 @@ import {
15
15
  createGithubInstallResponseSchema,
16
16
  githubCallbackResponseSchema,
17
17
  } from '@shipfox/api-integration-github-dto';
18
+ import type {
19
+ CompleteJiraSiteSelectionBodyDto,
20
+ CreateJiraInstallBodyDto,
21
+ JiraCallbackQueryDto,
22
+ } from '@shipfox/api-integration-jira-dto';
23
+ import {
24
+ createJiraInstallResponseSchema,
25
+ jiraCallbackResponseSchema,
26
+ } from '@shipfox/api-integration-jira-dto';
18
27
  import type {
19
28
  CreateLinearInstallBodyDto,
20
29
  LinearCallbackQueryDto,
@@ -56,14 +65,17 @@ import {
56
65
  type IntegrationConnection,
57
66
  type IntegrationProvider,
58
67
  isUsableConnection,
68
+ type JiraSite,
59
69
  type RepositoryPage,
60
70
  } from '#core/models.js';
71
+ import {serializeJiraCallbackQuery} from '#jira-callback.js';
61
72
  import {serializeLinearCallbackQuery} from '#linear-callback.js';
62
73
  import {serializeSlackCallbackQuery} from '#slack-callback.js';
63
74
  import {
64
75
  toInstallRedirect,
65
76
  toIntegrationConnection,
66
77
  toIntegrationProvider,
78
+ toJiraSite,
67
79
  toRepository,
68
80
  } from './integration-mapper.js';
69
81
 
@@ -261,6 +273,15 @@ export async function createLinearInstall(
261
273
  );
262
274
  }
263
275
 
276
+ export async function createJiraInstall(body: CreateJiraInstallBodyDto): Promise<InstallRedirect> {
277
+ return toInstallRedirect(
278
+ await checkedApiRequest(createJiraInstallResponseSchema, '/integrations/jira/install', {
279
+ method: 'POST',
280
+ body,
281
+ }),
282
+ );
283
+ }
284
+
264
285
  export async function createSlackInstall(
265
286
  body: CreateSlackInstallBodyDto,
266
287
  ): Promise<InstallRedirect> {
@@ -302,6 +323,44 @@ export async function completeSlackCallback({
302
323
  return toIntegrationConnection(response);
303
324
  }
304
325
 
326
+ export type JiraCallbackResult = IntegrationConnection | {sites: JiraSite[]};
327
+
328
+ export async function completeJiraCallback({
329
+ query,
330
+ token,
331
+ }: {
332
+ query: JiraCallbackQueryDto;
333
+ token: string;
334
+ }): Promise<JiraCallbackResult> {
335
+ const response = await checkedApiRequest(
336
+ jiraCallbackResponseSchema,
337
+ `/integrations/jira/callback/api?${serializeJiraCallbackQuery(query)}`,
338
+ {headers: {authorization: `Bearer ${token}`}},
339
+ );
340
+ return 'sites' in response
341
+ ? {sites: response.sites.map(toJiraSite)}
342
+ : toIntegrationConnection(response);
343
+ }
344
+
345
+ export async function completeJiraSiteSelection({
346
+ body,
347
+ token,
348
+ }: {
349
+ body: CompleteJiraSiteSelectionBodyDto;
350
+ token: string;
351
+ }): Promise<IntegrationConnection> {
352
+ const response = await checkedApiRequest(
353
+ integrationConnectionDtoSchema,
354
+ '/integrations/jira/callback/site',
355
+ {
356
+ method: 'POST',
357
+ body,
358
+ headers: {authorization: `Bearer ${token}`},
359
+ },
360
+ );
361
+ return toIntegrationConnection(response);
362
+ }
363
+
305
364
  // Called from the callback route with an explicit bearer (same as the GitHub
306
365
  // callback): the route refreshes auth itself before forwarding the grant code.
307
366
  export async function connectSentry({body, token}: {body: SentryConnectBodyDto; token: string}) {
@@ -452,6 +511,10 @@ export function useCreateSlackInstallMutation() {
452
511
  return useMutation({mutationFn: createSlackInstall});
453
512
  }
454
513
 
514
+ export function useCreateJiraInstallMutation() {
515
+ return useMutation({mutationFn: createJiraInstall});
516
+ }
517
+
455
518
  export function useCompleteLinearCallbackMutation() {
456
519
  return useMutation({mutationFn: completeLinearCallback});
457
520
  }
@@ -460,6 +523,14 @@ export function useCompleteSlackCallbackMutation() {
460
523
  return useMutation({mutationFn: completeSlackCallback});
461
524
  }
462
525
 
526
+ export function useCompleteJiraCallbackMutation() {
527
+ return useMutation({mutationFn: completeJiraCallback});
528
+ }
529
+
530
+ export function useCompleteJiraSiteSelectionMutation() {
531
+ return useMutation({mutationFn: completeJiraSiteSelection});
532
+ }
533
+
463
534
  export function useUpdateIntegrationConnectionMutation() {
464
535
  const queryClient = useQueryClient();
465
536
  return useMutation({
package/src/index.ts CHANGED
@@ -6,8 +6,12 @@ export * from './components/redirect-install-page.js';
6
6
  export * from './components/repository-picker.js';
7
7
  export * from './connection-status-badge.js';
8
8
  export * from './hooks/api/integrations.js';
9
+ export * from './jira-callback.js';
10
+ export * from './jira-form-errors.js';
9
11
  export * from './pages/gitea-install-page.js';
10
12
  export * from './pages/github-install-page.js';
13
+ export * from './pages/jira-callback-page.js';
14
+ export * from './pages/jira-install-page.js';
11
15
  export * from './pages/linear-callback-page.js';
12
16
  export * from './pages/linear-install-page.js';
13
17
  export * from './pages/sentry-callback-page.js';
@@ -0,0 +1,19 @@
1
+ import {createSingleFlight} from '@shipfox/client-ui';
2
+ import type {IntegrationConnection} from '#core/models.js';
3
+ import type {JiraCallbackResult} from '#hooks/api/integrations.js';
4
+
5
+ export const JIRA_CALLBACK_CACHE_SIZE = 32;
6
+
7
+ export const jiraCallbackRequests = createSingleFlight<string, JiraCallbackResult>({
8
+ maxTerminalResults: JIRA_CALLBACK_CACHE_SIZE,
9
+ });
10
+ export const jiraSiteSelectionRequests = createSingleFlight<string, IntegrationConnection>();
11
+ export const jiraCompletedConnections = new Map<string, IntegrationConnection>();
12
+ export const jiraToastedCallbacks = new Set<string>();
13
+
14
+ export function resetJiraCallbackState(): void {
15
+ jiraCallbackRequests.clear();
16
+ jiraSiteSelectionRequests.clear();
17
+ jiraCompletedConnections.clear();
18
+ jiraToastedCallbacks.clear();
19
+ }
@@ -0,0 +1,76 @@
1
+ import {
2
+ clearJiraInstallWorkspace,
3
+ JIRA_INSTALL_WORKSPACE_KEY,
4
+ parseJiraCallbackQuery,
5
+ readJiraInstallWorkspace,
6
+ saveJiraInstallWorkspace,
7
+ serializeJiraCallbackQuery,
8
+ } from './jira-callback.js';
9
+
10
+ describe('Jira callback helpers', () => {
11
+ it('parses and serializes success and provider-error callback queries', () => {
12
+ const success = parseJiraCallbackQuery({code: 'grant code', state: 'signed state'});
13
+ const providerError = parseJiraCallbackQuery({
14
+ error: 'access_denied',
15
+ error_description: 'User denied access',
16
+ state: 'signed state',
17
+ });
18
+
19
+ expect(success).toEqual({code: 'grant code', state: 'signed state'});
20
+ expect(providerError).toEqual({
21
+ error: 'access_denied',
22
+ error_description: 'User denied access',
23
+ state: 'signed state',
24
+ });
25
+ expect(success && serializeJiraCallbackQuery(success)).toBe(
26
+ 'code=grant+code&state=signed+state',
27
+ );
28
+ expect(providerError && serializeJiraCallbackQuery(providerError)).toBe(
29
+ 'error=access_denied&error_description=User+denied+access&state=signed+state',
30
+ );
31
+ });
32
+
33
+ it('rejects malformed callback queries and prefers a grant code', () => {
34
+ expect(parseJiraCallbackQuery({code: 'grant'})).toBeUndefined();
35
+ expect(parseJiraCallbackQuery({state: 'signed'})).toBeUndefined();
36
+ expect(parseJiraCallbackQuery({error: 'access_denied'})).toBeUndefined();
37
+ expect(parseJiraCallbackQuery({state: ['a', 'b']})).toBeUndefined();
38
+ expect(
39
+ parseJiraCallbackQuery({
40
+ code: 'grant',
41
+ error: 'access_denied',
42
+ state: 'signed',
43
+ }),
44
+ ).toEqual({code: 'grant', state: 'signed'});
45
+ });
46
+
47
+ it('round-trips workspace navigation storage and swallows storage errors', () => {
48
+ const storage = new Map<string, string>();
49
+ const workspaceStorage = {
50
+ getItem: (key: string) => storage.get(key) ?? null,
51
+ setItem: (key: string, value: string) => storage.set(key, value),
52
+ removeItem: (key: string) => storage.delete(key),
53
+ };
54
+
55
+ saveJiraInstallWorkspace(workspaceStorage, 'workspace-1');
56
+ expect(storage.get(JIRA_INSTALL_WORKSPACE_KEY)).toBe('workspace-1');
57
+ expect(readJiraInstallWorkspace(workspaceStorage)).toBe('workspace-1');
58
+ clearJiraInstallWorkspace(workspaceStorage);
59
+ expect(readJiraInstallWorkspace(workspaceStorage)).toBeUndefined();
60
+
61
+ const unavailableStorage = {
62
+ getItem: () => {
63
+ throw new Error('unavailable');
64
+ },
65
+ setItem: () => {
66
+ throw new Error('unavailable');
67
+ },
68
+ removeItem: () => {
69
+ throw new Error('unavailable');
70
+ },
71
+ };
72
+ expect(() => saveJiraInstallWorkspace(unavailableStorage, 'workspace-1')).not.toThrow();
73
+ expect(() => readJiraInstallWorkspace(unavailableStorage)).not.toThrow();
74
+ expect(() => clearJiraInstallWorkspace(unavailableStorage)).not.toThrow();
75
+ });
76
+ });