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 && ( {
- + { + if (!open && keepMenuOpenRef.current) { + keepMenuOpenRef.current = false; + return; + } + setAddMenuOpen(open); + if (!open) { + setSelectedForge(null); + } + }} + >
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`] = ` +
+
+
+
+ + +
+
+ +

+ Login to Bitbucket Cloud with API Token +

+
+
+
+
+
+
+
+
+ + + + + + + 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':