Skip to content

Commit 062d5f9

Browse files
committed
Add stack footer
1 parent 54caba3 commit 062d5f9

File tree

9 files changed

+134
-6
lines changed

9 files changed

+134
-6
lines changed

apps/desktop/src/lib/branch/BranchLaneContextMenu.svelte

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
33
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
44
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
5+
import { getForgePrService } from '$lib/forge/interface/forgePrService';
6+
import { updatePrDescriptionTables } from '$lib/forge/shared/prFooter';
7+
import { User } from '$lib/stores/user';
58
import { BranchController } from '$lib/vbranches/branchController';
69
import { VirtualBranch } from '$lib/vbranches/types';
710
import { getContext, getContextStore } from '@gitbutler/shared/context';
811
import Button from '@gitbutler/ui/Button.svelte';
912
import Modal from '@gitbutler/ui/Modal.svelte';
1013
import Toggle from '@gitbutler/ui/Toggle.svelte';
1114
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
15+
import { isDefined } from '@gitbutler/ui/utils/typeguards';
1216
1317
interface Props {
1418
prUrl?: string;
@@ -26,6 +30,8 @@
2630
2731
const branchStore = getContextStore(VirtualBranch);
2832
const branchController = getContext(BranchController);
33+
const prService = getForgePrService();
34+
const user = getContextStore(User);
2935
3036
let deleteBranchModal: Modal;
3137
let allowRebasing = $state<boolean>();
@@ -37,6 +43,8 @@
3743
allowRebasing = branch.allowRebasing;
3844
});
3945
46+
const allPrIds = $derived(branch.series.map((series) => series.prNumber).filter(isDefined));
47+
4048
async function toggleAllowRebasing() {
4149
branchController.updateBranchAllowRebasing(branch.id, !allowRebasing);
4250
}
@@ -113,6 +121,21 @@
113121
}}
114122
/>
115123
</ContextMenuSection>
124+
{#if $user && $user.role?.includes('admin')}
125+
<ContextMenuSection label="admin only">
126+
<ContextMenuItem
127+
label="Update PR footers"
128+
disabled={allPrIds.length === 0}
129+
onclick={() => {
130+
if ($prService && branch) {
131+
const allPrIds = branch.series.map((series) => series.prNumber).filter(isDefined);
132+
updatePrDescriptionTables($prService, allPrIds);
133+
}
134+
contextMenuEl?.close();
135+
}}
136+
/>
137+
</ContextMenuSection>
138+
{/if}
116139
</ContextMenu>
117140

118141
<Modal

apps/desktop/src/lib/branch/SeriesHeader.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@
9999
$cloudBranch.state === 'not-found'
100100
);
101101
102+
/**
103+
* We are starting to store pull request id's locally so if we find one that does not have
104+
* one locally stored then we set it once.
105+
*
106+
* TODO: Remove this after transition is complete.
107+
*/
108+
$effect(() => {
109+
if (
110+
$forge?.name === 'github' &&
111+
!currentSeries.prNumber &&
112+
listedPr?.number &&
113+
listedPr.number !== currentSeries.prNumber
114+
) {
115+
branchController.updateSeriesPrNumber(branch.id, currentSeries.name, listedPr.number);
116+
}
117+
});
118+
102119
async function handleReloadPR() {
103120
await Promise.allSettled([prMonitor?.refresh(), checksMonitor?.update()]);
104121
}

apps/desktop/src/lib/components/contextmenu/ContextMenuSection.svelte

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
<script lang="ts">
2+
import type { Snippet } from 'svelte';
3+
4+
const { label, children }: { label?: string; children: Snippet } = $props();
25
</script>
36

47
<div class="context-menu-section">
5-
<slot />
8+
{#if label}
9+
<div class="label text-12">{label}</div>
10+
{/if}
11+
{@render children()}
612
</div>
713

814
<style lang="postcss">
@@ -17,4 +23,10 @@
1723
border-top: 1px solid var(--clr-border-2);
1824
}
1925
}
26+
27+
.label {
28+
padding: 6px 8px;
29+
color: var(--clr-scale-ntrl-50);
30+
user-select: none;
31+
}
2032
</style>

apps/desktop/src/lib/forge/github/githubPrService.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,14 @@ export class GitHubPrService implements ForgePrService {
9696
prMonitor(prNumber: number): GitHubPrMonitor {
9797
return new GitHubPrMonitor(this, prNumber);
9898
}
99+
100+
async update(prNumber: number, details: { description?: string }) {
101+
const { description } = details;
102+
await this.octokit.pulls.update({
103+
owner: this.repo.owner,
104+
repo: this.repo.name,
105+
pull_number: prNumber,
106+
body: description
107+
});
108+
}
99109
}

apps/desktop/src/lib/forge/interface/forgePrService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Writable } from 'svelte/store';
55

66
export const [getForgePrService, createForgePrServiceStore] = buildContextStore<
77
ForgePrService | undefined
8-
>('gitBranchService');
8+
>('forgePrService');
99

1010
export interface ForgePrService {
1111
loading: Writable<boolean>;
@@ -20,4 +20,5 @@ export interface ForgePrService {
2020
merge(method: MergeMethod, prNumber: number): Promise<void>;
2121
reopen(prNumber: number): Promise<void>;
2222
prMonitor(prNumber: number): ForgePrMonitor;
23+
update(prNumber: number, details: { description?: string }): Promise<void>;
2324
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { ForgePrService } from '../interface/forgePrService';
2+
3+
export const FOOTER_BOUNDARY_TOP = '<!-- GitButler Footer Boundary Top -->';
4+
export const FOOTER_BOUNDARY_BOTTOM = '<!-- GitButler Footer Boundary Bottom -->';
5+
6+
/**
7+
* Updates a pull request description with a table pointing to other pull
8+
* requests in the same stack.
9+
*/
10+
export async function updatePrDescriptionTables(prService: ForgePrService, prNumbers: number[]) {
11+
if (prService && prNumbers.length > 1) {
12+
const prs = await Promise.all(prNumbers.map(async (id) => await prService.get(id)));
13+
const updates = prs.map((pr) => ({
14+
prNumber: pr.number,
15+
description: updateBody(pr.body, pr.number, prNumbers)
16+
}));
17+
await Promise.all(
18+
updates.map(async ({ prNumber, description }) => {
19+
await prService.update(prNumber, { description });
20+
})
21+
);
22+
}
23+
}
24+
25+
/**
26+
* Replaces or inserts a new footer into an existing body of text.
27+
*/
28+
function updateBody(body: string | undefined, prNumber: number, allPrNumbers: number[]) {
29+
const head = (body?.split(FOOTER_BOUNDARY_TOP).at(0) || '').trim();
30+
const tail = (body?.split(FOOTER_BOUNDARY_BOTTOM).at(1) || '').trim();
31+
const footer = generateFooter(prNumber, allPrNumbers);
32+
const description = head + '\n\n' + footer + '\n\n' + tail;
33+
return description;
34+
}
35+
36+
/**
37+
* Generates a footer for use in pull request descriptions when part of a stack.
38+
*/
39+
export function generateFooter(forPrNumber: number, allPrNumbers: number[]) {
40+
const stackIndex = allPrNumbers.findIndex((number) => number === forPrNumber);
41+
let footer = '';
42+
footer += FOOTER_BOUNDARY_TOP + '\n';
43+
footer += 'This is part of a stack made with GitButler:\n';
44+
allPrNumbers.forEach((prNumber, i) => {
45+
const current = i === stackIndex;
46+
footer += `- #${prNumber} ${current ? '👈 ' : ''}\n`;
47+
});
48+
footer += FOOTER_BOUNDARY_BOTTOM;
49+
return footer;
50+
}

apps/desktop/src/lib/pr/PrDetailsModal.svelte

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import { mapErrorToToast } from '$lib/forge/github/errorMap';
2222
import { getForge } from '$lib/forge/interface/forge';
2323
import { getForgePrService } from '$lib/forge/interface/forgePrService';
24+
import { type DetailedPullRequest, type PullRequest } from '$lib/forge/interface/types';
25+
import { updatePrDescriptionTables } from '$lib/forge/shared/prFooter';
2426
import { showError, showToast } from '$lib/notifications/toasts';
2527
import { isFailure } from '$lib/result';
2628
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
@@ -39,8 +41,8 @@
3941
import Textarea from '@gitbutler/ui/Textarea.svelte';
4042
import Textbox from '@gitbutler/ui/Textbox.svelte';
4143
import ToggleButton from '@gitbutler/ui/ToggleButton.svelte';
44+
import { isDefined } from '@gitbutler/ui/utils/typeguards';
4245
import { tick } from 'svelte';
43-
import type { DetailedPullRequest, PullRequest } from '$lib/forge/interface/types';
4446
4547
interface BaseProps {
4648
type: 'display' | 'preview' | 'preview-series';
@@ -165,6 +167,9 @@
165167
error('Pull request service not available');
166168
return;
167169
}
170+
if (props.type !== 'preview-series') {
171+
return;
172+
}
168173
169174
isLoading = true;
170175
try {
@@ -203,6 +208,9 @@
203208
return;
204209
}
205210
211+
// All ids that exists prior to creating a new one.
212+
const priorIds = branch.series.map((series) => series.prNumber).filter(isDefined);
213+
206214
const pr = await $prService.createPr({
207215
title: params.title,
208216
body: params.body,
@@ -217,6 +225,11 @@
217225
pr.number
218226
);
219227
}
228+
229+
// If we now have two or more pull requests we add a stack table to the description.
230+
if (priorIds.length > 0) {
231+
updatePrDescriptionTables($prService, priorIds.concat([pr.number]));
232+
}
220233
} catch (err: any) {
221234
console.error(err);
222235
const toast = mapErrorToToast(err);

apps/desktop/src/lib/stack/CurrentSeries.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { createForgeChecksMonitorStore } from '$lib/forge/interface/forgeChecksMonitor';
55
import { getForgeListingService } from '$lib/forge/interface/forgeListingService';
66
import { createForgePrMonitorStore } from '$lib/forge/interface/forgePrMonitor';
7-
import { createForgePrServiceStore } from '$lib/forge/interface/forgePrService';
7+
import { getForgePrService } from '$lib/forge/interface/forgePrService';
88
import type { PatchSeries } from '$lib/vbranches/types';
99
import type { Snippet } from 'svelte';
1010
@@ -17,8 +17,7 @@
1717
1818
// Setup PR Store and Monitor on a per-series basis
1919
const forge = getForge();
20-
const prService = createForgePrServiceStore(undefined);
21-
$effect(() => prService.set($forge?.prService()));
20+
const prService = getForgePrService();
2221
2322
// Pretty cumbersome way of getting the PR number, would be great if we can
2423
// make it more concise somehow.

apps/desktop/src/routes/[projectId]/+layout.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { octokitFromAccessToken } from '$lib/forge/github/octokit';
2020
import { createForgeStore } from '$lib/forge/interface/forge';
2121
import { createForgeListingServiceStore } from '$lib/forge/interface/forgeListingService';
22+
import { createForgePrServiceStore } from '$lib/forge/interface/forgePrService';
2223
import History from '$lib/history/History.svelte';
2324
import { HistoryService } from '$lib/history/history';
2425
import { SyncedSnapshotService } from '$lib/history/syncedSnapshotService';
@@ -104,6 +105,7 @@
104105
105106
const listServiceStore = createForgeListingServiceStore(undefined);
106107
const forgeStore = createForgeStore(undefined);
108+
const prService = createForgePrServiceStore(undefined);
107109
108110
$effect.pre(() => {
109111
const combinedBranchListingService = new CombinedBranchListingService(
@@ -143,6 +145,7 @@
143145
const ghListService = forge?.listService();
144146
listServiceStore.set(ghListService);
145147
forgeStore.set(forge);
148+
prService.set(forge ? forge.prService() : undefined);
146149
});
147150
148151
// Once on load and every time the project id changes

0 commit comments

Comments
 (0)