diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 3650754db8..4ac70caba1 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -93,6 +93,7 @@
"@gitbeaker/rest": "^42.2.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"dayjs": "^1.11.13",
+ "gitea-js": "^1.23.0",
"ollama": "^0.5.15",
"openai": "^4.87.3",
"reconnecting-websocket": "^4.4.0",
diff --git a/apps/desktop/src/components/ForgeForm.svelte b/apps/desktop/src/components/ForgeForm.svelte
index c333ef6dba..799a902fac 100644
--- a/apps/desktop/src/components/ForgeForm.svelte
+++ b/apps/desktop/src/components/ForgeForm.svelte
@@ -12,14 +12,22 @@
import SelectItem from '@gitbutler/ui/select/SelectItem.svelte';
import type { ForgeName } from '$lib/forge/interface/forge';
import type { Project } from '$lib/project/project';
+ import { GiteaState } from '$lib/forge/gitea/giteaState.svelte';
const forge = getContext(DefaultForgeFactory);
const gitLabState = getContext(GitLabState);
+ const giteaState = getContext(GiteaState);
+
const determinedForgeType = forge.determinedForgeType;
- const token = gitLabState.token;
- const forkProjectId = gitLabState.forkProjectId;
- const upstreamProjectId = gitLabState.upstreamProjectId;
- const instanceUrl = gitLabState.instanceUrl;
+ const gitLabToken = gitLabState.token;
+ const gitLabForkProjectId = gitLabState.forkProjectId;
+ const gitLabUpstreamProjectId = gitLabState.upstreamProjectId;
+ const gitLabInstanceUrl = gitLabState.instanceUrl;
+
+ const giteaToken = giteaState.token;
+ const giteaForkProjectId = giteaState.forkProjectId;
+ const giteaUpstreamProjectId = giteaState.upstreamProjectId;
+ const giteaInstanceUrl = giteaState.instanceUrl;
const projectsService = getContext(ProjectsService);
const projectService = getContext(ProjectService);
@@ -45,6 +53,10 @@
{
label: 'BitBucket',
value: 'bitbucket'
+ },
+ {
+ label: 'Gitea',
+ value: 'gitea'
}
];
let selectedOption = $derived($project?.forge_override || 'default');
@@ -120,21 +132,25 @@
{/snippet}
- ($token = value)} />
+ ($gitLabToken = value)}
+ />
($forkProjectId = value)}
+ value={$gitLabForkProjectId}
+ oninput={(value) => ($gitLabForkProjectId = value)}
/>
($upstreamProjectId = value)}
+ value={$gitLabUpstreamProjectId}
+ oninput={(value) => ($gitLabUpstreamProjectId = value)}
/>
($instanceUrl = value)}
+ value={$gitLabInstanceUrl}
+ oninput={(value) => ($gitLabInstanceUrl = value)}
/>
@@ -147,5 +163,57 @@
{/snippet}
{/if}
+
+ {#if forge.current.name === 'gitea'}
+
+ {#snippet title()}
+ Configure Gitea integration
+ {/snippet}
+
+ {#snippet caption()}
+ Learn how find your Gitea Personal Token and Project ID in our docs
+
+ The Fork Project ID is where your branches will be pushed, and the Upstream Project ID is where
+ you want merge requests to be created.
+
+ {/snippet}
+
+ ($giteaToken = value)}
+ />
+ {
+ // $giteaForkProjectId = value
+ }}
+ helperText="Must be a valid Gitea ID including owner/repo"
+ />
+ ($giteaUpstreamProjectId = value)}
+ helperText="Must be a valid Gitea ID including owner/repo"
+ />
+ ($giteaInstanceUrl = value)}
+ />
+
+
+
+ {#snippet caption()}
+ If you use a custom Gitea instance (not gitea.com), you will need to add it as a custom CSP
+ entry so that GitButler trusts connecting to that host. Read more in the docs
+ {/snippet}
+
+ {/if}
diff --git a/apps/desktop/src/lib/forge/forgeFactory.svelte.ts b/apps/desktop/src/lib/forge/forgeFactory.svelte.ts
index 57b9ea05ef..6c410e86bd 100644
--- a/apps/desktop/src/lib/forge/forgeFactory.svelte.ts
+++ b/apps/desktop/src/lib/forge/forgeFactory.svelte.ts
@@ -10,12 +10,14 @@ import type { PostHogWrapper } from '$lib/analytics/posthog';
import type { GitLabClient } from '$lib/forge/gitlab/gitlabClient.svelte';
import type { Forge, ForgeName } from '$lib/forge/interface/forge';
import type { ReadonlyBehaviorSubject } from '$lib/rxjs';
-import type { GitHubApi, GitLabApi } from '$lib/state/clientState.svelte';
+import type { GiteaApi, GitHubApi, GitLabApi } from '$lib/state/clientState.svelte';
import type { ReduxTag } from '$lib/state/tags';
import type { RepoInfo } from '$lib/url/gitUrl';
import type { Reactive } from '@gitbutler/shared/storeUtils';
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
import type { TagDescription } from '@reduxjs/toolkit/query';
+import { Gitea, GITEA_DOMAIN, GITEA_SUB_DOMAIN } from '$lib/forge/gitea/gitea';
+import type { GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
export type ForgeConfig = {
repo?: RepoInfo;
@@ -23,6 +25,7 @@ export type ForgeConfig = {
baseBranch?: string;
githubAuthenticated?: boolean;
gitlabAuthenticated?: boolean;
+ giteaAuthenticated?: boolean;
forgeOverride?: ForgeName;
};
@@ -37,6 +40,8 @@ export class DefaultForgeFactory implements Reactive {
gitHubApi: GitHubApi;
gitLabClient: GitLabClient;
gitLabApi: GitLabApi;
+ giteaClient: GiteaClient;
+ giteaApi: GiteaApi;
posthog: PostHogWrapper;
projectMetrics: ProjectMetrics;
dispatch: ThunkDispatch;
@@ -52,8 +57,15 @@ export class DefaultForgeFactory implements Reactive {
}
setConfig(config: ForgeConfig) {
- const { repo, pushRepo, baseBranch, githubAuthenticated, gitlabAuthenticated, forgeOverride } =
- config;
+ const {
+ repo,
+ pushRepo,
+ baseBranch,
+ githubAuthenticated,
+ gitlabAuthenticated,
+ giteaAuthenticated,
+ forgeOverride
+ } = config;
if (repo && baseBranch) {
this._determinedForgeType.next(this.determineForgeType(repo));
this._forge = this.build({
@@ -62,6 +74,7 @@ export class DefaultForgeFactory implements Reactive {
baseBranch,
githubAuthenticated,
gitlabAuthenticated,
+ giteaAuthenticated,
forgeOverride
});
} else {
@@ -75,6 +88,7 @@ export class DefaultForgeFactory implements Reactive {
baseBranch,
githubAuthenticated,
gitlabAuthenticated,
+ giteaAuthenticated,
forgeOverride
}: {
repo: RepoInfo;
@@ -82,6 +96,7 @@ export class DefaultForgeFactory implements Reactive {
baseBranch: string;
githubAuthenticated?: boolean;
gitlabAuthenticated?: boolean;
+ giteaAuthenticated?: boolean;
forgeOverride: ForgeName | undefined;
}): Forge {
let forgeType = this.determineForgeType(repo);
@@ -119,6 +134,17 @@ export class DefaultForgeFactory implements Reactive {
authenticated: !!gitlabAuthenticated
});
}
+ if (forgeType === 'gitea') {
+ const { giteaClient, giteaApi, posthog } = this.params;
+ return new Gitea({
+ ...baseParams,
+ api: giteaApi,
+ client: giteaClient,
+ posthog: posthog,
+ authenticated: !!giteaAuthenticated
+ });
+ }
+
if (forgeType === 'bitbucket') {
return new BitBucket(baseParams);
}
@@ -147,6 +173,13 @@ export class DefaultForgeFactory implements Reactive {
if (domain.includes(AZURE_DOMAIN)) {
return 'azure';
}
+ if (
+ domain.includes(GITEA_DOMAIN) ||
+ domain.startsWith(GITEA_SUB_DOMAIN + '.') ||
+ domain.startsWith('xy' + GITEA_SUB_DOMAIN + '.')
+ ) {
+ return 'gitea';
+ }
return 'default';
}
diff --git a/apps/desktop/src/lib/forge/forgeFactory.test.ts b/apps/desktop/src/lib/forge/forgeFactory.test.ts
index 6e6927a41c..b6bc09ffc8 100644
--- a/apps/desktop/src/lib/forge/forgeFactory.test.ts
+++ b/apps/desktop/src/lib/forge/forgeFactory.test.ts
@@ -9,6 +9,8 @@ import { expect, test, describe } from 'vitest';
import type { GitHubClient } from '$lib/forge/github/githubClient';
import type { GitLabClient } from '$lib/forge/gitlab/gitlabClient.svelte';
import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit';
+import type { GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
+import { Gitea } from '$lib/forge/gitea/gitea';
describe.concurrent('DefaultforgeFactory', () => {
const MockSettingsService = getSettingsdServiceMock();
@@ -27,10 +29,12 @@ describe.concurrent('DefaultforgeFactory', () => {
};
const gitHubClient = { onReset: () => {} } as any as GitHubClient;
const gitLabClient = { onReset: () => {} } as any as GitLabClient;
+ const giteaClient = { onReset: () => {} } as any as GiteaClient;
// TODO: Replace with a better mock.
const dispatch = (() => {}) as ThunkDispatch;
const gitLabApi: any = {};
+ const giteaApi: any = {};
test('Create GitHub service', async () => {
const factory = new DefaultForgeFactory({
@@ -38,6 +42,8 @@ describe.concurrent('DefaultforgeFactory', () => {
gitHubApi,
gitLabClient,
gitLabApi,
+ giteaClient,
+ giteaApi,
posthog,
projectMetrics,
dispatch
@@ -61,6 +67,8 @@ describe.concurrent('DefaultforgeFactory', () => {
gitHubApi,
gitLabClient,
gitLabApi,
+ giteaClient,
+ giteaApi,
posthog,
projectMetrics,
dispatch
@@ -84,6 +92,8 @@ describe.concurrent('DefaultforgeFactory', () => {
gitHubApi,
gitLabClient,
gitLabApi,
+ giteaClient,
+ giteaApi,
posthog,
projectMetrics,
dispatch
@@ -100,4 +110,54 @@ describe.concurrent('DefaultforgeFactory', () => {
})
).instanceOf(GitLab);
});
+
+ test('Create self hosted Gitea service', async () => {
+ const factory = new DefaultForgeFactory({
+ gitHubClient,
+ gitHubApi,
+ gitLabClient,
+ gitLabApi,
+ giteaClient,
+ giteaApi,
+ posthog,
+ projectMetrics,
+ dispatch
+ });
+ expect(
+ factory.build({
+ repo: {
+ domain: 'gitea.domain.com',
+ name: 'test-repo',
+ owner: 'test-owner'
+ },
+ baseBranch: 'some-base',
+ forgeOverride: undefined
+ })
+ ).instanceOf(Gitea);
+ });
+
+ test('Create Gitea service', async () => {
+ const factory = new DefaultForgeFactory({
+ gitHubClient,
+ gitHubApi,
+ gitLabClient,
+ gitLabApi,
+ giteaClient,
+ giteaApi,
+ posthog,
+ projectMetrics,
+ dispatch
+ });
+ expect(
+ factory.build({
+ repo: {
+ domain: 'gitea.com',
+ name: 'test-repo',
+ owner: 'test-owner'
+ },
+ baseBranch: 'some-base',
+ forgeOverride: undefined
+ })
+ ).instanceOf(Gitea);
+ });
});
diff --git a/apps/desktop/src/lib/forge/gitea/gitea.ts b/apps/desktop/src/lib/forge/gitea/gitea.ts
new file mode 100644
index 0000000000..29669b25b7
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/gitea.ts
@@ -0,0 +1,89 @@
+import type { PostHogWrapper } from '$lib/analytics/posthog';
+import type { Forge, ForgeName } from '$lib/forge/interface/forge';
+import type { DetailedPullRequest, ForgeArguments } from '$lib/forge/interface/types';
+import type { ProjectMetrics } from '$lib/metrics/projectMetrics';
+import type { GiteaApi, GitLabApi } from '$lib/state/clientState.svelte';
+import type { ReduxTag } from '$lib/state/tags';
+import type { TagDescription } from '@reduxjs/toolkit/query';
+import { gitea, type GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
+import { GiteaListingService } from '$lib/forge/gitea/gitlabListingService.svelte';
+import { GiteaPrService } from '$lib/forge/gitea/giteaPrService.svelte';
+import { GiteaBranch } from '$lib/forge/gitea/giteaBranch';
+import { isValidGiteaProjectId } from '$lib/forge/gitea/types';
+
+export type PrAction = 'creating_pr';
+export type PrState = { busy: boolean; branchId: string; action?: PrAction };
+export type PrCacheKey = { value: DetailedPullRequest | undefined; fetchedAt: Date };
+
+export const GITEA_DOMAIN = 'gitea.com';
+export const GITEA_SUB_DOMAIN = 'gitea'; // For self hosted instance of Gitlab
+
+/**
+ * PR support is pending OAuth support in the rust code.
+ *
+ * Follow this issue to stay in the loop:
+ * https://github.com/gitbutlerapp/gitbutler/issues/2511
+ */
+export class Gitea implements Forge {
+ readonly name: ForgeName = 'gitea';
+ readonly authenticated: boolean;
+ private baseUrl: string;
+ private baseBranch: string;
+ private forkStr?: string;
+
+ constructor(
+ private params: ForgeArguments & {
+ posthog?: PostHogWrapper;
+ projectMetrics?: ProjectMetrics;
+ api: GiteaApi;
+ client: GiteaClient;
+ }
+ ) {
+ const { api, client, baseBranch, forkStr, authenticated, repo } = this.params;
+ this.baseUrl = `https://${repo.domain}/${repo.owner}/${repo.name}`;
+ this.baseBranch = baseBranch;
+ this.forkStr = forkStr;
+ this.authenticated = authenticated;
+
+ // Reset the API when the token changes.
+ client.onReset(() => api.util.resetApiState());
+ }
+
+ branch(name: string) {
+ return new GiteaBranch(name, this.baseBranch, this.baseUrl, this.forkStr);
+ }
+
+ commitUrl(id: string): string {
+ return `${this.baseUrl}/-/commit/${id}`;
+ }
+
+ get listService() {
+ const { api: giteaApi, projectMetrics } = this.params;
+ return new GiteaListingService(giteaApi, projectMetrics);
+ }
+
+ get issueService() {
+ return undefined;
+ }
+
+ get prService() {
+ const { api: giteaApi, posthog } = this.params;
+ return new GiteaPrService(giteaApi, posthog);
+ }
+
+ get repoService() {
+ return undefined;
+ }
+
+ get checks() {
+ return undefined;
+ }
+
+ async pullRequestTemplateContent(_path?: string) {
+ return undefined;
+ }
+
+ invalidate(tags: TagDescription[]) {
+ return this.params.api.util.invalidateTags(tags);
+ }
+}
diff --git a/apps/desktop/src/lib/forge/gitea/giteaBranch.ts b/apps/desktop/src/lib/forge/gitea/giteaBranch.ts
new file mode 100644
index 0000000000..2d52f2ae5f
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/giteaBranch.ts
@@ -0,0 +1,11 @@
+import type { ForgeBranch } from '$lib/forge/interface/forgeBranch';
+
+export class GiteaBranch implements ForgeBranch {
+ readonly url: string;
+ constructor(name: string, baseBranch: string, baseUrl: string, fork?: string) {
+ if (fork) {
+ name = `${fork}:${name}`;
+ }
+ this.url = `${baseUrl}/-/compare/${baseBranch}...${name}`;
+ }
+}
diff --git a/apps/desktop/src/lib/forge/gitea/giteaClient.svelte.ts b/apps/desktop/src/lib/forge/gitea/giteaClient.svelte.ts
new file mode 100644
index 0000000000..f96d4e3f3e
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/giteaClient.svelte.ts
@@ -0,0 +1,80 @@
+import { giteaApi } from 'gitea-js';
+import { derived } from 'svelte/store';
+import type { GiteaState } from '$lib/forge/gitea/giteaState.svelte';
+import { isValidGiteaProjectId, type GiteaProjectId } from '$lib/forge/gitea/types';
+
+type GiteaInstance = ReturnType;
+
+export class GiteaClient {
+ api: GiteaInstance | undefined;
+ forkProjectId: GiteaProjectId | undefined;
+ upstreamProjectId: GiteaProjectId | undefined;
+ instanceUrl: string | undefined;
+
+ private callbacks: (() => void)[] = [];
+
+ set(giteaState: GiteaState) {
+ const subscribable = derived(
+ [
+ giteaState.forkProjectId,
+ giteaState.upstreamProjectId,
+ giteaState.instanceUrl,
+ giteaState.token
+ ],
+ ([forkProjectId, upstreamProjectId, instanceUrl, token]) => {
+ if (!isValidGiteaProjectId(forkProjectId) || !isValidGiteaProjectId(upstreamProjectId)) {
+ throw new Error('Invalid Gitea project ID');
+ }
+
+ this.forkProjectId = forkProjectId;
+ this.upstreamProjectId = upstreamProjectId;
+ if (token && instanceUrl) {
+ this.api = giteaApi(instanceUrl, { token });
+ } else {
+ this.api = undefined;
+ }
+ this.callbacks.every((cb) => cb());
+ }
+ );
+
+ $effect(() => {
+ const unsubscribe = subscribable.subscribe(() => {});
+ return unsubscribe;
+ });
+ }
+
+ onReset(fn: () => void) {
+ this.callbacks.push(fn);
+ return () => (this.callbacks = this.callbacks.filter((cb) => cb !== fn));
+ }
+}
+
+export function gitea(extra: unknown): {
+ api: GiteaInstance;
+ forkProjectId: GiteaProjectId;
+ upstreamProjectId: GiteaProjectId;
+} {
+ if (!hasGitea(extra)) throw new Error('No Gitea client!');
+ if (!extra.giteaClient.api) throw new Error('Failed to find Gitea client');
+ if (!extra.giteaClient.forkProjectId) throw new Error('Failed to find fork project ID');
+ if (!extra.giteaClient.upstreamProjectId) throw new Error('Failed to find upstream project ID');
+
+ // Equivalent to using the readable's `get` function
+ return {
+ api: extra.giteaClient.api!,
+ forkProjectId: extra.giteaClient.forkProjectId,
+ upstreamProjectId: extra.giteaClient.upstreamProjectId
+ };
+}
+
+export function hasGitea(extra: unknown): extra is {
+ giteaClient: GiteaClient;
+} {
+ return (
+ !!extra &&
+ typeof extra === 'object' &&
+ extra !== null &&
+ 'giteaClient' in extra &&
+ extra.giteaClient instanceof GiteaClient
+ );
+}
diff --git a/apps/desktop/src/lib/forge/gitea/giteaPrService.svelte.ts b/apps/desktop/src/lib/forge/gitea/giteaPrService.svelte.ts
new file mode 100644
index 0000000000..ca62903bbd
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/giteaPrService.svelte.ts
@@ -0,0 +1,181 @@
+import { providesItem, invalidatesItem, ReduxTag, invalidatesList } from '$lib/state/tags';
+import { sleep } from '$lib/utils/sleep';
+import { writable } from 'svelte/store';
+import type { PostHogWrapper } from '$lib/analytics/posthog';
+import type { ForgePrService } from '$lib/forge/interface/forgePrService';
+import type {
+ CreatePullRequestArgs,
+ DetailedPullRequest,
+ MergeMethod,
+ PullRequest
+} from '$lib/forge/interface/types';
+import type { QueryOptions } from '$lib/state/butlerModule';
+import type { GiteaApi } from '$lib/state/clientState.svelte';
+import type { StartQueryActionCreatorOptions } from '@reduxjs/toolkit/query';
+import { gitea } from '$lib/forge/gitea/giteaClient.svelte';
+import {
+ detailedPrToInstance,
+ prToInstance,
+ repoToInstance,
+ splitGiteaProjectId,
+ userToInstance
+} from '$lib/forge/gitea/types';
+
+export class GiteaPrService implements ForgePrService {
+ readonly unit = { name: 'Merge request', abbr: 'MR', symbol: '!' };
+ loading = writable(false);
+ private api: ReturnType;
+
+ constructor(
+ giteaApi: GiteaApi,
+ private posthog?: PostHogWrapper
+ ) {
+ this.api = injectEndpoints(giteaApi);
+ }
+
+ async createPr({
+ title,
+ body,
+ draft,
+ baseBranchName,
+ upstreamName
+ }: CreatePullRequestArgs): Promise {
+ this.loading.set(true);
+
+ const request = async () => {
+ return await this.api.endpoints.createPr.mutate({
+ head: upstreamName,
+ base: baseBranchName,
+ title,
+ body,
+ draft
+ });
+ };
+
+ let attempts = 0;
+ let lastError: any;
+
+ // Use retries since request can fail right after branch push.
+ while (attempts < 4) {
+ try {
+ const response = await request();
+ this.posthog?.capture('Gitea PR Successful');
+ return response;
+ } catch (err: any) {
+ lastError = err;
+ attempts++;
+ await sleep(500);
+ } finally {
+ this.loading.set(false);
+ }
+ }
+ this.posthog?.capture('Gitea PR Failure');
+
+ throw lastError;
+ }
+
+ async fetch(number: number, options?: QueryOptions) {
+ const result = this.api.endpoints.getPr.fetch({ number }, options);
+ return await result;
+ }
+
+ get(number: number, options?: StartQueryActionCreatorOptions) {
+ return this.api.endpoints.getPr.useQuery({ number }, options);
+ }
+
+ async merge(method: MergeMethod, number: number) {
+ await this.api.endpoints.mergePr.mutate({ method, number });
+ }
+
+ async reopen(number: number) {
+ await this.api.endpoints.updatePr.mutate({
+ number,
+ update: { state: 'open' }
+ });
+ }
+
+ async update(
+ number: number,
+ update: { description?: string; state?: 'open' | 'closed'; targetBase?: string }
+ ) {
+ await this.api.endpoints.updatePr.mutate({ number, update });
+ }
+}
+
+function injectEndpoints(api: GiteaApi) {
+ return api.injectEndpoints({
+ endpoints: (build) => ({
+ getPr: build.query({
+ queryFn: async (args, query) => {
+ const { api, upstreamProjectId } = gitea(query.extra);
+
+ const { owner, repo } = splitGiteaProjectId(upstreamProjectId);
+
+ const repository = repoToInstance(await api.repos.repoGet(owner, repo));
+
+ const pr = detailedPrToInstance(
+ await api.repos.repoGetPullRequest(owner, repo, args.number),
+ repository.permissions
+ );
+
+ const data = {
+ ...pr,
+ repositoryHttpsUrl: repository.httpsUrl,
+ repositorySshUrl: repository.sshUrl
+ };
+ return { data };
+ },
+ providesTags: (_result, _error, args) =>
+ providesItem(ReduxTag.GiteaPullRequests, args.number)
+ }),
+ createPr: build.mutation<
+ PullRequest,
+ { head: string; base: string; title: string; body: string; draft: boolean }
+ >({
+ queryFn: async ({ head, base, title, body }, query) => {
+ const { api, upstreamProjectId } = gitea(query.extra);
+ const { owner, repo } = splitGiteaProjectId(upstreamProjectId);
+
+ return {
+ data: prToInstance(
+ await api.repos.repoCreatePullRequest(owner, repo, {
+ base,
+ body,
+ head,
+ title
+ })
+ )
+ };
+ },
+ invalidatesTags: (result) => [invalidatesItem(ReduxTag.GiteaPullRequests, result?.number)]
+ }),
+ mergePr: build.mutation({
+ queryFn: async ({ number, method }, query) => {
+ const { api, upstreamProjectId } = gitea(query.extra);
+ const { owner, repo } = splitGiteaProjectId(upstreamProjectId);
+ await api.repos.repoMergePullRequest(owner, repo, number, { Do: method });
+ return { data: undefined };
+ },
+ invalidatesTags: [invalidatesList(ReduxTag.GiteaPullRequests)]
+ }),
+ updatePr: build.mutation<
+ void,
+ {
+ number: number;
+ update: {
+ targetBase?: string;
+ description?: string;
+ state?: 'open' | 'closed';
+ };
+ }
+ >({
+ queryFn: async ({ number, update }, query) => {
+ const { api, upstreamProjectId } = gitea(query.extra);
+ const { owner, repo } = splitGiteaProjectId(upstreamProjectId);
+ return { data: undefined };
+ },
+ invalidatesTags: [invalidatesList(ReduxTag.GiteaPullRequests)]
+ })
+ })
+ });
+}
diff --git a/apps/desktop/src/lib/forge/gitea/giteaState.svelte.ts b/apps/desktop/src/lib/forge/gitea/giteaState.svelte.ts
new file mode 100644
index 0000000000..c4a968f10f
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/giteaState.svelte.ts
@@ -0,0 +1,79 @@
+import { persisted } from '@gitbutler/shared/persisted';
+import { derived, get, writable, type Readable, type Writable } from 'svelte/store';
+import type { SecretsService } from '$lib/secrets/secretsService';
+import type { RepoInfo } from '$lib/url/gitUrl';
+import type { GiteaProjectId } from '$lib/forge/gitea/types';
+
+export class GiteaState {
+ readonly token: Writable;
+ readonly forkProjectId: Writable;
+ readonly upstreamProjectId: Writable;
+ readonly instanceUrl: Writable;
+ readonly configured: Readable;
+
+ constructor(
+ private readonly secretService: SecretsService,
+ repoInfo: RepoInfo | undefined,
+ projectId: string
+ ) {
+ // For whatever reason, the token _sometimes_ is incorrectly fetched as null.
+ // I have no idea why, but this seems to work. There were also some
+ // weird reactivity issues. Don't touch it as you might make it angry.
+ const tokenLoading = writable(true);
+ let tokenLoadedAsNull = false;
+ this.token = writable();
+ this.secretService.get(`gitea-token:${projectId}`).then((fetchedToken) => {
+ if (fetchedToken) {
+ this.token.set(fetchedToken ?? '');
+ } else {
+ tokenLoadedAsNull = true;
+ }
+ tokenLoading.set(false);
+ });
+ const unsubscribe = tokenLoading.subscribe((loading) => {
+ if (loading) {
+ return;
+ }
+ const unsubscribe = this.token.subscribe((token) => {
+ if (!token && tokenLoadedAsNull) {
+ return;
+ }
+ this.secretService.set(`gitea-token:${projectId}`, token ?? '');
+ tokenLoadedAsNull = false;
+ });
+ return unsubscribe;
+ });
+
+ $effect(() => {
+ return unsubscribe;
+ });
+
+ const forkProjectId = persisted(
+ undefined,
+ `gitea-project-id:${projectId}`
+ );
+ if (!get(forkProjectId) && repoInfo) {
+ forkProjectId.set(`${repoInfo.owner}/${repoInfo.name}`);
+ }
+ this.forkProjectId = forkProjectId;
+
+ const upstreamProjectId = persisted(
+ undefined,
+ `gitea-upstream-project-id:${projectId}`
+ );
+ if (!get(upstreamProjectId)) {
+ upstreamProjectId.set(get(forkProjectId));
+ }
+ this.upstreamProjectId = upstreamProjectId;
+
+ const instanceUrl = persisted('https://gitea.com', `gitea-instance-url:${projectId}`);
+ this.instanceUrl = instanceUrl;
+
+ this.configured = derived(
+ [this.token, this.forkProjectId, this.instanceUrl],
+ ([token, forkProjectId, instanceUrl]) => {
+ return !!token && !!forkProjectId && !!instanceUrl;
+ }
+ );
+ }
+}
diff --git a/apps/desktop/src/lib/forge/gitea/gitlabListingService.svelte.ts b/apps/desktop/src/lib/forge/gitea/gitlabListingService.svelte.ts
new file mode 100644
index 0000000000..0012a66093
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/gitlabListingService.svelte.ts
@@ -0,0 +1,130 @@
+import { createSelectByIds } from '$lib/state/customSelectors';
+import { combineResults } from '$lib/state/helpers';
+import { providesList, ReduxTag } from '$lib/state/tags';
+import { reactive } from '@gitbutler/shared/storeUtils';
+import { isDefined } from '@gitbutler/ui/utils/typeguards';
+import { createEntityAdapter, type EntityState } from '@reduxjs/toolkit';
+import type { ForgeListingService } from '$lib/forge/interface/forgeListingService';
+import type { PullRequest } from '$lib/forge/interface/types';
+import type { ProjectMetrics } from '$lib/metrics/projectMetrics';
+import type { GiteaApi } from '$lib/state/clientState.svelte';
+import { gitea } from '$lib/forge/gitea/giteaClient.svelte';
+import { prToInstance, splitGiteaProjectId } from '$lib/forge/gitea/types';
+import type { BaseQueryApi } from '@reduxjs/toolkit/query';
+
+export class GiteaListingService implements ForgeListingService {
+ private api: ReturnType;
+
+ constructor(
+ giteaApi: GiteaApi,
+ private projectMetrics?: ProjectMetrics
+ ) {
+ this.api = injectEndpoints(giteaApi);
+ }
+
+ list(projectId: string, pollingInterval?: number) {
+ const result = $derived(
+ this.api.endpoints.listPrs.useQuery(projectId, {
+ transform: (result) => prSelectors.selectAll(result),
+ subscriptionOptions: { pollingInterval }
+ })
+ );
+ $effect(() => {
+ const items = result.current.data;
+ if (items) {
+ this.projectMetrics?.setMetric(projectId, 'pr_count', items.length);
+ }
+ });
+ return reactive(() => result.current);
+ }
+
+ getByBranch(projectId: string, branchName: string) {
+ return this.api.endpoints.listPrs.useQuery(projectId, {
+ transform: (result) => prSelectors.selectById(result, branchName)
+ });
+ }
+
+ filterByBranch(projectId: string, branchName: string[]) {
+ return this.api.endpoints.listPrs.useQueryState(projectId, {
+ transform: (result) => prSelectors.selectByIds(result, branchName)
+ });
+ }
+
+ async fetchByBranch(projectId: string, branchNames: string[]) {
+ const results = await Promise.all(
+ branchNames.map((branch) =>
+ this.api.endpoints.listPrsByBranch.fetch({ projectId, branchName: branch })
+ )
+ );
+ const combined = combineResults(...results);
+
+ return combined.data?.filter(isDefined) ?? [];
+ }
+
+ async refresh(projectId: string): Promise {
+ await this.api.endpoints.listPrs.fetch(projectId, { forceRefetch: true });
+ }
+}
+
+async function getAllPrs(query: BaseQueryApi) {
+ const { api, upstreamProjectId, forkProjectId } = gitea(query.extra);
+ const { owner, repo } = splitGiteaProjectId(upstreamProjectId);
+ const { owner: forkOwner, repo: forkRepo } = splitGiteaProjectId(forkProjectId);
+ const upstreamPrs = await api.repos.repoListPullRequests(owner, repo, {
+ state: 'open'
+ });
+ const forkPrs = await api.repos.repoListPullRequests(forkOwner, forkRepo, {
+ state: 'open'
+ });
+ return [...upstreamPrs.data, ...forkPrs.data];
+}
+
+function injectEndpoints(api: GiteaApi) {
+ return api.injectEndpoints({
+ endpoints: (build) => ({
+ listPrs: build.query, string>({
+ queryFn: async (_, query) => {
+ var prs = await getAllPrs(query);
+ return {
+ data: prAdapter.addMany(
+ prAdapter.getInitialState(),
+ prs.map((data) => prToInstance({ data }))
+ )
+ };
+ },
+ providesTags: [providesList(ReduxTag.GiteaPullRequests)]
+ }),
+ listPrsByBranch: build.query({
+ queryFn: async ({ branchName }, query) => {
+ var allPrs = await getAllPrs(query);
+
+ var allPrsOnBranch = allPrs.filter((e) => e.base?.ref == branchName);
+
+ if (allPrsOnBranch.length === 0) {
+ return { data: null };
+ }
+
+ if (allPrsOnBranch.length > 1) {
+ return { error: new Error(`Multiple merge requests found for branch ${branchName}`) };
+ }
+
+ const data = allPrsOnBranch[0]!;
+
+ return {
+ data: prToInstance({ data })
+ };
+ }
+ })
+ })
+ });
+}
+
+const prAdapter = createEntityAdapter({
+ selectId: (pr) => pr.sourceBranch
+});
+
+const prSelectors = { ...prAdapter.getSelectors(), selectByIds: createSelectByIds() };
+
+// if (err.message.includes('you appear to have the correct authorization credentials')) {
+// this.disabled = true;
+// }
diff --git a/apps/desktop/src/lib/forge/gitea/types.ts b/apps/desktop/src/lib/forge/gitea/types.ts
new file mode 100644
index 0000000000..7c50b6cb60
--- /dev/null
+++ b/apps/desktop/src/lib/forge/gitea/types.ts
@@ -0,0 +1,123 @@
+import type { DetailedPullRequest, Label, PullRequest } from '$lib/forge/interface/types';
+import type {
+ PullRequest as GiteaPullRequest,
+ User as GiteaUser,
+ Repository as GiteaRepository,
+ Permission as GiteaPermission
+} from 'gitea-js';
+
+type Data = { data: T };
+
+export function detailedPrToInstance(
+ response: Data,
+ permissions: GiteaPermission | undefined = undefined
+): DetailedPullRequest {
+ const data = response.data;
+
+ const reviewers =
+ data.requested_reviewers?.map((assignee) => ({
+ srcUrl: assignee.avatar_url || '',
+ name: assignee?.full_name || assignee?.login || ''
+ })) || [];
+
+ return {
+ id: data.id || -1,
+ number: data?.number || -1,
+ author: data.user ? {} : null,
+ title: data.title || `${data.number}` || '',
+ body: data.body ?? undefined,
+ baseBranch: data.base?.ref || '',
+ sourceBranch: data.head?.ref || '',
+ draft: data.draft,
+ htmlUrl: data.html_url || '',
+ createdAt: data.created_at || '',
+ mergedAt: data.merged_at || undefined,
+ closedAt: data.closed_at || undefined,
+ updatedAt: data.updated_at || data.created_at || '',
+ merged: !!data.merged,
+ mergeable: !!data.mergeable,
+ mergeableState: data.state ?? 'unknown',
+ rebaseable: !!data.base?.repo?.allow_rebase,
+ squashable: !!data.base?.repo?.allow_squash_merge,
+ state: data.state === 'opened' ? 'open' : 'closed',
+ fork: !!data.head?.repo?.fork,
+ reviewers,
+ commentsCount: data?.comments || 0,
+ permissions: {
+ canMerge: !!permissions?.push
+ }
+ };
+}
+
+export function prToInstance(response: Data): PullRequest {
+ const data = response.data;
+
+ const reviewers =
+ data.requested_reviewers?.map((assignee) => ({
+ srcUrl: assignee.avatar_url || '',
+ name: assignee?.full_name || assignee?.login || ''
+ })) || [];
+
+ return {
+ number: data?.number || -1,
+ author: data.user ? {} : null,
+ title: data.title || `${data.number}` || '',
+ body: data.body ?? undefined,
+ sourceBranch: data.head?.ref || '',
+ htmlUrl: data.html_url || '',
+ createdAt: data.created_at || '',
+ mergedAt: data.merged_at || undefined,
+ closedAt: data.closed_at || undefined,
+ modifiedAt: data.updated_at || data.created_at || '',
+ draft: !!data.draft,
+ reviewers,
+ labels: data?.labels?.map((e) => ({ name: '', description: '', color: '', ...e })) || [],
+ targetBranch: data.base?.ref || '',
+ sha: data.head?.sha || ''
+ };
+}
+
+export function userToInstance(response: Data) {
+ const user = response.data;
+ return {
+ name: user.full_name || undefined,
+ email: user.email || undefined,
+ isBot: false,
+ gravatarUrl: user.avatar_url
+ };
+}
+
+export function repoToInstance(response: Data) {
+ const repo = response.data;
+ return {
+ id: repo.id || -1,
+ name: repo.name || '',
+ fullName: `${repo.owner?.login}/${repo.name}` || '',
+ htmlUrl: repo.html_url || '',
+ httpsUrl: repo.clone_url || '',
+ sshUrl: repo.ssh_url || '',
+ permissions: repo.permissions
+ };
+}
+
+export type GiteaProjectId = `${string}/${string}`;
+
+export function splitGiteaProjectId(projectId: GiteaProjectId): {
+ owner: string;
+ repo: string;
+} {
+ const parts = projectId.split('/');
+
+ const owner = parts.at(0);
+ const repo = parts.at(1);
+
+ if (!owner || !repo) {
+ throw new Error(`Invalid Gitea project ID: ${projectId}`);
+ }
+ return { owner, repo };
+}
+
+export function isValidGiteaProjectId(projectId?: string): projectId is GiteaProjectId {
+ const parts = projectId?.split('/') ?? [];
+ return parts.length === 2 && parts.every((part) => part.length > 0);
+}
diff --git a/apps/desktop/src/lib/forge/interface/forge.ts b/apps/desktop/src/lib/forge/interface/forge.ts
index c80d1cbd25..266affda91 100644
--- a/apps/desktop/src/lib/forge/interface/forge.ts
+++ b/apps/desktop/src/lib/forge/interface/forge.ts
@@ -8,7 +8,7 @@ import type { ReduxTag } from '$lib/state/tags';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { TagDescription } from '@reduxjs/toolkit/query';
-export type ForgeName = 'github' | 'gitlab' | 'bitbucket' | 'azure' | 'default';
+export type ForgeName = 'github' | 'gitlab' | 'bitbucket' | 'azure' | 'gitea' | 'default';
export interface Forge {
readonly name: ForgeName;
diff --git a/apps/desktop/src/lib/state/clientState.svelte.ts b/apps/desktop/src/lib/state/clientState.svelte.ts
index 74f9cc70c3..4bfe8d650a 100644
--- a/apps/desktop/src/lib/state/clientState.svelte.ts
+++ b/apps/desktop/src/lib/state/clientState.svelte.ts
@@ -16,6 +16,7 @@ import type { GitLabClient } from '$lib/forge/gitlab/gitlabClient.svelte';
import type { IrcClient } from '$lib/irc/ircClient.svelte';
import type { Settings } from '$lib/settings/userSettings';
import type { Readable } from 'svelte/store';
+import type { GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
/**
* GitHub API object that enables the declaration and usage of endpoints
@@ -35,6 +36,12 @@ export type GitHubApi = ReturnType;
*/
export type GitLabApi = ReturnType;
+/**
+ * Gitea API object that enables the declaration and usage of endpoints
+ * colocated with the feature they support.
+ */
+export type GiteaApi = ReturnType;
+
/**
* A redux store with dependency injection through middleware.
*/
@@ -58,6 +65,9 @@ export class ClientState {
/** rtk-query api for communicating with GitLab. */
readonly gitlabApi: GitLabApi;
+ /** rtk-query api for communicating with Gitea. */
+ readonly giteaApi: GiteaApi;
+
get reactiveState() {
return this.rootState;
}
@@ -66,6 +76,7 @@ export class ClientState {
tauri: Tauri,
gitHubClient: GitHubClient,
gitLabClient: GitLabClient,
+ giteaClient: GiteaClient,
ircClient: IrcClient,
posthog: PostHogWrapper,
settingsService: SettingsService,
@@ -80,15 +91,18 @@ export class ClientState {
this.githubApi = createGitHubApi(butlerMod);
this.gitlabApi = createGitLabApi(butlerMod);
this.backendApi = createBackendApi(butlerMod);
+ this.giteaApi = createGiteaApi(butlerMod);
const { store, reducer } = createStore({
tauri,
gitHubClient,
gitLabClient,
+ giteaClient,
ircClient,
backendApi: this.backendApi,
githubApi: this.githubApi,
gitlabApi: this.gitlabApi,
+ giteaApi: this.giteaApi,
posthog,
settingsService,
userSettings
@@ -127,10 +141,12 @@ function createStore(params: {
tauri: Tauri;
gitHubClient: GitHubClient;
gitLabClient: GitLabClient;
+ giteaClient: GiteaClient;
ircClient: IrcClient;
backendApi: BackendApi;
githubApi: GitHubApi;
gitlabApi: GitLabApi;
+ giteaApi: GiteaApi;
posthog: PostHogWrapper;
settingsService: SettingsService;
userSettings: Readable;
@@ -143,6 +159,7 @@ function createStore(params: {
backendApi,
githubApi,
gitlabApi,
+ giteaApi,
posthog,
settingsService,
userSettings
@@ -154,7 +171,8 @@ function createStore(params: {
const reducer = combineSlices(
// RTK Query API for the back end.
backendApi,
- gitlabApi
+ gitlabApi,
+ giteaApi
)
.inject({
reducerPath: uiStateSlice.reducerPath,
@@ -266,3 +284,21 @@ export function createGitLabApi(butlerMod: ReturnType) {
}
});
}
+
+export function createGiteaApi(butlerMod: ReturnType) {
+ return buildCreateApi(
+ coreModule(),
+ butlerMod
+ )({
+ reducerPath: 'gitea',
+ tagTypes: Object.values(ReduxTag),
+ invalidationBehavior: 'immediately',
+ baseQuery: tauriBaseQuery,
+ refetchOnFocus: true,
+ refetchOnReconnect: true,
+ keepUnusedDataFor: KEEP_UNUSED_SECONDS,
+ endpoints: (_) => {
+ return {};
+ }
+ });
+}
diff --git a/apps/desktop/src/lib/state/tags.ts b/apps/desktop/src/lib/state/tags.ts
index e5d9ad561b..956d8f192e 100644
--- a/apps/desktop/src/lib/state/tags.ts
+++ b/apps/desktop/src/lib/state/tags.ts
@@ -14,7 +14,9 @@ export enum ReduxTag {
BranchChanges = 'BranchChanges',
PullRequests = 'PullRequests',
GitLabPullRequests = 'GitLabPullRequests',
+ GiteaPullRequests = 'GiteaPullRequests',
Checks = 'Checks',
+ GiteaChecks = 'GiteaChecks',
RepoInfo = 'RepoInfo',
BaseBranchData = 'BaseBranchData',
UpstreamIntegrationStatus = 'UpstreamIntegrationStatus',
diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte
index cca93ea038..ace2f65c38 100644
--- a/apps/desktop/src/routes/+layout.svelte
+++ b/apps/desktop/src/routes/+layout.svelte
@@ -88,6 +88,8 @@
import { Toaster } from 'svelte-french-toast';
import type { LayoutData } from './$types';
import { env } from '$env/dynamic/public';
+ import { GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
+
const { data, children }: { data: LayoutData; children: Snippet } = $props();
const userSettings = loadUserSettings();
@@ -97,8 +99,10 @@
const gitHubClient = new GitHubClient();
const gitLabClient = new GitLabClient();
+ const giteaClient = new GiteaClient();
setContext(GitHubClient, gitHubClient);
setContext(GitLabClient, gitLabClient);
+ setContext(GiteaClient, giteaClient);
const user = data.userService.user;
const accessToken = $derived($user?.github_access_token);
$effect(() => gitHubClient.setToken(accessToken));
@@ -110,6 +114,7 @@
data.tauri,
gitHubClient,
gitLabClient,
+ giteaClient,
ircClient,
data.posthog,
data.settingsService,
@@ -132,8 +137,10 @@
const forgeFactory = new DefaultForgeFactory({
gitHubClient,
gitLabClient,
+ giteaClient,
gitHubApi: clientState['githubApi'],
gitLabApi: clientState['gitlabApi'],
+ giteaApi: clientState['giteaApi'],
dispatch: clientState.dispatch,
posthog: data.posthog,
projectMetrics: data.projectMetrics
diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte
index f82ed37c3a..96e36b9e0a 100644
--- a/apps/desktop/src/routes/[projectId]/+layout.svelte
+++ b/apps/desktop/src/routes/[projectId]/+layout.svelte
@@ -55,6 +55,8 @@
import { onDestroy, setContext, untrack, type Snippet } from 'svelte';
import type { ProjectMetrics } from '$lib/metrics/projectMetrics';
import type { LayoutData } from './$types';
+ import { GiteaState } from '$lib/forge/gitea/giteaState.svelte';
+ import { GiteaClient } from '$lib/forge/gitea/giteaClient.svelte';
const { data, children: pageChildren }: { data: LayoutData; children: Snippet } = $props();
@@ -91,6 +93,15 @@
gitLabClient.set(gitLabState);
});
+ const giteaState = $derived(new GiteaState(secretService, repoInfo, projectId));
+ $effect.pre(() => {
+ setContext(GiteaState, giteaState);
+ });
+ const giteaClient = getContext(GiteaClient);
+ $effect.pre(() => {
+ giteaClient.set(giteaState);
+ });
+
const branchesError = $derived(vbranchService.branchesError);
const user = $derived(userService.user);
const accessToken = $derived($user?.github_access_token);
@@ -172,6 +183,7 @@
});
const gitlabConfigured = $derived(gitLabState.configured);
+ const giteaConfigured = $derived(giteaState.configured);
$effect(() => {
forgeFactory.setConfig({
@@ -180,6 +192,7 @@
baseBranch: baseBranchName,
githubAuthenticated: !!$user?.github_access_token,
gitlabAuthenticated: !!$gitlabConfigured,
+ giteaAuthenticated: !!$giteaConfigured,
forgeOverride: $projects?.find((project) => project.id === projectId)?.forge_override
});
});