@shipfox/client-integrations 12.0.2 → 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 +52 -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 +4 -3
- package/dist/components/callback-status-shell.d.ts.map +1 -1
- package/dist/components/callback-status-shell.js +11 -11
- package/dist/components/callback-status-shell.js.map +1 -1
- package/dist/components/installed-integrations-section.d.ts +2 -1
- package/dist/components/installed-integrations-section.d.ts.map +1 -1
- package/dist/components/installed-integrations-section.js +7 -6
- package/dist/components/installed-integrations-section.js.map +1 -1
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -1
- package/dist/components/integration-gallery-for-workspace.js +11 -2
- package/dist/components/integration-gallery-for-workspace.js.map +1 -1
- package/dist/components/integration-gallery.stories.js +10 -9
- package/dist/components/integration-gallery.stories.js.map +1 -1
- package/dist/components/provider-grid.d.ts +2 -2
- package/dist/components/provider-grid.d.ts.map +1 -1
- package/dist/components/provider-grid.js +4 -4
- package/dist/components/provider-grid.js.map +1 -1
- package/dist/components/redirect-install-page.d.ts +1 -1
- package/dist/components/redirect-install-page.js +2 -2
- package/dist/components/redirect-install-page.js.map +1 -1
- package/dist/components/webhook/webhook-modals.stories.js +2 -2
- package/dist/components/webhook/webhook-modals.stories.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 +15 -7
- package/dist/feature.d.ts.map +1 -1
- package/dist/feature.js +17 -7
- 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 +45 -2
- 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/gitea-install-page.js +4 -4
- package/dist/pages/gitea-install-page.js.map +1 -1
- 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/pages/linear-callback-page.d.ts.map +1 -1
- package/dist/pages/linear-callback-page.js +58 -12
- package/dist/pages/linear-callback-page.js.map +1 -1
- package/dist/pages/sentry-callback-page.d.ts.map +1 -1
- package/dist/pages/sentry-callback-page.js +22 -9
- package/dist/pages/sentry-callback-page.js.map +1 -1
- package/dist/pages/slack-callback-page.d.ts.map +1 -1
- package/dist/pages/slack-callback-page.js +50 -18
- package/dist/pages/slack-callback-page.js.map +1 -1
- package/dist/pages/source-control-onboarding-page.js +1 -1
- package/dist/pages/source-control-onboarding-page.js.map +1 -1
- package/dist/provider-catalog.d.ts +1 -1
- package/dist/provider-catalog.d.ts.map +1 -1
- package/dist/provider-catalog.js +10 -5
- 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/sentry-callback.js +2 -2
- package/dist/sentry-callback.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/workspace-navigation.d.ts +13 -0
- package/dist/workspace-navigation.d.ts.map +1 -0
- package/dist/workspace-navigation.js +17 -0
- package/dist/workspace-navigation.js.map +1 -0
- package/package.json +14 -13
- 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 +17 -10
- package/src/components/installed-integrations-section.tsx +16 -9
- package/src/components/integration-gallery-for-workspace.tsx +20 -10
- package/src/components/integration-gallery.stories.tsx +10 -10
- package/src/components/integration-gallery.test.tsx +30 -15
- package/src/components/integration-usage-events.test.ts +13 -0
- package/src/components/provider-grid.tsx +6 -6
- package/src/components/redirect-install-page.test.tsx +3 -3
- package/src/components/redirect-install-page.tsx +2 -2
- package/src/components/webhook/webhook-modals.stories.tsx +2 -2
- package/src/core/models.ts +18 -0
- package/src/feature.ts +17 -7
- package/src/hooks/api/integration-mapper.ts +6 -0
- package/src/hooks/api/integrations.test.ts +92 -0
- package/src/hooks/api/integrations.ts +72 -1
- 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/gitea-install-page.tsx +2 -2
- 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/pages/linear-callback-page.test.tsx +25 -12
- package/src/pages/linear-callback-page.tsx +73 -12
- package/src/pages/sentry-callback-page.test.tsx +8 -5
- package/src/pages/sentry-callback-page.tsx +32 -8
- package/src/pages/slack-callback-page.test.tsx +78 -0
- package/src/pages/slack-callback-page.tsx +56 -18
- package/src/pages/source-control-onboarding-page.test.tsx +4 -4
- package/src/pages/source-control-onboarding-page.tsx +1 -1
- package/src/provider-catalog.test.ts +10 -4
- package/src/provider-catalog.ts +16 -10
- package/src/routes/jira-callback.tsx +8 -0
- package/src/routes/jira.tsx +4 -0
- package/src/sentry-callback.ts +2 -2
- package/src/workspace-navigation.test.ts +43 -0
- package/src/workspace-navigation.ts +38 -0
- package/test/render.tsx +2 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -11,12 +11,12 @@ import type {IntegrationProvider} from '#core/models.js';
|
|
|
11
11
|
import {PROVIDER_CATALOG} from '#provider-catalog.js';
|
|
12
12
|
|
|
13
13
|
export interface ProviderGridProps {
|
|
14
|
+
workspaceSlug: string;
|
|
14
15
|
providers: IntegrationProvider[];
|
|
15
16
|
isPending: boolean;
|
|
16
17
|
isFetching?: boolean;
|
|
17
18
|
error?: Error | null | undefined;
|
|
18
19
|
onRetry?: () => void;
|
|
19
|
-
workspaceId: string;
|
|
20
20
|
emptyMessage: string;
|
|
21
21
|
loadingLabel?: string;
|
|
22
22
|
errorSubject?: string;
|
|
@@ -29,12 +29,12 @@ export const PROVIDER_SURFACE_CLASS =
|
|
|
29
29
|
'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
|
|
30
30
|
|
|
31
31
|
export function ProviderGrid({
|
|
32
|
+
workspaceSlug,
|
|
32
33
|
providers,
|
|
33
34
|
isPending,
|
|
34
35
|
isFetching = false,
|
|
35
36
|
error,
|
|
36
37
|
onRetry,
|
|
37
|
-
workspaceId,
|
|
38
38
|
emptyMessage,
|
|
39
39
|
loadingLabel = 'Loading providers',
|
|
40
40
|
errorSubject = 'available integrations',
|
|
@@ -79,7 +79,7 @@ export function ProviderGrid({
|
|
|
79
79
|
<li key={provider.provider}>
|
|
80
80
|
<ProviderCard
|
|
81
81
|
provider={provider}
|
|
82
|
-
|
|
82
|
+
workspaceSlug={workspaceSlug}
|
|
83
83
|
onOpenProvider={onOpenProvider}
|
|
84
84
|
/>
|
|
85
85
|
</li>
|
|
@@ -90,11 +90,11 @@ export function ProviderGrid({
|
|
|
90
90
|
|
|
91
91
|
function ProviderCard({
|
|
92
92
|
provider,
|
|
93
|
-
|
|
93
|
+
workspaceSlug,
|
|
94
94
|
onOpenProvider,
|
|
95
95
|
}: {
|
|
96
96
|
provider: IntegrationProvider;
|
|
97
|
-
|
|
97
|
+
workspaceSlug: string;
|
|
98
98
|
onOpenProvider?: ((provider: string) => void) | undefined;
|
|
99
99
|
}) {
|
|
100
100
|
const catalog = PROVIDER_CATALOG[provider.provider];
|
|
@@ -116,7 +116,7 @@ function ProviderCard({
|
|
|
116
116
|
return (
|
|
117
117
|
<Link
|
|
118
118
|
to={catalog.setupPath}
|
|
119
|
-
params={{
|
|
119
|
+
params={{workspaceSlug}}
|
|
120
120
|
aria-label={`Install ${provider.displayName}`}
|
|
121
121
|
className="group flex h-full min-w-0 items-center justify-between gap-12 rounded-8 border border-border-neutral-base bg-background-neutral-base p-16 transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
|
|
122
122
|
>
|
|
@@ -11,8 +11,8 @@ function renderInstallPage(
|
|
|
11
11
|
options?: {strict?: boolean},
|
|
12
12
|
) {
|
|
13
13
|
return renderIntegrationsPage({
|
|
14
|
-
path:
|
|
15
|
-
routePath: '/
|
|
14
|
+
path: '/w/acme/integrations/github',
|
|
15
|
+
routePath: '/w/$workspaceSlug/integrations/github',
|
|
16
16
|
element: options?.strict ? (
|
|
17
17
|
<StrictMode>
|
|
18
18
|
<RedirectInstallPage {...props} />
|
|
@@ -20,7 +20,7 @@ function renderInstallPage(
|
|
|
20
20
|
) : (
|
|
21
21
|
<RedirectInstallPage {...props} />
|
|
22
22
|
),
|
|
23
|
-
extraRoutes: ['/
|
|
23
|
+
extraRoutes: ['/w/$workspaceSlug/integrations'],
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -14,7 +14,7 @@ interface RedirectInstallPageProps {
|
|
|
14
14
|
loadingLabel?: string;
|
|
15
15
|
/**
|
|
16
16
|
* Runs before leaving the app (e.g. to persist the workspace id for a
|
|
17
|
-
* state-less provider callback
|
|
17
|
+
* state-less provider callback. It must not throw. A failed side effect
|
|
18
18
|
* should never block the redirect.
|
|
19
19
|
*/
|
|
20
20
|
beforeRedirect?: (workspaceId: string) => void;
|
|
@@ -59,7 +59,7 @@ export function RedirectInstallPage({
|
|
|
59
59
|
<Text size="sm">{errorMessage}</Text>
|
|
60
60
|
</Callout>
|
|
61
61
|
<ButtonLink asChild variant="muted" className="w-fit">
|
|
62
|
-
<Link to="/
|
|
62
|
+
<Link to="/w/$workspaceSlug/integrations" params={{workspaceSlug: workspace.slug}}>
|
|
63
63
|
Back to integrations
|
|
64
64
|
</Link>
|
|
65
65
|
</ButtonLink>
|
|
@@ -176,7 +176,7 @@ function createStoryRouter(scenario: Scenario) {
|
|
|
176
176
|
const rootRoute = createRootRoute({component: Outlet});
|
|
177
177
|
const workspaceRoute = createRoute({
|
|
178
178
|
getParentRoute: () => rootRoute,
|
|
179
|
-
path: '/
|
|
179
|
+
path: '/w/$workspaceSlug',
|
|
180
180
|
component: Outlet,
|
|
181
181
|
});
|
|
182
182
|
const integrationsRoute = createRoute({
|
|
@@ -192,7 +192,7 @@ function createStoryRouter(scenario: Scenario) {
|
|
|
192
192
|
|
|
193
193
|
return createRouter({
|
|
194
194
|
history: createMemoryHistory({
|
|
195
|
-
initialEntries: [
|
|
195
|
+
initialEntries: ['/w/acme/settings/integrations'],
|
|
196
196
|
}),
|
|
197
197
|
routeTree: rootRoute.addChildren([
|
|
198
198
|
workspaceRoute.addChildren([integrationsRoute, eventsRoute]),
|
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',
|
|
@@ -34,37 +39,42 @@ export const integrationsFeature = defineClientFeature({
|
|
|
34
39
|
impl: '@shipfox/client-integrations/routes/slack-callback',
|
|
35
40
|
},
|
|
36
41
|
{
|
|
37
|
-
path: '/
|
|
42
|
+
path: '/w/$workspaceSlug/integrations',
|
|
38
43
|
parent: 'workspaceLayout',
|
|
39
44
|
impl: '@shipfox/client-integrations/routes/integrations',
|
|
40
45
|
},
|
|
41
46
|
{
|
|
42
|
-
path: '/
|
|
47
|
+
path: '/w/$workspaceSlug/integrations/gitea',
|
|
43
48
|
parent: 'workspaceLayout',
|
|
44
49
|
impl: '@shipfox/client-integrations/routes/gitea',
|
|
45
50
|
},
|
|
46
51
|
{
|
|
47
|
-
path: '/
|
|
52
|
+
path: '/w/$workspaceSlug/integrations/github',
|
|
48
53
|
parent: 'workspaceLayout',
|
|
49
54
|
impl: '@shipfox/client-integrations/routes/github',
|
|
50
55
|
},
|
|
51
56
|
{
|
|
52
|
-
path: '/
|
|
57
|
+
path: '/w/$workspaceSlug/integrations/linear',
|
|
53
58
|
parent: 'workspaceLayout',
|
|
54
59
|
impl: '@shipfox/client-integrations/routes/linear',
|
|
55
60
|
},
|
|
56
61
|
{
|
|
57
|
-
path: '/
|
|
62
|
+
path: '/w/$workspaceSlug/integrations/jira',
|
|
63
|
+
parent: 'workspaceLayout',
|
|
64
|
+
impl: '@shipfox/client-integrations/routes/jira',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
path: '/w/$workspaceSlug/integrations/sentry',
|
|
58
68
|
parent: 'workspaceLayout',
|
|
59
69
|
impl: '@shipfox/client-integrations/routes/sentry',
|
|
60
70
|
},
|
|
61
71
|
{
|
|
62
|
-
path: '/
|
|
72
|
+
path: '/w/$workspaceSlug/integrations/slack',
|
|
63
73
|
parent: 'workspaceLayout',
|
|
64
74
|
impl: '@shipfox/client-integrations/routes/slack',
|
|
65
75
|
},
|
|
66
76
|
{
|
|
67
|
-
path: '/
|
|
77
|
+
path: '/w/$workspaceSlug/settings/integrations',
|
|
68
78
|
parent: 'workspaceSettings',
|
|
69
79
|
impl: '@shipfox/client-integrations/routes/integrations-settings',
|
|
70
80
|
},
|
|
@@ -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
|
|
|
@@ -175,7 +187,7 @@ export async function listSourceConnections({
|
|
|
175
187
|
});
|
|
176
188
|
// The endpoint returns every lifecycle status (the settings hub needs that),
|
|
177
189
|
// but source-control consumers (onboarding redirect, project creation) only
|
|
178
|
-
// act on usable connections
|
|
190
|
+
// act on usable connections. A disabled/error one must read as "not there".
|
|
179
191
|
return connections.filter(isUsableConnection);
|
|
180
192
|
}
|
|
181
193
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type {JiraCallbackQueryDto} from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import {
|
|
3
|
+
type BrowserStorage,
|
|
4
|
+
type BrowserStorageKey,
|
|
5
|
+
createTypedBrowserStorage,
|
|
6
|
+
} from '@shipfox/client-ui';
|
|
7
|
+
|
|
8
|
+
export const JIRA_INSTALL_WORKSPACE_KEY = 'shipfox.jira-install.workspace-id';
|
|
9
|
+
|
|
10
|
+
type WorkspaceStorage = BrowserStorage | undefined;
|
|
11
|
+
|
|
12
|
+
const jiraInstallWorkspaceStorageKey = {
|
|
13
|
+
key: JIRA_INSTALL_WORKSPACE_KEY,
|
|
14
|
+
lifetime: 'session',
|
|
15
|
+
// This is a one-shot navigation hint, not private workspace state. It must
|
|
16
|
+
// remain readable on an OAuth callback before workspace hydration completes;
|
|
17
|
+
// the signed callback state and returned connection are authoritative.
|
|
18
|
+
principalScope: 'global',
|
|
19
|
+
serialize: (workspaceId: string) => workspaceId,
|
|
20
|
+
parse: (value: string) => value || undefined,
|
|
21
|
+
} satisfies BrowserStorageKey<string>;
|
|
22
|
+
|
|
23
|
+
export function saveJiraInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
|
|
24
|
+
installWorkspaceStorage(storage).write(workspaceId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readJiraInstallWorkspace(storage: WorkspaceStorage): string | undefined {
|
|
28
|
+
return installWorkspaceStorage(storage).read();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearJiraInstallWorkspace(storage: WorkspaceStorage): void {
|
|
32
|
+
installWorkspaceStorage(storage).remove();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installWorkspaceStorage(storage: WorkspaceStorage) {
|
|
36
|
+
return createTypedBrowserStorage(() => storage, jiraInstallWorkspaceStorageKey);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseJiraCallbackQuery(
|
|
40
|
+
search: Record<string, unknown>,
|
|
41
|
+
): JiraCallbackQueryDto | undefined {
|
|
42
|
+
const state = stringParam(search.state);
|
|
43
|
+
if (!state) return undefined;
|
|
44
|
+
|
|
45
|
+
const code = stringParam(search.code);
|
|
46
|
+
if (code) return {code, state};
|
|
47
|
+
|
|
48
|
+
const error = stringParam(search.error);
|
|
49
|
+
if (!error) return undefined;
|
|
50
|
+
const errorDescription = stringParam(search.error_description);
|
|
51
|
+
return errorDescription ? {error, error_description: errorDescription, state} : {error, state};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function serializeJiraCallbackQuery(query: JiraCallbackQueryDto): string {
|
|
55
|
+
const params = new URLSearchParams();
|
|
56
|
+
if ('code' in query) params.set('code', query.code);
|
|
57
|
+
else {
|
|
58
|
+
params.set('error', query.error);
|
|
59
|
+
if (query.error_description) params.set('error_description', query.error_description);
|
|
60
|
+
}
|
|
61
|
+
params.set('state', query.state);
|
|
62
|
+
return params.toString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stringParam(value: unknown): string | undefined {
|
|
66
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
67
|
+
}
|