Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
130 changes: 130 additions & 0 deletions apps/desktop/src/components/LabelSection.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<script lang="ts">
import ReduxResult from '$components/ReduxResult.svelte';
import { Badge, Select, SelectItem, Button } from '@gitbutler/ui';
import type { ForgePrService } from '$lib/forge/interface/forgePrService';

interface Props {
projectId: string;
prService: ForgePrService | undefined;
selectedLabels: string[];
disabled?: boolean;
}

let { projectId, prService, selectedLabels = $bindable(), disabled }: Props = $props();

let labelsQuery = $state<ReturnType<NonNullable<typeof prService>['labels']>>();

function fetchLabels() {
if (prService) {
if (!labelsQuery) {
labelsQuery = prService.labels();
} else {
labelsQuery.result.refetch();
}
}
}
</script>

<div class="label-section">
<div class="label-section__header">
<span class="label-section__title text-13 text-semibold">Labels</span>
<Select
options={(labelsQuery?.result?.data || [])
.filter((l) => !selectedLabels.includes(l.name))
.map((l) => ({ label: l.name, value: l.name }))}
loading={labelsQuery?.result?.status === 'pending'}
searchable
autoWidth={true}
popupAlign="left"
closeOnSelect={false}
{disabled}
onselect={(value) => {
if (!selectedLabels.includes(value)) {
selectedLabels = [...selectedLabels, value];
}
}}
ontoggle={(isOpen) => {
if (isOpen) fetchLabels();
}}
>
{#snippet customSelectButton()}
<Button
kind="ghost"
class="text-13"
{disabled}
loading={labelsQuery?.result?.status === 'pending'}
>
Edit
</Button>
{/snippet}
{#snippet itemSnippet({ item, highlighted })}
<SelectItem selected={selectedLabels.includes(item.value)} {highlighted}>
{item.label}
</SelectItem>
{/snippet}
</Select>
</div>

<div class="label-section__content">
{#if selectedLabels.length > 0}
<div class="labels-list">
{#each selectedLabels as label}
<Badge
color="gray"
kind="soft"
size="tag"
icon="cross-small"
reversedDirection
onclick={() => (selectedLabels = selectedLabels.filter((l) => l !== label))}
>
{label}
</Badge>
{/each}
</div>
{:else}
<span class="text-13 text-secondary">None</span>
{/if}
</div>

{#if labelsQuery?.result?.error}
<ReduxResult {projectId} result={labelsQuery.result} />
{/if}
</div>

<style lang="postcss">
.label-section {
display: flex;
flex-direction: column;
gap: 6px;
}

.label-section__header {
display: flex;
justify-content: space-between;
align-items: center;

& :global(.btn-label) {
font-size: var(--size-13);
}
}

.label-section__title {
color: var(--clr-text-1);
}

.label-section__content {
display: flex;
flex-wrap: wrap;
min-height: 20px;
}

.labels-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
}

.text-secondary {
color: var(--clr-text-2);
}
</style>
11 changes: 9 additions & 2 deletions apps/desktop/src/components/ReviewCreation.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
body: string;
draft: boolean;
upstreamBranchName: string | undefined;
labels: string[];
}
</script>

<script lang="ts">
import LabelSection from '$components/LabelSection.svelte';
import PrTemplateSection from '$components/PrTemplateSection.svelte';
import MessageEditor from '$components/editor/MessageEditor.svelte';
import MessageEditorInput from '$components/editor/MessageEditorInput.svelte';
Expand Down Expand Up @@ -64,6 +66,8 @@
const settingsService = inject(SETTINGS_SERVICE);
const appSettings = settingsService.appSettings;

let selectedLabels = $state<string[]>([]);

const gitLabState = inject(GITLAB_STATE);
const gitLabConfigured = $derived(gitLabState.configured);

Expand Down Expand Up @@ -222,7 +226,8 @@
title,
body,
draft,
upstreamBranchName: branch
upstreamBranchName: branch,
labels: selectedLabels
});

prBody.reset();
Expand Down Expand Up @@ -310,7 +315,8 @@
body: params.body,
draft: params.draft,
baseBranchName: base,
upstreamName
upstreamName: upstreamName,
labels: params.labels
});

// Store the new pull request number with the branch data.
Expand Down Expand Up @@ -388,6 +394,7 @@
</script>

<div class="pr-editor">
<LabelSection {projectId} {prService} bind:selectedLabels disabled={isExecuting} />
<PrTemplateSection
{projectId}
template={{ enabled: templateEnabled, path: templatePath }}
Expand Down
46 changes: 40 additions & 6 deletions apps/desktop/src/lib/forge/github/githubPrService.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
MergeMethod,
type CreatePullRequestArgs,
type DetailedPullRequest,
type PullRequest
type PullRequest,
type Label
} from '$lib/forge/interface/types';
import { eventualConsistencyCheck } from '$lib/forge/shared/progressivePolling';
import { providesItem, invalidatesItem, ReduxTag, invalidatesList } from '$lib/state/tags';
Expand Down Expand Up @@ -39,7 +40,8 @@ export class GitHubPrService implements ForgePrService {
body,
draft,
baseBranchName,
upstreamName
upstreamName,
labels
}: CreatePullRequestArgs): Promise<PullRequest> {
this.loading.set(true);
const request = async () => {
Expand All @@ -49,7 +51,8 @@ export class GitHubPrService implements ForgePrService {
base: baseBranchName,
title,
body,
draft
draft,
labels
})
);
};
Expand Down Expand Up @@ -102,6 +105,9 @@ export class GitHubPrService implements ForgePrService {
) {
await this.api.endpoints.updatePr.mutate({ number, update });
}
labels(options?: StartQueryActionCreatorOptions) {
return this.api.endpoints.getLabels.useQuery(undefined, options);
}
}

async function fetchRepoPermissions(
Expand Down Expand Up @@ -183,17 +189,45 @@ function injectEndpoints(api: GitHubApi) {
}),
createPr: build.mutation<
CreatePrResult,
{ head: string; base: string; title: string; body: string; draft: boolean }
{
head: string;
base: string;
title: string;
body: string;
draft: boolean;
labels: string[];
}
>({
queryFn: async ({ head, base, title, body, draft }, api) =>
queryFn: async ({ head, base, title, body, draft, labels }, api) =>
await ghQuery({
domain: 'pulls',
action: 'create',
parameters: { head, base, title, body, draft },
parameters: { head, base, title, body, draft, labels },
extra: api.extra
}),
invalidatesTags: (result) => [invalidatesItem(ReduxTag.PullRequests, result?.number)]
}),
getLabels: build.query<Label[], void>({
queryFn: async (_args, api) => {
const result = await ghQuery({
domain: 'issues',
action: 'listLabelsForRepo',
parameters: {},
extra: api.extra
});
if (result.error) {
return { error: result.error };
}
return {
data: result.data.map((l: any) => ({
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The parameter type is typed as any. This should be typed properly to ensure type safety. Based on the GitHub API documentation, labels returned from listLabelsForRepo should have a specific structure that can be typed.

Copilot uses AI. Check for mistakes.
name: l.name,
description: l.description || undefined,
color: l.color
}))
};
},
providesTags: [ReduxTag.PullRequests]
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The getLabels query is tagged with ReduxTag.PullRequests, which means invalidating pull requests will also invalidate the labels cache. This could cause unnecessary refetching of labels. Consider using a dedicated tag like ReduxTag.Labels or using providesType(ReduxTag.PullRequests) instead to avoid unintended cache invalidation when individual PRs are updated.

Suggested change
providesTags: [ReduxTag.PullRequests]
providesTags: [ReduxTag.Labels]

Copilot uses AI. Check for mistakes.
}),
mergePr: build.mutation<void, { number: number; method: MergeMethod }>({
queryFn: async ({ number, method: method }, api) => {
const result = await ghQuery({
Expand Down
45 changes: 39 additions & 6 deletions apps/desktop/src/lib/forge/gitlab/gitlabPrService.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import type {
CreatePullRequestArgs,
DetailedPullRequest,
MergeMethod,
PullRequest
PullRequest,
Label
} from '$lib/forge/interface/types';
import type { QueryOptions } from '$lib/state/butlerModule';
import type { GitLabApi } from '$lib/state/clientState.svelte';
Expand All @@ -33,7 +34,8 @@ export class GitLabPrService implements ForgePrService {
body,
draft,
baseBranchName,
upstreamName
upstreamName,
labels
}: CreatePullRequestArgs): Promise<PullRequest> {
this.loading.set(true);

Expand All @@ -43,7 +45,8 @@ export class GitLabPrService implements ForgePrService {
base: baseBranchName,
title,
body,
draft
draft,
labels
});
};

Expand Down Expand Up @@ -95,6 +98,10 @@ export class GitLabPrService implements ForgePrService {
) {
await this.api.endpoints.updatePr.mutate({ number, update });
}

labels(options?: StartQueryActionCreatorOptions) {
return this.api.endpoints.getLabels.useQuery(undefined, options);
}
}

function injectEndpoints(api: GitLabApi) {
Expand Down Expand Up @@ -123,9 +130,16 @@ function injectEndpoints(api: GitLabApi) {
}),
createPr: build.mutation<
PullRequest,
{ head: string; base: string; title: string; body: string; draft: boolean }
{
head: string;
base: string;
title: string;
body: string;
draft: boolean;
labels: string[];
}
>({
queryFn: async ({ head, base, title, body, draft }, query) => {
queryFn: async ({ head, base, title, body, draft, labels }, query) => {
try {
const { api, upstreamProjectId, forkProjectId } = gitlab(query.extra);
const upstreamProject = await api.Projects.show(upstreamProjectId);
Expand All @@ -136,7 +150,8 @@ function injectEndpoints(api: GitLabApi) {
const mr = await api.MergeRequests.create(forkProjectId, head, base, finalTitle, {
description: body,
targetProjectId: upstreamProject.id,
removeSourceBranch: true
removeSourceBranch: true,
labels: labels.join(',')
});
return { data: mrToInstance(mr) };
} catch (e: unknown) {
Expand All @@ -145,6 +160,24 @@ function injectEndpoints(api: GitLabApi) {
},
invalidatesTags: (result) => [invalidatesItem(ReduxTag.GitLabPullRequests, result?.number)]
}),
getLabels: build.query<Label[], void>({
queryFn: async (_args, query) => {
try {
const { api, upstreamProjectId } = gitlab(query.extra);
const labels = await api.ProjectLabels.all(upstreamProjectId);
return {
data: labels.map((l: any) => ({
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The parameter type is typed as any. This should be typed properly to ensure type safety. Based on the GitLab API documentation, labels returned from ProjectLabels.all() should have a specific structure that can be typed.

Copilot uses AI. Check for mistakes.
name: l.name,
description: l.description || undefined,
color: l.color
}))
};
} catch (e: unknown) {
return { error: toSerializable(e) };
}
},
providesTags: [ReduxTag.GitLabPullRequests]
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The getLabels query is tagged with ReduxTag.GitLabPullRequests, which means invalidating pull requests will also invalidate the labels cache. This could cause unnecessary refetching of labels. Consider using a dedicated tag like ReduxTag.Labels or using providesType(ReduxTag.GitLabPullRequests) instead to avoid unintended cache invalidation when individual MRs are updated.

Suggested change
providesTags: [ReduxTag.GitLabPullRequests]
providesTags: [ReduxTag.Labels]

Copilot uses AI. Check for mistakes.
}),
mergePr: build.mutation<undefined, { number: number; method: MergeMethod }>({
queryFn: async ({ number }, query) => {
try {
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/lib/forge/interface/forgePrService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type {
CreatePullRequestArgs,
DetailedPullRequest,
MergeMethod,
PullRequest
PullRequest,
Label
} from '$lib/forge/interface/types';
import type { ReactiveQuery } from '$lib/state/butlerModule';
import type { StartQueryActionCreatorOptions } from '@reduxjs/toolkit/query';
Expand Down Expand Up @@ -38,4 +39,5 @@ export interface ForgePrService {
prNumber: number,
details: { description?: string; state?: 'open' | 'closed'; targetBase?: string }
): Promise<void>;
labels(options?: StartQueryActionCreatorOptions): ReactiveQuery<Label[]>;
}
1 change: 1 addition & 0 deletions apps/desktop/src/lib/forge/interface/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,5 @@ export type CreatePullRequestArgs = {
draft: boolean;
baseBranchName: string;
upstreamName: string;
labels: string[];
};
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/state/clientState.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ const FORGE_API_CONFIG = {
tagTypes: Object.values(ReduxTag),
invalidationBehavior: 'immediately' as const,
baseQuery: fakeBaseQuery,
refetchOnFocus: true,
refetchOnFocus: false,
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

Disabling refetchOnFocus for all forge APIs is a significant behavioral change that affects more than just labels. This change impacts all GitHub and GitLab API calls (PRs, users, etc.). If the intent is to prevent labels from refetching on focus, consider making this configuration more granular by setting it per-endpoint instead of globally for all forge APIs.

Suggested change
refetchOnFocus: false,

Copilot uses AI. Check for mistakes.
refetchOnReconnect: true,
keepUnusedDataFor: FORGE_CACHE_TTL_SECONDS,
endpoints: () => ({})
Expand Down
Loading
Loading