Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
20ce142
docs(contributing): replace github-only stance with multi-forge policy
afonsojramos May 6, 2026
8ab2764
feat(types): add Forge type and required Account.forge field with mig…
afonsojramos May 6, 2026
a43767b
feat(forges): add ForgeAdapter interface, registry, and GitHub adapter
afonsojramos May 6, 2026
2078d85
refactor(forges): route notifications, features, and auth through ada…
afonsojramos May 6, 2026
9e56554
test(notifications): expect mark-as-done to fall back to mark-as-read…
afonsojramos May 6, 2026
c1b68fa
refactor(login): route PAT login through forge adapter
afonsojramos May 6, 2026
188c023
refactor(login): render login methods from forge adapter registry
afonsojramos May 6, 2026
6ddac7c
feat(forges/gitea): add Gitea adapter and login flow
afonsojramos May 6, 2026
8834c83
refactor(links): route developer settings URL through forge adapter
afonsojramos May 6, 2026
7591e7b
fix(forges): preserve refresh response shape and limit gitea scope sh…
afonsojramos May 6, 2026
e4ef3ed
refactor(forges/github): relocate GitHub modules into forges/github
afonsojramos May 6, 2026
ebad481
fix(forges/github): point graphql codegen output and import paths at …
afonsojramos May 6, 2026
db282cc
fix(auth): coerce missing enterprise version header to null
afonsojramos May 6, 2026
29a6c5e
test(settings): drop obsolete snapshot for go-back test
afonsojramos May 6, 2026
05ebcae
test(forges): cover registry dispatch and unknown-forge errors
afonsojramos May 6, 2026
f52c75c
test(forges/gitea): cover client request paths and pagination
afonsojramos May 6, 2026
f7668b1
test(forges/gitea): cover adapter dispatch and capabilities
afonsojramos May 6, 2026
eac24ad
test(forges/github): cover adapter dispatch and login fields
afonsojramos May 6, 2026
f032863
test(forges/github): cover raw notification transform
afonsojramos May 6, 2026
4d9e45f
style(forges): apply biome formatter to new test files
afonsojramos May 6, 2026
ff936a5
fix(test): widen latest_comment_url cast to allow null in transform test
afonsojramos May 6, 2026
ba011e6
chore(codeowners): route forge adapters to their maintainers
afonsojramos May 6, 2026
dda29d1
docs(readme): add forge adapter support matrix
afonsojramos May 6, 2026
36fdcf7
docs(readme): document forge status symbols (experimental, in progress)
afonsojramos May 6, 2026
9a654d7
docs(readme): drop Bitbucket Data Center from forge matrix (EOL)
afonsojramos May 6, 2026
2e1d93f
refactor(forges): move OAuth scope checks onto the adapter
afonsojramos May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -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
15 changes: 13 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gitea missing?


#### Things we won't do

Expand All @@ -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.

<!-- LINK LABELS -->
[biome-website]: https://biomejs.dev/
Expand Down
24 changes: 24 additions & 0 deletions MAINTAINERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Maintainers
Comment thread
setchy marked this conversation as resolved.

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/<forge-id>/`.
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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
afonsojramos marked this conversation as resolved.


## Quick Start

1. **Download** Gitify for free from [gitify.io][website].
Expand Down
6 changes: 3 additions & 3 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/__mocks__/account-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -48,13 +52,23 @@ export const mockGitHubCloudAccount: Account = {
};

export const mockGitHubEnterpriseServerAccount: Account = {
forge: 'github',
platform: 'GitHub Enterprise Server',
method: 'Personal Access Token',
token: 'token-1234568790' as Token,
hostname: 'github.gitify.io' as Hostname,
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,
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/__mocks__/notifications-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../types';

import {
mockGiteaAccount,
mockGitHubAppAccount,
mockGitHubCloudAccount,
mockGitHubEnterpriseServerAccount,
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/components/notifications/NotificationRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(<NotificationRow {...props} />);

expect(
screen.queryByTestId('notification-unsubscribe-from-thread'),
).not.toBeInTheDocument();
});
});
});
6 changes: 5 additions & 1 deletion src/renderer/components/notifications/NotificationRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,6 +157,7 @@ export const NotificationRow: FC<NotificationRowProps> = ({

<HoverButton
action={actionUnsubscribeFromThread}
enabled={isUnsubscribeThreadSupported(notification.account)}
icon={BellSlashIcon}
label="Unsubscribe from thread"
testid="notification-unsubscribe-from-thread"
Expand Down
1 change: 1 addition & 0 deletions src/renderer/context/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ describe('renderer/context/App.tsx', () => {
'GitHub App',
'token',
'github.com',
'github',
);
});

Expand Down
39 changes: 32 additions & 7 deletions src/renderer/context/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
Account,
AccountNotifications,
AuthState,
Forge,
GitifyError,
GitifyNotification,
Hostname,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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' })),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?? only covers undefined/null. A persisted account with forge: '' or forge: 'GitHub' (case mismatch) will pass through

};
}

export interface AppContextState {
auth: AuthState;
isLoggedIn: boolean;
Expand Down Expand Up @@ -130,7 +142,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {

const [auth, setAuth] = useState<AuthState>(
existingState.auth
? { ...defaultAuth, ...existingState.auth }
? migrateLegacyAuthState({ ...defaultAuth, ...existingState.auth })
: defaultAuth,
);

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -504,6 +522,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
'OAuth App',
token,
authOptions.hostname,
'github',
);

persistAuth(updatedAuth);
Expand All @@ -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);
Expand All @@ -541,6 +565,7 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
'Personal Access Token',
token,
hostname,
resolvedForge,
);

persistAuth(updatedAuth);
Expand Down
Loading
Loading