@shipfox/client-integrations 0.2.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/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/dist/components/connection-picker.d.ts +7 -0
- package/dist/components/connection-picker.d.ts.map +1 -0
- package/dist/components/connection-picker.js +98 -0
- package/dist/components/connection-picker.js.map +1 -0
- package/dist/components/installed-integrations-section.d.ts +14 -0
- package/dist/components/installed-integrations-section.d.ts.map +1 -0
- package/dist/components/installed-integrations-section.js +203 -0
- package/dist/components/installed-integrations-section.js.map +1 -0
- package/dist/components/integration-delete-confirm-modal.d.ts +10 -0
- package/dist/components/integration-delete-confirm-modal.d.ts.map +1 -0
- package/dist/components/integration-delete-confirm-modal.js +61 -0
- package/dist/components/integration-delete-confirm-modal.js.map +1 -0
- package/dist/components/integration-gallery-for-workspace.d.ts +9 -0
- package/dist/components/integration-gallery-for-workspace.d.ts.map +1 -0
- package/dist/components/integration-gallery-for-workspace.js +169 -0
- package/dist/components/integration-gallery-for-workspace.js.map +1 -0
- package/dist/components/integration-gallery.d.ts +8 -0
- package/dist/components/integration-gallery.d.ts.map +1 -0
- package/dist/components/integration-gallery.js +26 -0
- package/dist/components/integration-gallery.js.map +1 -0
- package/dist/components/integration-gallery.stories.d.ts +26 -0
- package/dist/components/integration-gallery.stories.d.ts.map +1 -0
- package/dist/components/integration-gallery.stories.js +335 -0
- package/dist/components/integration-gallery.stories.js.map +1 -0
- package/dist/components/integration-usage-events.d.ts +4 -0
- package/dist/components/integration-usage-events.d.ts.map +1 -0
- package/dist/components/integration-usage-events.js +127 -0
- package/dist/components/integration-usage-events.js.map +1 -0
- package/dist/components/integration-usage-modal.d.ts +16 -0
- package/dist/components/integration-usage-modal.d.ts.map +1 -0
- package/dist/components/integration-usage-modal.js +184 -0
- package/dist/components/integration-usage-modal.js.map +1 -0
- package/dist/components/provider-grid.d.ts +14 -0
- package/dist/components/provider-grid.d.ts.map +1 -0
- package/dist/components/provider-grid.js +152 -0
- package/dist/components/provider-grid.js.map +1 -0
- package/dist/components/redirect-install-page.d.ts +19 -0
- package/dist/components/redirect-install-page.d.ts.map +1 -0
- package/dist/components/redirect-install-page.js +68 -0
- package/dist/components/redirect-install-page.js.map +1 -0
- package/dist/components/repository-picker.d.ts +15 -0
- package/dist/components/repository-picker.d.ts.map +1 -0
- package/dist/components/repository-picker.js +78 -0
- package/dist/components/repository-picker.js.map +1 -0
- package/dist/components/webhook/copyable-value.d.ts +10 -0
- package/dist/components/webhook/copyable-value.d.ts.map +1 -0
- package/dist/components/webhook/copyable-value.js +65 -0
- package/dist/components/webhook/copyable-value.js.map +1 -0
- package/dist/components/webhook/webhook-create-modal.d.ts +10 -0
- package/dist/components/webhook/webhook-create-modal.d.ts.map +1 -0
- package/dist/components/webhook/webhook-create-modal.js +233 -0
- package/dist/components/webhook/webhook-create-modal.js.map +1 -0
- package/dist/components/webhook/webhook-form-errors.d.ts +11 -0
- package/dist/components/webhook/webhook-form-errors.d.ts.map +1 -0
- package/dist/components/webhook/webhook-form-errors.js +27 -0
- package/dist/components/webhook/webhook-form-errors.js.map +1 -0
- package/dist/components/webhook/webhook-modals.stories.d.ts +25 -0
- package/dist/components/webhook/webhook-modals.stories.d.ts.map +1 -0
- package/dist/components/webhook/webhook-modals.stories.js +222 -0
- package/dist/components/webhook/webhook-modals.stories.js.map +1 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.d.ts +2 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.d.ts.map +1 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.js +20 -0
- package/dist/components/webhook/webhook-public-endpoint-alert.js.map +1 -0
- package/dist/components/webhook/webhook-usage-details.d.ts +7 -0
- package/dist/components/webhook/webhook-usage-details.d.ts.map +1 -0
- package/dist/components/webhook/webhook-usage-details.js +49 -0
- package/dist/components/webhook/webhook-usage-details.js.map +1 -0
- package/dist/connection-status-badge.d.ts +12 -0
- package/dist/connection-status-badge.d.ts.map +1 -0
- package/dist/connection-status-badge.js +34 -0
- package/dist/connection-status-badge.js.map +1 -0
- package/dist/feature.d.ts +37 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +48 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/integrations.d.ts +265 -0
- package/dist/hooks/api/integrations.d.ts.map +1 -0
- package/dist/hooks/api/integrations.js +229 -0
- package/dist/hooks/api/integrations.js.map +1 -0
- package/dist/hooks/api/webhook-connections.d.ts +91 -0
- package/dist/hooks/api/webhook-connections.d.ts.map +1 -0
- package/dist/hooks/api/webhook-connections.js +97 -0
- package/dist/hooks/api/webhook-connections.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/integration-icon.d.ts +17 -0
- package/dist/integration-icon.d.ts.map +1 -0
- package/dist/integration-icon.js +22 -0
- package/dist/integration-icon.js.map +1 -0
- package/dist/linear-callback.d.ts +17 -0
- package/dist/linear-callback.d.ts.map +1 -0
- package/dist/linear-callback.js +113 -0
- package/dist/linear-callback.js.map +1 -0
- package/dist/pages/gitea-form-errors.d.ts +11 -0
- package/dist/pages/gitea-form-errors.d.ts.map +1 -0
- package/dist/pages/gitea-form-errors.js +35 -0
- package/dist/pages/gitea-form-errors.js.map +1 -0
- package/dist/pages/gitea-install-page.d.ts +2 -0
- package/dist/pages/gitea-install-page.d.ts.map +1 -0
- package/dist/pages/gitea-install-page.js +139 -0
- package/dist/pages/gitea-install-page.js.map +1 -0
- package/dist/pages/github-install-page.d.ts +2 -0
- package/dist/pages/github-install-page.d.ts.map +1 -0
- package/dist/pages/github-install-page.js +11 -0
- package/dist/pages/github-install-page.js.map +1 -0
- package/dist/pages/linear-callback-page.d.ts +2 -0
- package/dist/pages/linear-callback-page.d.ts.map +1 -0
- package/dist/pages/linear-callback-page.js +192 -0
- package/dist/pages/linear-callback-page.js.map +1 -0
- package/dist/pages/linear-install-page.d.ts +2 -0
- package/dist/pages/linear-install-page.d.ts.map +1 -0
- package/dist/pages/linear-install-page.js +24 -0
- package/dist/pages/linear-install-page.js.map +1 -0
- package/dist/pages/sentry-callback-page.d.ts +2 -0
- package/dist/pages/sentry-callback-page.d.ts.map +1 -0
- package/dist/pages/sentry-callback-page.js +256 -0
- package/dist/pages/sentry-callback-page.js.map +1 -0
- package/dist/pages/sentry-install-page.d.ts +2 -0
- package/dist/pages/sentry-install-page.d.ts.map +1 -0
- package/dist/pages/sentry-install-page.js +17 -0
- package/dist/pages/sentry-install-page.js.map +1 -0
- package/dist/pages/source-control-onboarding-page.d.ts +2 -0
- package/dist/pages/source-control-onboarding-page.d.ts.map +1 -0
- package/dist/pages/source-control-onboarding-page.js +37 -0
- package/dist/pages/source-control-onboarding-page.js.map +1 -0
- package/dist/provider-catalog.d.ts +15 -0
- package/dist/provider-catalog.d.ts.map +1 -0
- package/dist/provider-catalog.js +28 -0
- package/dist/provider-catalog.js.map +1 -0
- package/dist/routes/gitea.d.ts +6 -0
- package/dist/routes/gitea.d.ts.map +1 -0
- package/dist/routes/gitea.js +7 -0
- package/dist/routes/gitea.js.map +1 -0
- package/dist/routes/github-callback.d.ts +6 -0
- package/dist/routes/github-callback.d.ts.map +1 -0
- package/dist/routes/github-callback.js +129 -0
- package/dist/routes/github-callback.js.map +1 -0
- package/dist/routes/github.d.ts +6 -0
- package/dist/routes/github.d.ts.map +1 -0
- package/dist/routes/github.js +7 -0
- package/dist/routes/github.js.map +1 -0
- package/dist/routes/integrations.d.ts +6 -0
- package/dist/routes/integrations.d.ts.map +1 -0
- package/dist/routes/integrations.js +7 -0
- package/dist/routes/integrations.js.map +1 -0
- package/dist/routes/linear-callback.d.ts +6 -0
- package/dist/routes/linear-callback.d.ts.map +1 -0
- package/dist/routes/linear-callback.js +7 -0
- package/dist/routes/linear-callback.js.map +1 -0
- package/dist/routes/linear.d.ts +6 -0
- package/dist/routes/linear.d.ts.map +1 -0
- package/dist/routes/linear.js +7 -0
- package/dist/routes/linear.js.map +1 -0
- package/dist/routes/sentry-callback.d.ts +6 -0
- package/dist/routes/sentry-callback.d.ts.map +1 -0
- package/dist/routes/sentry-callback.js +7 -0
- package/dist/routes/sentry-callback.js.map +1 -0
- package/dist/routes/sentry.d.ts +6 -0
- package/dist/routes/sentry.d.ts.map +1 -0
- package/dist/routes/sentry.js +7 -0
- package/dist/routes/sentry.js.map +1 -0
- package/dist/sentry-callback.d.ts +35 -0
- package/dist/sentry-callback.d.ts.map +1 -0
- package/dist/sentry-callback.js +132 -0
- package/dist/sentry-callback.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +120 -0
- package/src/components/connection-picker.tsx +89 -0
- package/src/components/installed-integrations-section.tsx +198 -0
- package/src/components/integration-delete-confirm-modal.tsx +62 -0
- package/src/components/integration-gallery-for-workspace.tsx +193 -0
- package/src/components/integration-gallery.stories.tsx +302 -0
- package/src/components/integration-gallery.test.tsx +692 -0
- package/src/components/integration-gallery.tsx +49 -0
- package/src/components/integration-usage-events.test.ts +80 -0
- package/src/components/integration-usage-events.ts +116 -0
- package/src/components/integration-usage-modal.tsx +178 -0
- package/src/components/provider-grid.tsx +163 -0
- package/src/components/redirect-install-page.test.tsx +98 -0
- package/src/components/redirect-install-page.tsx +68 -0
- package/src/components/repository-picker.tsx +102 -0
- package/src/components/webhook/copyable-value.test.tsx +41 -0
- package/src/components/webhook/copyable-value.tsx +72 -0
- package/src/components/webhook/webhook-create-modal.tsx +247 -0
- package/src/components/webhook/webhook-form-errors.test.ts +45 -0
- package/src/components/webhook/webhook-form-errors.ts +32 -0
- package/src/components/webhook/webhook-modals.stories.tsx +225 -0
- package/src/components/webhook/webhook-public-endpoint-alert.tsx +15 -0
- package/src/components/webhook/webhook-usage-details.tsx +44 -0
- package/src/connection-status-badge.tsx +38 -0
- package/src/feature.ts +47 -0
- package/src/hooks/api/integrations.test.ts +105 -0
- package/src/hooks/api/integrations.ts +287 -0
- package/src/hooks/api/webhook-connections.ts +115 -0
- package/src/index.ts +16 -0
- package/src/integration-icon.react.test.tsx +40 -0
- package/src/integration-icon.test.ts +19 -0
- package/src/integration-icon.tsx +26 -0
- package/src/linear-callback.test.ts +227 -0
- package/src/linear-callback.ts +136 -0
- package/src/pages/gitea-form-errors.test.ts +55 -0
- package/src/pages/gitea-form-errors.ts +36 -0
- package/src/pages/gitea-install-page.tsx +108 -0
- package/src/pages/github-install-page.tsx +11 -0
- package/src/pages/linear-callback-page.test.tsx +157 -0
- package/src/pages/linear-callback-page.tsx +175 -0
- package/src/pages/linear-install-page.tsx +26 -0
- package/src/pages/sentry-callback-page.test.tsx +205 -0
- package/src/pages/sentry-callback-page.tsx +238 -0
- package/src/pages/sentry-install-page.tsx +17 -0
- package/src/pages/source-control-onboarding-page.test.tsx +46 -0
- package/src/pages/source-control-onboarding-page.tsx +26 -0
- package/src/provider-catalog.test.ts +42 -0
- package/src/provider-catalog.ts +49 -0
- package/src/routes/gitea.tsx +4 -0
- package/src/routes/github-callback.tsx +114 -0
- package/src/routes/github.tsx +4 -0
- package/src/routes/integrations.tsx +4 -0
- package/src/routes/linear-callback.tsx +4 -0
- package/src/routes/linear.tsx +4 -0
- package/src/routes/sentry-callback.tsx +4 -0
- package/src/routes/sentry.tsx +4 -0
- package/src/sentry-callback.test.ts +239 -0
- package/src/sentry-callback.ts +142 -0
- package/test/render.tsx +111 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +75 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
import {
|
|
3
|
+
classifySentryConnectError,
|
|
4
|
+
clearSentryInstallWorkspace,
|
|
5
|
+
parseSentryCallbackParams,
|
|
6
|
+
preselectSentryWorkspace,
|
|
7
|
+
readSentryInstallWorkspace,
|
|
8
|
+
SENTRY_INSTALL_WORKSPACE_KEY,
|
|
9
|
+
saveSentryInstallWorkspace,
|
|
10
|
+
} from './sentry-callback.js';
|
|
11
|
+
|
|
12
|
+
function fakeStorage(initial: Record<string, string> = {}) {
|
|
13
|
+
const map = new Map(Object.entries(initial));
|
|
14
|
+
return {
|
|
15
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
16
|
+
setItem: (key: string, value: string) => {
|
|
17
|
+
map.set(key, value);
|
|
18
|
+
},
|
|
19
|
+
removeItem: (key: string) => {
|
|
20
|
+
map.delete(key);
|
|
21
|
+
},
|
|
22
|
+
map,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function throwingStorage() {
|
|
27
|
+
return {
|
|
28
|
+
getItem: () => {
|
|
29
|
+
throw new Error('storage disabled');
|
|
30
|
+
},
|
|
31
|
+
setItem: () => {
|
|
32
|
+
throw new Error('storage disabled');
|
|
33
|
+
},
|
|
34
|
+
removeItem: () => {
|
|
35
|
+
throw new Error('storage disabled');
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function apiError(params: {code: string; status: number; message?: string; details?: unknown}) {
|
|
41
|
+
return new ApiError({
|
|
42
|
+
message: params.message ?? 'failed',
|
|
43
|
+
code: params.code,
|
|
44
|
+
status: params.status,
|
|
45
|
+
details: params.details,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('parseSentryCallbackParams', () => {
|
|
50
|
+
test('parses camelCase params with display-only orgSlug', () => {
|
|
51
|
+
const params = parseSentryCallbackParams({
|
|
52
|
+
code: 'the-code',
|
|
53
|
+
installationId: 'install-1',
|
|
54
|
+
orgSlug: 'acme',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(params).toEqual({code: 'the-code', installationId: 'install-1', orgSlug: 'acme'});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('accepts snake_case params defensively', () => {
|
|
61
|
+
const params = parseSentryCallbackParams({
|
|
62
|
+
code: 'the-code',
|
|
63
|
+
installation_id: 'install-1',
|
|
64
|
+
org_slug: 'acme',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(params).toEqual({code: 'the-code', installationId: 'install-1', orgSlug: 'acme'});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('returns undefined when code is missing', () => {
|
|
71
|
+
const params = parseSentryCallbackParams({installationId: 'install-1'});
|
|
72
|
+
|
|
73
|
+
expect(params).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns undefined when installationId is missing or empty', () => {
|
|
77
|
+
const missing = parseSentryCallbackParams({code: 'the-code'});
|
|
78
|
+
const empty = parseSentryCallbackParams({code: 'the-code', installationId: ''});
|
|
79
|
+
|
|
80
|
+
expect(missing).toBeUndefined();
|
|
81
|
+
expect(empty).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('preselectSentryWorkspace', () => {
|
|
86
|
+
const workspaces = [
|
|
87
|
+
{id: 'ws-1', name: 'One'},
|
|
88
|
+
{id: 'ws-2', name: 'Two'},
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
test('pre-selects the stored workspace when it is still valid', () => {
|
|
92
|
+
const result = preselectSentryWorkspace('ws-2', workspaces);
|
|
93
|
+
|
|
94
|
+
expect(result).toEqual({kind: 'pick', preselectedId: 'ws-2'});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('ignores a stale stored workspace id', () => {
|
|
98
|
+
const result = preselectSentryWorkspace('gone', workspaces);
|
|
99
|
+
|
|
100
|
+
expect(result).toEqual({kind: 'pick', preselectedId: undefined});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('pre-selects a sole workspace without stored evidence', () => {
|
|
104
|
+
const result = preselectSentryWorkspace(undefined, [{id: 'ws-1', name: 'One'}]);
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual({kind: 'pick', preselectedId: 'ws-1'});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('pre-selects nothing for several workspaces without stored evidence', () => {
|
|
110
|
+
const result = preselectSentryWorkspace(undefined, workspaces);
|
|
111
|
+
|
|
112
|
+
expect(result).toEqual({kind: 'pick', preselectedId: undefined});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('reports none when the user has no workspace', () => {
|
|
116
|
+
const result = preselectSentryWorkspace('ws-1', []);
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual({kind: 'none'});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('classifySentryConnectError', () => {
|
|
123
|
+
test('409 already-linked is terminal without start-over', () => {
|
|
124
|
+
const result = classifySentryConnectError(
|
|
125
|
+
apiError({code: 'sentry-installation-already-linked', status: 409}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({
|
|
129
|
+
kind: 'terminal',
|
|
130
|
+
message: 'This Sentry org is already installed in another workspace.',
|
|
131
|
+
startOver: false,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('429 rate-limited is retryable and carries retry_after_seconds', () => {
|
|
136
|
+
const result = classifySentryConnectError(
|
|
137
|
+
// Real wire shape: client-api stores the whole {code, details} body as ApiError.details.
|
|
138
|
+
apiError({
|
|
139
|
+
code: 'rate-limited',
|
|
140
|
+
status: 429,
|
|
141
|
+
details: {code: 'rate-limited', details: {retry_after_seconds: 30}},
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
kind: 'retryable',
|
|
147
|
+
message: 'Sentry is rate limiting requests. Try again in a moment.',
|
|
148
|
+
retryAfterSeconds: 30,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('503 verification-in-progress is retryable and carries retry_after_seconds', () => {
|
|
153
|
+
const result = classifySentryConnectError(
|
|
154
|
+
apiError({
|
|
155
|
+
code: 'sentry-verification-in-progress',
|
|
156
|
+
status: 503,
|
|
157
|
+
details: {code: 'sentry-verification-in-progress', details: {retry_after_seconds: 2}},
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
expect(result).toEqual({
|
|
162
|
+
kind: 'retryable',
|
|
163
|
+
message: 'Finishing Sentry verification. This only takes a moment.',
|
|
164
|
+
retryAfterSeconds: 2,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test.each(['timeout', 'provider-unavailable'])('503 %s is retryable', (code) => {
|
|
169
|
+
const result = classifySentryConnectError(apiError({code, status: 503}));
|
|
170
|
+
|
|
171
|
+
expect(result).toEqual({kind: 'retryable', message: 'Sentry is unreachable. Try again.'});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test.each([
|
|
175
|
+
'access-denied',
|
|
176
|
+
'malformed-provider-response',
|
|
177
|
+
])('422 %s is terminal with start-over', (code) => {
|
|
178
|
+
const result = classifySentryConnectError(
|
|
179
|
+
apiError({code, status: 422, message: 'Sentry rejected the code.'}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(result).toEqual({
|
|
183
|
+
kind: 'terminal',
|
|
184
|
+
message: 'Sentry rejected the code.',
|
|
185
|
+
startOver: true,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('network failure (status 0) is retryable, not terminal', () => {
|
|
190
|
+
const result = classifySentryConnectError(apiError({code: 'network-error', status: 0}));
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
kind: 'retryable',
|
|
194
|
+
message: 'Could not reach Shipfox. Check your connection and try again.',
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('5xx ApiError falls back to retryable', () => {
|
|
199
|
+
const result = classifySentryConnectError(apiError({code: 'server-error', status: 500}));
|
|
200
|
+
|
|
201
|
+
expect(result).toEqual({kind: 'retryable', message: 'Could not install Sentry. Try again.'});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('unknown errors fall back to retryable', () => {
|
|
205
|
+
const result = classifySentryConnectError(new Error('network down'));
|
|
206
|
+
|
|
207
|
+
expect(result).toEqual({kind: 'retryable', message: 'Could not install Sentry. Try again.'});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('sentry install workspace storage helpers', () => {
|
|
212
|
+
test('round-trips the workspace id', () => {
|
|
213
|
+
const storage = fakeStorage();
|
|
214
|
+
|
|
215
|
+
saveSentryInstallWorkspace(storage, 'ws-1');
|
|
216
|
+
const read = readSentryInstallWorkspace(storage);
|
|
217
|
+
|
|
218
|
+
expect(read).toBe('ws-1');
|
|
219
|
+
expect(storage.map.get(SENTRY_INSTALL_WORKSPACE_KEY)).toBe('ws-1');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('clear removes the key', () => {
|
|
223
|
+
const storage = fakeStorage({[SENTRY_INSTALL_WORKSPACE_KEY]: 'ws-1'});
|
|
224
|
+
|
|
225
|
+
clearSentryInstallWorkspace(storage);
|
|
226
|
+
|
|
227
|
+
expect(readSentryInstallWorkspace(storage)).toBeUndefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('helpers swallow storage failures instead of throwing', () => {
|
|
231
|
+
const storage = throwingStorage();
|
|
232
|
+
|
|
233
|
+
saveSentryInstallWorkspace(storage, 'ws-1');
|
|
234
|
+
const read = readSentryInstallWorkspace(storage);
|
|
235
|
+
clearSentryInstallWorkspace(storage);
|
|
236
|
+
|
|
237
|
+
expect(read).toBeUndefined();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {ApiError} from '@shipfox/client-api';
|
|
2
|
+
|
|
3
|
+
export const SENTRY_INSTALL_WORKSPACE_KEY = 'shipfox.sentry-install.workspace-id';
|
|
4
|
+
|
|
5
|
+
type WorkspaceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
|
6
|
+
|
|
7
|
+
// Storage helpers swallow failures (quota, private mode, disabled storage):
|
|
8
|
+
// the handoff is an optimization, never a requirement — the callback always
|
|
9
|
+
// asks for explicit confirmation and only uses the stored id to pre-select.
|
|
10
|
+
export function saveSentryInstallWorkspace(storage: WorkspaceStorage, workspaceId: string): void {
|
|
11
|
+
try {
|
|
12
|
+
storage.setItem(SENTRY_INSTALL_WORKSPACE_KEY, workspaceId);
|
|
13
|
+
} catch {
|
|
14
|
+
// The callback falls back to its sole-workspace pre-selection.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readSentryInstallWorkspace(storage: WorkspaceStorage): string | undefined {
|
|
19
|
+
try {
|
|
20
|
+
return storage.getItem(SENTRY_INSTALL_WORKSPACE_KEY) ?? undefined;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clearSentryInstallWorkspace(storage: WorkspaceStorage): void {
|
|
27
|
+
try {
|
|
28
|
+
storage.removeItem(SENTRY_INSTALL_WORKSPACE_KEY);
|
|
29
|
+
} catch {
|
|
30
|
+
// A tab-scoped leftover key is harmless: it only pre-selects.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SentryCallbackParams {
|
|
35
|
+
code: string;
|
|
36
|
+
installationId: string;
|
|
37
|
+
/** Display-only: rendered in the confirm copy, never sent to the API. */
|
|
38
|
+
orgSlug?: string | undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseSentryCallbackParams(
|
|
42
|
+
search: Record<string, unknown>,
|
|
43
|
+
): SentryCallbackParams | undefined {
|
|
44
|
+
const code = stringParam(search.code);
|
|
45
|
+
// Sentry documents camelCase params; accept snake_case defensively.
|
|
46
|
+
const installationId = stringParam(search.installationId) ?? stringParam(search.installation_id);
|
|
47
|
+
if (!code || !installationId) return undefined;
|
|
48
|
+
|
|
49
|
+
const orgSlug = stringParam(search.orgSlug) ?? stringParam(search.org_slug);
|
|
50
|
+
return {code, installationId, orgSlug};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SentryWorkspaceOption {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SentryWorkspacePreselection =
|
|
59
|
+
| {kind: 'pick'; preselectedId: string | undefined}
|
|
60
|
+
| {kind: 'none'};
|
|
61
|
+
|
|
62
|
+
// Sentry's redirect carries no state token, so the callback can never prove it
|
|
63
|
+
// belongs to an install the user started — installation always requires an
|
|
64
|
+
// explicit click. The stored id (and a sole workspace) only pre-select.
|
|
65
|
+
export function preselectSentryWorkspace(
|
|
66
|
+
storedId: string | undefined,
|
|
67
|
+
workspaces: SentryWorkspaceOption[],
|
|
68
|
+
): SentryWorkspacePreselection {
|
|
69
|
+
if (workspaces.length === 0) return {kind: 'none'};
|
|
70
|
+
if (storedId && workspaces.some((workspace) => workspace.id === storedId)) {
|
|
71
|
+
return {kind: 'pick', preselectedId: storedId};
|
|
72
|
+
}
|
|
73
|
+
if (workspaces.length === 1) return {kind: 'pick', preselectedId: workspaces[0]?.id};
|
|
74
|
+
return {kind: 'pick', preselectedId: undefined};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type SentryConnectFailure =
|
|
78
|
+
| {kind: 'retryable'; message: string; retryAfterSeconds?: number | undefined}
|
|
79
|
+
| {kind: 'terminal'; message: string; startOver: boolean};
|
|
80
|
+
|
|
81
|
+
export function classifySentryConnectError(error: unknown): SentryConnectFailure {
|
|
82
|
+
if (error instanceof ApiError) {
|
|
83
|
+
if (error.code === 'sentry-installation-already-linked') {
|
|
84
|
+
return {
|
|
85
|
+
kind: 'terminal',
|
|
86
|
+
message: 'This Sentry org is already installed in another workspace.',
|
|
87
|
+
// Retrying or starting over would fail identically.
|
|
88
|
+
startOver: false,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (error.code === 'sentry-verification-in-progress') {
|
|
92
|
+
// A concurrent signed webhook is still verifying the install. The grant code
|
|
93
|
+
// is untouched, so the existing backoff re-calls and finds the verified row.
|
|
94
|
+
return {
|
|
95
|
+
kind: 'retryable',
|
|
96
|
+
message: 'Finishing Sentry verification. This only takes a moment.',
|
|
97
|
+
retryAfterSeconds: retryAfterSeconds(error.details),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (error.code === 'rate-limited') {
|
|
101
|
+
return {
|
|
102
|
+
kind: 'retryable',
|
|
103
|
+
message: 'Sentry is rate limiting requests. Try again in a moment.',
|
|
104
|
+
retryAfterSeconds: retryAfterSeconds(error.details),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (error.code === 'timeout' || error.code === 'provider-unavailable') {
|
|
108
|
+
return {kind: 'retryable', message: 'Sentry is unreachable. Try again.'};
|
|
109
|
+
}
|
|
110
|
+
if (error.status === 0 || error.code === 'network-error') {
|
|
111
|
+
// A network failure (apiRequest throws status 0) never reached the
|
|
112
|
+
// server, so the grant code is untouched and a plain Retry can recover.
|
|
113
|
+
// Must precede the status < 500 branch, which would otherwise treat it as
|
|
114
|
+
// a spent code and force a full restart.
|
|
115
|
+
return {
|
|
116
|
+
kind: 'retryable',
|
|
117
|
+
message: 'Could not reach Shipfox. Check your connection and try again.',
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (error.status < 500) {
|
|
121
|
+
// 4xx (access-denied, malformed-provider-response, validation): the grant
|
|
122
|
+
// code is spent or rejected — only a fresh install can recover.
|
|
123
|
+
return {kind: 'terminal', message: error.message, startOver: true};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {kind: 'retryable', message: 'Could not install Sentry. Try again.'};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function retryAfterSeconds(details: unknown): number | undefined {
|
|
130
|
+
// client-api's toApiError stores the whole response body as ApiError.details,
|
|
131
|
+
// so the structured payload lives one level deeper at details.details (mirrors
|
|
132
|
+
// project-error.ts apiDetails()).
|
|
133
|
+
if (typeof details !== 'object' || details === null) return undefined;
|
|
134
|
+
const inner = (details as Record<string, unknown>).details;
|
|
135
|
+
if (typeof inner !== 'object' || inner === null) return undefined;
|
|
136
|
+
const value = (inner as Record<string, unknown>).retry_after_seconds;
|
|
137
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stringParam(value: unknown): string | undefined {
|
|
141
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
142
|
+
}
|
package/test/render.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {type AuthState, authStateAtom} from '@shipfox/client-auth';
|
|
3
|
+
import {Toaster} from '@shipfox/react-ui/toast';
|
|
4
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
5
|
+
import {
|
|
6
|
+
createMemoryHistory,
|
|
7
|
+
createRootRoute,
|
|
8
|
+
createRoute,
|
|
9
|
+
createRouter,
|
|
10
|
+
Outlet,
|
|
11
|
+
RouterProvider,
|
|
12
|
+
} from '@tanstack/react-router';
|
|
13
|
+
import {type RenderResult, render} from '@testing-library/react';
|
|
14
|
+
import {Provider as JotaiProvider, useSetAtom} from 'jotai';
|
|
15
|
+
import {type ReactElement, useEffect} from 'react';
|
|
16
|
+
|
|
17
|
+
export const INTEGRATIONS_TEST_WID = '11111111-1111-4111-8111-111111111111';
|
|
18
|
+
|
|
19
|
+
export interface TestWorkspace {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
membershipId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function testWorkspace(overrides: Partial<TestWorkspace> = {}): TestWorkspace {
|
|
26
|
+
return {id: INTEGRATIONS_TEST_WID, name: 'Acme', membershipId: 'm-1', ...overrides};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
30
|
+
return new Response(JSON.stringify(body), {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: {'content-type': 'application/json'},
|
|
33
|
+
...init,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function authState(workspaces: TestWorkspace[]): AuthState {
|
|
38
|
+
return {
|
|
39
|
+
status: 'authenticated',
|
|
40
|
+
token: 'token',
|
|
41
|
+
user: {
|
|
42
|
+
id: '22222222-2222-4222-8222-222222222222',
|
|
43
|
+
email: 'user@example.com',
|
|
44
|
+
name: null,
|
|
45
|
+
email_verified_at: new Date().toISOString(),
|
|
46
|
+
status: 'active',
|
|
47
|
+
created_at: new Date().toISOString(),
|
|
48
|
+
updated_at: new Date().toISOString(),
|
|
49
|
+
},
|
|
50
|
+
workspaces,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RenderIntegrationsPageOptions {
|
|
55
|
+
/** Initial history entry, may carry a query string. */
|
|
56
|
+
path: string;
|
|
57
|
+
/** Route template the element mounts at (e.g. /integrations/sentry/callback). */
|
|
58
|
+
routePath: string;
|
|
59
|
+
element: ReactElement;
|
|
60
|
+
workspaces?: TestWorkspace[];
|
|
61
|
+
/** Additional route templates registered with a stub component, for navigation targets. */
|
|
62
|
+
extraRoutes?: string[];
|
|
63
|
+
/** Seed auth as still-loading (the cold-load window) instead of authenticated. */
|
|
64
|
+
loadingAuth?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function AuthSeed({workspaces, loading}: {workspaces: TestWorkspace[]; loading?: boolean}) {
|
|
68
|
+
const setAuth = useSetAtom(authStateAtom);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setAuth(loading ? {status: 'loading'} : authState(workspaces));
|
|
72
|
+
}, [setAuth, workspaces, loading]);
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderIntegrationsPage(options: RenderIntegrationsPageOptions): RenderResult {
|
|
78
|
+
const workspaces = options.workspaces ?? [testWorkspace()];
|
|
79
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
80
|
+
|
|
81
|
+
const rootRoute = createRootRoute({component: Outlet});
|
|
82
|
+
const mainRoute = createRoute({
|
|
83
|
+
getParentRoute: () => rootRoute,
|
|
84
|
+
path: options.routePath,
|
|
85
|
+
component: () => options.element,
|
|
86
|
+
});
|
|
87
|
+
const extraRoutes = (options.extraRoutes ?? []).map((path) =>
|
|
88
|
+
createRoute({
|
|
89
|
+
getParentRoute: () => rootRoute,
|
|
90
|
+
path,
|
|
91
|
+
component: () => <div data-testid={`route:${path}`} />,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const router = createRouter({
|
|
96
|
+
history: createMemoryHistory({initialEntries: [options.path]}),
|
|
97
|
+
routeTree: rootRoute.addChildren([mainRoute, ...extraRoutes]),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
configureApiClient({baseUrl: 'https://api.example.test'});
|
|
101
|
+
|
|
102
|
+
return render(
|
|
103
|
+
<QueryClientProvider client={queryClient}>
|
|
104
|
+
<JotaiProvider>
|
|
105
|
+
<AuthSeed workspaces={workspaces} loading={options.loadingAuth ?? false} />
|
|
106
|
+
<RouterProvider router={router} />
|
|
107
|
+
<Toaster />
|
|
108
|
+
</JotaiProvider>
|
|
109
|
+
</QueryClientProvider>,
|
|
110
|
+
);
|
|
111
|
+
}
|
package/test/setup.ts
ADDED