diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1d8111e6c..d06017d61 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,5 @@ * @afonsojramos @bmulholland @setchy + +# Forge adapters — see MAINTAINERS.md +/src/renderer/utils/forges/github/ @afonsojramos @setchy +/src/renderer/utils/forges/gitea/ @afonsojramos @bircni diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75aedc309..fd07af04c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,18 @@ The release process is automated. Follow the steps below. ### Project Philosophy -This project is a tool for monitoring new notifications from GitHub. It's not meant to be a full-featured GitHub client. We want to keep it simple and focused on that core functionality. We're happy to accept contributions that help us achieve that goal, but we're also happy to say no to things that don't. We're not trying to be everything to everyone. +This project is a tool for monitoring new notifications from supported Git forges. It's not meant to be a full-featured forge client. We want to keep it simple and focused on that core functionality. We're happy to accept contributions that help us achieve that goal, but we're also happy to say no to things that don't. We're not trying to be everything to everyone. + +#### Multi-forge support + +Gitify supports notifications from multiple Git forges. New forges may be added under the following conditions: + +- **Adapter-based:** the forge is implemented behind the `ForgeAdapter` interface in `src/renderer/utils/forges/`. No forge-specific branching outside the adapter module. +- **Designated maintainer:** every forge has at least one named maintainer in [`MAINTAINERS.md`](./MAINTAINERS.md) who owns triage and CI for that adapter. +- **Capability-honest UI:** features unsupported by a forge (e.g. mark-as-done) must hide gracefully, not silently no-op. +- **No core-platform churn:** Octicons, Octokit, and the Primer Design System remain in place. Octokit is scoped to the GitHub adapter; other adapters use plain `fetch`. + +Currently supported forges: **GitHub** (Cloud, Enterprise Server, Enterprise Cloud with Data Residency). #### Things we won't do @@ -100,7 +111,7 @@ This project is a tool for monitoring new notifications from GitHub. It's not me * Seeing past notifications. This is a tool for monitoring new notifications, not seeing old ones, which can be seen at https://github.com/notifications. * Specific UX/UI changes that add options and/or visual complexity for minor workflow improvements. e.g. https://github.com/gitify-app/gitify/issues/358, https://github.com/gitify-app/gitify/issues/411 and https://github.com/gitify-app/gitify/issues/979 * UI for something that isn't core to Gitify, and/or can be trivially done another way. e.g. https://github.com/gitify-app/gitify/issues/476 and https://github.com/gitify-app/gitify/issues/221 -* Support anything other than GitHub. Doing so would be a major undertaking that we may consider in future. +* Add a forge adapter without a designated maintainer who will own it long-term. [biome-website]: https://biomejs.dev/ diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..90eed36ee --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,24 @@ +# Maintainers + +Gitify is maintained by a small team. Each forge adapter has at least one designated maintainer responsible for triage, review, and CI for that adapter. + +## Core maintainers + +- [@setchy](https://github.com/setchy) +- [@afonsojramos](https://github.com/afonsojramos) + +## Forge adapter maintainers + +| Forge | Maintainer | Source | +| ------ | --------------------------------------------------- | --------------------------------------- | +| GitHub | [@setchy](https://github.com/setchy), [@afonsojramos](https://github.com/afonsojramos) | `src/renderer/utils/forges/github/` | +| Gitea | [@bircni](https://github.com/bircni), [@afonsojramos](https://github.com/afonsojramos) | `src/renderer/utils/forges/gitea/` | + +## Adding a new forge + +See [`CONTRIBUTING.md`](./CONTRIBUTING.md#multi-forge-support) for the policy. In short: + +1. Open an issue proposing the forge and volunteering as its maintainer. +2. Implement the forge behind the `ForgeAdapter` interface in `src/renderer/utils/forges//`. +3. Register the adapter in `src/renderer/utils/forges/registry.ts`. +4. Add yourself to the table above and to `CODEOWNERS` for the adapter directory. diff --git a/README.md b/README.md index 2fde30439..f6a79ae68 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,26 @@ - ⚡ Fast, native experience +## Supported forges + +Gitify uses a forge adapter pattern (see [`MAINTAINERS.md`](MAINTAINERS.md)) so notifications can come from any compatible Git forge, not just GitHub. + +| Forge | Status | Notifications | Mark read | Mark done | Unsubscribe | Enriched details | +| ----------------------------------------------- | -------------- | :-----------: | :-------: | :-------: | :---------: | :--------------: | +| **GitHub** Cloud | ✅ Supported | ✅ | ✅ | ✅ | ✅ | ✅ | +| **GitHub** Enterprise Server (≥ 3.13) | ✅ Supported | ✅ | ✅ | ✅ | ✅ | ✅ | +| **GitHub** Enterprise Cloud with Data Residency | ✅ Supported | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Gitea** (incl. Forgejo, Codeberg) | ✅ Supported | ✅ | ✅ | — | — | — | +| **GitLab** (todos) | 💭 Considering | — | — | — | — | — | +| **Bitbucket** Cloud | 💭 Considering | — | — | — | — | — | +| **Azure DevOps** | 💭 Considering | — | — | — | — | — | +| **Gerrit** | 💭 Considering | — | — | — | — | — | + +**Status legend:** ✅ Supported · 🧪 Experimental (rough edges expected) · 🚧 In progress (adapter is being worked on) · 💭 Considering (open to a maintainer picking it up). + +A new forge needs an [adapter](src/renderer/utils/forges/) plus a designated maintainer — see [the contributing guide](CONTRIBUTING.md#multi-forge-support) for the full policy. + + ## Quick Start 1. **Download** Gitify for free from [gitify.io][website]. diff --git a/codegen.ts b/codegen.ts index 27f5df507..f2b2afc95 100644 --- a/codegen.ts +++ b/codegen.ts @@ -22,16 +22,16 @@ const config: CodegenConfig = { }, }, }, - documents: ['src/renderer/utils/api/**/*.graphql'], + documents: ['src/renderer/utils/forges/github/**/*.graphql'], generates: { - 'src/renderer/utils/api/graphql/generated/graphql.ts': { + 'src/renderer/utils/forges/github/graphql/generated/graphql.ts': { plugins: ['typescript-operations', 'typed-document-node'], config: { documentMode: 'string', // enumType: 'native', scalars: { DateTime: 'string', - URI: '../../../../types#Link', + URI: '../../../../../types#Link', }, useTypeImports: true, }, diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index a40a0cc4c..2ca4cbbf9 100644 --- a/src/renderer/__mocks__/account-mocks.ts +++ b/src/renderer/__mocks__/account-mocks.ts @@ -12,6 +12,7 @@ import { getRecommendedScopeNames } from '../utils/auth/scopes'; import { mockGitifyUser } from './user-mocks'; export const mockGitHubAppAccount: Account = { + forge: 'github', platform: 'GitHub Cloud', method: 'GitHub App', token: 'token-987654321' as Token, @@ -21,6 +22,7 @@ export const mockGitHubAppAccount: Account = { }; export const mockPersonalAccessTokenAccount: Account = { + forge: 'github', platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, @@ -30,6 +32,7 @@ export const mockPersonalAccessTokenAccount: Account = { }; export const mockOAuthAccount: Account = { + forge: 'github', platform: 'GitHub Enterprise Server', method: 'OAuth App', token: 'token-1234568790' as Token, @@ -39,6 +42,7 @@ export const mockOAuthAccount: Account = { }; export const mockGitHubCloudAccount: Account = { + forge: 'github', platform: 'GitHub Cloud', method: 'Personal Access Token', token: 'token-123-456' as Token, @@ -48,6 +52,7 @@ export const mockGitHubCloudAccount: Account = { }; export const mockGitHubEnterpriseServerAccount: Account = { + forge: 'github', platform: 'GitHub Enterprise Server', method: 'Personal Access Token', token: 'token-1234568790' as Token, @@ -55,6 +60,15 @@ export const mockGitHubEnterpriseServerAccount: Account = { user: mockGitifyUser, }; +export const mockGiteaAccount: Account = { + forge: 'gitea', + platform: 'Gitea', + method: 'Personal Access Token', + token: 'token-gitea' as Token, + hostname: 'gitea.example.com' as Hostname, + user: mockGitifyUser, +}; + export function mockAccountWithError(error: GitifyError): AccountNotifications { return { account: mockGitHubCloudAccount, diff --git a/src/renderer/__mocks__/notifications-mocks.ts b/src/renderer/__mocks__/notifications-mocks.ts index abe57cb6a..ab8914d17 100644 --- a/src/renderer/__mocks__/notifications-mocks.ts +++ b/src/renderer/__mocks__/notifications-mocks.ts @@ -16,6 +16,7 @@ import { } from '../types'; import { + mockGiteaAccount, mockGitHubAppAccount, mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount, @@ -218,6 +219,12 @@ export const mockGithubEnterpriseGitifyNotifications: GitifyNotification[] = [ export const mockGitifyNotification: GitifyNotification = mockGitHubCloudGitifyNotifications[0]; +/** Same shape as cloud notification, but bound to a Gitea account. */ +export const mockGiteaGitifyNotification: GitifyNotification = { + ...mockGitifyNotification, + account: mockGiteaAccount, +}; + export const mockMultipleAccountNotifications: AccountNotifications[] = [ { account: mockGitHubCloudAccount, diff --git a/src/renderer/components/notifications/NotificationRow.test.tsx b/src/renderer/components/notifications/NotificationRow.test.tsx index 0e81a211b..b8e546976 100644 --- a/src/renderer/components/notifications/NotificationRow.test.tsx +++ b/src/renderer/components/notifications/NotificationRow.test.tsx @@ -2,7 +2,10 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../__helpers__/test-utils'; -import { mockGitifyNotification } from '../../__mocks__/notifications-mocks'; +import { + mockGiteaGitifyNotification, + mockGitifyNotification, +} from '../../__mocks__/notifications-mocks'; import { mockSettings } from '../../__mocks__/state-mocks'; import { GroupBy } from '../../types'; @@ -260,5 +263,18 @@ describe('renderer/components/notifications/NotificationRow.tsx', () => { expect(unsubscribeNotificationMock).toHaveBeenCalledTimes(1); }); + + it('should not show unsubscribe for Gitea notifications', () => { + const props: NotificationRowProps = { + notification: mockGiteaGitifyNotification, + isRepositoryAnimatingExit: false, + }; + + renderWithProviders(); + + expect( + screen.queryByTestId('notification-unsubscribe-from-thread'), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx index 579ac8680..085202d1b 100644 --- a/src/renderer/components/notifications/NotificationRow.tsx +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -10,7 +10,10 @@ import { HoverGroup } from '../primitives/HoverGroup'; import { type GitifyNotification, Opacity, Size } from '../../types'; -import { isMarkAsDoneFeatureSupported } from '../../utils/api/features'; +import { + isMarkAsDoneFeatureSupported, + isUnsubscribeThreadSupported, +} from '../../utils/api/features'; import { isGroupByDate } from '../../utils/notifications/group'; import { shouldRemoveNotificationsFromState } from '../../utils/notifications/remove'; import { openNotification } from '../../utils/system/links'; @@ -154,6 +157,7 @@ export const NotificationRow: FC = ({ { 'GitHub App', 'token', 'github.com', + 'github', ); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index dd3630a42..92023a62a 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -21,6 +21,7 @@ import type { Account, AccountNotifications, AuthState, + Forge, GitifyError, GitifyNotification, Hostname, @@ -36,8 +37,6 @@ import type { LoginPersonalAccessTokenOptions, } from '../utils/auth/types'; -import { fetchAuthenticatedUserDetails } from '../utils/api/client'; -import { clearOctokitClientCache } from '../utils/api/octokit'; import { exchangeAuthCodeForAccessToken, performGitHubWebOAuth, @@ -52,6 +51,8 @@ import { removeAccount, } from '../utils/auth/utils'; import { clearState, loadState, saveState } from '../utils/core/storage'; +import { clearOctokitClientCache } from '../utils/forges/github/octokit'; +import { getAdapterById } from '../utils/forges/registry'; import { applyKeyboardShortcut, decryptValue, @@ -72,6 +73,17 @@ import { import { zoomLevelToPercentage, zoomPercentageToLevel } from '../utils/ui/zoom'; import { defaultAuth, defaultSettings } from './defaults'; +/** + * Backfill the `forge` field on accounts persisted before multi-forge support. + * Legacy accounts default to GitHub. + */ +function migrateLegacyAuthState(auth: AuthState): AuthState { + return { + ...auth, + accounts: auth.accounts.map((a) => ({ ...a, forge: a.forge ?? 'github' })), + }; +} + export interface AppContextState { auth: AuthState; isLoggedIn: boolean; @@ -130,7 +142,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { const [auth, setAuth] = useState( existingState.auth - ? { ...defaultAuth, ...existingState.auth } + ? migrateLegacyAuthState({ ...defaultAuth, ...existingState.auth }) : defaultAuth, ); @@ -470,7 +482,13 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { await removeAccountNotifications(existingAccount); } - const updatedAuth = await addAccount(auth, 'GitHub App', token, hostname); + const updatedAuth = await addAccount( + auth, + 'GitHub App', + token, + hostname, + 'github', + ); persistAuth(updatedAuth); await fetchNotifications({ auth: updatedAuth, settings }); @@ -504,6 +522,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { 'OAuth App', token, authOptions.hostname, + 'github', ); persistAuth(updatedAuth); @@ -522,15 +541,20 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { * Login with Personal Access Token (PAT). */ const loginWithPersonalAccessToken = useCallback( - async ({ token, hostname }: LoginPersonalAccessTokenOptions) => { + async ({ token, hostname, forge }: LoginPersonalAccessTokenOptions) => { + const resolvedForge: Forge = forge ?? 'github'; const encryptedToken = (await encryptValue(token)) as Token; - await fetchAuthenticatedUserDetails({ + await getAdapterById(resolvedForge).fetchAuthenticatedUser({ + forge: resolvedForge, hostname, token: encryptedToken, } as Account); const existingAccount = auth.accounts.find( - (a) => a.hostname === hostname && a.method === 'Personal Access Token', + (a) => + a.hostname === hostname && + a.method === 'Personal Access Token' && + a.forge === resolvedForge, ); if (existingAccount) { await removeAccountNotifications(existingAccount); @@ -541,6 +565,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { 'Personal Access Token', token, hostname, + resolvedForge, ); persistAuth(updatedAuth); diff --git a/src/renderer/hooks/useNotifications.test.ts b/src/renderer/hooks/useNotifications.test.ts index 4c24e492c..a0217e0fb 100644 --- a/src/renderer/hooks/useNotifications.test.ts +++ b/src/renderer/hooks/useNotifications.test.ts @@ -5,6 +5,7 @@ import { mockGitHubEnterpriseServerAccount, } from '../__mocks__/account-mocks'; import { + mockGiteaGitifyNotification, mockGitHubCloudGitifyNotifications, mockGitifyNotification, mockMultipleAccountNotifications, @@ -12,9 +13,9 @@ import { } from '../__mocks__/notifications-mocks'; import { mockAuth, mockSettings, mockState } from '../__mocks__/state-mocks'; -import * as apiClient from '../utils/api/client'; import { Errors } from '../utils/core/errors'; import * as logger from '../utils/core/logger'; +import * as apiClient from '../utils/forges/github/client'; import * as notificationsUtils from '../utils/notifications/notifications'; import * as sound from '../utils/system/audio'; import * as native from '../utils/system/native'; @@ -529,8 +530,17 @@ describe('renderer/hooks/useNotifications.ts', () => { expect(result.current.notifications.length).toBe(0); }); - it('should not mark as done when account does not support the feature', async () => { - // GitHub Enterprise Server without version doesn't support mark as done + it('should fall back to mark as read when mark-as-done is not supported', async () => { + // GitHub Enterprise Server without version doesn't support mark as done; + // the action falls back to mark-as-read so the user-visible behaviour + // (the thread leaves the inbox) is preserved across forges/versions. + const readSpy = vi + .spyOn(apiClient, 'markNotificationThreadAsRead') + .mockResolvedValue(undefined); + const doneSpy = vi + .spyOn(apiClient, 'markNotificationThreadAsDone') + .mockResolvedValue(undefined); + const mockEnterpriseNotification = { ...mockGitifyNotification, account: mockGitHubEnterpriseServerAccount, // No version set @@ -538,16 +548,21 @@ describe('renderer/hooks/useNotifications.ts', () => { const { result } = renderHook(() => useNotifications()); - // The API should NOT be called when account doesn't support the feature act(() => { result.current.markNotificationsAsDone(mockState, [ mockEnterpriseNotification, ]); }); - // Status should remain 'success' (not change to 'loading' since we return early) - expect(result.current.status).toBe('success'); - // No API calls should have been made - nock will fail if unexpected calls are made + await waitFor(() => { + expect(result.current.status).toBe('success'); + }); + + expect(readSpy).toHaveBeenCalledWith( + mockGitHubEnterpriseServerAccount, + mockEnterpriseNotification.id, + ); + expect(doneSpy).not.toHaveBeenCalled(); }); it('should mark notifications as done with failure', async () => { @@ -573,6 +588,27 @@ describe('renderer/hooks/useNotifications.ts', () => { }); describe('unsubscribeNotification', () => { + it('should not call unsubscribe APIs when thread ignore is unsupported (Gitea)', async () => { + const ignoreSpy = vi.spyOn( + apiClient, + 'ignoreNotificationThreadSubscription', + ); + const readSpy = vi.spyOn(apiClient, 'markNotificationThreadAsRead'); + + const { result } = renderHook(() => useNotifications()); + + act(() => { + result.current.unsubscribeNotification( + mockState, + mockGiteaGitifyNotification, + ); + }); + + expect(ignoreSpy).not.toHaveBeenCalled(); + expect(readSpy).not.toHaveBeenCalled(); + expect(result.current.status).toBe('success'); + }); + it('should unsubscribe from a notification with success - markAsDoneOnUnsubscribe = false', async () => { vi.spyOn( apiClient, diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index 3c924c447..eef88d79a 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -9,18 +9,13 @@ import type { Status, } from '../types'; -import { - ignoreNotificationThreadSubscription, - markNotificationThreadAsDone, - markNotificationThreadAsRead, -} from '../utils/api/client'; -import { isMarkAsDoneFeatureSupported } from '../utils/api/features'; import { getAccountUUID } from '../utils/auth/utils'; import { areAllAccountErrorsSame, doesAllAccountsHaveErrors, } from '../utils/core/errors'; import { rendererLogError, toError } from '../utils/core/logger'; +import { getAdapter } from '../utils/forges/registry'; import { getAllNotifications, getNotificationCount, @@ -168,7 +163,10 @@ export const useNotifications = (): NotificationsState => { try { await Promise.all( readNotifications.map((notification) => - markNotificationThreadAsRead(notification.account, notification.id), + getAdapter(notification.account).markThreadAsRead( + notification.account, + notification.id, + ), ), ); @@ -195,10 +193,16 @@ export const useNotifications = (): NotificationsState => { const markNotificationsAsDone = useCallback( async (state: GitifyState, doneNotifications: GitifyNotification[]) => { - if ( - !state.settings || - !isMarkAsDoneFeatureSupported(doneNotifications[0].account) - ) { + if (!state.settings) { + return; + } + + const account = doneNotifications[0].account; + + // Forges that don't support a distinct "done" state fall back to + // marking as read so the user-visible action still removes the thread. + if (!getAdapter(account).capabilities.markAsDone(account)) { + await markNotificationsAsRead(state, doneNotifications); return; } @@ -207,7 +211,10 @@ export const useNotifications = (): NotificationsState => { try { await Promise.all( doneNotifications.map((notification) => - markNotificationThreadAsDone(notification.account, notification.id), + getAdapter(notification.account).markThreadAsDone( + notification.account, + notification.id, + ), ), ); @@ -229,7 +236,7 @@ export const useNotifications = (): NotificationsState => { setStatus('success'); }, - [notifications], + [notifications, markNotificationsAsRead], ); const unsubscribeNotification = useCallback( @@ -238,13 +245,18 @@ export const useNotifications = (): NotificationsState => { return; } + const adapter = getAdapter(notification.account); + + // Forges without thread-subscription support cannot unsubscribe; the UI + // already hides the action, but treat duplicate calls as no-ops. + if (!adapter.capabilities.unsubscribeThread(notification.account)) { + return; + } + setStatus('loading'); try { - await ignoreNotificationThreadSubscription( - notification.account, - notification.id, - ); + await adapter.unsubscribeThread(notification.account, notification.id); if (state.settings.markAsDoneOnUnsubscribe) { await markNotificationsAsDone(state, [notification]); diff --git a/src/renderer/routes/Accounts.test.tsx b/src/renderer/routes/Accounts.test.tsx index 1eb4462d1..55289eee9 100644 --- a/src/renderer/routes/Accounts.test.tsx +++ b/src/renderer/routes/Accounts.test.tsx @@ -272,5 +272,25 @@ describe('renderer/routes/Accounts.tsx', () => { replace: true, }); }); + + it('should show login with gitea personal access token', async () => { + await act(async () => { + renderWithProviders(, { + auth: { accounts: [mockOAuthAccount] }, + }); + }); + + await userEvent.click(screen.getByTestId('account-add-new')); + await userEvent.click(screen.getByTestId('account-add-gitea-pat')); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith( + '/login-personal-access-token', + { + replace: true, + state: { forge: 'gitea' }, + }, + ); + }); }); }); diff --git a/src/renderer/routes/Accounts.tsx b/src/renderer/routes/Accounts.tsx index 096f55e49..76a9e8477 100644 --- a/src/renderer/routes/Accounts.tsx +++ b/src/renderer/routes/Accounts.tsx @@ -7,6 +7,7 @@ import { MarkGithubIcon, PersonAddIcon, PersonIcon, + ServerIcon, ShieldCheckIcon, SignOutIcon, StarFillIcon, @@ -112,6 +113,13 @@ export const AccountsRoute: FC = () => { return navigate('/login-personal-access-token', { replace: true }); }; + const loginWithGiteaPersonalAccessToken = () => { + return navigate('/login-personal-access-token', { + replace: true, + state: { forge: 'gitea' as const }, + }); + }; + const loginWithOAuthApp = () => { return navigate('/login-oauth-app', { replace: true }); }; @@ -356,6 +364,16 @@ export const AccountsRoute: FC = () => { Login with OAuth App + + loginWithGiteaPersonalAccessToken()} + > + + + + Login with Gitea (Personal Access Token) + diff --git a/src/renderer/routes/Login.test.tsx b/src/renderer/routes/Login.test.tsx index 2bbd0de35..dfc561a96 100644 --- a/src/renderer/routes/Login.test.tsx +++ b/src/renderer/routes/Login.test.tsx @@ -55,4 +55,15 @@ describe('renderer/routes/Login.tsx', () => { expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledWith('/login-oauth-app'); }); + + it('should navigate to login with Gitea personal access token', async () => { + renderWithProviders(, { isLoggedIn: false }); + + await userEvent.click(screen.getByTestId('login-gitea-pat')); + + expect(navigateMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith('/login-personal-access-token', { + state: { forge: 'gitea' }, + }); + }); }); diff --git a/src/renderer/routes/Login.tsx b/src/renderer/routes/Login.tsx index 4e0183278..96b3a42eb 100644 --- a/src/renderer/routes/Login.tsx +++ b/src/renderer/routes/Login.tsx @@ -1,7 +1,6 @@ import { type FC, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { KeyIcon, MarkGithubIcon, PersonIcon } from '@primer/octicons-react'; import { Button, Heading, Stack, Text } from '@primer/react'; import { useAppContext } from '../hooks/useAppContext'; @@ -11,10 +10,12 @@ import { Centered } from '../components/layout/Centered'; import { Size } from '../types'; +import { listAdapters } from '../utils/forges/registry'; import { showWindow } from '../utils/system/comms'; export const LoginRoute: FC = () => { const navigate = useNavigate(); + const adapters = listAdapters(); const { isLoggedIn } = useAppContext(); @@ -31,37 +32,36 @@ export const LoginRoute: FC = () => { - GitHub Notifications + Notifications on your menu bar - - Login with - - - - - - + + {adapters.map((adapter) => ( + + {adapter.displayName} + {adapter.loginMethods.map((method) => ( + + ))} + + ))} diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index 6b364ff36..82fb6d3a1 100644 --- a/src/renderer/routes/LoginWithPersonalAccessToken.tsx +++ b/src/renderer/routes/LoginWithPersonalAccessToken.tsx @@ -18,8 +18,6 @@ import { Tooltip, } from '@primer/react'; -import { Constants } from '../constants'; - import { useAppContext } from '../hooks/useAppContext'; import { Contents } from '../components/layout/Contents'; @@ -27,20 +25,17 @@ import { Page } from '../components/layout/Page'; import { Footer } from '../components/primitives/Footer'; import { Header } from '../components/primitives/Header'; -import type { Account, Hostname, Token } from '../types'; -import type { LoginPersonalAccessTokenOptions } from '../utils/auth/types'; +import type { Account, Forge, Hostname, Token } from '../types'; import { formatRecommendedOAuthScopes } from '../utils/auth/scopes'; -import { - getNewTokenURL, - isValidHostname, - isValidToken, -} from '../utils/auth/utils'; +import { isValidHostname } from '../utils/auth/utils'; import { rendererLogError, toError } from '../utils/core/logger'; +import { getAdapterById } from '../utils/forges/registry'; import { openExternalLink } from '../utils/system/comms'; interface LocationState { account?: Account; + forge?: Forge; } export interface IFormData { @@ -54,8 +49,12 @@ interface IFormErrors { invalidCredentialsForHost?: string; } -export const validateForm = (values: IFormData): IFormErrors => { +export const validateForm = ( + values: IFormData, + forge: Forge = 'github', +): IFormErrors => { const errors: IFormErrors = {}; + const adapter = getAdapterById(forge); if (!values.hostname) { errors.hostname = 'Hostname is required'; @@ -65,7 +64,7 @@ export const validateForm = (values: IFormData): IFormErrors => { if (!values.token) { errors.token = 'Token is required'; - } else if (!isValidToken(values.token)) { + } else if (!adapter.validateToken(values.token)) { errors.token = 'Token format is invalid'; } @@ -75,7 +74,12 @@ export const validateForm = (values: IFormData): IFormErrors => { export const LoginWithPersonalAccessTokenRoute: FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { account: reAuthAccount } = (location.state ?? {}) as LocationState; + const { account: reAuthAccount, forge: stateForge } = (location.state ?? + {}) as LocationState; + + const forge: Forge = reAuthAccount?.forge ?? stateForge ?? 'github'; + const adapter = getAdapterById(forge); + const isGitea = forge === 'gitea'; const { loginWithPersonalAccessToken } = useAppContext(); @@ -84,7 +88,8 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const [isVerifyingCredentials, setIsVerifyingCredentials] = useState(false); const [formData, setFormData] = useState({ - hostname: reAuthAccount?.hostname ?? Constants.GITHUB_HOSTNAME, + hostname: + reAuthAccount?.hostname ?? adapter.defaultHostname ?? ('' as Hostname), token: '' as Token, } as IFormData); @@ -92,7 +97,7 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const handleSubmit = async () => { setIsVerifyingCredentials(true); - const newErrors = validateForm(formData); + const newErrors = validateForm(formData, forge); setErrors(newErrors); @@ -113,9 +118,11 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { const verifyLoginCredentials = useCallback( async (data: IFormData) => { try { - await loginWithPersonalAccessToken( - data as LoginPersonalAccessTokenOptions, - ); + await loginWithPersonalAccessToken({ + hostname: data.hostname, + token: data.token, + forge, + }); navigate('/'); } catch (err) { rendererLogError( @@ -128,12 +135,16 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { }); } }, - [loginWithPersonalAccessToken], + [loginWithPersonalAccessToken, forge, navigate], ); return ( -
Login with Personal Access Token
+
+ {isGitea + ? 'Login to Gitea with Personal Access Token' + : 'Login with Personal Access Token'} +
{errors.invalidCredentialsForHost && ( @@ -156,7 +167,9 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { Hostname - Change only if you are using GitHub Enterprise Server + {isGitea + ? 'Your Gitea instance hostname (for example gitea.example.com)' + : 'Change only if you are using GitHub Enterprise Server'} { data-testid="login-hostname" name="hostname" onChange={handleInputChange} - placeholder="github.com" + placeholder={isGitea ? 'gitea.example.com' : 'github.com'} value={formData.hostname} /> {errors.hostname && ( @@ -182,26 +195,34 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { disabled={!formData.hostname} leadingVisual={KeyIcon} onClick={() => - openExternalLink(getNewTokenURL(formData.hostname)) + openExternalLink( + adapter.getPersonalAccessTokenSettingsUrl( + formData.hostname, + ), + ) } size="small" > - Generate a PAT + {isGitea ? 'Open token settings' : 'Generate a PAT'} - on GitHub to paste the token below. + {isGitea + ? 'on your Gitea instance to create a token, then paste it below.' + : 'on GitHub to paste the token below.'} - - The{' '} - - - {' '} - will be automatically selected for you. - + {!isGitea && ( + + The{' '} + + + {' '} + will be automatically selected for you. + + )} @@ -209,16 +230,15 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { {