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 }); });