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
36 changes: 36 additions & 0 deletions src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { AxiosError, AxiosHeaders } from 'axios';

import { isMissingScopeError } from '@lib/api';

const axiosErrorWithStatus = (status: number): AxiosError => {
const error = new AxiosError('request failed');
error.response = {
status,
statusText: '',
data: {},
headers: {},
config: { headers: new AxiosHeaders() },
};
return error;
};

describe('isMissingScopeError', () => {
it('treats 401 as the benign missing-scope degradation', () => {
expect(isMissingScopeError(axiosErrorWithStatus(401))).toBe(true);
});

it('treats 403 as the benign missing-scope degradation', () => {
expect(isMissingScopeError(axiosErrorWithStatus(403))).toBe(true);
});

it('does not swallow other HTTP errors', () => {
expect(isMissingScopeError(axiosErrorWithStatus(500))).toBe(false);
expect(isMissingScopeError(axiosErrorWithStatus(404))).toBe(false);
});

it('does not treat non-axios errors as missing-scope', () => {
expect(isMissingScopeError(new Error('boom'))).toBe(false);
expect(isMissingScopeError('boom')).toBe(false);
expect(isMissingScopeError(undefined)).toBe(false);
});
});
20 changes: 18 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,9 @@ 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.
* (including the SlackConnectScreen poll) decide how to degrade. A
* missing-scope 401/403 is an expected outcome, not a crash: use
* `isMissingScopeError` to tell it apart from failures worth capturing.
*/
export async function fetchSlackConnected(
accessToken: string,
Expand All @@ -261,6 +262,21 @@ export async function fetchSlackConnected(
return parsed.data.results.some((i) => i.kind === 'slack');
}

/**
* True when an error from `fetchSlackConnected` is the expected, documented
* degradation: the access token lacks the `integration:read` scope, so the
* integrations endpoint answers 401/403. This is a benign "scope unavailable
* / treat as not connected" outcome (see `CONNECT_SLACK_SCOPE_ADDITIONS`),
* not a crash — callers fall back to the connect nudge and should NOT capture
* it as an exception. Genuinely unexpected failures (network, 5xx, parse)
* return false here and stay worth capturing.
*/
export function isMissingScopeError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
const status = error.response?.status;
return status === 401 || status === 403;
}

export function handleApiError(error: unknown, operation: string): ApiError {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<{ detail?: string }>;
Expand Down
24 changes: 15 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 { fetchSlackConnected, isMissingScopeError } 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,23 @@ 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 either way — repeating a failing call every tick
// would spam. The nudge copy is the fallback; 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' },
);
// A 401/403 is the documented degradation path: the token lacks
// `integration:read`, so the check can't run. That's expected and
// already handled by the nudge fallback — don't pollute error
// tracking with it. Capture only genuinely unexpected failures.
if (!isMissingScopeError(err)) {
analytics.captureException(
err instanceof Error ? err : new Error(String(err)),
{ step: 'slack_connected_check' },
);
}
});
};
check();
Expand Down
Loading