diff --git a/README.md b/README.md
index 4574b8701..3f4247004 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
## Features
- 🔔 Unified notifications from your Git platforms
-- ⚒️ Multi-forge: GitHub Cloud, GitHub Enterprise, Gitea, Forgejo, Codeberg
+- ⚒️ Multi-forge: GitHub Cloud, GitHub Enterprise, Gitea, Forgejo, Codeberg, Bitbucket Cloud
- 💻 Cross-platform: macOS, Windows, and Linux
- 🎨 Customizable settings, filters and themes
- 🖥️ Tray/menu bar integration
@@ -28,8 +28,8 @@ Gitify uses a forge adapter pattern so notifications can come from any compatibl
| **GitHub** Enterprise Server (≥ 3.13) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **GitHub** Enterprise Cloud with Data Residency | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Gitea** (incl. Forgejo, Codeberg) | ✅ | ✅ | ✅ | — | — | — |
+| **Bitbucket** Cloud | ✅ | ✅ | ✅ | — | — | — |
| **GitLab** (todos) | 💭 | — | — | — | — | — |
-| **Bitbucket** Cloud | 💭 | — | — | — | — | — |
| **Azure DevOps** | 💭 | — | — | — | — | — |
| **Gerrit** | 💭 | — | — | — | — | — |
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 596acb237..ffc100092 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -7,6 +7,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { AccountsRoute } from './routes/Accounts';
import { AccountScopesRoute } from './routes/AccountScopes';
+import { BitbucketLoginWithPersonalAccessTokenRoute } from './routes/bitbucket/LoginWithPersonalAccessToken';
import { FiltersRoute } from './routes/Filters';
import { GiteaLoginWithPersonalAccessTokenRoute } from './routes/gitea/LoginWithPersonalAccessToken';
import { GitHubLoginWithDeviceFlowRoute } from './routes/github/LoginWithDeviceFlow';
@@ -108,6 +109,10 @@ export const App = () => {
element={}
path="/login/gitea/personal-access-token"
/>
+ }
+ path="/login/bitbucket/personal-access-token"
+ />
diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts
index d7d05e162..b2d8d8b2d 100644
--- a/src/renderer/__mocks__/account-mocks.ts
+++ b/src/renderer/__mocks__/account-mocks.ts
@@ -63,6 +63,16 @@ export const mockGiteaAccount: Account = {
user: mockGitifyUser,
};
+export const mockBitbucketAccount: Account = {
+ forge: 'bitbucket',
+ platform: 'Bitbucket Cloud',
+ method: 'Personal Access Token',
+ token: 'token-bitbucket' as Token,
+ hostname: 'bitbucket.org' as Hostname,
+ username: 'user@example.com',
+ user: mockGitifyUser,
+};
+
export function mockAccountWithError(error: GitifyError): AccountNotifications {
return {
account: mockGitHubCloudAccount,
diff --git a/src/renderer/components/filters/__snapshots__/SubjectTypeFilter.test.tsx.snap b/src/renderer/components/filters/__snapshots__/SubjectTypeFilter.test.tsx.snap
index d71a998a1..e0f35089a 100644
--- a/src/renderer/components/filters/__snapshots__/SubjectTypeFilter.test.tsx.snap
+++ b/src/renderer/components/filters/__snapshots__/SubjectTypeFilter.test.tsx.snap
@@ -112,6 +112,35 @@ exports[`renderer/components/filters/SubjectTypeFilter.tsx > should render itsel
0
+
+
+
+
+ 0
+
+
so it is a drop-in for any place that expects an
+ * octicon-shaped component.
+ */
+export const BitbucketIcon: FC
= ({
+ size = 16,
+ fill = 'currentColor',
+ className,
+ ...props
+}) => (
+
+);
diff --git a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx
index 6b3b0a7ab..38fbd2d10 100644
--- a/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx
+++ b/src/renderer/components/login/LoginWithPersonalAccessTokenForm.tsx
@@ -25,11 +25,13 @@ interface LocationState {
export interface IFormData {
token: Token;
hostname: Hostname;
+ username?: string;
}
interface IFormErrors {
token?: string;
hostname?: string;
+ username?: string;
invalidCredentialsForHost?: string;
}
@@ -43,6 +45,10 @@ export const validateForm = (values: IFormData, forge: Forge = 'github'): IFormE
errors.hostname = 'Hostname format is invalid';
}
+ if (forge === 'bitbucket' && !values.username) {
+ errors.username = 'Atlassian email is required';
+ }
+
if (!values.token) {
errors.token = 'Token is required';
} else if (!adapter.validateToken(values.token)) {
@@ -56,9 +62,15 @@ export interface LoginWithPersonalAccessTokenFormProps {
forge: Forge;
/** Page header title. */
title: string;
- /** Caption below the hostname label. */
- hostnameCaption: string;
- hostnamePlaceholder: string;
+ /**
+ * When false the hostname field is hidden and the value from
+ * `adapter.defaultHostname` is used silently (e.g. Bitbucket, which is
+ * always bitbucket.org).
+ */
+ showHostnameField?: boolean;
+ /** Caption below the hostname label. Only used when showHostnameField is true. */
+ hostnameCaption?: string;
+ hostnamePlaceholder?: string;
/** Label for the button that opens the forge's token settings page. */
tokenSettingsLabel: string;
/** Text rendered beside the token settings button. */
@@ -66,6 +78,10 @@ export interface LoginWithPersonalAccessTokenFormProps {
tokenPlaceholder: string;
/** Tooltip for the documentation button. */
docsTooltip: string;
+ /** When true, renders an email/username field above the token input (for Bitbucket). */
+ showUsernameField?: boolean;
+ usernamePlaceholder?: string;
+ usernameCaption?: string;
/** Forge-specific content rendered below the token settings row (e.g. scope hints). */
children?: ReactNode;
}
@@ -79,12 +95,16 @@ export interface LoginWithPersonalAccessTokenFormProps {
export const LoginWithPersonalAccessTokenForm: FC = ({
forge,
title,
- hostnameCaption,
- hostnamePlaceholder,
+ hostnameCaption = '',
+ hostnamePlaceholder = '',
tokenSettingsLabel,
tokenSettingsCaption,
tokenPlaceholder,
docsTooltip,
+ showHostnameField = true,
+ showUsernameField = false,
+ usernamePlaceholder = '',
+ usernameCaption = '',
children,
}) => {
const navigate = useNavigate();
@@ -101,6 +121,7 @@ export const LoginWithPersonalAccessTokenForm: FC
)}
-
- Hostname
-
- {hostnameCaption}
-
-
- {errors.hostname && (
- {errors.hostname}
- )}
-
+ {showUsernameField && (
+
+ Atlassian Email
+ {usernameCaption && (
+
+ {usernameCaption}
+
+ )}
+
+ {errors.username && (
+ {errors.username}
+ )}
+
+ )}
+
+ {showHostnameField && (
+
+ Hostname
+
+ {hostnameCaption}
+
+
+ {errors.hostname && (
+ {errors.hostname}
+ )}
+
+ )}
diff --git a/src/renderer/components/notifications/AccountNotifications.tsx b/src/renderer/components/notifications/AccountNotifications.tsx
index b8b4a28d7..d07044585 100644
--- a/src/renderer/components/notifications/AccountNotifications.tsx
+++ b/src/renderer/components/notifications/AccountNotifications.tsx
@@ -10,6 +10,7 @@ import { HoverGroup } from '../primitives/HoverGroup';
import { type Account, type GitifyError, type GitifyNotification, Size } from '../../types';
+import { getAdapter } from '../../utils/forges/registry';
import {
groupNotificationsByRepository,
isGroupByRepository,
@@ -83,7 +84,7 @@ export const AccountNotifications: FC = (
>
diff --git a/src/renderer/hooks/useLogins.test.tsx b/src/renderer/hooks/useLogins.test.tsx
index 6addeeca5..ff20c1591 100644
--- a/src/renderer/hooks/useLogins.test.tsx
+++ b/src/renderer/hooks/useLogins.test.tsx
@@ -4,7 +4,7 @@ import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { setNotificationsOverrides } from '../__helpers__/hook-mocks';
-import { mockGitHubCloudAccount } from '../__mocks__/account-mocks';
+import { mockBitbucketAccount, mockGitHubCloudAccount } from '../__mocks__/account-mocks';
import { Constants } from '../constants';
@@ -159,4 +159,34 @@ describe('renderer/hooks/useLogins.ts', () => {
expect(removeAccountNotificationsMock).toHaveBeenCalledWith(mockGitHubCloudAccount);
expect(removeAccountSpy).toHaveBeenCalledWith(mockGitHubCloudAccount);
});
+
+ it('loginWithPersonalAccessToken forwards username for Bitbucket accounts', async () => {
+ vi.spyOn(getAdapter('bitbucket'), 'fetchAuthenticatedUser').mockResolvedValue({
+ user: {
+ id: mockBitbucketAccount.user!.id,
+ login: mockBitbucketAccount.user!.login,
+ name: mockBitbucketAccount.user!.name,
+ avatar: mockBitbucketAccount.user!.avatar!,
+ },
+ });
+
+ const { result } = renderLoginsHook();
+
+ await act(async () => {
+ await result.current.loginWithPersonalAccessToken({
+ token: mockBitbucketAccount.token,
+ hostname: mockBitbucketAccount.hostname,
+ forge: 'bitbucket',
+ username: 'user@example.com',
+ });
+ });
+
+ expect(createAccountSpy).toHaveBeenCalledWith(
+ 'Personal Access Token',
+ mockBitbucketAccount.token,
+ mockBitbucketAccount.hostname,
+ 'bitbucket',
+ 'user@example.com',
+ );
+ });
});
diff --git a/src/renderer/hooks/useLogins.ts b/src/renderer/hooks/useLogins.ts
index e4513bd8a..6590ed6cc 100644
--- a/src/renderer/hooks/useLogins.ts
+++ b/src/renderer/hooks/useLogins.ts
@@ -133,13 +133,14 @@ export const useLogins = (): LoginsState => {
* Login with Personal Access Token (PAT).
*/
const loginWithPersonalAccessToken = useCallback(
- async ({ token, hostname, forge }: LoginPersonalAccessTokenOptions) => {
+ async ({ token, hostname, forge, username }: LoginPersonalAccessTokenOptions) => {
const resolvedForge: Forge = forge ?? 'github';
const encryptedToken = (await encryptValue(token)) as Token;
await getAdapter(resolvedForge).fetchAuthenticatedUser({
forge: resolvedForge,
hostname,
token: encryptedToken,
+ username,
} as Account);
const existingAccount = accounts.find(
@@ -152,7 +153,7 @@ export const useLogins = (): LoginsState => {
await removeAccountNotifications(existingAccount);
}
- await createAccount('Personal Access Token', token, hostname, resolvedForge);
+ await createAccount('Personal Access Token', token, hostname, resolvedForge, username);
},
[accounts, createAccount, removeAccountNotifications],
);
diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx
index 7cef40135..95003991a 100644
--- a/src/renderer/routes/Accounts.test.tsx
+++ b/src/renderer/routes/Accounts.test.tsx
@@ -196,7 +196,7 @@ describe('renderer/routes/Accounts.tsx', () => {
});
describe('Add new accounts', () => {
- it('should show login with github app', async () => {
+ it('should show github login methods after selecting GitHub forge', async () => {
await act(async () => {
renderWithProviders(, {
accounts: [mockOAuthAccount],
@@ -205,7 +205,11 @@ describe('renderer/routes/Accounts.tsx', () => {
await userEvent.click(screen.getByTestId('account-add-new'));
- expect(screen.getByTestId('account-add-github')).toHaveTextContent('Login with GitHub');
+ expect(screen.getByTestId('account-add-forge-github')).toHaveTextContent('GitHub');
+
+ await userEvent.click(screen.getByTestId('account-add-forge-github'));
+
+ expect(screen.getByTestId('account-add-github')).toHaveTextContent('GitHub');
await userEvent.click(screen.getByTestId('account-add-github'));
@@ -215,7 +219,7 @@ describe('renderer/routes/Accounts.tsx', () => {
});
});
- it('should show login with personal access token', async () => {
+ it('should show login with personal access token after selecting GitHub forge', async () => {
await act(async () => {
renderWithProviders(, {
accounts: [mockOAuthAccount],
@@ -223,10 +227,9 @@ describe('renderer/routes/Accounts.tsx', () => {
});
await userEvent.click(screen.getByTestId('account-add-new'));
+ await userEvent.click(screen.getByTestId('account-add-forge-github'));
- expect(screen.getByTestId('account-add-pat')).toHaveTextContent(
- 'Login with GitHub (Personal Access Token)',
- );
+ expect(screen.getByTestId('account-add-pat')).toHaveTextContent('Personal Access Token');
await userEvent.click(screen.getByTestId('account-add-pat'));
@@ -236,7 +239,7 @@ describe('renderer/routes/Accounts.tsx', () => {
});
});
- it('should show login with oauth app', async () => {
+ it('should show login with oauth app after selecting GitHub forge', async () => {
await act(async () => {
renderWithProviders(, {
accounts: [mockPersonalAccessTokenAccount],
@@ -244,10 +247,9 @@ describe('renderer/routes/Accounts.tsx', () => {
});
await userEvent.click(screen.getByTestId('account-add-new'));
+ await userEvent.click(screen.getByTestId('account-add-forge-github'));
- expect(screen.getByTestId('account-add-oauth-app')).toHaveTextContent(
- 'Login with GitHub (OAuth App)',
- );
+ expect(screen.getByTestId('account-add-oauth-app')).toHaveTextContent('OAuth App');
await userEvent.click(screen.getByTestId('account-add-oauth-app'));
@@ -257,7 +259,7 @@ describe('renderer/routes/Accounts.tsx', () => {
});
});
- it('should show login with gitea personal access token', async () => {
+ it('should navigate directly to gitea login (single method forge)', async () => {
await act(async () => {
renderWithProviders(, {
accounts: [mockOAuthAccount],
@@ -266,17 +268,34 @@ describe('renderer/routes/Accounts.tsx', () => {
await userEvent.click(screen.getByTestId('account-add-new'));
- expect(screen.getByTestId('account-add-gitea-pat')).toHaveTextContent(
- 'Login with Gitea (Personal Access Token)',
- );
+ expect(screen.getByTestId('account-add-forge-gitea')).toHaveTextContent('Gitea');
- await userEvent.click(screen.getByTestId('account-add-gitea-pat'));
+ await userEvent.click(screen.getByTestId('account-add-forge-gitea'));
expect(navigateMock).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/login/gitea/personal-access-token', {
replace: true,
});
});
+
+ it('should navigate directly to bitbucket login (single method forge)', async () => {
+ await act(async () => {
+ renderWithProviders(, {
+ accounts: [mockOAuthAccount],
+ });
+ });
+
+ await userEvent.click(screen.getByTestId('account-add-new'));
+
+ expect(screen.getByTestId('account-add-forge-bitbucket')).toHaveTextContent('Bitbucket');
+
+ await userEvent.click(screen.getByTestId('account-add-forge-bitbucket'));
+
+ expect(navigateMock).toHaveBeenCalledTimes(1);
+ expect(navigateMock).toHaveBeenCalledWith('/login/bitbucket/personal-access-token', {
+ replace: true,
+ });
+ });
});
describe('Re-authenticate', () => {
diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx
index 67a48d748..cc9fba433 100644
--- a/src/renderer/routes/Accounts.tsx
+++ b/src/renderer/routes/Accounts.tsx
@@ -1,8 +1,10 @@
-import { type FC, useCallback, useState } from 'react';
+import { type FC, useCallback, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
AlertFillIcon,
+ ArrowLeftIcon,
+ ChevronRightIcon,
KeyIcon,
PersonAddIcon,
PersonIcon,
@@ -25,6 +27,7 @@ import { Footer } from '../components/primitives/Footer';
import { Header } from '../components/primitives/Header';
import { type Account, type GitifyError, IconColor, Size } from '../types';
+import type { ForgeAdapter } from '../utils/forges/types';
import { determineFailureType } from '../utils/api/errors';
import { hasAlternateScopes, hasRecommendedScopes } from '../utils/auth/scopes';
@@ -48,6 +51,10 @@ export const AccountsRoute: FC = () => {
const [refreshErrorStates, setRefreshErrorStates] = useState>({});
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
+ const [selectedForge, setSelectedForge] = useState(null);
+ const keepMenuOpenRef = useRef(false);
+
const logoutAccount = useCallback(
(account: Account) => {
logoutFromAccount(account);
@@ -130,7 +137,8 @@ export const AccountsRoute: FC = () => {
{accounts.map((account, i) => {
- const AuthMethodIcon = getAdapter(account).getAuthMethodIcon(account.method);
+ const adapter = getAdapter(account);
+ const AuthMethodIcon = adapter.getAuthMethodIcon(account.method);
const PlatformIcon = getPlatformIcon(account.platform);
const accountUUID = getAccountUUID(account);
const accountError = getAccountError(account);
@@ -147,7 +155,7 @@ export const AccountsRoute: FC = () => {
>
@@ -220,7 +228,7 @@ export const AccountsRoute: FC = () => {
variant={i === 0 ? 'primary' : 'default'}
/>
- {!hasBadCredentials && getAdapter(account).oauthScopes && (
+ {!hasBadCredentials && adapter.oauthScopes && (
{
diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx
index fccd4a6d8..ff0c56548 100644
--- a/src/renderer/routes/Login.tsx
+++ b/src/renderer/routes/Login.tsx
@@ -93,7 +93,7 @@ export const LoginRoute: FC = () => {
gap="none"
style={{ animationDelay: '60ms' }}
>
- Notifications
+ Git Notifications
on your menu bar
diff --git a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap
index 0bd910e4f..dcab13d85 100644
--- a/src/renderer/routes/__snapshots__/Filters.test.tsx.snap
+++ b/src/renderer/routes/__snapshots__/Filters.test.tsx.snap
@@ -793,6 +793,35 @@ exports[`renderer/routes/Filters.tsx > General > should render itself & its chil
0
+
+
+
+
+ 0
+
+
should render itself & its children 1`] = `
class="prc-Heading-Heading-MtWFE"
data-component="Heading"
>
- Notifications
+ Git Notifications
should render itself & its children 1`] = `
Gitea
+
should render itself & its children 1`] = `
data-component="Text"
data-size="small"
>
- github.com & GitHub Enterprise
+ GitHub Cloud & GitHub Enterprise Server
diff --git a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx
new file mode 100644
index 000000000..395672989
--- /dev/null
+++ b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.test.tsx
@@ -0,0 +1,137 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { navigateMock, renderWithProviders } from '../../__helpers__/test-utils';
+import { mockBitbucketAccount } from '../../__mocks__/account-mocks';
+
+import {
+ type IFormData,
+ validateForm,
+} from '../../components/login/LoginWithPersonalAccessTokenForm';
+
+import type { Hostname, Token } from '../../types';
+
+import * as comms from '../../utils/system/comms';
+import { BitbucketLoginWithPersonalAccessTokenRoute } from './LoginWithPersonalAccessToken';
+
+describe('renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx', () => {
+ const loginWithPersonalAccessTokenMock = vi.fn();
+ const openExternalLinkSpy = vi.spyOn(comms, 'openExternalLink').mockImplementation(vi.fn());
+
+ it('renders correctly', () => {
+ const tree = renderWithProviders();
+
+ expect(tree.container).toMatchSnapshot();
+ });
+
+ describe('form validation', () => {
+ it('requires username (Atlassian email) for bitbucket', () => {
+ const values: IFormData = {
+ hostname: 'bitbucket.org' as Hostname,
+ token: 'my-api-token' as Token,
+ username: '',
+ };
+
+ expect(validateForm(values, 'bitbucket').username).toBe('Atlassian email is required');
+ });
+
+ it('passes validation with username and token', () => {
+ const values: IFormData = {
+ hostname: 'bitbucket.org' as Hostname,
+ token: 'my-api-token' as Token,
+ username: 'user@example.com',
+ };
+
+ expect(validateForm(values, 'bitbucket')).toEqual({});
+ });
+ });
+
+ it('renders the username field', () => {
+ renderWithProviders();
+ expect(screen.getByTestId('login-username')).toBeInTheDocument();
+ });
+
+ it('should open the token settings page', async () => {
+ renderWithProviders(, {
+ loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock,
+ });
+
+ await userEvent.click(screen.getByTestId('login-create-token'));
+
+ expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
+ expect(openExternalLinkSpy).toHaveBeenCalledWith(
+ 'https://id.atlassian.com/manage-profile/security/api-tokens',
+ );
+ });
+
+ it('should login successfully with username and token', async () => {
+ loginWithPersonalAccessTokenMock.mockResolvedValueOnce(null);
+
+ renderWithProviders(, {
+ loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock,
+ });
+
+ await userEvent.type(screen.getByTestId('login-username'), 'user@example.com');
+ await userEvent.type(screen.getByTestId('login-token'), 'my-api-token');
+
+ await userEvent.click(screen.getByTestId('login-submit'));
+
+ await waitFor(() => {
+ expect(loginWithPersonalAccessTokenMock).toHaveBeenCalledTimes(1);
+ expect(loginWithPersonalAccessTokenMock).toHaveBeenCalledWith({
+ hostname: 'bitbucket.org',
+ token: 'my-api-token',
+ username: 'user@example.com',
+ forge: 'bitbucket',
+ });
+ expect(navigateMock).toHaveBeenCalledWith('/');
+ });
+ });
+
+ it('shows an error banner on login failure', async () => {
+ loginWithPersonalAccessTokenMock.mockRejectedValueOnce(new Error('invalid credentials'));
+
+ renderWithProviders(, {
+ loginWithPersonalAccessToken: loginWithPersonalAccessTokenMock,
+ });
+
+ await userEvent.type(screen.getByTestId('login-username'), 'user@example.com');
+ await userEvent.type(screen.getByTestId('login-token'), 'my-api-token');
+ await userEvent.click(screen.getByTestId('login-submit'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('login-errors')).toHaveTextContent(
+ 'Failed to validate provided token against bitbucket.org',
+ );
+ expect(navigateMock).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should open help docs in the browser', async () => {
+ renderWithProviders();
+
+ await userEvent.click(screen.getByTestId('login-docs'));
+
+ expect(openExternalLinkSpy).toHaveBeenCalledTimes(1);
+ expect(openExternalLinkSpy).toHaveBeenCalledWith(
+ 'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/',
+ );
+ });
+
+ it('prefills username and hostname from the re-auth account in location state', () => {
+ renderWithProviders(, {
+ initialEntries: [
+ {
+ pathname: '/login/bitbucket/personal-access-token',
+ state: {
+ account: mockBitbucketAccount,
+ },
+ },
+ ],
+ });
+
+ expect(screen.getByTestId('login-username')).toHaveValue('user@example.com');
+ // hostname is always bitbucket.org and not shown as an editable field
+ expect(screen.queryByTestId('login-hostname')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx
new file mode 100644
index 000000000..f3a1e3c0b
--- /dev/null
+++ b/src/renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx
@@ -0,0 +1,18 @@
+import type { FC } from 'react';
+
+import { LoginWithPersonalAccessTokenForm } from '../../components/login/LoginWithPersonalAccessTokenForm';
+
+export const BitbucketLoginWithPersonalAccessTokenRoute: FC = () => (
+
+);
diff --git a/src/renderer/routes/bitbucket/__snapshots__/LoginWithPersonalAccessToken.test.tsx.snap b/src/renderer/routes/bitbucket/__snapshots__/LoginWithPersonalAccessToken.test.tsx.snap
new file mode 100644
index 000000000..c0024525e
--- /dev/null
+++ b/src/renderer/routes/bitbucket/__snapshots__/LoginWithPersonalAccessToken.test.tsx.snap
@@ -0,0 +1,446 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`renderer/routes/bitbucket/LoginWithPersonalAccessToken.tsx > renders correctly 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Your Atlassian account email address
+
+
+
+
+
+
+
+ on your Atlassian account to create an API token, then paste it below.
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/renderer/stores/types.ts b/src/renderer/stores/types.ts
index 4db45bf7b..e5502b999 100644
--- a/src/renderer/stores/types.ts
+++ b/src/renderer/stores/types.ts
@@ -37,6 +37,7 @@ export interface AccountsActions {
token: Token,
hostname: Hostname,
forge?: Forge,
+ username?: string,
) => Promise;
/**
diff --git a/src/renderer/stores/useAccountsStore.ts b/src/renderer/stores/useAccountsStore.ts
index 8a2a63fde..a1920182e 100644
--- a/src/renderer/stores/useAccountsStore.ts
+++ b/src/renderer/stores/useAccountsStore.ts
@@ -32,6 +32,7 @@ export function sanitizeAccounts(accounts: Account[]): Account[] {
version: a.version,
hostname: a.hostname,
token: a.token,
+ username: a.username,
user: a.user,
scopes: a.scopes,
};
@@ -55,6 +56,7 @@ const useAccountsStore = create()(
token: Token,
hostname: Hostname,
forge: Forge = 'github',
+ username?: string,
) => {
const encryptedToken = (await encryptValue(token)) as Token;
@@ -64,6 +66,7 @@ const useAccountsStore = create()(
method: method,
platform: resolvePlatform(forge, hostname),
token: encryptedToken,
+ username,
user: null, // Will be updated during the refresh call below
};
diff --git a/src/renderer/types.ts b/src/renderer/types.ts
index 041ca30dc..f0b131509 100644
--- a/src/renderer/types.ts
+++ b/src/renderer/types.ts
@@ -62,7 +62,7 @@ export type AccountUUID = Branded;
export type KeyboardAcceleratorShortcut = Branded;
/** Code hosting provider for an account. New forges register themselves here. */
-export type Forge = 'github' | 'gitea';
+export type Forge = 'github' | 'gitea' | 'bitbucket';
export interface Account {
forge: Forge;
@@ -71,6 +71,8 @@ export interface Account {
version?: string;
hostname: Hostname;
token: Token;
+ /** Atlassian account email — required for Bitbucket Basic Auth (email:token). */
+ username?: string;
user: GitifyUser | null;
scopes?: string[];
}
@@ -522,6 +524,7 @@ export type Reason =
| 'team_mention';
export type SubjectType =
+ | 'BitbucketNotification'
| 'CheckSuite'
| 'Commit'
| 'Discussion'
diff --git a/src/renderer/utils/auth/platform.test.ts b/src/renderer/utils/auth/platform.test.ts
index 40ad9b435..786929c6d 100644
--- a/src/renderer/utils/auth/platform.test.ts
+++ b/src/renderer/utils/auth/platform.test.ts
@@ -4,6 +4,7 @@ import {
getPlatformFromHostname,
isCloudDataResidencyHost,
isEnterpriseServerHost,
+ resolvePlatform,
} from './platform';
describe('renderer/utils/auth/platform.ts', () => {
@@ -61,4 +62,18 @@ describe('renderer/utils/auth/platform.ts', () => {
expect(isEnterpriseServerHost('acme-corp.ghe.com' as Hostname)).toBe(false);
});
});
+
+ describe('resolvePlatform', () => {
+ it('returns Bitbucket Cloud for bitbucket forge', () => {
+ expect(resolvePlatform('bitbucket', 'bitbucket.org' as Hostname)).toBe('Bitbucket Cloud');
+ });
+
+ it('returns Gitea for gitea forge', () => {
+ expect(resolvePlatform('gitea', 'gitea.example.com' as Hostname)).toBe('Gitea');
+ });
+
+ it('returns GitHub Cloud for github.com', () => {
+ expect(resolvePlatform('github', 'github.com' as Hostname)).toBe('GitHub Cloud');
+ });
+ });
});
diff --git a/src/renderer/utils/auth/platform.ts b/src/renderer/utils/auth/platform.ts
index 415843d75..878b92410 100644
--- a/src/renderer/utils/auth/platform.ts
+++ b/src/renderer/utils/auth/platform.ts
@@ -10,6 +10,9 @@ import type { PlatformType } from './types';
* Server, Enterprise Cloud with Data Residency).
*/
export function resolvePlatform(forge: Forge, hostname: Hostname): PlatformType {
+ if (forge === 'bitbucket') {
+ return 'Bitbucket Cloud';
+ }
if (forge === 'gitea') {
return 'Gitea';
}
diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts
index 55fd43ed2..6d5cbfd36 100644
--- a/src/renderer/utils/auth/types.ts
+++ b/src/renderer/utils/auth/types.ts
@@ -3,6 +3,7 @@ import type { AuthCode, ClientID, ClientSecret, Forge, Hostname, Token } from '.
export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App';
export type PlatformType =
+ | 'Bitbucket Cloud'
| 'GitHub Cloud'
| 'GitHub Enterprise Server'
| 'GitHub Enterprise Cloud with Data Residency'
@@ -27,6 +28,8 @@ export interface DeviceFlowSession {
export interface LoginPersonalAccessTokenOptions {
hostname: Hostname;
token: Token;
+ /** Atlassian account email — required for Bitbucket Basic Auth. */
+ username?: string;
/** Defaults to GitHub when omitted. */
forge?: Forge;
}
diff --git a/src/renderer/utils/forges/bitbucket/adapter.test.ts b/src/renderer/utils/forges/bitbucket/adapter.test.ts
new file mode 100644
index 000000000..1d2cdf17d
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/adapter.test.ts
@@ -0,0 +1,302 @@
+import { KeyIcon } from '@primer/octicons-react';
+
+import { mockBitbucketAccount } from '../../../__mocks__/account-mocks';
+import { mockPartialGitifyNotification } from '../../../__mocks__/notifications-mocks';
+
+import { useSettingsStore } from '../../../stores';
+
+import { BitbucketIcon } from '../../../components/icons/BitbucketIcon';
+
+import type { Hostname, Link, Token } from '../../../types';
+import { IconColor } from '../../../types';
+import type { AtlassianNotificationFragment } from './types';
+
+import { bitbucketAdapter } from './adapter';
+import * as client from './client';
+import {
+ InfluentsNotificationCategory,
+ InfluentsNotificationReadState,
+} from './graphql/generated/graphql';
+
+vi.mock('../../system/comms', () => ({
+ decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted-token' }),
+}));
+
+const makeMockFragment = (
+ overrides: Partial = {},
+): AtlassianNotificationFragment => ({
+ groupId: 'group-1',
+ groupSize: 1,
+ additionalActors: [],
+ headNotification: {
+ notificationId: 'notif-abc',
+ timestamp: '2024-06-01T10:00:00Z',
+ readState: InfluentsNotificationReadState.Unread,
+ category: InfluentsNotificationCategory.Direct,
+ content: {
+ type: 'pr:reviewer:approved',
+ message: 'Alice approved your PR',
+ url: 'https://bitbucket.org/myorg/myrepo/pull-requests/1',
+ entity: {
+ title: 'My PR',
+ iconUrl: null,
+ url: 'https://bitbucket.org/myorg/myrepo/pull-requests/1',
+ },
+ path: null,
+ actor: { displayName: 'Alice', avatarURL: null },
+ },
+ analyticsAttributes: [{ key: 'registrationProduct', value: 'bitbucket' }],
+ },
+ ...overrides,
+});
+
+describe('renderer/utils/forges/bitbucket/adapter.ts', () => {
+ describe('static fields', () => {
+ it('identifies as bitbucket', () => {
+ expect(bitbucketAdapter.id).toBe('bitbucket');
+ expect(bitbucketAdapter.displayName).toBe('Bitbucket');
+ expect(bitbucketAdapter.tagline).toBe('Bitbucket Cloud');
+ });
+
+ it('reports unsupported capabilities', () => {
+ expect(bitbucketAdapter.capabilities.markAsDone(mockBitbucketAccount)).toBe(false);
+ expect(bitbucketAdapter.capabilities.unsubscribeThread(mockBitbucketAccount)).toBe(false);
+ });
+
+ it('exposes a single PAT login method', () => {
+ expect(bitbucketAdapter.loginMethods).toHaveLength(1);
+ expect(bitbucketAdapter.loginMethods[0]).toMatchObject({
+ testId: 'login-bitbucket-pat',
+ route: '/login/bitbucket/personal-access-token',
+ authMethod: 'Personal Access Token',
+ });
+ });
+
+ it('sets the default hostname to bitbucket.org', () => {
+ expect(bitbucketAdapter.defaultHostname).toBe('bitbucket.org');
+ });
+
+ it('returns the Atlassian token settings URL for PAT settings', () => {
+ expect(bitbucketAdapter.getPersonalAccessTokenSettingsUrl('bitbucket.org' as Hostname)).toBe(
+ 'https://id.atlassian.com/manage-profile/security/api-tokens',
+ );
+ });
+
+ it('returns the Atlassian token settings URL for account settings', () => {
+ expect(bitbucketAdapter.getAccountSettingsUrl(mockBitbucketAccount)).toBe(
+ 'https://id.atlassian.com/manage-profile/security/api-tokens',
+ );
+ });
+
+ it('returns the Atlassian docs URL', () => {
+ expect(bitbucketAdapter.documentationUrl).toContain('atlassian.com');
+ });
+
+ it('returns KeyIcon for every auth method', () => {
+ expect(bitbucketAdapter.getAuthMethodIcon('Personal Access Token')).toBe(KeyIcon);
+ expect(bitbucketAdapter.getAuthMethodIcon('OAuth App')).toBe(KeyIcon);
+ });
+ });
+
+ describe('validateToken', () => {
+ it('accepts any non-empty token', () => {
+ expect(bitbucketAdapter.validateToken('abc123' as Token)).toBe(true);
+ expect(bitbucketAdapter.validateToken('a'.repeat(40) as Token)).toBe(true);
+ });
+
+ it('rejects an empty token', () => {
+ expect(bitbucketAdapter.validateToken('' as Token)).toBe(false);
+ });
+ });
+
+ describe('fetchAuthenticatedUser', () => {
+ it('maps the Atlassian user payload onto the shared shape', async () => {
+ vi.spyOn(client, 'fetchBitbucketAuthenticatedUser').mockResolvedValue({
+ me: {
+ user: {
+ accountId: 'atlas-123',
+ name: 'Alice Atlassian',
+ picture: 'https://example.com/alice.png',
+ },
+ },
+ });
+
+ const result = await bitbucketAdapter.fetchAuthenticatedUser(mockBitbucketAccount);
+
+ expect(result).toEqual({
+ user: {
+ id: 'atlas-123',
+ login: mockBitbucketAccount.username,
+ name: 'Alice Atlassian',
+ avatar: 'https://example.com/alice.png',
+ },
+ });
+ });
+
+ it('falls back to accountId as login when account has no username', async () => {
+ vi.spyOn(client, 'fetchBitbucketAuthenticatedUser').mockResolvedValue({
+ me: {
+ user: {
+ accountId: 'atlas-456',
+ name: 'Bob',
+ picture: '',
+ },
+ },
+ });
+
+ const accountWithoutUsername = { ...mockBitbucketAccount, username: undefined };
+ const result = await bitbucketAdapter.fetchAuthenticatedUser(accountWithoutUsername);
+
+ expect(result.user.login).toBe('atlas-456');
+ });
+
+ it('falls back to null name and empty avatar when fields are missing', async () => {
+ vi.spyOn(client, 'fetchBitbucketAuthenticatedUser').mockResolvedValue({
+ me: {
+ user: {
+ accountId: 'atlas-789',
+ name: null as unknown as string,
+ picture: null as unknown as string,
+ },
+ },
+ });
+
+ const result = await bitbucketAdapter.fetchAuthenticatedUser(mockBitbucketAccount);
+
+ expect(result.user.name).toBeNull();
+ expect(result.user.avatar).toBe('');
+ });
+
+ it('throws when the user field is null', async () => {
+ vi.spyOn(client, 'fetchBitbucketAuthenticatedUser').mockResolvedValue({
+ me: { user: null },
+ });
+
+ await expect(bitbucketAdapter.fetchAuthenticatedUser(mockBitbucketAccount)).rejects.toThrow(
+ 'Failed to retrieve Bitbucket authenticated user.',
+ );
+ });
+ });
+
+ describe('listNotifications', () => {
+ it('fetches only unread notifications when fetchReadNotifications is false', async () => {
+ const listSpy = vi
+ .spyOn(client, 'listRawBitbucketNotifications')
+ .mockResolvedValue([makeMockFragment()]);
+
+ useSettingsStore.setState({ fetchReadNotifications: false });
+
+ const result = await bitbucketAdapter.listNotifications(mockBitbucketAccount);
+
+ expect(listSpy).toHaveBeenCalledWith(mockBitbucketAccount, true);
+ expect(result).toHaveLength(1);
+ expect(result[0].account).toBe(mockBitbucketAccount);
+ });
+
+ it('fetches all notifications when fetchReadNotifications is true', async () => {
+ const listSpy = vi.spyOn(client, 'listRawBitbucketNotifications').mockResolvedValue([]);
+
+ useSettingsStore.setState({ fetchReadNotifications: true });
+
+ await bitbucketAdapter.listNotifications(mockBitbucketAccount);
+
+ expect(listSpy).toHaveBeenCalledWith(mockBitbucketAccount, false);
+ });
+ });
+
+ describe('thread mutation methods', () => {
+ it('markThreadAsRead delegates to markBitbucketNotificationsAsRead', async () => {
+ const markSpy = vi
+ .spyOn(client, 'markBitbucketNotificationsAsRead')
+ .mockResolvedValue(undefined);
+
+ await bitbucketAdapter.markThreadAsRead(mockBitbucketAccount, 'notif-abc');
+
+ expect(markSpy).toHaveBeenCalledWith(mockBitbucketAccount, ['notif-abc']);
+ });
+
+ it('markThreadAsDone throws — check capabilities before calling', () => {
+ expect(() => bitbucketAdapter.markThreadAsDone(mockBitbucketAccount, 'notif-abc')).toThrow(
+ /check capabilities.markAsDone/,
+ );
+ });
+
+ it('unsubscribeThread throws — check capabilities before calling', () => {
+ expect(() => bitbucketAdapter.unsubscribeThread(mockBitbucketAccount, 'notif-abc')).toThrow(
+ /not supported for Bitbucket/,
+ );
+ });
+ });
+
+ describe('followUrl', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('fetches the URL with Basic auth and returns the parsed JSON', async () => {
+ const payload = { key: 'value' };
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => payload,
+ } as Response);
+
+ const result = await bitbucketAdapter.followUrl<{ key: string }>(
+ mockBitbucketAccount,
+ 'https://bitbucket.org/api/resource' as Link,
+ );
+
+ expect(result).toEqual(payload);
+ const call = vi.mocked(fetch).mock.calls[0];
+ expect(call[0]).toBe('https://bitbucket.org/api/resource');
+ const headers = (call[1] as RequestInit).headers as Record;
+ expect(headers.Authorization).toMatch(/^Basic /);
+ });
+
+ it('throws a descriptive error on non-OK HTTP response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ } as Response);
+
+ await expect(
+ bitbucketAdapter.followUrl(mockBitbucketAccount, 'https://bitbucket.org/missing' as Link),
+ ).rejects.toThrow('Bitbucket fetch error: 404');
+ });
+ });
+
+ describe('getDisplayHelpers', () => {
+ it('returns the key icon with gray color and the notification subject URL', () => {
+ const notification = mockPartialGitifyNotification(
+ {
+ title: 'Bitbucket PR comment',
+ type: 'BitbucketNotification',
+ url: 'https://bitbucket.org/myorg/myrepo/pull-requests/1' as Link,
+ },
+ {
+ htmlUrl: 'https://bitbucket.org/myorg/myrepo' as Link,
+ },
+ );
+ notification.account = mockBitbucketAccount;
+
+ const helpers = bitbucketAdapter.getDisplayHelpers(notification);
+
+ expect(helpers.iconType).toBe(BitbucketIcon);
+ expect(helpers.iconColor).toBe(IconColor.GRAY);
+ expect(helpers.defaultUrl).toBe('https://bitbucket.org/myorg/myrepo/pull-requests/1');
+ expect(helpers.defaultUserType).toBe('User');
+ });
+
+ it('falls back to an empty string when the subject has no URL', () => {
+ const notification = mockPartialGitifyNotification({
+ title: 'Bitbucket notification',
+ type: 'BitbucketNotification',
+ url: null,
+ });
+ notification.account = mockBitbucketAccount;
+
+ const helpers = bitbucketAdapter.getDisplayHelpers(notification);
+
+ expect(helpers.defaultUrl).toBe('');
+ });
+ });
+});
diff --git a/src/renderer/utils/forges/bitbucket/adapter.ts b/src/renderer/utils/forges/bitbucket/adapter.ts
new file mode 100644
index 000000000..ebaf0921f
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/adapter.ts
@@ -0,0 +1,137 @@
+import { KeyIcon } from '@primer/octicons-react';
+
+import useSettingsStore from '../../../stores/useSettingsStore';
+
+import { BitbucketIcon } from '../../../components/icons/BitbucketIcon';
+
+import type { Account, Hostname, Link, RawGitifyNotification } from '../../../types';
+import { IconColor } from '../../../types';
+import type {
+ ForgeAdapter,
+ ForgeCapabilities,
+ NotificationDisplayHelpers,
+ RefreshAccountData,
+} from '../types';
+
+import { decryptValue } from '../../system/comms';
+import {
+ fetchBitbucketAuthenticatedUser,
+ listRawBitbucketNotifications,
+ markBitbucketNotificationsAsRead,
+} from './client';
+import { transformBitbucketNotifications } from './transform';
+
+const BITBUCKET_DOCS_URL =
+ 'https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/' as Link;
+
+const ATLASSIAN_TOKEN_SETTINGS_URL =
+ 'https://id.atlassian.com/manage-profile/security/api-tokens' as Link;
+
+const capabilities: ForgeCapabilities = {
+ markAsDone: () => false,
+ unsubscribeThread: () => false,
+};
+
+async function fetchAuthenticatedUser(account: Account): Promise {
+ const data = await fetchBitbucketAuthenticatedUser(account);
+ const user = data.me?.user;
+
+ if (!user) {
+ throw new Error('Failed to retrieve Bitbucket authenticated user.');
+ }
+
+ return {
+ user: {
+ id: user.accountId,
+ login: account.username ?? user.accountId,
+ name: user.name ?? null,
+ avatar: user.picture ?? '',
+ },
+ };
+}
+
+async function listNotifications(account: Account): Promise {
+ const { fetchReadNotifications } = useSettingsStore.getState();
+ const fetchOnlyUnread = !fetchReadNotifications;
+ const raw = await listRawBitbucketNotifications(account, fetchOnlyUnread);
+ return transformBitbucketNotifications(raw, account);
+}
+
+function getDisplayHelpers(notification: RawGitifyNotification): NotificationDisplayHelpers {
+ return {
+ iconType: BitbucketIcon,
+ iconColor: IconColor.GRAY,
+ defaultUrl: notification.subject.url ?? ('' as Link),
+ defaultUserType: 'User',
+ };
+}
+
+async function followUrl(account: Account, url: Link): Promise {
+ const { token } = await decryptValue(account.token);
+ const credentials = btoa(`${account.username}:${token}`);
+
+ const response = await fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Basic ${credentials}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Bitbucket fetch error: ${response.status}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export const bitbucketAdapter: ForgeAdapter = {
+ id: 'bitbucket',
+ displayName: 'Bitbucket',
+ tagline: 'Bitbucket Cloud',
+ icon: BitbucketIcon,
+ capabilities,
+
+ formatUserLogin: (login) => login,
+
+ fetchAuthenticatedUser,
+ listNotifications,
+
+ markThreadAsRead: async (account, threadId) => {
+ await markBitbucketNotificationsAsRead(account, [threadId]);
+ },
+ markThreadAsDone: () => {
+ throw new Error(
+ 'Mark-as-done is not supported for Bitbucket; check capabilities.markAsDone before calling.',
+ );
+ },
+ unsubscribeThread: () => {
+ throw new Error(
+ 'Unsubscribing threads is not supported for Bitbucket; check capabilities.unsubscribeThread before calling.',
+ );
+ },
+
+ followUrl,
+ getDisplayHelpers,
+
+ defaultHostname: 'bitbucket.org' as Hostname,
+
+ validateToken: (token) => token.length > 0,
+
+ getPersonalAccessTokenSettingsUrl: (_hostname: Hostname) => ATLASSIAN_TOKEN_SETTINGS_URL,
+
+ getAccountSettingsUrl: (_account: Account) => ATLASSIAN_TOKEN_SETTINGS_URL,
+
+ documentationUrl: BITBUCKET_DOCS_URL,
+
+ getAuthMethodIcon: () => KeyIcon,
+
+ loginMethods: [
+ {
+ testId: 'login-bitbucket-pat',
+ icon: KeyIcon,
+ label: 'Atlassian API Token',
+ route: '/login/bitbucket/personal-access-token',
+ authMethod: 'Personal Access Token',
+ },
+ ],
+};
diff --git a/src/renderer/utils/forges/bitbucket/client.test.ts b/src/renderer/utils/forges/bitbucket/client.test.ts
new file mode 100644
index 000000000..0e2dd3c9b
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/client.test.ts
@@ -0,0 +1,251 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { mockBitbucketAccount } from '../../../__mocks__/account-mocks';
+
+import {
+ fetchBitbucketAuthenticatedUser,
+ listRawBitbucketNotifications,
+ markBitbucketNotificationsAsRead,
+ markBitbucketNotificationsAsUnread,
+} from './client';
+import {
+ InfluentsNotificationCategory,
+ InfluentsNotificationReadState,
+} from './graphql/generated/graphql';
+
+vi.mock('../../system/comms', () => ({
+ decryptValue: vi.fn().mockResolvedValue({ token: 'decrypted-token' }),
+}));
+
+const makeRawNode = (registrationProduct: string) => ({
+ groupId: 'g1',
+ groupSize: 1,
+ additionalActors: [],
+ headNotification: {
+ notificationId: 'n1',
+ timestamp: '2024-01-01T00:00:00Z',
+ readState: InfluentsNotificationReadState.Unread,
+ category: InfluentsNotificationCategory.Direct,
+ content: {
+ type: 'pr:comment:added',
+ message: 'Someone commented',
+ url: 'https://bitbucket.org/org/repo/pull-requests/1',
+ entity: {
+ title: 'PR title',
+ iconUrl: null,
+ url: 'https://bitbucket.org/org/repo/pull-requests/1',
+ },
+ path: null,
+ actor: { displayName: 'Bob', avatarURL: null },
+ },
+ analyticsAttributes: [{ key: 'registrationProduct', value: registrationProduct }],
+ },
+});
+
+describe('listRawBitbucketNotifications', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('filters out non-Bitbucket notifications', async () => {
+ const mockResponse = {
+ data: {
+ notifications: {
+ unseenNotificationCount: 2,
+ notificationFeed: {
+ pageInfo: { hasNextPage: false },
+ nodes: [makeRawNode('bitbucket'), makeRawNode('jira'), makeRawNode('confluence')],
+ },
+ },
+ },
+ };
+
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await listRawBitbucketNotifications(mockBitbucketAccount);
+ expect(result).toHaveLength(1);
+ expect(
+ result[0].headNotification.analyticsAttributes?.find((a) => a.key === 'registrationProduct')
+ ?.value,
+ ).toBe('bitbucket');
+ });
+
+ it('returns empty array when no notifications', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: {
+ notifications: {
+ unseenNotificationCount: 0,
+ notificationFeed: { pageInfo: { hasNextPage: false }, nodes: [] },
+ },
+ },
+ }),
+ } as Response);
+
+ const result = await listRawBitbucketNotifications(mockBitbucketAccount);
+ expect(result).toHaveLength(0);
+ });
+
+ it('throws on non-OK HTTP response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ } as Response);
+
+ await expect(listRawBitbucketNotifications(mockBitbucketAccount)).rejects.toThrow(
+ 'Atlassian API 401 Unauthorized',
+ );
+ });
+
+ it('throws on GraphQL errors in response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ errors: [{ message: 'Unauthorized' }],
+ data: null,
+ }),
+ } as Response);
+
+ await expect(listRawBitbucketNotifications(mockBitbucketAccount)).rejects.toThrow(
+ 'Atlassian GraphQL errors',
+ );
+ });
+
+ it('throws when account has no username', async () => {
+ const accountWithoutUsername = { ...mockBitbucketAccount, username: undefined };
+ await expect(listRawBitbucketNotifications(accountWithoutUsername)).rejects.toThrow(
+ 'Bitbucket account is missing username',
+ );
+ });
+});
+
+describe('markBitbucketNotificationsAsRead', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('calls the MarkAsRead mutation and resolves', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: {
+ notifications: { markNotificationsByIdsAsRead: 'ok' },
+ },
+ }),
+ } as Response);
+
+ await expect(
+ markBitbucketNotificationsAsRead(mockBitbucketAccount, ['n1', 'n2']),
+ ).resolves.toBeUndefined();
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
+ expect(body.variables.notificationIDs).toEqual(['n1', 'n2']);
+ });
+
+ it('throws on non-OK HTTP response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 403,
+ statusText: 'Forbidden',
+ } as Response);
+
+ await expect(markBitbucketNotificationsAsRead(mockBitbucketAccount, ['n1'])).rejects.toThrow(
+ 'Atlassian API 403 Forbidden',
+ );
+ });
+});
+
+describe('markBitbucketNotificationsAsUnread', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('calls the MarkAsUnread mutation and resolves', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: {
+ notifications: { markNotificationsByIdsAsUnread: 'ok' },
+ },
+ }),
+ } as Response);
+
+ await expect(
+ markBitbucketNotificationsAsUnread(mockBitbucketAccount, ['n1']),
+ ).resolves.toBeUndefined();
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
+ expect(body.variables.notificationIDs).toEqual(['n1']);
+ });
+
+ it('throws on non-OK HTTP response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ } as Response);
+
+ await expect(markBitbucketNotificationsAsUnread(mockBitbucketAccount, ['n1'])).rejects.toThrow(
+ 'Atlassian API 500 Internal Server Error',
+ );
+ });
+});
+
+describe('fetchBitbucketAuthenticatedUser', () => {
+ beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('returns the me query response on success', async () => {
+ const mockResponse = {
+ data: {
+ me: {
+ user: {
+ accountId: 'atlas-001',
+ name: 'Test User',
+ picture: 'https://example.com/avatar.png',
+ },
+ },
+ },
+ };
+
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await fetchBitbucketAuthenticatedUser(mockBitbucketAccount);
+
+ expect(result.me?.user).toMatchObject({
+ accountId: 'atlas-001',
+ name: 'Test User',
+ picture: 'https://example.com/avatar.png',
+ });
+ });
+
+ it('throws on non-OK HTTP response', async () => {
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ } as Response);
+
+ await expect(fetchBitbucketAuthenticatedUser(mockBitbucketAccount)).rejects.toThrow(
+ 'Atlassian API 401 Unauthorized',
+ );
+ });
+
+ it('throws when account has no username', async () => {
+ const accountWithoutUsername = { ...mockBitbucketAccount, username: undefined };
+ await expect(fetchBitbucketAuthenticatedUser(accountWithoutUsername)).rejects.toThrow(
+ 'Bitbucket account is missing username',
+ );
+ });
+});
diff --git a/src/renderer/utils/forges/bitbucket/client.ts b/src/renderer/utils/forges/bitbucket/client.ts
new file mode 100644
index 000000000..b11f3a2af
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/client.ts
@@ -0,0 +1,124 @@
+import type { Account } from '../../../types';
+import {
+ ATLASSIAN_GRAPHQL_URL,
+ type AtlassianNotificationFragment,
+ BITBUCKET_REGISTRATION_PRODUCT,
+ MAX_NOTIFICATIONS,
+} from './types';
+
+import { decryptValue } from '../../system/comms';
+import type {
+ MarkAsReadMutation,
+ MarkAsReadMutationVariables,
+ MarkAsUnreadMutation,
+ MarkAsUnreadMutationVariables,
+ MeQuery,
+ MeQueryVariables,
+ MyNotificationsQuery,
+ MyNotificationsQueryVariables,
+ TypedDocumentString,
+} from './graphql/generated/graphql';
+import {
+ InfluentsNotificationReadState,
+ MarkAsReadDocument,
+ MarkAsUnreadDocument,
+ MeDocument,
+ MyNotificationsDocument,
+} from './graphql/generated/graphql';
+
+async function buildAuthHeaders(account: Account): Promise {
+ if (!account.username) {
+ throw new Error(
+ 'Bitbucket account is missing username (Atlassian email). Cannot build auth headers.',
+ );
+ }
+ const { token } = await decryptValue(account.token);
+ const credentials = btoa(`${account.username}:${token}`);
+ return {
+ Accept: 'application/json',
+ Authorization: `Basic ${credentials}`,
+ 'Cache-Control': 'no-cache',
+ 'Content-Type': 'application/json',
+ };
+}
+
+async function performAtlassianRequest(
+ account: Account,
+ query: TypedDocumentString,
+ variables?: TVariables,
+): Promise {
+ const headers = await buildAuthHeaders(account);
+
+ const response = await fetch(ATLASSIAN_GRAPHQL_URL, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ query: query.toString(),
+ variables: variables ?? {},
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Atlassian API ${response.status} ${response.statusText}`);
+ }
+
+ const json = (await response.json()) as { data: TResult; errors?: unknown[] };
+
+ if (json.errors?.length) {
+ throw new Error(`Atlassian GraphQL errors: ${JSON.stringify(json.errors)}`);
+ }
+
+ return json.data;
+}
+
+export async function fetchBitbucketAuthenticatedUser(account: Account): Promise {
+ return performAtlassianRequest(account, MeDocument);
+}
+
+export async function listRawBitbucketNotifications(
+ account: Account,
+ fetchOnlyUnread = true,
+): Promise {
+ const readState = fetchOnlyUnread ? InfluentsNotificationReadState.Unread : undefined;
+
+ const data = await performAtlassianRequest(
+ account,
+ MyNotificationsDocument,
+ {
+ first: MAX_NOTIFICATIONS,
+ flat: true,
+ readState,
+ },
+ );
+
+ const nodes = data.notifications?.notificationFeed?.nodes ?? [];
+
+ return nodes.filter((node) => {
+ const registrationProduct = node.headNotification.analyticsAttributes
+ ?.find((attr) => attr.key === 'registrationProduct')
+ ?.value?.toLowerCase();
+ return registrationProduct === BITBUCKET_REGISTRATION_PRODUCT;
+ });
+}
+
+export async function markBitbucketNotificationsAsRead(
+ account: Account,
+ notificationIds: string[],
+): Promise {
+ await performAtlassianRequest(
+ account,
+ MarkAsReadDocument,
+ { notificationIDs: notificationIds },
+ );
+}
+
+export async function markBitbucketNotificationsAsUnread(
+ account: Account,
+ notificationIds: string[],
+): Promise {
+ await performAtlassianRequest(
+ account,
+ MarkAsUnreadDocument,
+ { notificationIDs: notificationIds },
+ );
+}
diff --git a/src/renderer/utils/forges/bitbucket/graphql/client.graphql b/src/renderer/utils/forges/bitbucket/graphql/client.graphql
new file mode 100644
index 000000000..45f699cf0
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/graphql/client.graphql
@@ -0,0 +1,81 @@
+query Me {
+ me {
+ user {
+ accountId
+ name
+ picture
+ }
+ }
+}
+
+query MyNotifications(
+ $readState: InfluentsNotificationReadState
+ $flat: Boolean = true
+ $first: Int
+) {
+ notifications {
+ unseenNotificationCount
+ notificationFeed(flat: $flat, first: $first, filter: { readStateFilter: $readState }) {
+ pageInfo {
+ hasNextPage
+ }
+ nodes {
+ ...AtlassianNotification
+ }
+ }
+ }
+}
+
+fragment AtlassianNotification on InfluentsNotificationHeadItem {
+ groupId
+ groupSize
+ additionalActors {
+ displayName
+ avatarURL
+ }
+ headNotification {
+ ...AtlassianHeadNotification
+ }
+}
+
+mutation MarkAsRead($notificationIDs: [String!]!) {
+ notifications {
+ markNotificationsByIdsAsRead(ids: $notificationIDs)
+ }
+}
+
+mutation MarkAsUnread($notificationIDs: [String!]!) {
+ notifications {
+ markNotificationsByIdsAsUnread(ids: $notificationIDs)
+ }
+}
+
+fragment AtlassianHeadNotification on InfluentsNotificationItem {
+ notificationId
+ timestamp
+ readState
+ category
+ content {
+ type
+ message
+ url
+ entity {
+ title
+ iconUrl
+ url
+ }
+ path {
+ title
+ iconUrl
+ url
+ }
+ actor {
+ displayName
+ avatarURL
+ }
+ }
+ analyticsAttributes {
+ key
+ value
+ }
+}
diff --git a/src/renderer/utils/forges/bitbucket/graphql/generated/graphql.ts b/src/renderer/utils/forges/bitbucket/graphql/generated/graphql.ts
new file mode 100644
index 000000000..ad5718542
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/graphql/generated/graphql.ts
@@ -0,0 +1,224 @@
+/** Internal type. DO NOT USE DIRECTLY. */
+type Exact = { [K in keyof T]: T[K] };
+/** Internal type. DO NOT USE DIRECTLY. */
+export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
+import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';
+export enum InfluentsNotificationCategory {
+ Direct = 'direct',
+ Watching = 'watching'
+}
+
+export enum InfluentsNotificationReadState {
+ Read = 'read',
+ Unread = 'unread'
+}
+
+export type MeQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type MeQuery = { me: { user:
+ | { accountId: string, name: string, picture: string }
+ | { accountId: string, name: string, picture: string }
+ | { accountId: string, name: string, picture: string }
+ | null } };
+
+export type MyNotificationsQueryVariables = Exact<{
+ readState?: InfluentsNotificationReadState | null | undefined;
+ flat?: boolean | null | undefined;
+ first?: number | null | undefined;
+}>;
+
+
+export type MyNotificationsQuery = { notifications: { unseenNotificationCount: number, notificationFeed: { pageInfo: { hasNextPage: boolean }, nodes: Array<{ groupId: string, groupSize: number, additionalActors: Array<{ displayName: string | null, avatarURL: string | null }>, headNotification: { notificationId: string, timestamp: string, readState: InfluentsNotificationReadState, category: InfluentsNotificationCategory, content: { type: string, message: string, url: string | null, entity: { title: string | null, iconUrl: string | null, url: string | null } | null, path: Array<{ title: string | null, iconUrl: string | null, url: string | null }> | null, actor: { displayName: string | null, avatarURL: string | null } }, analyticsAttributes: Array<{ key: string | null, value: string | null }> | null } }> } } | null };
+
+export type AtlassianNotificationFragment = { groupId: string, groupSize: number, additionalActors: Array<{ displayName: string | null, avatarURL: string | null }>, headNotification: { notificationId: string, timestamp: string, readState: InfluentsNotificationReadState, category: InfluentsNotificationCategory, content: { type: string, message: string, url: string | null, entity: { title: string | null, iconUrl: string | null, url: string | null } | null, path: Array<{ title: string | null, iconUrl: string | null, url: string | null }> | null, actor: { displayName: string | null, avatarURL: string | null } }, analyticsAttributes: Array<{ key: string | null, value: string | null }> | null } };
+
+export type MarkAsReadMutationVariables = Exact<{
+ notificationIDs: Array | string;
+}>;
+
+
+export type MarkAsReadMutation = { notifications: { markNotificationsByIdsAsRead: string | null } | null };
+
+export type MarkAsUnreadMutationVariables = Exact<{
+ notificationIDs: Array | string;
+}>;
+
+
+export type MarkAsUnreadMutation = { notifications: { markNotificationsByIdsAsUnread: string | null } | null };
+
+export type AtlassianHeadNotificationFragment = { notificationId: string, timestamp: string, readState: InfluentsNotificationReadState, category: InfluentsNotificationCategory, content: { type: string, message: string, url: string | null, entity: { title: string | null, iconUrl: string | null, url: string | null } | null, path: Array<{ title: string | null, iconUrl: string | null, url: string | null }> | null, actor: { displayName: string | null, avatarURL: string | null } }, analyticsAttributes: Array<{ key: string | null, value: string | null }> | null };
+
+export class TypedDocumentString
+ extends String
+ implements DocumentTypeDecoration
+{
+ __apiType?: NonNullable['__apiType']>;
+ private value: string;
+ public __meta__?: Record | undefined;
+
+ constructor(value: string, __meta__?: Record | undefined) {
+ super(value);
+ this.value = value;
+ this.__meta__ = __meta__;
+ }
+
+ override toString(): string & DocumentTypeDecoration {
+ return this.value;
+ }
+}
+export const AtlassianHeadNotificationFragmentDoc = new TypedDocumentString(`
+ fragment AtlassianHeadNotification on InfluentsNotificationItem {
+ notificationId
+ timestamp
+ readState
+ category
+ content {
+ type
+ message
+ url
+ entity {
+ title
+ iconUrl
+ url
+ }
+ path {
+ title
+ iconUrl
+ url
+ }
+ actor {
+ displayName
+ avatarURL
+ }
+ }
+ analyticsAttributes {
+ key
+ value
+ }
+}
+ `, {"fragmentName":"AtlassianHeadNotification"}) as unknown as TypedDocumentString;
+export const AtlassianNotificationFragmentDoc = new TypedDocumentString(`
+ fragment AtlassianNotification on InfluentsNotificationHeadItem {
+ groupId
+ groupSize
+ additionalActors {
+ displayName
+ avatarURL
+ }
+ headNotification {
+ ...AtlassianHeadNotification
+ }
+}
+ fragment AtlassianHeadNotification on InfluentsNotificationItem {
+ notificationId
+ timestamp
+ readState
+ category
+ content {
+ type
+ message
+ url
+ entity {
+ title
+ iconUrl
+ url
+ }
+ path {
+ title
+ iconUrl
+ url
+ }
+ actor {
+ displayName
+ avatarURL
+ }
+ }
+ analyticsAttributes {
+ key
+ value
+ }
+}`, {"fragmentName":"AtlassianNotification"}) as unknown as TypedDocumentString;
+export const MeDocument = new TypedDocumentString(`
+ query Me {
+ me {
+ user {
+ accountId
+ name
+ picture
+ }
+ }
+}
+ `) as unknown as TypedDocumentString;
+export const MyNotificationsDocument = new TypedDocumentString(`
+ query MyNotifications($readState: InfluentsNotificationReadState, $flat: Boolean = true, $first: Int) {
+ notifications {
+ unseenNotificationCount
+ notificationFeed(
+ flat: $flat
+ first: $first
+ filter: {readStateFilter: $readState}
+ ) {
+ pageInfo {
+ hasNextPage
+ }
+ nodes {
+ ...AtlassianNotification
+ }
+ }
+ }
+}
+ fragment AtlassianNotification on InfluentsNotificationHeadItem {
+ groupId
+ groupSize
+ additionalActors {
+ displayName
+ avatarURL
+ }
+ headNotification {
+ ...AtlassianHeadNotification
+ }
+}
+fragment AtlassianHeadNotification on InfluentsNotificationItem {
+ notificationId
+ timestamp
+ readState
+ category
+ content {
+ type
+ message
+ url
+ entity {
+ title
+ iconUrl
+ url
+ }
+ path {
+ title
+ iconUrl
+ url
+ }
+ actor {
+ displayName
+ avatarURL
+ }
+ }
+ analyticsAttributes {
+ key
+ value
+ }
+}`) as unknown as TypedDocumentString;
+export const MarkAsReadDocument = new TypedDocumentString(`
+ mutation MarkAsRead($notificationIDs: [String!]!) {
+ notifications {
+ markNotificationsByIdsAsRead(ids: $notificationIDs)
+ }
+}
+ `) as unknown as TypedDocumentString;
+export const MarkAsUnreadDocument = new TypedDocumentString(`
+ mutation MarkAsUnread($notificationIDs: [String!]!) {
+ notifications {
+ markNotificationsByIdsAsUnread(ids: $notificationIDs)
+ }
+}
+ `) as unknown as TypedDocumentString;
diff --git a/src/renderer/utils/forges/bitbucket/transform.test.ts b/src/renderer/utils/forges/bitbucket/transform.test.ts
new file mode 100644
index 000000000..2bb1306be
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/transform.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest';
+
+import { mockBitbucketAccount } from '../../../__mocks__/account-mocks';
+
+import type { AtlassianNotificationFragment } from './types';
+
+import {
+ InfluentsNotificationCategory,
+ InfluentsNotificationReadState,
+} from './graphql/generated/graphql';
+import { transformBitbucketNotifications } from './transform';
+
+function makeMockNotification(
+ overrides: Partial = {},
+): AtlassianNotificationFragment {
+ return {
+ groupId: 'group-1',
+ groupSize: 1,
+ additionalActors: [],
+ headNotification: {
+ notificationId: 'notif-123',
+ timestamp: '2024-01-01T00:00:00Z',
+ readState: InfluentsNotificationReadState.Unread,
+ category: InfluentsNotificationCategory.Direct,
+ content: {
+ type: 'pr:reviewer:approved',
+ message: 'Alice approved your pull request',
+ url: 'https://bitbucket.org/myorg/myrepo/pull-requests/42',
+ entity: {
+ title: 'Add new feature',
+ iconUrl: null,
+ url: 'https://bitbucket.org/myorg/myrepo/pull-requests/42',
+ },
+ path: [
+ {
+ title: 'myorg/myrepo',
+ iconUrl: null,
+ url: 'https://bitbucket.org/myorg/myrepo',
+ },
+ ],
+ actor: {
+ displayName: 'Alice',
+ avatarURL: 'https://bitbucket.org/account/alice/avatar',
+ },
+ },
+ analyticsAttributes: [{ key: 'registrationProduct', value: 'bitbucket' }],
+ },
+ ...overrides,
+ };
+}
+
+describe('transformBitbucketNotifications', () => {
+ it('transforms a basic Bitbucket notification', () => {
+ const raw = makeMockNotification();
+ const result = transformBitbucketNotifications([raw], mockBitbucketAccount);
+
+ expect(result).toHaveLength(1);
+ const notif = result[0];
+
+ expect(notif.id).toBe('notif-123');
+ expect(notif.unread).toBe(true);
+ expect(notif.updatedAt).toBe('2024-01-01T00:00:00Z');
+ expect(notif.account).toBe(mockBitbucketAccount);
+ expect(notif.order).toBe(0);
+ });
+
+ it('maps reason from direct category to assign', () => {
+ const raw = makeMockNotification({
+ headNotification: {
+ ...makeMockNotification().headNotification,
+ category: InfluentsNotificationCategory.Direct,
+ },
+ });
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.reason.code).toBe('assign');
+ });
+
+ it('maps reason from watching category to subscribed', () => {
+ const raw = makeMockNotification({
+ headNotification: {
+ ...makeMockNotification().headNotification,
+ category: InfluentsNotificationCategory.Watching,
+ },
+ });
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.reason.code).toBe('subscribed');
+ });
+
+ it('sets subject type to BitbucketNotification', () => {
+ const raw = makeMockNotification();
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.subject.type).toBe('BitbucketNotification');
+ });
+
+ it('uses entity title as subject title', () => {
+ const raw = makeMockNotification();
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.subject.title).toBe('Add new feature');
+ });
+
+ it('sets subject url from entity url', () => {
+ const raw = makeMockNotification();
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.subject.url).toBe('https://bitbucket.org/myorg/myrepo/pull-requests/42');
+ });
+
+ it('extracts repository owner/repo from entity url', () => {
+ const raw = makeMockNotification();
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.repository.fullName).toBe('myorg/myrepo');
+ expect(notif.repository.name).toBe('myrepo');
+ expect(notif.repository.owner.login).toBe('myorg');
+ });
+
+ it('marks notification as read when readState is read', () => {
+ const raw = makeMockNotification({
+ headNotification: {
+ ...makeMockNotification().headNotification,
+ readState: InfluentsNotificationReadState.Read,
+ },
+ });
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.unread).toBe(false);
+ });
+
+ it('handles missing entity url gracefully', () => {
+ const raw = makeMockNotification();
+ raw.headNotification.content.entity = null;
+ raw.headNotification.content.url = null;
+
+ const [notif] = transformBitbucketNotifications([raw], mockBitbucketAccount);
+ expect(notif.subject.url).toBeNull();
+ expect(notif.repository.fullName).toBe('bitbucket');
+ });
+
+ it('transforms multiple notifications', () => {
+ const raw1 = makeMockNotification();
+ const raw2 = makeMockNotification({
+ headNotification: {
+ ...makeMockNotification().headNotification,
+ notificationId: 'notif-456',
+ },
+ });
+ const result = transformBitbucketNotifications([raw1, raw2], mockBitbucketAccount);
+ expect(result).toHaveLength(2);
+ expect(result[1].id).toBe('notif-456');
+ });
+});
diff --git a/src/renderer/utils/forges/bitbucket/transform.ts b/src/renderer/utils/forges/bitbucket/transform.ts
new file mode 100644
index 000000000..2f3e85950
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/transform.ts
@@ -0,0 +1,109 @@
+import type {
+ Account,
+ GitifyReason,
+ GitifyRepository,
+ GitifySubject,
+ RawGitifyNotification,
+ Reason,
+} from '../../../types';
+import { toLink } from '../../../types';
+import type { AtlassianNotificationFragment } from './types';
+
+import { getReasonDetails } from '../../notifications/reason';
+import { InfluentsNotificationCategory } from './graphql/generated/graphql';
+
+const CATEGORY_REASON_MAP: Record = {
+ [InfluentsNotificationCategory.Direct]: 'assign',
+ [InfluentsNotificationCategory.Watching]: 'subscribed',
+};
+
+function mapCategoryToReason(category: InfluentsNotificationCategory): GitifyReason {
+ const code: Reason = CATEGORY_REASON_MAP[category] ?? 'subscribed';
+ const details = getReasonDetails(code);
+ return {
+ code,
+ title: details.title,
+ description: details.description ?? '',
+ };
+}
+
+/**
+ * Extract the repository "owner/repo" from a Bitbucket entity URL.
+ *
+ * Bitbucket entity URLs look like:
+ * https://bitbucket.org/myorg/myrepo/pull-requests/42
+ *
+ * Returns "myorg/myrepo" or a fallback when the URL can't be parsed.
+ */
+function extractRepositoryName(entityUrl: string | null | undefined): string {
+ if (!entityUrl) {
+ return 'bitbucket';
+ }
+ const parts = entityUrl.split('/');
+ // parts[0] = 'https:', [1] = '', [2] = 'bitbucket.org', [3] = owner, [4] = repo
+ if (parts.length >= 5) {
+ return `${parts[3]}/${parts[4]}`;
+ }
+ return 'bitbucket';
+}
+
+function transformSubject(raw: AtlassianNotificationFragment): GitifySubject {
+ const content = raw.headNotification.content;
+ const entityUrl = content.entity?.url ?? content.url ?? null;
+ return {
+ title: content.entity?.title ?? content.message,
+ type: 'BitbucketNotification',
+ url: entityUrl ? toLink(entityUrl) : null,
+ latestCommentUrl: null,
+ htmlUrl: entityUrl ? toLink(entityUrl) : undefined,
+ };
+}
+
+function transformRepository(
+ raw: AtlassianNotificationFragment,
+ _account: Account,
+): GitifyRepository {
+ const content = raw.headNotification.content;
+ const entityUrl = content.entity?.url ?? content.url ?? null;
+ const fullName = extractRepositoryName(entityUrl);
+ const [owner] = fullName.split('/');
+
+ const repoUrl =
+ entityUrl?.split('/').slice(0, 5).join('/') ?? `https://bitbucket.org/${fullName}`;
+
+ return {
+ name: fullName.split('/')[1] ?? fullName,
+ fullName,
+ htmlUrl: toLink(repoUrl),
+ owner: {
+ login: owner,
+ avatarUrl: toLink(`https://bitbucket.org/${owner}/avatar`),
+ type: 'Organization',
+ },
+ };
+}
+
+export function transformBitbucketNotifications(
+ raw: AtlassianNotificationFragment[],
+ account: Account,
+): RawGitifyNotification[] {
+ return raw.map((notification) => transformBitbucketNotification(notification, account));
+}
+
+function transformBitbucketNotification(
+ raw: AtlassianNotificationFragment,
+ account: Account,
+): RawGitifyNotification {
+ const head = raw.headNotification;
+
+ return {
+ id: head.notificationId,
+ unread: head.readState === 'unread',
+ updatedAt: head.timestamp,
+ reason: mapCategoryToReason(head.category),
+ subject: transformSubject(raw),
+ repository: transformRepository(raw, account),
+ account,
+ order: 0,
+ };
+}
diff --git a/src/renderer/utils/forges/bitbucket/types.ts b/src/renderer/utils/forges/bitbucket/types.ts
new file mode 100644
index 000000000..a61ad7a7f
--- /dev/null
+++ b/src/renderer/utils/forges/bitbucket/types.ts
@@ -0,0 +1,10 @@
+export type { AtlassianNotificationFragment } from './graphql/generated/graphql';
+
+/** The product string returned in analyticsAttributes for Bitbucket notifications. */
+export const BITBUCKET_REGISTRATION_PRODUCT = 'bitbucket';
+
+/** Atlassian GraphQL gateway endpoint. */
+export const ATLASSIAN_GRAPHQL_URL = 'https://api.atlassian.com/graphql' as const;
+
+/** Maximum number of notifications to request per poll. */
+export const MAX_NOTIFICATIONS = 200 as const;
diff --git a/src/renderer/utils/forges/gitea/adapter.ts b/src/renderer/utils/forges/gitea/adapter.ts
index a8eadddd7..6c9e30f31 100644
--- a/src/renderer/utils/forges/gitea/adapter.ts
+++ b/src/renderer/utils/forges/gitea/adapter.ts
@@ -63,6 +63,8 @@ export const giteaAdapter: ForgeAdapter = {
icon: ServerIcon,
capabilities,
+ formatUserLogin: (login) => `@${login}`,
+
fetchAuthenticatedUser,
listNotifications,
diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts
index c24cd4db4..6117b4c21 100644
--- a/src/renderer/utils/forges/github/adapter.ts
+++ b/src/renderer/utils/forges/github/adapter.ts
@@ -76,10 +76,12 @@ function getDisplayHelpers(notification: RawGitifyNotification): NotificationDis
export const githubAdapter: ForgeAdapter = {
id: 'github',
displayName: 'GitHub',
- tagline: 'github.com & GitHub Enterprise',
+ tagline: 'GitHub Cloud & GitHub Enterprise Server',
icon: MarkGithubIcon,
capabilities: githubCapabilities,
+ formatUserLogin: (login) => `@${login}`,
+
fetchAuthenticatedUser,
listNotifications,
diff --git a/src/renderer/utils/forges/github/handlers/index.test.ts b/src/renderer/utils/forges/github/handlers/index.test.ts
index 49df0ff90..af3083907 100644
--- a/src/renderer/utils/forges/github/handlers/index.test.ts
+++ b/src/renderer/utils/forges/github/handlers/index.test.ts
@@ -20,6 +20,7 @@ import { workflowRunHandler } from './workflowRun';
describe('renderer/utils/notifications/handlers/index.ts', () => {
describe('createNotificationHandler', () => {
const cases = {
+ BitbucketNotification: defaultHandler,
CheckSuite: checkSuiteHandler,
Commit: commitHandler,
Discussion: discussionHandler,
diff --git a/src/renderer/utils/forges/registry.test.ts b/src/renderer/utils/forges/registry.test.ts
index c98bd5308..4dda450d7 100644
--- a/src/renderer/utils/forges/registry.test.ts
+++ b/src/renderer/utils/forges/registry.test.ts
@@ -1,4 +1,8 @@
-import { mockGiteaAccount, mockGitHubCloudAccount } from '../../__mocks__/account-mocks';
+import {
+ mockBitbucketAccount,
+ mockGiteaAccount,
+ mockGitHubCloudAccount,
+} from '../../__mocks__/account-mocks';
import type { Account, Forge } from '../../types';
@@ -14,9 +18,14 @@ describe('renderer/utils/forges/registry.ts', () => {
expect(getAdapter(mockGiteaAccount).id).toBe('gitea');
});
+ it('returns the Bitbucket adapter for bitbucket accounts', () => {
+ expect(getAdapter(mockBitbucketAccount).id).toBe('bitbucket');
+ });
+
it('returns the registered adapter by forge id', () => {
expect(getAdapter('github').id).toBe('github');
expect(getAdapter('gitea').id).toBe('gitea');
+ expect(getAdapter('bitbucket').id).toBe('bitbucket');
});
it('throws for an unknown forge on an account', () => {
@@ -36,6 +45,7 @@ describe('renderer/utils/forges/registry.ts', () => {
it('accepts every value in the Forge union', () => {
expect(isKnownForge('github')).toBe(true);
expect(isKnownForge('gitea')).toBe(true);
+ expect(isKnownForge('bitbucket')).toBe(true);
});
it('rejects nullish, casing mismatch, empty, and stranger values', () => {
@@ -51,14 +61,11 @@ describe('renderer/utils/forges/registry.ts', () => {
describe('listAdapters / KNOWN_FORGES', () => {
it('returns every registered adapter', () => {
const ids = listAdapters().map((a) => a.id);
- expect(ids).toEqual(expect.arrayContaining(['github', 'gitea']));
+ expect(ids).toEqual(expect.arrayContaining(['github', 'gitea', 'bitbucket']));
});
it('every Forge value has a registered adapter (exhaustive)', () => {
- // Adding a new variant to the Forge union but forgetting to register
- // an adapter would break this assertion at test-time rather than at
- // first-use in production.
- const forges: Forge[] = ['github', 'gitea'];
+ const forges: Forge[] = ['github', 'gitea', 'bitbucket'];
for (const id of forges) {
expect(KNOWN_FORGES.has(id)).toBe(true);
expect(() => getAdapter(id)).not.toThrow();
diff --git a/src/renderer/utils/forges/registry.ts b/src/renderer/utils/forges/registry.ts
index a016f80e3..0c7c267d7 100644
--- a/src/renderer/utils/forges/registry.ts
+++ b/src/renderer/utils/forges/registry.ts
@@ -1,6 +1,7 @@
import type { Account, Forge } from '../../types';
import type { ForgeAdapter } from './types';
+import { bitbucketAdapter } from './bitbucket/adapter';
import { giteaAdapter } from './gitea/adapter';
import { githubAdapter } from './github/adapter';
@@ -13,6 +14,7 @@ import { githubAdapter } from './github/adapter';
const ADAPTERS: Record = {
github: githubAdapter,
gitea: giteaAdapter,
+ bitbucket: bitbucketAdapter,
};
/** Single source of truth for the runtime set of registered forges. */
diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts
index c3b9bc17b..752c0a746 100644
--- a/src/renderer/utils/forges/types.ts
+++ b/src/renderer/utils/forges/types.ts
@@ -104,6 +104,12 @@ export interface ForgeAdapter {
/** Static or computed capability matrix for this forge. */
readonly capabilities: ForgeCapabilities;
+ /**
+ * Format a user login for display (e.g. prepend "@" for GitHub/Gitea,
+ * return as-is for Bitbucket where the login is an email address).
+ */
+ formatUserLogin(login: string): string;
+
/** Fetch the authenticated user (used during login & on refresh). */
fetchAuthenticatedUser(account: Account): Promise;
diff --git a/src/renderer/utils/notifications/filters/subjectType.ts b/src/renderer/utils/notifications/filters/subjectType.ts
index f85c396b2..a6812d820 100644
--- a/src/renderer/utils/notifications/filters/subjectType.ts
+++ b/src/renderer/utils/notifications/filters/subjectType.ts
@@ -9,6 +9,9 @@ import type {
import type { Filter } from './types';
const SUBJECT_TYPE_DETAILS: Record = {
+ BitbucketNotification: {
+ title: 'Bitbucket',
+ },
CheckSuite: {
title: 'Check Suite',
},
diff --git a/src/renderer/utils/ui/icons.ts b/src/renderer/utils/ui/icons.ts
index de8295861..2a3ecb75a 100644
--- a/src/renderer/utils/ui/icons.ts
+++ b/src/renderer/utils/ui/icons.ts
@@ -12,6 +12,8 @@ import {
ShieldCheckIcon,
} from '@primer/octicons-react';
+import { BitbucketIcon } from '../../components/icons/BitbucketIcon';
+
import {
type GitifyPullRequestReview,
IconColor,
@@ -59,6 +61,8 @@ export function getPullRequestReviewIcon(
export function getPlatformIcon(platform: PlatformType): FC | null {
switch (platform) {
+ case 'Bitbucket Cloud':
+ return BitbucketIcon;
case 'Gitea':
return ServerIcon;
case 'GitHub Enterprise Server':