@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +18 -0
- package/dist/application/complete-integration-callback.d.ts +11 -0
- package/dist/application/complete-integration-callback.d.ts.map +1 -1
- package/dist/application/complete-integration-callback.js +38 -8
- package/dist/application/complete-integration-callback.js.map +1 -1
- package/dist/components/callback-status-shell.d.ts +1 -1
- package/dist/components/callback-status-shell.d.ts.map +1 -1
- package/dist/components/callback-status-shell.js.map +1 -1
- package/dist/core/models.d.ts +6 -0
- package/dist/core/models.d.ts.map +1 -1
- package/dist/core/models.js +12 -0
- package/dist/core/models.js.map +1 -1
- package/dist/feature.d.ts +8 -0
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +10 -0
- package/dist/feature.js.map +1 -1
- package/dist/hooks/api/integration-mapper.d.ts +3 -1
- package/dist/hooks/api/integration-mapper.d.ts.map +1 -1
- package/dist/hooks/api/integration-mapper.js +8 -0
- package/dist/hooks/api/integration-mapper.js.map +1 -1
- package/dist/hooks/api/integrations.d.ts +25 -1
- package/dist/hooks/api/integrations.d.ts.map +1 -1
- package/dist/hooks/api/integrations.js +44 -1
- package/dist/hooks/api/integrations.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/jira-callback-state.d.ts +9 -0
- package/dist/jira-callback-state.d.ts.map +1 -0
- package/dist/jira-callback-state.js +16 -0
- package/dist/jira-callback-state.js.map +1 -0
- package/dist/jira-callback.d.ts +11 -0
- package/dist/jira-callback.d.ts.map +1 -0
- package/dist/jira-callback.js +59 -0
- package/dist/jira-callback.js.map +1 -0
- package/dist/jira-form-errors.d.ts +8 -0
- package/dist/jira-form-errors.d.ts.map +1 -0
- package/dist/jira-form-errors.js +55 -0
- package/dist/jira-form-errors.js.map +1 -0
- package/dist/pages/jira-callback-page.d.ts +2 -0
- package/dist/pages/jira-callback-page.d.ts.map +1 -0
- package/dist/pages/jira-callback-page.js +341 -0
- package/dist/pages/jira-callback-page.js.map +1 -0
- package/dist/pages/jira-install-page.d.ts +2 -0
- package/dist/pages/jira-install-page.d.ts.map +1 -0
- package/dist/pages/jira-install-page.js +26 -0
- package/dist/pages/jira-install-page.js.map +1 -0
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +5 -0
- package/dist/provider-catalog.js.map +1 -1
- package/dist/routes/jira-callback.d.ts +8 -0
- package/dist/routes/jira-callback.d.ts.map +1 -0
- package/dist/routes/jira-callback.js +9 -0
- package/dist/routes/jira-callback.js.map +1 -0
- package/dist/routes/jira.d.ts +6 -0
- package/dist/routes/jira.d.ts.map +1 -0
- package/dist/routes/jira.js +7 -0
- package/dist/routes/jira.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -8
- package/src/application/complete-integration-callback.test.ts +44 -1
- package/src/application/complete-integration-callback.ts +61 -8
- package/src/components/callback-status-shell.tsx +4 -1
- package/src/components/integration-usage-events.test.ts +13 -0
- package/src/core/models.ts +18 -0
- package/src/feature.ts +10 -0
- package/src/hooks/api/integration-mapper.ts +6 -0
- package/src/hooks/api/integrations.test.ts +92 -0
- package/src/hooks/api/integrations.ts +71 -0
- package/src/index.ts +4 -0
- package/src/jira-callback-state.ts +19 -0
- package/src/jira-callback.test.ts +76 -0
- package/src/jira-callback.ts +67 -0
- package/src/jira-form-errors.test.ts +57 -0
- package/src/jira-form-errors.ts +94 -0
- package/src/pages/jira-callback-page.test.tsx +250 -0
- package/src/pages/jira-callback-page.tsx +366 -0
- package/src/pages/jira-install-page.tsx +28 -0
- package/src/provider-catalog.test.ts +6 -0
- package/src/provider-catalog.ts +7 -1
- package/src/routes/jira-callback.tsx +8 -0
- package/src/routes/jira.tsx +4 -0
- 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": "
|
|
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-
|
|
38
|
-
"@shipfox/
|
|
39
|
-
"@shipfox/
|
|
40
|
-
"@shipfox/
|
|
41
|
-
"@shipfox/
|
|
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 {
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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:
|
|
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,
|
package/src/core/models.ts
CHANGED
|
@@ -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
|
+
});
|