Skip to content

Commit

Permalink
Add stack footer
Browse files Browse the repository at this point in the history
  • Loading branch information
mtsgrd committed Nov 6, 2024
1 parent 54caba3 commit f9bc9e2
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 24 deletions.
24 changes: 24 additions & 0 deletions apps/desktop/src/lib/branch/BranchLaneContextMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@
import ContextMenu from '$lib/components/contextmenu/ContextMenu.svelte';
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { getForgePrService } from '$lib/forge/interface/forgePrService';
import { updatePrDescriptionTables } from '$lib/forge/shared/prFooter';
import { User } from '$lib/stores/user';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranch } from '$lib/vbranches/types';
import { getContext, getContextStore } from '@gitbutler/shared/context';
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Toggle from '@gitbutler/ui/Toggle.svelte';
import Tooltip from '@gitbutler/ui/Tooltip.svelte';
import { isDefined } from '@gitbutler/ui/utils/typeguards';
interface Props {
prUrl?: string;
Expand All @@ -26,6 +30,8 @@
const branchStore = getContextStore(VirtualBranch);
const branchController = getContext(BranchController);
const prService = getForgePrService();
const user = getContextStore(User);
let deleteBranchModal: Modal;
let allowRebasing = $state<boolean>();
Expand All @@ -37,6 +43,8 @@
allowRebasing = branch.allowRebasing;
});
const allPrIds = $derived(branch.series.map((series) => series.prNumber).filter(isDefined));
async function toggleAllowRebasing() {
branchController.updateBranchAllowRebasing(branch.id, !allowRebasing);
}
Expand Down Expand Up @@ -113,6 +121,22 @@
}}
/>
</ContextMenuSection>
{#if $user && $user.role?.includes('admin')}
<!-- TODO: Remove after iterating on the pull request footer. -->
<ContextMenuSection label="admin only">
<ContextMenuItem
label="Update PR footers"
disabled={allPrIds.length === 0}
onclick={() => {
if ($prService && branch) {
const allPrIds = branch.series.map((series) => series.prNumber).filter(isDefined);
updatePrDescriptionTables($prService, allPrIds);
}
contextMenuEl?.close();
}}
/>
</ContextMenuSection>
{/if}
</ContextMenu>

<Modal
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/lib/branch/SeriesHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@
$cloudBranch.state === 'not-found'
);
/**
* We are starting to store pull request id's locally so if we find one that does not have
* one locally stored then we set it once.
*
* TODO: Remove this after transition is complete.
*/
$effect(() => {
if (
$forge?.name === 'github' &&
!currentSeries.prNumber &&
listedPr?.number &&
listedPr.number !== currentSeries.prNumber
) {
branchController.updateBranchPrNumber(branch.id, currentSeries.name, listedPr.number);
}
});
async function handleReloadPR() {
await Promise.allSettled([prMonitor?.refresh(), checksMonitor?.update()]);
}
Expand Down Expand Up @@ -183,7 +200,7 @@
description={currentSeries.description ?? ''}
onGenerateBranchName={generateBranchName}
hasForgeBranch={!!forgeBranch}
prUrl={$pr?.htmlUrl}
pr={$pr}
openPrDetailsModal={handleOpenPR}
reloadPR={handleReloadPR}
onopen={() => (contextMenuOpened = true)}
Expand Down
40 changes: 31 additions & 9 deletions apps/desktop/src/lib/branch/SeriesHeaderContextMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import ContextMenuItem from '$lib/components/contextmenu/ContextMenuItem.svelte';
import ContextMenuSection from '$lib/components/contextmenu/ContextMenuSection.svelte';
import { projectAiGenEnabled } from '$lib/config/config';
import { getForgePrService } from '$lib/forge/interface/forgePrService';
import { updatePrDescriptionTables } from '$lib/forge/shared/prFooter';
import Link from '$lib/shared/Link.svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import { openExternalUrl } from '$lib/utils/url';
import { BranchController } from '$lib/vbranches/branchController';
Expand All @@ -13,14 +16,16 @@
import Button from '@gitbutler/ui/Button.svelte';
import Modal from '@gitbutler/ui/Modal.svelte';
import Textbox from '@gitbutler/ui/Textbox.svelte';
import { isDefined } from '@gitbutler/ui/utils/typeguards';
import type { DetailedPullRequest } from '$lib/forge/interface/types';
interface Props {
contextMenuEl?: ReturnType<typeof ContextMenu>;
target?: HTMLElement;
headName: string;
seriesCount: number;
hasForgeBranch: boolean;
prUrl?: string;
pr?: DetailedPullRequest;
branchType: CommitStatus;
description: string;
toggleDescription: () => Promise<void>;
Expand All @@ -37,7 +42,7 @@
seriesCount,
hasForgeBranch,
headName,
prUrl,
pr,
branchType,
description,
toggleDescription,
Expand All @@ -53,6 +58,7 @@
const branchStore = getContextStore(VirtualBranch);
const branchController = getContext(BranchController);
const aiGenEnabled = projectAiGenEnabled(project.id);
const prService = getForgePrService();
let deleteSeriesModal: Modal;
let renameSeriesModal: Modal;
Expand Down Expand Up @@ -105,25 +111,25 @@
<ContextMenuItem
label="Delete"
onclick={() => {
deleteSeriesModal.show(branch);
deleteSeriesModal.show();
contextMenuEl?.close();
}}
/>
{/if}
</ContextMenuSection>
{#if prUrl}
{#if pr}
<ContextMenuSection>
<ContextMenuItem
label="Open PR in browser"
onclick={() => {
openExternalUrl(prUrl);
openExternalUrl(pr.htmlUrl);
contextMenuEl?.close();
}}
/>
<ContextMenuItem
label="Copy PR link"
onclick={() => {
copyToClipboard(prUrl);
copyToClipboard(pr.htmlUrl);
contextMenuEl?.close();
}}
/>
Expand Down Expand Up @@ -177,17 +183,33 @@
title="Delete branch"
bind:this={deleteSeriesModal}
onSubmit={async (close) => {
isDeleting = true;
try {
isDeleting = true;
await branchController.removePatchSeries(branch.id, headName);
await branchController.removeBranch(branch.id, headName);
if (pr && $prService) {
// Close associated pull request.
$prService.update(pr.number, { state: 'closed' });
// Update stack list, excluding the closed pull request.
const prIds = branch.series
.map((series) => series.prNumber)
.filter((id) => id !== pr.number)
.filter(isDefined);
updatePrDescriptionTables($prService, prIds);
}
close();
} finally {
isDeleting = false;
}
}}
>
{#snippet children()}
Are you sure you want to delete <code class="code-string">{headName}</code>?
<p>Are you sure you want to delete <code class="code-string">{headName}</code>?</p>
{#if pr}
<p>We will automatically close the following branch:</p>
<ul>
<li><Link href={pr.htmlUrl}>#{pr.number}</Link> {pr.title}</li>
</ul>
{/if}
{/snippet}
{#snippet controls(close)}
<Button style="ghost" outline onclick={close}>Cancel</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
const { label, children }: { label?: string; children: Snippet } = $props();
</script>

<div class="context-menu-section">
<slot />
{#if label}
<div class="label text-12">{label}</div>
{/if}
{@render children()}
</div>

<style lang="postcss">
Expand All @@ -17,4 +23,10 @@
border-top: 1px solid var(--clr-border-2);
}
}
.label {
padding: 6px 8px;
color: var(--clr-scale-ntrl-50);
user-select: none;
}
</style>
11 changes: 11 additions & 0 deletions apps/desktop/src/lib/forge/github/githubPrService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,15 @@ export class GitHubPrService implements ForgePrService {
prMonitor(prNumber: number): GitHubPrMonitor {
return new GitHubPrMonitor(this, prNumber);
}

async update(prNumber: number, details: { description?: string; state?: 'open' | 'closed' }) {
const { description, state } = details;
await this.octokit.pulls.update({
owner: this.repo.owner,
repo: this.repo.name,
pull_number: prNumber,
body: description,
state: state
});
}
}
6 changes: 5 additions & 1 deletion apps/desktop/src/lib/forge/interface/forgePrService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Writable } from 'svelte/store';

export const [getForgePrService, createForgePrServiceStore] = buildContextStore<
ForgePrService | undefined
>('gitBranchService');
>('forgePrService');

export interface ForgePrService {
loading: Writable<boolean>;
Expand All @@ -20,4 +20,8 @@ export interface ForgePrService {
merge(method: MergeMethod, prNumber: number): Promise<void>;
reopen(prNumber: number): Promise<void>;
prMonitor(prNumber: number): ForgePrMonitor;
update(
prNumber: number,
details: { description?: string; state?: 'open' | 'closed' }
): Promise<void>;
}
50 changes: 50 additions & 0 deletions apps/desktop/src/lib/forge/shared/prFooter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ForgePrService } from '../interface/forgePrService';

export const FOOTER_BOUNDARY_TOP = '<!-- GitButler Footer Boundary Top -->';
export const FOOTER_BOUNDARY_BOTTOM = '<!-- GitButler Footer Boundary Bottom -->';

/**
* Updates a pull request description with a table pointing to other pull
* requests in the same stack.
*/
export async function updatePrDescriptionTables(prService: ForgePrService, prNumbers: number[]) {
if (prService && prNumbers.length > 1) {
const prs = await Promise.all(prNumbers.map(async (id) => await prService.get(id)));
const updates = prs.map((pr) => ({
prNumber: pr.number,
description: updateBody(pr.body, pr.number, prNumbers)
}));
await Promise.all(
updates.map(async ({ prNumber, description }) => {
await prService.update(prNumber, { description });
})
);
}
}

/**
* Replaces or inserts a new footer into an existing body of text.
*/
function updateBody(body: string | undefined, prNumber: number, allPrNumbers: number[]) {
const head = (body?.split(FOOTER_BOUNDARY_TOP).at(0) || '').trim();
const tail = (body?.split(FOOTER_BOUNDARY_BOTTOM).at(1) || '').trim();
const footer = generateFooter(prNumber, allPrNumbers);
const description = head + '\n\n' + footer + '\n\n' + tail;
return description;
}

/**
* Generates a footer for use in pull request descriptions when part of a stack.
*/
export function generateFooter(forPrNumber: number, allPrNumbers: number[]) {
const stackIndex = allPrNumbers.findIndex((number) => number === forPrNumber);
let footer = '';
footer += FOOTER_BOUNDARY_TOP + '\n';
footer += 'This is part of a stack made with GitButler:\n';
allPrNumbers.forEach((prNumber, i) => {
const current = i === stackIndex;
footer += `- #${prNumber} ${current ? '👈 ' : ''}\n`;
});
footer += FOOTER_BOUNDARY_BOTTOM;
return footer;
}
27 changes: 20 additions & 7 deletions apps/desktop/src/lib/pr/PrDetailsModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import { mapErrorToToast } from '$lib/forge/github/errorMap';
import { getForge } from '$lib/forge/interface/forge';
import { getForgePrService } from '$lib/forge/interface/forgePrService';
import { type DetailedPullRequest, type PullRequest } from '$lib/forge/interface/types';
import { updatePrDescriptionTables as updatePrStackInfo } from '$lib/forge/shared/prFooter';
import { showError, showToast } from '$lib/notifications/toasts';
import { isFailure } from '$lib/result';
import ScrollableContainer from '$lib/scroll/ScrollableContainer.svelte';
Expand All @@ -39,8 +41,8 @@
import Textarea from '@gitbutler/ui/Textarea.svelte';
import Textbox from '@gitbutler/ui/Textbox.svelte';
import ToggleButton from '@gitbutler/ui/ToggleButton.svelte';
import { isDefined } from '@gitbutler/ui/utils/typeguards';
import { tick } from 'svelte';
import type { DetailedPullRequest, PullRequest } from '$lib/forge/interface/types';
interface BaseProps {
type: 'display' | 'preview' | 'preview-series';
Expand Down Expand Up @@ -165,6 +167,9 @@
error('Pull request service not available');
return;
}
if (props.type !== 'preview-series') {
return;
}
isLoading = true;
try {
Expand Down Expand Up @@ -203,19 +208,27 @@
return;
}
// All ids that existed prior to creating a new one (including archived).
const priorIds = branch.series.map((series) => series.prNumber).filter(isDefined);
const pr = await $prService.createPr({
title: params.title,
body: params.body,
draft: params.draft,
baseBranchName,
upstreamName: upstreamBranchName
});
if (props.type === 'preview-series') {
await branchController.updateSeriesPrNumber(
props.stackId,
props.currentSeries.name,
pr.number
);
// Store the new pull request number with the branch data.
await branchController.updateBranchPrNumber(
props.stackId,
props.currentSeries.name,
pr.number
);
// If we now have two or more pull requests we add a stack table to the description.
if (priorIds.length > 0) {
updatePrStackInfo($prService, priorIds.concat([pr.number]));
}
} catch (err: any) {
console.error(err);
Expand Down
5 changes: 2 additions & 3 deletions apps/desktop/src/lib/stack/CurrentSeries.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { createForgeChecksMonitorStore } from '$lib/forge/interface/forgeChecksMonitor';
import { getForgeListingService } from '$lib/forge/interface/forgeListingService';
import { createForgePrMonitorStore } from '$lib/forge/interface/forgePrMonitor';
import { createForgePrServiceStore } from '$lib/forge/interface/forgePrService';
import { getForgePrService } from '$lib/forge/interface/forgePrService';
import type { PatchSeries } from '$lib/vbranches/types';
import type { Snippet } from 'svelte';
Expand All @@ -17,8 +17,7 @@
// Setup PR Store and Monitor on a per-series basis
const forge = getForge();
const prService = createForgePrServiceStore(undefined);
$effect(() => prService.set($forge?.prService()));
const prService = getForgePrService();
// Pretty cumbersome way of getting the PR number, would be great if we can
// make it more concise somehow.
Expand Down
Loading

0 comments on commit f9bc9e2

Please sign in to comment.