From 20ce142069a6235215cdfe20b9be2a70bf872c5a Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:14:28 +0200 Subject: [PATCH 01/26] docs(contributing): replace github-only stance with multi-forge policy --- CONTRIBUTING.md | 15 +++++++++++++-- MAINTAINERS.md | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 MAINTAINERS.md 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. From 8ab27642a8941537e0c11f77fe85be9ef7455f69 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:18:26 +0200 Subject: [PATCH 02/26] feat(types): add Forge type and required Account.forge field with migration --- src/renderer/__mocks__/account-mocks.ts | 5 ++++ src/renderer/context/App.test.tsx | 1 + src/renderer/context/App.tsx | 33 ++++++++++++++++++++++--- src/renderer/types.ts | 4 +++ src/renderer/utils/auth/platform.ts | 18 +++++++++++++- src/renderer/utils/auth/types.ts | 6 ++++- src/renderer/utils/auth/utils.test.ts | 4 +++ src/renderer/utils/auth/utils.ts | 7 ++++-- 8 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/renderer/__mocks__/account-mocks.ts b/src/renderer/__mocks__/account-mocks.ts index a40a0cc4c..4ef75021e 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, diff --git a/src/renderer/context/App.test.tsx b/src/renderer/context/App.test.tsx index 17dd588ae..54d55b497 100644 --- a/src/renderer/context/App.test.tsx +++ b/src/renderer/context/App.test.tsx @@ -274,6 +274,7 @@ describe('renderer/context/App.tsx', () => { 'GitHub App', 'token', 'github.com', + 'github', ); }); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index dd3630a42..99e8232f0 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, @@ -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({ + 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/types.ts b/src/renderer/types.ts index 8176b6a79..cc42b8209 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -47,7 +47,11 @@ export type KeyboardAcceleratorShortcut = Branded< 'KeyboardAcceleratorShortcut' >; +/** Code hosting provider for an account. New forges register themselves here. */ +export type Forge = 'github' | 'gitea'; + export interface Account { + forge: Forge; method: AuthMethod; platform: PlatformType; version?: string; diff --git a/src/renderer/utils/auth/platform.ts b/src/renderer/utils/auth/platform.ts index c6b1ef002..db05e7a39 100644 --- a/src/renderer/utils/auth/platform.ts +++ b/src/renderer/utils/auth/platform.ts @@ -1,8 +1,24 @@ import { Constants } from '../../constants'; -import type { Hostname } from '../../types'; +import type { Forge, Hostname } from '../../types'; import type { PlatformType } from './types'; +/** + * Resolve the UI platform label from forge + hostname. + * + * Gitea always reports as 'Gitea'; GitHub varies by hostname (Cloud, Enterprise + * Server, Enterprise Cloud with Data Residency). + */ +export function resolvePlatform( + forge: Forge, + hostname: Hostname, +): PlatformType { + if (forge === 'gitea') { + return 'Gitea'; + } + return getPlatformFromHostname(hostname); +} + export function getPlatformFromHostname(hostname: string): PlatformType { if (hostname.endsWith(Constants.GITHUB_HOSTNAME)) { return 'GitHub Cloud'; diff --git a/src/renderer/utils/auth/types.ts b/src/renderer/utils/auth/types.ts index 1b7be6db5..303a15c28 100644 --- a/src/renderer/utils/auth/types.ts +++ b/src/renderer/utils/auth/types.ts @@ -2,6 +2,7 @@ import type { AuthCode, ClientID, ClientSecret, + Forge, Hostname, Token, } from '../../types'; @@ -11,7 +12,8 @@ export type AuthMethod = 'GitHub App' | 'Personal Access Token' | 'OAuth App'; export type PlatformType = | 'GitHub Cloud' | 'GitHub Enterprise Server' - | 'GitHub Enterprise Cloud with Data Residency'; + | 'GitHub Enterprise Cloud with Data Residency' + | 'Gitea'; export interface LoginOAuthWebOptions { hostname: Hostname; @@ -38,6 +40,8 @@ export type DeviceFlowErrorResponse = { export interface LoginPersonalAccessTokenOptions { hostname: Hostname; token: Token; + /** Defaults to GitHub when omitted. */ + forge?: Forge; } export interface AuthResponse { diff --git a/src/renderer/utils/auth/utils.test.ts b/src/renderer/utils/auth/utils.test.ts index 66316c5ab..4496757bc 100644 --- a/src/renderer/utils/auth/utils.test.ts +++ b/src/renderer/utils/auth/utils.test.ts @@ -71,6 +71,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(result.accounts).toEqual([ { + forge: 'github', hostname: 'github.com' as Hostname, method: 'Personal Access Token', platform: 'GitHub Cloud', @@ -97,6 +98,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(result.accounts).toEqual([ { + forge: 'github', hostname: 'github.com' as Hostname, method: 'OAuth App', platform: 'GitHub Cloud', @@ -137,6 +139,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(result.accounts).toEqual([ { + forge: 'github', hostname: 'github.gitify.io' as Hostname, method: 'Personal Access Token', platform: 'GitHub Enterprise Server', @@ -163,6 +166,7 @@ describe('renderer/utils/auth/utils.ts', () => { expect(result.accounts).toEqual([ { + forge: 'github', hostname: 'github.gitify.io' as Hostname, method: 'OAuth App', platform: 'GitHub Enterprise Server', diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 45c471dac..91756d0ef 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -10,6 +10,7 @@ import type { AccountUUID, AuthState, ClientID, + Forge, Hostname, Link, Token, @@ -25,7 +26,7 @@ import { toError, } from '../core/logger'; import { encryptValue } from '../system/comms'; -import { getPlatformFromHostname } from './platform'; +import { getPlatformFromHostname, resolvePlatform } from './platform'; import { getRecommendedScopeNames, hasRequiredScopes } from './scopes'; /** @@ -46,14 +47,16 @@ export async function addAccount( method: AuthMethod, token: Token, hostname: Hostname, + forge: Forge = 'github', ): Promise { const accountList = auth.accounts; const encryptedToken = await encryptValue(token); let newAccount = { + forge, hostname: hostname, method: method, - platform: getPlatformFromHostname(hostname), + platform: resolvePlatform(forge, hostname), token: encryptedToken, user: null, // Will be updated during the refresh call below } as Account; From a43767b925bc943a0de97ff231d47d5db18f42df Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:26:09 +0200 Subject: [PATCH 03/26] feat(forges): add ForgeAdapter interface, registry, and GitHub adapter --- src/renderer/utils/forges/github/adapter.ts | 109 +++++++++++++++++ .../utils/forges/github/capabilities.ts | 40 +++++++ src/renderer/utils/forges/registry.ts | 41 +++++++ src/renderer/utils/forges/types.ts | 111 ++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 src/renderer/utils/forges/github/adapter.ts create mode 100644 src/renderer/utils/forges/github/capabilities.ts create mode 100644 src/renderer/utils/forges/registry.ts create mode 100644 src/renderer/utils/forges/types.ts diff --git a/src/renderer/utils/forges/github/adapter.ts b/src/renderer/utils/forges/github/adapter.ts new file mode 100644 index 000000000..f8f5891f2 --- /dev/null +++ b/src/renderer/utils/forges/github/adapter.ts @@ -0,0 +1,109 @@ +import { + KeyIcon, + MarkGithubIcon, + PersonIcon, +} from '@primer/octicons-react'; + +import { Constants } from '../../../constants'; + +import type { + Account, + GitifyNotification, + Hostname, + Link, + SettingsState, + Token, +} from '../../../types'; + +import { + fetchAuthenticatedUserDetails, + ignoreNotificationThreadSubscription, + listNotificationsForAuthenticatedUser, + markNotificationThreadAsDone, + markNotificationThreadAsRead, +} from '../../api/client'; +import { createOctokitClient } from '../../api/octokit'; +import { transformNotifications } from '../../api/transform'; +import { + getDeveloperSettingsURL as legacyGetDeveloperSettingsURL, + getNewTokenURL as legacyGetNewTokenURL, + isValidToken as legacyIsValidToken, +} from '../../auth/utils'; +import type { ForgeAdapter, RefreshAccountData } from '../types'; +import { githubCapabilities } from './capabilities'; + +async function fetchAuthenticatedUser( + account: Account, +): Promise { + const response = await fetchAuthenticatedUserDetails(account); + return { + data: response.data as RefreshAccountData['data'], + headers: response.headers as RefreshAccountData['headers'], + }; +} + +async function listNotifications( + account: Account, + settings: SettingsState, +): Promise { + const raw = await listNotificationsForAuthenticatedUser(account, settings); + return transformNotifications(raw, account); +} + +async function followUrl(account: Account, url: Link): Promise { + const octokit = await createOctokitClient(account, 'rest'); + const response = await octokit.request('GET {+url}', { url }); + return response.data as T; +} + +export const githubAdapter: ForgeAdapter = { + id: 'github', + displayName: 'GitHub', + icon: MarkGithubIcon, + capabilities: githubCapabilities, + + fetchAuthenticatedUser, + listNotifications, + + markThreadAsRead: async (account, threadId) => { + await markNotificationThreadAsRead(account, threadId); + }, + markThreadAsDone: async (account, threadId) => { + await markNotificationThreadAsDone(account, threadId); + }, + unsubscribeThread: async (account, threadId) => { + await ignoreNotificationThreadSubscription(account, threadId); + }, + + followUrl, + + defaultHostname: Constants.GITHUB_HOSTNAME, + validateToken: (token: Token) => legacyIsValidToken(token), + getPersonalAccessTokenSettingsUrl: (hostname: Hostname) => + legacyGetNewTokenURL(hostname), + getDeveloperSettingsUrl: (account: Account) => + legacyGetDeveloperSettingsURL(account), + documentationUrl: Constants.GITHUB_DOCS.PAT_URL as Link, + + loginMethods: [ + { + testId: 'login-github', + icon: MarkGithubIcon, + label: 'GitHub', + variant: 'primary', + route: '/login-device-flow', + }, + { + testId: 'login-pat', + icon: KeyIcon, + label: 'Personal Access Token', + route: '/login-personal-access-token', + }, + { + testId: 'login-oauth-app', + icon: PersonIcon, + label: 'OAuth App', + route: '/login-oauth-app', + }, + ], +}; diff --git a/src/renderer/utils/forges/github/capabilities.ts b/src/renderer/utils/forges/github/capabilities.ts new file mode 100644 index 000000000..9f5339c14 --- /dev/null +++ b/src/renderer/utils/forges/github/capabilities.ts @@ -0,0 +1,40 @@ +import semver from 'semver'; + +import type { Account } from '../../../types'; + +import { isEnterpriseServerHost } from '../../auth/platform'; +import type { ForgeCapabilities } from '../types'; + +/** + * GitHub feature capabilities. + * + * GitHub Cloud and GitHub Enterprise Cloud with Data Residency support + * everything; GitHub Enterprise Server gates certain features behind a + * minimum version. + */ +export const githubCapabilities: ForgeCapabilities = { + markAsDone(account: Account): boolean { + if (!isEnterpriseServerHost(account.hostname)) { + return true; + } + if (account.version) { + return semver.gte(account.version, '3.13.0'); + } + return false; + }, + unsubscribeThread(): boolean { + return true; + }, + enrichment(): boolean { + return true; + }, + answeredDiscussion(account: Account): boolean { + if (!isEnterpriseServerHost(account.hostname)) { + return true; + } + if (account.version) { + return semver.gte(account.version, '3.12.0'); + } + return false; + }, +}; diff --git a/src/renderer/utils/forges/registry.ts b/src/renderer/utils/forges/registry.ts new file mode 100644 index 000000000..6c3968d6c --- /dev/null +++ b/src/renderer/utils/forges/registry.ts @@ -0,0 +1,41 @@ +import type { Account, Forge } from '../../types'; +import type { ForgeAdapter } from './types'; + +import { githubAdapter } from './github/adapter'; + +/** + * Central forge adapter registry. + * + * Adding a new forge is one entry in this map. Shared code routes through + * `getAdapter(account)` and never imports forge-specific modules directly. + */ +const ADAPTERS: Partial> = { + github: githubAdapter, +}; + +/** + * Resolve the adapter for a given account. + * + * Throws if the account's forge is not registered — this should be impossible + * once `Account.forge` is required and migration has run, but we surface a + * loud error rather than crashing on a property access. + */ +export function getAdapter(account: Account): ForgeAdapter { + const adapter = ADAPTERS[account.forge]; + if (!adapter) { + throw new Error(`No forge adapter registered for "${account.forge}"`); + } + return adapter; +} + +export function getAdapterById(forge: Forge): ForgeAdapter { + const adapter = ADAPTERS[forge]; + if (!adapter) { + throw new Error(`No forge adapter registered for "${forge}"`); + } + return adapter; +} + +export function listAdapters(): ForgeAdapter[] { + return Object.values(ADAPTERS); +} diff --git a/src/renderer/utils/forges/types.ts b/src/renderer/utils/forges/types.ts new file mode 100644 index 000000000..ef75ff743 --- /dev/null +++ b/src/renderer/utils/forges/types.ts @@ -0,0 +1,111 @@ +import type { FC } from 'react'; + +import type { OcticonProps } from '@primer/octicons-react'; + +import type { + Account, + Forge, + GitifyNotification, + Hostname, + Link, + SettingsState, + Token, +} from '../../types'; + +/** + * Capability flags exposed by a forge adapter. + * + * Each capability is a function over the account because for some forges (e.g. + * GitHub Enterprise Server) capabilities depend on the hostname and version. + */ +export interface ForgeCapabilities { + /** Whether the forge supports a "mark as done" action distinct from "mark as read". */ + markAsDone(account: Account): boolean; + /** Whether the forge supports ignoring a thread's subscription (unsubscribe). */ + unsubscribeThread(account: Account): boolean; + /** Whether the forge supports detailed notification enrichment (typically GraphQL). */ + enrichment(account: Account): boolean; + /** Whether the forge surfaces an "answered" state for discussions. */ + answeredDiscussion(account: Account): boolean; +} + +/** + * Refreshed account data returned by `fetchAuthenticatedUser`. + * + * Uses the snake_case shape produced by the GitHub REST API so the existing + * GitHub-side `refreshAccount` logic continues to work unmodified. Other + * adapters map their native response into this shape. + */ +export interface RefreshAccountData { + data: { + id: string | number; + login: string; + name?: string | null; + avatar_url?: string; + }; + headers: Record; +} + +/** + * A login entry rendered in the Login route's forge section. + */ +export interface LoginMethodDescriptor { + testId: string; + icon: FC; + label: string; + variant?: 'primary' | 'default'; + /** Route to navigate to. */ + route: string; + /** Optional router state to pass with navigation. */ + state?: Record; +} + +/** + * The contract every forge adapter must implement. + * + * Goal: shared code (notifications orchestrator, hooks, UI) routes through + * `getAdapter(account)` and never imports forge-specific modules directly. + */ +export interface ForgeAdapter { + readonly id: Forge; + /** User-facing forge name (e.g. "GitHub", "Gitea"). */ + readonly displayName: string; + /** Icon used for the platform in the UI. */ + readonly icon: FC; + /** Static or computed capability matrix for this forge. */ + readonly capabilities: ForgeCapabilities; + + /** Fetch the authenticated user (used during login & on refresh). */ + fetchAuthenticatedUser(account: Account): Promise; + + /** List notifications (already transformed to GitifyNotification). */ + listNotifications( + account: Account, + settings: SettingsState, + ): Promise; + + markThreadAsRead(account: Account, threadId: string): Promise; + markThreadAsDone(account: Account, threadId: string): Promise; + unsubscribeThread(account: Account, threadId: string): Promise; + + /** + * GET an arbitrary forge URL and return JSON. Used by notification + * handlers to follow subject/comment URLs. + */ + followUrl(account: Account, url: Link): Promise; + + // --- Login & token UX --- + + /** Default hostname pre-filled in the PAT login form. */ + defaultHostname?: Hostname; + /** Whether the supplied token matches the forge's PAT format. */ + validateToken(token: Token): boolean; + /** URL to manage/create a personal access token on the forge. */ + getPersonalAccessTokenSettingsUrl(hostname: Hostname): Link; + /** URL to the developer settings page for the account's auth method. */ + getDeveloperSettingsUrl(account: Account): Link; + /** Login entries rendered in the Login route. */ + loginMethods: ReadonlyArray; + /** External documentation link shown in the PAT login route. */ + documentationUrl: Link; +} From 2078d8563ea440606a999ee6fa815a9b4da3fa9c Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:26:19 +0200 Subject: [PATCH 04/26] refactor(forges): route notifications, features, and auth through adapter --- .../notifications/NotificationRow.tsx | 6 ++- src/renderer/context/App.tsx | 4 +- src/renderer/hooks/useNotifications.ts | 46 ++++++++++++------- src/renderer/utils/api/client.ts | 6 +-- src/renderer/utils/api/features.ts | 40 ++++++---------- src/renderer/utils/auth/scopes.ts | 6 +++ src/renderer/utils/auth/utils.ts | 12 ++--- .../utils/notifications/notifications.ts | 28 ++++++----- 8 files changed, 81 insertions(+), 67 deletions(-) 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 = ({ { 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, diff --git a/src/renderer/hooks/useNotifications.ts b/src/renderer/hooks/useNotifications.ts index 3c924c447..8e80d076d 100644 --- a/src/renderer/hooks/useNotifications.ts +++ b/src/renderer/hooks/useNotifications.ts @@ -9,13 +9,8 @@ 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 { getAdapter } from '../utils/forges/registry'; import { areAllAccountErrorsSame, doesAllAccountsHaveErrors, @@ -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/utils/api/client.ts b/src/renderer/utils/api/client.ts index e4da8a475..700ef0fe2 100644 --- a/src/renderer/utils/api/client.ts +++ b/src/renderer/utils/api/client.ts @@ -17,8 +17,8 @@ import type { MarkNotificationThreadAsReadResponse, } from './types'; +import { githubCapabilities } from '../forges/github/capabilities'; import { createNotificationHandler } from '../notifications/handlers'; -import { isAnsweredDiscussionFeatureSupported } from './features'; import { FetchDiscussionByNumberDocument, type FetchDiscussionByNumberQuery, @@ -228,7 +228,7 @@ export async function fetchDiscussionByNumber( firstLabels: Constants.GRAPHQL_ARGS.FIRST_LABELS, lastThreadedComments: Constants.GRAPHQL_ARGS.LAST_THREADED_COMMENTS, lastReplies: Constants.GRAPHQL_ARGS.LAST_REPLIES, - includeIsAnswered: isAnsweredDiscussionFeatureSupported( + includeIsAnswered: githubCapabilities.answeredDiscussion( notification.account, ), }, @@ -327,7 +327,7 @@ export async function fetchNotificationDetailsForList( } builder.setSharedVariables({ - includeIsAnswered: isAnsweredDiscussionFeatureSupported( + includeIsAnswered: githubCapabilities.answeredDiscussion( notifications[0].account, ), firstClosingIssues: Constants.GRAPHQL_ARGS.FIRST_CLOSING_ISSUES, diff --git a/src/renderer/utils/api/features.ts b/src/renderer/utils/api/features.ts index 72847c052..085cdfda7 100644 --- a/src/renderer/utils/api/features.ts +++ b/src/renderer/utils/api/features.ts @@ -1,41 +1,31 @@ -import semver from 'semver'; - import type { Account } from '../../types'; -import { isEnterpriseServerHost } from '../auth/platform'; +import { getAdapter } from '../forges/registry'; /** - * Check if the "Mark as done" feature is supported for the given account. + * Whether the account's forge supports a distinct "mark as done" action. * - * GitHub Cloud or GitHub Enterprise Server 3.13 or newer is required to support this feature. + * Capability resolution is delegated to the forge adapter; for example, GitHub + * Enterprise Server requires version 3.13 or newer, while Gitea has no + * equivalent and always reports false. */ export function isMarkAsDoneFeatureSupported(account: Account): boolean { - if (isEnterpriseServerHost(account.hostname)) { - if (account.version) { - return semver.gte(account.version, '3.13.0'); - } - - return false; - } - - return true; + return getAdapter(account).capabilities.markAsDone(account); } /** - * Check if the "answered" discussions are supported for the given account. - * - * GitHub Cloud or GitHub Enterprise Server 3.12 or newer is required to support this feature. + * Whether the account's forge surfaces an "answered" discussion state during + * notification enrichment. */ export function isAnsweredDiscussionFeatureSupported( account: Account, ): boolean { - if (isEnterpriseServerHost(account.hostname)) { - if (account.version) { - return semver.gte(account.version, '3.12.0'); - } - - return false; - } + return getAdapter(account).capabilities.answeredDiscussion(account); +} - return true; +/** + * Whether the account's forge supports ignoring a thread subscription. + */ +export function isUnsubscribeThreadSupported(account: Account): boolean { + return getAdapter(account).capabilities.unsubscribeThread(account); } diff --git a/src/renderer/utils/auth/scopes.ts b/src/renderer/utils/auth/scopes.ts index d2e3a7f51..d1b1581f2 100644 --- a/src/renderer/utils/auth/scopes.ts +++ b/src/renderer/utils/auth/scopes.ts @@ -5,9 +5,15 @@ import type { Account } from '../../types'; /** * Return true if the account has all required OAuth scopes. * + * Scopes only apply to GitHub OAuth tokens; non-GitHub forges do not expose a + * comparable scope header, so they are treated as already satisfying the check. + * * @param account - The account whose scopes to check. */ export function hasRequiredScopes(account: Account): boolean { + if (account.forge !== 'github') { + return true; + } return Constants.OAUTH_SCOPES.REQUIRED.every(({ name }) => (account.scopes ?? []).includes(name), ); diff --git a/src/renderer/utils/auth/utils.ts b/src/renderer/utils/auth/utils.ts index 91756d0ef..378e76741 100644 --- a/src/renderer/utils/auth/utils.ts +++ b/src/renderer/utils/auth/utils.ts @@ -17,8 +17,8 @@ import type { } from '../../types'; import type { AuthMethod } from './types'; -import { fetchAuthenticatedUserDetails } from '../api/client'; import { clearOctokitClientCacheForAccount } from '../api/octokit'; +import { getAdapter } from '../forges/registry'; import { rendererLogError, rendererLogInfo, @@ -115,7 +115,7 @@ export function removeAccount(auth: AuthState, account: Account): AuthState { */ export async function refreshAccount(account: Account): Promise { try { - const response = await fetchAuthenticatedUserDetails(account); + const response = await getAdapter(account).fetchAuthenticatedUser(account); const user = response.data; @@ -123,14 +123,12 @@ export async function refreshAccount(account: Account): Promise { account.user = { id: String(user.id), login: user.login, - name: user.name, - avatar: user.avatar_url as Link, + name: user.name ?? null, + avatar: (user.avatar_url ?? '') as Link, }; - account.version = 'latest'; - account.version = extractHostVersion( - response.headers['x-github-enterprise-version'] as string, + response.headers['x-github-enterprise-version'], ); const accountScopes = response.headers['x-oauth-scopes'] diff --git a/src/renderer/utils/notifications/notifications.ts b/src/renderer/utils/notifications/notifications.ts index fb58c910c..d38d663db 100644 --- a/src/renderer/utils/notifications/notifications.ts +++ b/src/renderer/utils/notifications/notifications.ts @@ -9,14 +9,11 @@ import type { SettingsState, } from '../../types'; -import { - fetchNotificationDetailsForList, - listNotificationsForAuthenticatedUser, -} from '../api/client'; +import { fetchNotificationDetailsForList } from '../api/client'; import { determineFailureType } from '../api/errors'; import type { FetchMergedDetailsTemplateQuery } from '../api/graphql/generated/graphql'; -import { transformNotifications } from '../api/transform'; import { rendererLogError, rendererLogWarn, toError } from '../core/logger'; +import { getAdapter } from '../forges/registry'; import { filterBaseNotifications, filterDetailedNotifications, @@ -60,7 +57,7 @@ function getNotifications(auth: AuthState, settings: SettingsState) { return auth.accounts.map((account) => { return { account, - notifications: listNotificationsForAuthenticatedUser(account, settings), + notifications: getAdapter(account).listNotifications(account, settings), }; }); } @@ -94,12 +91,9 @@ export async function getAllNotifications( .filter((response) => !!response) .map(async (accountNotifications) => { try { - const rawNotifications = await accountNotifications.notifications; - - let notifications = transformNotifications( - rawNotifications, - accountNotifications.account, - ); + // Adapter `listNotifications` returns already-transformed + // GitifyNotification objects (each adapter owns its raw types). + let notifications = await accountNotifications.notifications; notifications = filterBaseNotifications(notifications); @@ -157,6 +151,16 @@ export async function enrichNotifications( return notifications; } + // Forges without GraphQL enrichment (e.g. Gitea) skip detail enrichment. + if ( + notifications.length && + !getAdapter(notifications[0].account).capabilities.enrichment( + notifications[0].account, + ) + ) { + return notifications; + } + const mergedResults = await fetchNotificationDetailsInBatches(notifications); const enrichedNotifications = await Promise.all( From 9e56554f01cc690e0ad7b1f815bfb31fb63c6f28 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:27:07 +0200 Subject: [PATCH 05/26] test(notifications): expect mark-as-done to fall back to mark-as-read when unsupported --- src/renderer/hooks/useNotifications.test.ts | 26 ++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/renderer/hooks/useNotifications.test.ts b/src/renderer/hooks/useNotifications.test.ts index 4c24e492c..9caea46c2 100644 --- a/src/renderer/hooks/useNotifications.test.ts +++ b/src/renderer/hooks/useNotifications.test.ts @@ -529,8 +529,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 +547,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 () => { From c1b68fac4e64830af3f4fb4c2138d6716ee29e44 Mon Sep 17 00:00:00 2001 From: Afonso Jorge Ramos Date: Wed, 6 May 2026 09:28:03 +0200 Subject: [PATCH 06/26] refactor(login): route PAT login through forge adapter --- .../routes/LoginWithPersonalAccessToken.tsx | 103 +++++++++++------- 1 file changed, 62 insertions(+), 41 deletions(-) diff --git a/src/renderer/routes/LoginWithPersonalAccessToken.tsx b/src/renderer/routes/LoginWithPersonalAccessToken.tsx index 6b364ff36..529b4f67f 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,32 @@ 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 +228,15 @@ export const LoginWithPersonalAccessTokenRoute: FC = () => { {