diff --git a/assets/icons/bitbucket.svg b/assets/icons/bitbucket.svg new file mode 100644 index 000000000..894ed83bf --- /dev/null +++ b/assets/icons/bitbucket.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/src/app.tsx b/src/app.tsx index ba4c4e721..c22af7d42 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -12,6 +12,7 @@ import { AppContext, AppProvider } from './context/App'; import { AccountsRoute } from './routes/Accounts'; import { FiltersRoute } from './routes/Filters'; import { LoginRoute } from './routes/Login'; +import { LoginWithBitbucketCloud } from './routes/LoginWithBitbucketCloud'; import { LoginWithOAuthApp } from './routes/LoginWithOAuthApp'; import { LoginWithPersonalAccessToken } from './routes/LoginWithPersonalAccessToken'; import { NotificationsRoute } from './routes/Notifications'; @@ -61,7 +62,7 @@ export const App = () => { } /> @@ -74,6 +75,10 @@ export const App = () => { element={} /> } /> + } + /> diff --git a/src/components/AccountNotifications.tsx b/src/components/AccountNotifications.tsx index 6ed8afa8d..c0f30a0a3 100644 --- a/src/components/AccountNotifications.tsx +++ b/src/components/AccountNotifications.tsx @@ -13,6 +13,7 @@ import type { Notification } from '../typesGitHub'; import { cn } from '../utils/cn'; import { openAccountProfile, + openBitbucketPulls, openGitHubIssues, openGitHubPulls, } from '../utils/links'; @@ -115,17 +116,31 @@ export const AccountNotifications: FC = ( - openGitHubIssues(account.hostname)} - /> + {account.platform !== 'Bitbucket Cloud' && ( + ) => { + // Don't trigger onClick of parent element. + event.stopPropagation(); + openGitHubIssues(account.hostname); + }} + /> + )} openGitHubPulls(account.hostname)} + onClick={(event: MouseEvent) => { + // Don't trigger onClick of parent element. + event.stopPropagation(); + if (account.platform === 'Bitbucket Cloud') { + openBitbucketPulls(account); + } else { + openGitHubPulls(account.hostname); + } + }} /> = ({ const [showAsRead, setShowAsRead] = useState(false); const handleNotification = useCallback(() => { + if (notification.account.platform === 'Bitbucket Cloud') { + openNotification(notification); + return; + } + setAnimateExit(!settings.delayNotificationState); setShowAsRead(settings.delayNotificationState); @@ -127,7 +132,7 @@ export const NotificationRow: FC = ({ - {!animateExit && ( + {!animateExit && notification.account.platform !== 'Bitbucket Cloud' && ( {isMarkAsDoneFeatureSupported(notification.account) && ( = ({ - {!animateExit && ( - - {isMarkAsDoneFeatureSupported(repoNotifications[0].account) && ( + {!animateExit && + repoNotifications[0].account.platform !== 'Bitbucket Cloud' && ( + + {isMarkAsDoneFeatureSupported(repoNotifications[0].account) && ( + ) => { + // Don't trigger onClick of parent element. + event.stopPropagation(); + setAnimateExit(!settings.delayNotificationState); + setShowAsRead(settings.delayNotificationState); + markRepoNotificationsDone(repoNotifications[0]); + }} + /> + )} ) => { // Don't trigger onClick of parent element. event.stopPropagation(); setAnimateExit(!settings.delayNotificationState); setShowAsRead(settings.delayNotificationState); - markRepoNotificationsDone(repoNotifications[0]); + markRepoNotificationsRead(repoNotifications[0]); }} /> - )} - ) => { - // Don't trigger onClick of parent element. - event.stopPropagation(); - setAnimateExit(!settings.delayNotificationState); - setShowAsRead(settings.delayNotificationState); - markRepoNotificationsRead(repoNotifications[0]); - }} - /> - - - )} + + + )} {showRepositoryNotifications && diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6391d205f..b6edf5ebc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { quitApp } from '../utils/comms'; import Constants from '../utils/constants'; import { getFilterCount } from '../utils/helpers'; import { + openBitbucketPulls, openGitHubIssues, openGitHubNotifications, openGitHubPulls, @@ -38,8 +39,10 @@ export const Sidebar: FC = () => { } = useContext(AppContext); // We naively assume that the first account is the primary account for the purposes of our sidebar quick links + const primaryAccount = auth.accounts[0]; + const primaryAccountHostname = - auth.accounts[0]?.hostname ?? Constants.DEFAULT_AUTH_OPTIONS.hostname; + primaryAccount?.hostname ?? Constants.DEFAULT_AUTH_OPTIONS.hostname; const toggleFilters = () => { if (location.pathname.startsWith('/filters')) { @@ -91,16 +94,24 @@ export const Sidebar: FC = () => { onClick={() => openGitHubNotifications(primaryAccountHostname)} /> - openGitHubIssues(primaryAccountHostname)} - /> + {primaryAccount?.platform !== 'Bitbucket Cloud' && ( + openGitHubIssues(primaryAccountHostname)} + /> + )} openGitHubPulls(primaryAccountHostname)} + onClick={() => { + if (primaryAccount?.platform === 'Bitbucket Cloud') { + openBitbucketPulls(primaryAccount); + } else { + openGitHubPulls(primaryAccountHostname); + } + }} /> diff --git a/src/components/icons/AuthMethodIcon.tsx b/src/components/icons/AuthMethodIcon.tsx index 90251bd79..aff914c5e 100644 --- a/src/components/icons/AuthMethodIcon.tsx +++ b/src/components/icons/AuthMethodIcon.tsx @@ -14,6 +14,7 @@ export const AuthMethodIcon: FC = (props: IAuthMethodIcon) => { {props.type === 'GitHub App' && } {props.type === 'Personal Access Token' && } {props.type === 'OAuth App' && } + {props.type === 'App Password' && } ); }; diff --git a/src/components/icons/BitbucketIcon.tsx b/src/components/icons/BitbucketIcon.tsx new file mode 100644 index 000000000..dadc1f763 --- /dev/null +++ b/src/components/icons/BitbucketIcon.tsx @@ -0,0 +1,50 @@ +import type { FC } from 'react'; +import { Size } from '../../types'; +import { cn } from '../../utils/cn'; + +interface IBitbucketIcon { + onClick?: () => void; + size: Size; +} + +export const BitbucketIcon: FC = ({ + onClick, + size = Size.MEDIUM, + ...props +}: IBitbucketIcon) => ( + onClick?.()} + xmlns="https://www.w3.org/2000/svg" + xmlnsXlink="https://www.w3.org/1999/xlink" + viewBox="0 0 512 512" + role="img" + aria-label="Bitbucket Cloud" + {...props} + > + + + + + + + + +); diff --git a/src/components/icons/PlatformIcon.tsx b/src/components/icons/PlatformIcon.tsx index 7fb50501c..d33b71d06 100644 --- a/src/components/icons/PlatformIcon.tsx +++ b/src/components/icons/PlatformIcon.tsx @@ -2,6 +2,7 @@ import { MarkGithubIcon, ServerIcon } from '@primer/octicons-react'; import type { FC } from 'react'; import type { Size } from '../../types'; import type { PlatformType } from '../../utils/auth/types'; +import { BitbucketIcon } from './BitbucketIcon'; export interface IPlatformIcon { type: PlatformType; @@ -15,6 +16,7 @@ export const PlatformIcon: FC = (props: IPlatformIcon) => { {props.type === 'GitHub Enterprise Server' && ( )} + {props.type === 'Bitbucket Cloud' && } ); }; diff --git a/src/components/notification/Pills.tsx b/src/components/notification/Pills.tsx index 5b6f877da..0519ce9cb 100644 --- a/src/components/notification/Pills.tsx +++ b/src/components/notification/Pills.tsx @@ -1,4 +1,5 @@ import { + CheckboxIcon, CommentIcon, IssueClosedIcon, MilestoneIcon, @@ -24,6 +25,10 @@ export const Pills: FC = ({ notification }: IPills) => { notification.subject.comments > 1 ? 'comments' : 'comment' }`; + const tasksPillDescription = `${notification.subject.tasks} ${ + notification.subject.tasks > 1 ? 'tasks' : 'task' + }`; + const labelsPillDescription = notification.subject.labels ?.map((label) => `🏷️ ${label}`) .join('\n'); @@ -87,6 +92,14 @@ export const Pills: FC = ({ notification }: IPills) => { } /> )} + {notification.subject?.tasks > 0 && ( + + )} ) ); diff --git a/src/components/settings/SettingsFooter.test.tsx b/src/components/settings/SettingsFooter.test.tsx index 86b624f01..094a7232e 100644 --- a/src/components/settings/SettingsFooter.test.tsx +++ b/src/components/settings/SettingsFooter.test.tsx @@ -125,7 +125,7 @@ describe('routes/components/settings/SettingsFooter.tsx', () => { }); fireEvent.click(screen.getByTitle('Accounts')); - expect(mockNavigate).toHaveBeenCalledWith('/accounts'); + expect(mockNavigate).toHaveBeenCalledWith('/settings/accounts'); }); it('should quit the app', async () => { diff --git a/src/components/settings/SettingsFooter.tsx b/src/components/settings/SettingsFooter.tsx index a5fd609a4..58d4454e5 100644 --- a/src/components/settings/SettingsFooter.tsx +++ b/src/components/settings/SettingsFooter.tsx @@ -39,7 +39,7 @@ export const SettingsFooter: FC = () => { className={BUTTON_CLASS_NAME} title="Accounts" onClick={() => { - navigate('/accounts'); + navigate('/settings/accounts'); }} > diff --git a/src/context/App.tsx b/src/context/App.tsx index eb93a8e91..bb32ee388 100644 --- a/src/context/App.tsx +++ b/src/context/App.tsx @@ -15,6 +15,7 @@ import { type AuthState, type GitifyError, GroupBy, + type Hostname, OpenPreference, type SettingsState, type SettingsValue, @@ -25,6 +26,7 @@ import type { Notification } from '../typesGitHub'; import { headNotifications } from '../utils/api/client'; import { migrateAuthenticatedAccounts } from '../utils/auth/migration'; import type { + LoginBitbucketCloudOptions, LoginOAuthAppOptions, LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; @@ -97,6 +99,7 @@ interface AppContextState { loginWithGitHubApp: () => void; loginWithOAuthApp: (data: LoginOAuthAppOptions) => void; loginWithPersonalAccessToken: (data: LoginPersonalAccessTokenOptions) => void; + loginWithBitbucketCloud: (data: LoginBitbucketCloudOptions) => void; logoutFromAccount: (account: Account) => void; notifications: AccountNotifications[]; @@ -242,6 +245,21 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { [auth, settings], ); + const loginWithBitbucketCloud = useCallback( + async ({ token, workspace, username }: LoginBitbucketCloudOptions) => { + const updatedAuth = await addAccount( + auth, + 'App Password', + token, + `https://api.bitbucket.org/internal/workspaces/${workspace}` as Hostname, // TODO - ideally we don't set it like this + username, + ); + setAuth(updatedAuth); + saveState({ auth: updatedAuth, settings }); + }, + [auth, settings], + ); + const logoutFromAccount = useCallback( async (account: Account) => { // Remove notifications for account @@ -320,6 +338,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { loginWithGitHubApp, loginWithOAuthApp, loginWithPersonalAccessToken, + loginWithBitbucketCloud, logoutFromAccount, notifications, diff --git a/src/routes/Accounts.test.tsx b/src/routes/Accounts.test.tsx index 768c7f25f..b03726d17 100644 --- a/src/routes/Accounts.test.tsx +++ b/src/routes/Accounts.test.tsx @@ -196,7 +196,7 @@ describe('routes/Accounts.tsx', () => { 'token-123-456', ); await waitFor(() => - expect(mockNavigate).toHaveBeenNthCalledWith(1, '/accounts', { + expect(mockNavigate).toHaveBeenNthCalledWith(1, '/settings/accounts', { replace: true, }), ); diff --git a/src/routes/Accounts.tsx b/src/routes/Accounts.tsx index ae8ffc8b8..48f8eeb84 100644 --- a/src/routes/Accounts.tsx +++ b/src/routes/Accounts.tsx @@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom'; import { Header } from '../components/Header'; import { AuthMethodIcon } from '../components/icons/AuthMethodIcon'; import { AvatarIcon } from '../components/icons/AvatarIcon'; +import { BitbucketIcon } from '../components/icons/BitbucketIcon'; import { PlatformIcon } from '../components/icons/PlatformIcon'; import { AppContext } from '../context/App'; import { BUTTON_CLASS_NAME } from '../styles/gitify'; @@ -48,7 +49,7 @@ export const AccountsRoute: FC = () => { const setAsPrimaryAccount = useCallback((account: Account) => { auth.accounts = [account, ...auth.accounts.filter((a) => a !== account)]; saveState({ auth, settings }); - navigate('/accounts', { replace: true }); + navigate('/settings/accounts', { replace: true }); }, []); const loginWithGitHub = useCallback(async () => { @@ -67,6 +68,10 @@ export const AccountsRoute: FC = () => { return navigate('/login-oauth-app', { replace: true }); }, []); + const loginWithBitbucketCloud = useCallback(() => { + return navigate('/login-bitbucket-cloud', { replace: true }); + }, []); + return (
Accounts
@@ -107,8 +112,13 @@ export const AccountsRoute: FC = () => { title="Open Host" onClick={() => openHost(account.hostname)} > - - {account.hostname} +
+ + {account.hostname.split('/').pop()} +
@@ -155,7 +165,7 @@ export const AccountsRoute: FC = () => { title={`Refresh ${account.user.login}`} onClick={async () => { await refreshAccount(account); - navigate('/accounts', { replace: true }); + navigate('/settings/accounts', { replace: true }); }} > { +
diff --git a/src/routes/Login.tsx b/src/routes/Login.tsx index 2a661eb67..208fd44a8 100644 --- a/src/routes/Login.tsx +++ b/src/routes/Login.tsx @@ -3,6 +3,7 @@ import log from 'electron-log'; import { type FC, useCallback, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../components/buttons/Button'; +import { BitbucketIcon } from '../components/icons/BitbucketIcon'; import { LogoIcon } from '../components/icons/LogoIcon'; import { AppContext } from '../context/App'; import { Size } from '../types'; @@ -63,6 +64,13 @@ export const LoginRoute: FC = () => { > OAuth App + ); }; diff --git a/src/routes/LoginWithBitbucketCloud.tsx b/src/routes/LoginWithBitbucketCloud.tsx new file mode 100644 index 000000000..e80096673 --- /dev/null +++ b/src/routes/LoginWithBitbucketCloud.tsx @@ -0,0 +1,138 @@ +import { BookIcon, KeyIcon, SignInIcon } from '@primer/octicons-react'; +import log from 'electron-log'; +import { type FC, useCallback, useContext, useState } from 'react'; +import { Form, type FormRenderProps } from 'react-final-form'; +import { useNavigate } from 'react-router-dom'; +import { Header } from '../components/Header'; +import { Button } from '../components/buttons/Button'; +import { FieldInput } from '../components/fields/FieldInput'; +import { AppContext } from '../context/App'; +import { Size, type Token, type Username, type Workspace } from '../types'; +import type { LoginBitbucketCloudOptions } from '../utils/auth/types'; +import { isValidAppPassword } from '../utils/auth/utils'; +import { Constants } from '../utils/constants'; + +interface IValues { + username: Username; + token?: Token; + workspace?: Workspace; +} + +interface IFormErrors { + username?: string; + token?: string; + workspace?: string; +} + +export const validate = (values: IValues): IFormErrors => { + const errors: IFormErrors = {}; + + if (!values.username) { + errors.token = 'Required'; + } + + if (!values.token) { + errors.token = 'Required'; + } else if (!isValidAppPassword(values.token)) { + errors.token = 'Invalid app password.'; + } + + if (!values.workspace) { + errors.workspace = 'Required'; + } + + return errors; +}; + +export const LoginWithBitbucketCloud: FC = () => { + const { loginWithBitbucketCloud } = useContext(AppContext); + const navigate = useNavigate(); + const [isValidToken, setIsValidToken] = useState(true); + + const renderForm = (formProps: FormRenderProps) => { + const { handleSubmit, submitting, pristine, values } = formProps; + + // TODO - Correctly set account.id and account.hostname + return ( +
+ + + + + + + {!isValidToken && ( +
+ This token could not be validated with {values.workspace}. +
+ )} + +
+ + +
+ + ); + }; + + const login = useCallback( + async (data: IValues) => { + setIsValidToken(true); + try { + await loginWithBitbucketCloud(data as LoginBitbucketCloudOptions); + navigate(-1); + } catch (err) { + log.error('Auth: failed to login with personal access token', err); + setIsValidToken(false); + } + }, + [loginWithBitbucketCloud], + ); + + return ( + <> +
Login with Bitbucket Cloud
+ +
+
+ {renderForm} +
+
+ + ); +}; diff --git a/src/routes/__snapshots__/Accounts.test.tsx.snap b/src/routes/__snapshots__/Accounts.test.tsx.snap index 4da1a9b83..6539c5bd2 100644 --- a/src/routes/__snapshots__/Accounts.test.tsx.snap +++ b/src/routes/__snapshots__/Accounts.test.tsx.snap @@ -94,26 +94,30 @@ exports[`routes/Accounts.tsx General should render itself & its children 1`] = ` title="Open Host" type="button" > - - - - github.com + + + github.com +
@@ -267,26 +271,30 @@ exports[`routes/Accounts.tsx General should render itself & its children 1`] = ` title="Open Host" type="button" > - - - - github.gitify.io + + + github.gitify.io +
@@ -440,26 +448,30 @@ exports[`routes/Accounts.tsx General should render itself & its children 1`] = ` title="Open Host" type="button" > - - - - github.com + + + github.com +
@@ -695,6 +707,70 @@ exports[`routes/Accounts.tsx General should render itself & its children 1`] = ` /> +
diff --git a/src/routes/__snapshots__/Login.test.tsx.snap b/src/routes/__snapshots__/Login.test.tsx.snap index e3c8e0754..d661efa35 100644 --- a/src/routes/__snapshots__/Login.test.tsx.snap +++ b/src/routes/__snapshots__/Login.test.tsx.snap @@ -128,6 +128,52 @@ exports[`routes/Login.tsx should render itself & its children 1`] = ` OAuth App + , @@ -255,6 +301,52 @@ exports[`routes/Login.tsx should render itself & its children 1`] = ` OAuth App + , "debug": [Function], diff --git a/src/types.ts b/src/types.ts index d21ffb95f..c17a6b83f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,10 @@ export type ClientSecret = Branded; export type Hostname = Branded; +export type Username = Branded; + +export type Workspace = Branded; + export type Link = Branded; export type Status = 'loading' | 'success' | 'error'; diff --git a/src/typesGitHub.ts b/src/typesGitHub.ts index a9ebf20fc..1d67e6715 100644 --- a/src/typesGitHub.ts +++ b/src/typesGitHub.ts @@ -268,6 +268,7 @@ export interface GitifySubject { reviews?: GitifyPullRequestReview[]; linkedIssues?: string[]; comments?: number; + tasks?: number; labels?: string[]; milestone?: Milestone; } diff --git a/src/utils/api/bitbucket.ts b/src/utils/api/bitbucket.ts new file mode 100644 index 000000000..17d54a1dd --- /dev/null +++ b/src/utils/api/bitbucket.ts @@ -0,0 +1,38 @@ +import type { AxiosPromise } from 'axios'; +import type { Account, Link } from '../../types'; +import type { Notification, UserDetails } from '../../typesGitHub'; +import { apiRequestBitbucket } from './request'; +/** + * List all notifications for the current user, sorted by most recently updated. + * + * Endpoint documentation: https://docs.github.com/en/rest/activity/notifications#list-notifications-for-the-authenticated-user + */ +// TODO - Correct types +export function listBitbucketWork( + account: Account, +): AxiosPromise { + const url = `${account.hostname}/overview-view-state?fields=pullRequests.reviewing.id,pullRequests.reviewing.title,pullRequest.reviewing.state,pullRequests.reviewing.author,pullRequests.reviewing.created_on,pullRequests.reviewing.updated_on,pullRequests.reviewing.links,pullRequests.reviewing.task_count,pullRequests.reviewing.comment_count,pullRequests.reviewing.destination.repository.*`; + + return apiRequestBitbucket( + url.toString() as Link, + 'GET', + account.user.login, + account.token, + ); +} + +/** + * Get the authenticated user + * + * Endpoint documentation: https://docs.github.com/en/rest/users/users#get-the-authenticated-user + */ +export function getBitbucketUser(account: Account): AxiosPromise { + const url = 'https://api.bitbucket.org/2.0/user'; + + return apiRequestBitbucket( + url.toString() as Link, + 'GET', + account.user.login, + account.token, + ); +} diff --git a/src/utils/api/request.ts b/src/utils/api/request.ts index 03a71b175..90f955c8b 100644 --- a/src/utils/api/request.ts +++ b/src/utils/api/request.ts @@ -24,3 +24,17 @@ export function apiRequestAuth( axios.defaults.headers.common['Content-Type'] = 'application/json'; return axios({ method, url, data }); } + +export function apiRequestBitbucket( + url: Link, + method: Method, + username: string, + token: Token, + data = {}, +): AxiosPromise | null { + axios.defaults.headers.common.Accept = 'application/json'; + axios.defaults.headers.common.Authorization = `Basic ${btoa(`${username}:${token}`)}`; + axios.defaults.headers.common['Cache-Control'] = 'no-cache'; + axios.defaults.headers.common['Content-Type'] = 'application/json'; + return axios({ method, url, data }); +} diff --git a/src/utils/auth/types.ts b/src/utils/auth/types.ts index d66bbbce4..730840d2a 100644 --- a/src/utils/auth/types.ts +++ b/src/utils/auth/types.ts @@ -4,11 +4,20 @@ import type { ClientSecret, Hostname, Token, + Username, + Workspace, } from '../../types'; -export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; +export type AuthMethod = + | 'GitHub App' + | 'Personal Access Token' + | 'OAuth App' + | 'App Password'; -export type PlatformType = 'GitHub Cloud' | 'GitHub Enterprise Server'; +export type PlatformType = + | 'GitHub Cloud' + | 'GitHub Enterprise Server' + | 'Bitbucket Cloud'; export interface LoginOAuthAppOptions { hostname: Hostname; @@ -21,6 +30,12 @@ export interface LoginPersonalAccessTokenOptions { token: Token; } +export interface LoginBitbucketCloudOptions { + username: Username; + token: Token; + workspace: Workspace; +} + export interface AuthResponse { authCode: AuthCode; authOptions: LoginOAuthAppOptions; diff --git a/src/utils/auth/utils.ts b/src/utils/auth/utils.ts index d79fcbce0..8a64d78d6 100644 --- a/src/utils/auth/utils.ts +++ b/src/utils/auth/utils.ts @@ -10,8 +10,10 @@ import type { Hostname, Link, Token, + Username, } from '../../types'; import type { UserDetails } from '../../typesGitHub'; +import { getBitbucketUser } from '../api/bitbucket'; import { getAuthenticatedUser } from '../api/client'; import { apiRequest } from '../api/request'; import { Constants } from '../constants'; @@ -128,6 +130,7 @@ export async function addAccount( method: AuthMethod, token: Token, hostname: Hostname, + username?: Username, ): Promise { let newAccount = { hostname: hostname, @@ -136,6 +139,16 @@ export async function addAccount( token: token, } as Account; + // TODO - find a better way to pass the username through + if (username) { + newAccount.user = { + id: 0, + login: username, + name: username, + avatar: '' as Link, + }; + } + newAccount = await refreshAccount(newAccount); return { @@ -154,6 +167,36 @@ export function removeAccount(auth: AuthState, account: Account): AuthState { } export async function refreshAccount(account: Account): Promise { + if (account.platform === 'Bitbucket Cloud') { + return refreshBitbucketAccount(account); + } + + return refreshGitHubAccount(account); +} + +export async function refreshBitbucketAccount( + account: Account, +): Promise { + try { + // TODO correctly type + // biome-ignore lint/suspicious/noExplicitAny: + const res: any = await getBitbucketUser(account); + + // Refresh user data + account.user = { + id: res.data.account_id, + login: res.data.username, + name: res.data.display_name, + avatar: res.data.links.avatar.href, + }; + } catch (error) { + log.error('Failed to refresh account', error); + } + + return account; +} + +export async function refreshGitHubAccount(account: Account): Promise { try { const res = await getAuthenticatedUser(account.hostname, account.token); @@ -238,6 +281,10 @@ export function isValidToken(token: Token) { return /^[A-Z0-9_]{40}$/i.test(token); } +export function isValidAppPassword(token: Token) { + return /^[A-Z0-9_]{36}$/i.test(token); +} + export function getAccountUUID(account: Account): string { return btoa(`${account.hostname}-${account.user.id}-${account.method}`); } diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eabacbaeb..ead888d29 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -11,6 +11,8 @@ export const Constants = { clientSecret: process.env.OAUTH_CLIENT_SECRET as ClientSecret, }, + BITBUCKET_API_BASE_URL: 'https://api.bitbucket.org/', + GITHUB_API_BASE_URL: 'https://api.github.com/', GITHUB_API_GRAPHQL_URL: 'https://api.github.com/graphql', @@ -35,6 +37,11 @@ export const Constants = { PARTICIPATING_URL: 'https://docs.github.com/en/account-and-profile/managing-subscriptions-and-notifications-on-github/setting-up-notifications/configuring-notifications#about-participating-and-watching-notifications' as Link, }, + + ATLASSIAN_DOCS: { + BITBUCKET_APP_PASSWORD_URL: + 'https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/' as Link, + }, }; export const Errors: Record = { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index eb688437d..755467da6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -12,6 +12,10 @@ import { } from './subject'; export function getPlatformFromHostname(hostname: string): PlatformType { + if (hostname.startsWith(Constants.BITBUCKET_API_BASE_URL)) { + return 'Bitbucket Cloud'; + } + return hostname.endsWith(Constants.DEFAULT_AUTH_OPTIONS.hostname) ? 'GitHub Cloud' : 'GitHub Enterprise Server'; diff --git a/src/utils/links.ts b/src/utils/links.ts index e19aaadde..f0bb19c82 100644 --- a/src/utils/links.ts +++ b/src/utils/links.ts @@ -33,12 +33,23 @@ export function openGitHubPulls(hostname: Hostname) { openExternalLink(url.toString() as Link); } -export function openAccountProfile(account: Account) { - const url = new URL(`https://${account.hostname}`); - url.pathname = account.user.login; +export function openBitbucketPulls(account: Account) { + const url = new URL( + `${account.hostname.replace('api.bitbucket.org/internal/workspaces', 'bitbucket.org')}/workspace/pull-requests/?user_filter=ALL&author=${account.user.id}`, + ); openExternalLink(url.toString() as Link); } +export function openAccountProfile(account: Account) { + if (account.platform === 'Bitbucket Cloud') { + openExternalLink('https://bitbucket.org/account/settings/' as Link); + } else { + const url = new URL(`https://${account.hostname}`); + url.pathname = account.user.login; + openExternalLink(url.toString() as Link); + } +} + export function openUserProfile(user: SubjectUser) { openExternalLink(user.html_url); } @@ -57,7 +68,13 @@ export function openRepository(repository: Repository) { } export async function openNotification(notification: Notification) { - const url = await generateGitHubWebUrl(notification); + let url = '' as Link; + if (notification.account.platform === 'Bitbucket Cloud') { + url = notification.url; + } else { + url = await generateGitHubWebUrl(notification); + } + openExternalLink(url); } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index 0a2dc618f..da5ef0847 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -5,6 +5,7 @@ import type { SettingsState, } from '../types'; import { Notification } from '../typesGitHub'; +import { listBitbucketWork } from './api/bitbucket'; import { listNotificationsForAuthenticatedUser } from './api/client'; import { determineFailureType } from './api/errors'; import { getAccountUUID } from './auth/utils'; @@ -114,10 +115,10 @@ function getNotifications(state: GitifyState) { return state.auth.accounts.map((account) => { return { account, - notifications: listNotificationsForAuthenticatedUser( - account, - state.settings, - ), + notifications: + account.platform === 'Bitbucket Cloud' + ? listBitbucketWork(account) + : listNotificationsForAuthenticatedUser(account, state.settings), }; }); } @@ -132,22 +133,70 @@ export async function getAllNotifications( .filter((response) => !!response) .map(async (accountNotifications) => { try { - let notifications = ( - await accountNotifications.notifications - ).data.map((notification: Notification) => ({ - ...notification, - account: accountNotifications.account, - })); - - notifications = await enrichNotifications(notifications, state); - - notifications = filterNotifications(notifications, state.settings); - - return { - account: accountNotifications.account, - notifications: notifications, - error: null, - }; + // TODO - this needs to be correctly implemented + if (accountNotifications.account.platform === 'Bitbucket Cloud') { + // biome-ignore lint/suspicious/noExplicitAny: + const res = (await accountNotifications.notifications).data as any; + + // TODO - when using IP allowlists, Bitbucket doesn't return any response indicator + + const pulls = res.pullRequests?.reviewing; + + // console.log(JSON.stringify(pulls)); + // biome-ignore lint/suspicious/noExplicitAny: + const notifications = pulls?.map((pull: any) => ({ + id: `${pull.destination.repository.full_name}-${pull.id}`, + reason: 'review_requested', + updated_at: pull.updated_on, + url: pull.links.html.href, + repository: { + full_name: pull.destination.repository.full_name, + owner: { + avatar_url: pull.destination.repository.links.avatar.href, + }, + html_url: pull.destination.repository.links.html.href, + }, + subject: { + number: pull.id, + title: pull.title, + url: pull.links.html.href, + type: 'PullRequest', + state: 'open', + user: { + login: pull.author.display_name, + html_url: pull.author.links.html.href, + avatar_url: pull.author.links.avatar.href, + type: 'User', + }, + comments: pull.comment_count, + tasks: pull.task_count, + }, + account: accountNotifications.account, + })); + + return { + account: accountNotifications.account, + notifications: notifications, + error: null, + }; + } else { + let notifications = ( + await accountNotifications.notifications + ).data.map((notification: Notification) => ({ + ...notification, + account: accountNotifications.account, + })); + + notifications = await enrichNotifications(notifications, state); + + notifications = filterNotifications(notifications, state.settings); + + return { + account: accountNotifications.account, + notifications: notifications, + error: null, + }; + } } catch (error) { log.error( 'Error occurred while fetching account notifications',