Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,29 +236,36 @@ const IntegrationsResponseSchema = z.object({

/**
* Check whether the project already has a Slack integration connected.
* Requires the `integration:read` scope. Throws on failure — callers
* (including the SlackConnectScreen poll) decide how to degrade and
* are responsible for capturing the error exactly once.
* Requires the `integration:read` scope. Throws an {@link ApiError} on
* failure (wrapped via {@link handleApiError} for a clean message and a
* `statusCode`) — callers (including the SlackConnectScreen poll) decide
* how to degrade and are responsible for reporting the error exactly once.
* Expected auth statuses (401/403) on the background poll are benign; the
* `statusCode` lets callers treat them as such rather than a real exception.
*/
export async function fetchSlackConnected(
accessToken: string,
projectId: number,
baseUrl: string,
signal?: AbortSignal,
): Promise<boolean> {
const response = await axios.get(
`${baseUrl}/api/projects/${projectId}/integrations/`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': WIZARD_USER_AGENT,
try {
const response = await axios.get(
`${baseUrl}/api/projects/${projectId}/integrations/`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'User-Agent': WIZARD_USER_AGENT,
},
signal,
},
signal,
},
);
const parsed = IntegrationsResponseSchema.safeParse(response.data);
if (!parsed.success) return false;
return parsed.data.results.some((i) => i.kind === 'slack');
);
const parsed = IntegrationsResponseSchema.safeParse(response.data);
if (!parsed.success) return false;
return parsed.data.results.some((i) => i.kind === 'slack');
} catch (error) {
throw handleApiError(error, 'check Slack connection');
}
}

export function handleApiError(error: unknown, operation: string): ApiError {
Expand Down
13 changes: 8 additions & 5 deletions src/lib/oauth/program-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,14 @@ export const WAREHOUSE_SOURCE_SCOPE_ADDITIONS = [
*
* The step polls `/api/projects/:id/integrations/` (`fetchSlackConnected`)
* to render the already-connected variant and to flip live once the user
* completes the Slack OAuth step in the browser. Without `integration:read`
* the first poll 403s, the screen stops polling, and an already-connected
* project is nagged with the connect nudge. Used by the default integration
* run (the step ends the run) and by the standalone `wizard slack` flow
* (the step is the whole program).
* completes the Slack OAuth step in the browser. If the poll can't read
* integrations — a missing `integration:read` scope (403) or an
* invalid/expired token (401) — the screen stops polling and an
* already-connected project is nagged with the connect nudge. Both auth
* statuses are treated as an expected degradation (a benign analytics
* event, not a captured exception). Used by the default integration run
* (the step ends the run) and by the standalone `wizard slack` flow (the
* step is the whole program).
*/
export const CONNECT_SLACK_SCOPE_ADDITIONS = ['integration:read'] as const;

Expand Down
30 changes: 21 additions & 9 deletions src/ui/tui/screens/SlackConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { Colors, Icons } from '@ui/tui/styles';
import { PickerMenu, LoadingBox } from '@ui/tui/primitives/index';
import { useKeyBindings, KeyMatch } from '@ui/tui/hooks/useKeyBindings';
import { getSlackAppCard } from '@lib/mcp-role-prompts';
import { fetchSlackConnected } from '@lib/api';
import { ApiError, fetchSlackConnected } from '@lib/api';
import { Program } from '@lib/programs/program-registry';
import { getOrAskForProjectData } from '@utils/setup-utils';
import { analytics } from '@utils/analytics';
Expand Down Expand Up @@ -155,17 +155,29 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => {
})
.catch((err: unknown) => {
if (cancelled) return;
// Capture once and stop polling — repeating a failing call
// every tick would spam error tracking. The nudge copy is
// the fallback either way; a failed check counts as not
// connected so the screen doesn't sit on the loading state.
// Stop polling — repeating a failing call every tick would spam
// error tracking. The nudge copy is the fallback either way; a
// failed check counts as not connected so the screen doesn't sit
// on the loading state.
if (store.session.slackConnected === null) {
store.setSlackConnected(false);
}
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
);
// An expired/invalid token (401) or a missing `integration:read`
// scope (403) is an expected degradation here, not a bug — record
// it as a benign analytics event so it doesn't create error
// tracking noise. Anything else is a real exception worth capturing.
const status = err instanceof ApiError ? err.statusCode : undefined;
if (status === 401 || status === 403) {
analytics.wizardCapture('slack connect check unauthorized', {
role,
status,
});
} else {
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
);
}
});
};
check();
Expand Down
Loading