Merge pull request #3880 from gitbutlerapp/remote-branch-display

Add a "apply from remote" flow
This commit is contained in:
Caleb Owens 2024-05-28 19:16:04 +02:00 committed by GitHub
commit b71a30258c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 307 additions and 102 deletions

View File

@ -116,7 +116,7 @@ function mergeBranchesAndPrs(
if (remoteBranches) {
contributions.push(
...remoteBranches
.filter((rb) => !contributions.some((cb) => rb.sha == cb.sha))
.filter((rb) => !contributions.some((cb) => rb.sha == cb.upstreamSha))
.map((rb) => {
const pr = pullRequests?.find((pr) => pr.sha == rb.sha);
return new CombinedBranch({ remoteBranch: rb, pr });
@ -128,7 +128,7 @@ function mergeBranchesAndPrs(
if (pullRequests) {
contributions.push(
...pullRequests
.filter((pr) => !contributions.some((cb) => pr.sha == cb.sha))
.filter((pr) => !contributions.some((cb) => pr.sha == cb.upstreamSha))
.map((pr) => {
return new CombinedBranch({ pr });
})

View File

@ -20,8 +20,14 @@ export class CombinedBranch {
this.pr = pr;
}
get sha(): string {
return this.pr?.sha || this.remoteBranch?.sha || this.vbranch?.head || 'unknown';
get upstreamSha(): string {
return (
this.pr?.sha ||
this.remoteBranch?.sha ||
this.vbranch?.upstream?.sha ||
this.vbranch?.head ||
'unknown'
);
}
get displayName(): string {

View File

@ -31,7 +31,7 @@
const baseBranch = getContextStore(BaseBranch);
$: branch = $branchStore;
$: pr$ = githubService.getPr$(branch.upstreamName);
$: pr$ = githubService.getPr$(branch.upstream?.sha || branch.head);
$: hasPullRequest = branch.upstreamName && $pr$;
let meatballButton: HTMLDivElement;

View File

@ -15,11 +15,11 @@
import { Branch } from '$lib/vbranches/types';
import { distinctUntilChanged } from 'rxjs';
import { onDestroy } from 'svelte';
import { derived, type Readable } from 'svelte/store';
import type { ChecksStatus, DetailedPullRequest } from '$lib/github/types';
import type { ComponentColor } from '$lib/vbranches/types';
import type { MessageStyle } from './InfoMessage.svelte';
import type iconsJson from '../icons/icons.json';
import type { Readable } from 'svelte/store';
type StatusInfo = {
text: string;
@ -44,13 +44,7 @@
let checksStatus: ChecksStatus | null | undefined = undefined;
let lastDetailsFetch: Readable<string> | undefined;
// We only want to call `.getPr$()` when the upstream name changes, rather
// than each time the branch object updates.
let distinctUpstreamName = derived<Readable<Branch>, string | undefined>(branch, (b, set) => {
set(b.upstreamName);
});
$: pr$ = githubService.getPr$($distinctUpstreamName).pipe(
$: pr$ = githubService.getPr$($branch.upstream?.sha || $branch.head).pipe(
// Only emit a new objcect if the modified timestamp has changed.
distinctUntilChanged((prev, curr) => {
return prev?.modifiedAt.getTime() === curr?.modifiedAt.getTime();
@ -63,15 +57,16 @@
$: prStatusInfo = getPrStatusInfo(detailedPr);
async function updateDetailsAndChecks() {
if (!isFetchingDetails) await updateDetailedPullRequest($pr$?.targetBranch, true);
if (!$pr$) return;
if (!isFetchingDetails) await updateDetailedPullRequest($pr$.sha, true);
if (!isFetchingChecks) await fetchChecks();
}
async function updateDetailedPullRequest(targetBranch: string | undefined, skipCache: boolean) {
async function updateDetailedPullRequest(targetBranchSha: string, skipCache: boolean) {
detailsError = undefined;
isFetchingDetails = true;
try {
detailedPr = await githubService.getDetailedPr(targetBranch, skipCache);
detailedPr = await githubService.getDetailedPr(targetBranchSha, skipCache);
mergeableState = detailedPr?.mergeableState;
lastDetailsFetch = createTimeAgoStore(new Date(), true);
} catch (err: any) {
@ -347,7 +342,7 @@
toasts.error('Failed to merge pull request');
} finally {
isMerging = false;
baseBranchService.fetchFromTarget();
baseBranchService.fetchFromRemotes();
branchService.reloadVirtualBranches();
updateDetailsAndChecks();
}

View File

@ -1,71 +1,143 @@
<script lang="ts">
// This is always displayed in the context of not having a cooresponding vbranch or remote
import { Project } from '$lib/backend/projects';
import Button from '$lib/components/Button.svelte';
import Link from '$lib/components/Link.svelte';
import Modal from '$lib/components/Modal.svelte';
import Tag from '$lib/components/Tag.svelte';
import TextBox from '$lib/components/TextBox.svelte';
import { RemotesService } from '$lib/remotes/service';
import { getContext } from '$lib/utils/context';
import * as toasts from '$lib/utils/toasts';
import { BaseBranchService } from '$lib/vbranches/baseBranch';
import { BranchController } from '$lib/vbranches/branchController';
import { VirtualBranchService } from '$lib/vbranches/virtualBranch';
import { marked } from 'marked';
import type { PullRequest } from '$lib/github/types';
import { goto } from '$app/navigation';
export let pullrequest: PullRequest | undefined;
export let pullrequest: PullRequest;
const branchController = getContext(BranchController);
const project = getContext(Project);
const remotesService = getContext(RemotesService);
const baseBranchService = getContext(BaseBranchService);
const virtualBranchService = getContext(VirtualBranchService);
let remoteName = structuredClone(pullrequest.repoName) || '';
let createRemoteModal: Modal | undefined;
let loading = false;
function closeModal() {
remoteName = structuredClone(pullrequest.repoName) || '';
createRemoteModal?.close();
}
async function createRemoteAndBranch() {
if (!pullrequest.sshUrl) return;
const remotes = await remotesService.remotes(project.id);
if (remotes.includes(remoteName)) {
toasts.error('Remote already exists');
return;
}
loading = true;
try {
await remotesService.addRemote(project.id, remoteName, pullrequest.sshUrl);
await baseBranchService.fetchFromRemotes();
await branchController.createvBranchFromBranch(
`refs/remotes/${remoteName}/${pullrequest.targetBranch}`
);
await virtualBranchService.reload();
const vbranch = await virtualBranchService.getByUpstreamSha(pullrequest.sha);
// This is a little absurd, but it makes it soundly typed
if (!vbranch) {
goto(`/${project.id}/board`);
return;
}
// Active seems to be a more reliable metric to determine whether to go to the branch page
if (vbranch.active) {
goto(`/${project.id}/board`);
} else {
goto(`/${project.id}/stashed/${vbranch.id}`);
}
createRemoteModal?.close();
} finally {
loading = false;
}
}
</script>
{#if pullrequest != undefined}
<div class="wrapper">
<div class="card">
<div class="card__header text-base-body-14 text-semibold">
<h2 class="text-base-14 text-semibold">
{pullrequest.title}
<span class="card__title-pr">
<Link target="_blank" rel="noreferrer" href={pullrequest.htmlUrl}>
#{pullrequest.number}
</Link>
</span>
</h2>
{#if pullrequest.draft}
<Tag style="neutral" icon="draft-pr-small">Draft</Tag>
{:else}
<Tag style="success" kind="solid" icon="pr-small">Open</Tag>
{/if}
</div>
<Modal width="small" bind:this={createRemoteModal}>
<p class="text-base-15 fork-notice">
In order to apply a branch from a fork, GitButler must first add a remote.
</p>
<TextBox label="Choose a remote name" bind:value={remoteName}></TextBox>
<svelte:fragment slot="controls">
<Button style="ghost" kind="solid" on:click={closeModal}>Cancel</Button>
<Button style="pop" kind="solid" grow on:click={createRemoteAndBranch} {loading}>Confirm</Button
>
</svelte:fragment>
</Modal>
<div class="card__content">
<div class="text-base-13">
<span class="text-bold">
{pullrequest.author?.name}
</span>
wants to merge into
<span class="code-string">
{pullrequest.sourceBranch}
</span>
from
<span class="code-string">
{pullrequest.targetBranch}
</span>
</div>
{#if pullrequest.body}
<div class="markdown">
{@html marked.parse(pullrequest.body)}
</div>
{/if}
<div class="wrapper">
<div class="card">
<div class="card__header text-base-body-14 text-semibold">
<h2 class="text-base-14 text-semibold">
{pullrequest.title}
<span class="card__title-pr">
<Link target="_blank" rel="noreferrer" href={pullrequest.htmlUrl}>
#{pullrequest.number}
</Link>
</span>
</h2>
{#if pullrequest.draft}
<Tag style="neutral" icon="draft-pr-small">Draft</Tag>
{:else}
<Tag style="success" kind="solid" icon="pr-small">Open</Tag>
{/if}
</div>
<div class="card__content">
<div class="text-base-13">
<span class="text-bold">
{pullrequest.author?.name}
</span>
wants to merge into
<span class="code-string">
{pullrequest.sourceBranch}
</span>
from
<span class="code-string">
{pullrequest.targetBranch}
</span>
</div>
<div class="card__footer">
{#if pullrequest.body}
<div class="markdown">
{@html marked.parse(pullrequest.body)}
</div>
{/if}
</div>
<div class="card__footer">
{#if !pullrequest.repoName && !pullrequest.sshUrl}
<p>Cannot apply pull request due to insufficient information</p>
{:else}
<Button
style="pop"
kind="solid"
help="Does not create a commit. Can be toggled."
on:click={async () =>
await (pullrequest &&
branchController.createvBranchFromBranch(
'refs/remotes/origin/' + pullrequest.targetBranch
))}>Apply</Button
on:click={async () => createRemoteModal?.show()}>Apply from fork</Button
>
</div>
{/if}
</div>
</div>
{/if}
</div>
<style lang="postcss">
.wrapper {
@ -81,4 +153,8 @@
opacity: 0.4;
margin-left: var(--size-4);
}
.fork-notice {
margin-bottom: var(--size-8);
}
</style>

View File

@ -23,7 +23,7 @@
on:mousedown={async (e) => {
e.preventDefault();
e.stopPropagation();
await baseBranchService.fetchFromTarget('modal');
await baseBranchService.fetchFromRemotes('modal');
if (githubService.isEnabled) {
await githubService.reload();
}

View File

@ -173,21 +173,19 @@ export class GitHubService {
}
async getDetailedPr(
branch: string | undefined,
branchSha: string,
skipCache: boolean
): Promise<DetailedPullRequest | undefined> {
if (!branch) return;
const cachedPr = !skipCache && this.prCache.get(branch);
const cachedPr = !skipCache && this.prCache.get(branchSha);
if (cachedPr) {
const cacheTimeMs = 2 * 1000;
const age = new Date().getTime() - cachedPr.fetchedAt.getTime();
if (age < cacheTimeMs) return cachedPr.value;
}
const prNumber = this.getListedPr(branch)?.number;
const prNumber = this.getListedPr(branchSha)?.number;
if (!prNumber) {
toasts.error('No pull request number for branch ' + branch);
toasts.error('No pull request number for branch ' + branchSha);
return;
}
@ -209,7 +207,7 @@ export class GitHubService {
attempt++;
try {
pr = await request();
if (pr) this.prCache.set(branch, { value: pr, fetchedAt: new Date() });
if (pr) this.prCache.set(branchSha, { value: pr, fetchedAt: new Date() });
return pr;
} catch (err: any) {
if (err.status != 422) throw err;
@ -229,18 +227,12 @@ export class GitHubService {
if (checkSuites.some((suite) => suite.status != 'completed')) return true;
}
getListedPr(branch: string | undefined): PullRequest | undefined {
if (!branch) return;
return this.prs?.find((pr) => pr.targetBranch == branch);
getListedPr(branchSha: string): PullRequest | undefined {
return this.prs?.find((pr) => pr.sha == branchSha);
}
getPr$(branch: string | undefined): Observable<PullRequest | undefined> {
if (!branch) return of(undefined);
return this.prs$.pipe(map((prs) => prs.find((pr) => pr.targetBranch == branch)));
}
hasPr(branch: string): boolean {
return !!this.prs$.value.find((pr) => pr.targetBranch == branch);
getPr$(branchSha: string): Observable<PullRequest | undefined> {
return this.prs$.pipe(map((prs) => prs.find((pr) => pr.sha == branchSha)));
}
/* TODO: Figure out a way to cleanup old behavior subjects */

View File

@ -22,6 +22,8 @@ export interface PullRequest {
modifiedAt: Date;
mergedAt?: Date;
closedAt?: Date;
repoName?: string;
sshUrl?: string;
}
export type DetailedGitHubPullRequest = RestEndpointMethodTypes['pulls']['get']['response']['data'];
@ -102,7 +104,9 @@ export function ghResponseToInstance(
sourceBranch: pr.base.ref,
sha: pr.head.sha,
mergedAt: pr.merged_at ? new Date(pr.merged_at) : undefined,
closedAt: pr.closed_at ? new Date(pr.closed_at) : undefined
closedAt: pr.closed_at ? new Date(pr.closed_at) : undefined,
repoName: pr.head.repo?.full_name,
sshUrl: pr.head.repo?.ssh_url
};
}

View File

@ -0,0 +1,16 @@
import { invoke } from '$lib/backend/ipc';
import { showError } from '$lib/notifications/toasts';
export class RemotesService {
async remotes(projectId: string) {
return await invoke<string[]>('list_remotes', { projectId });
}
async addRemote(projectId: string, name: string, url: string) {
try {
await invoke('add_remote', { projectId, name, url });
} catch (e) {
showError('Failed to add remote', e);
}
}
}

View File

@ -28,7 +28,7 @@ export function mockTauri() {
return null;
}
if (cmd === 'fetch_from_target') {
if (cmd === 'fetch_from_remotes') {
return true;
}

View File

@ -63,12 +63,12 @@ export class BaseBranchService {
[this.base, this.error] = observableToStore(this.base$, this.reload$);
}
async fetchFromTarget(action: string | undefined = undefined) {
async fetchFromRemotes(action: string | undefined = undefined) {
this.busy$.next(true);
try {
// Note that we expect the back end to emit new fetches event, and therefore
// trigger a base branch reload. It feels a bit awkward and should be improved.
await invoke<void>('fetch_from_target', {
await invoke<void>('fetch_from_remotes', {
projectId: this.projectId,
action: action || 'auto'
});
@ -94,7 +94,7 @@ export class BaseBranchService {
branch,
pushRemote
});
await this.fetchFromTarget();
await this.fetchFromRemotes();
}
reload() {

View File

@ -139,6 +139,15 @@ export class VirtualBranchService {
)
);
}
async getByUpstreamSha(upstreamSha: string) {
return await firstValueFrom(
this.branches$.pipe(
timeout(10000),
map((branches) => branches?.find((b) => b.upstream?.sha == upstreamSha))
)
);
}
}
function subscribeToVirtualBranches(projectId: string, callback: (branches: Branch[]) => void) {

View File

@ -13,6 +13,7 @@
import ShareIssueModal from '$lib/components/ShareIssueModal.svelte';
import { GitHubService } from '$lib/github/service';
import ToastController from '$lib/notifications/ToastController.svelte';
import { RemotesService } from '$lib/remotes/service';
import { SETTINGS, loadUserSettings } from '$lib/settings/userSettings';
import { User, UserService } from '$lib/stores/user';
import * as events from '$lib/utils/events';
@ -42,6 +43,7 @@
setContext(AuthService, data.authService);
setContext(HttpClient, data.cloud);
setContext(User, data.userService.user);
setContext(RemotesService, data.remotesService);
let shareIssueModal: ShareIssueModal;

View File

@ -7,6 +7,7 @@ import { ProjectService } from '$lib/backend/projects';
import { PromptService } from '$lib/backend/prompt';
import { UpdaterService } from '$lib/backend/updater';
import { GitHubService } from '$lib/github/service';
import { RemotesService } from '$lib/remotes/service';
import { UserService } from '$lib/stores/user';
import { mockTauri } from '$lib/testing/index';
import lscache from 'lscache';
@ -52,6 +53,7 @@ export async function load() {
const gitConfig = new GitConfigService();
const aiService = new AIService(gitConfig, httpClient);
const remotesService = new RemotesService();
return {
authService,
@ -64,6 +66,7 @@ export async function load() {
// These observables are provided for convenience
remoteUrl$,
gitConfig,
aiService
aiService,
remotesService
};
}

View File

@ -54,10 +54,10 @@
$: if (projectId) setupFetchInterval();
function setupFetchInterval() {
baseBranchService.fetchFromTarget();
baseBranchService.fetchFromRemotes();
clearFetchInterval();
const intervalMs = 15 * 60 * 1000; // 15 minutes
intervalId = setInterval(async () => await baseBranchService.fetchFromTarget(), intervalMs);
intervalId = setInterval(async () => await baseBranchService.fetchFromRemotes(), intervalMs);
}
function clearFetchInterval() {

View File

@ -1,4 +1,9 @@
<script lang="ts">
// This page is displayed when:
// - A pr is found
// - And it does NOT have a cooresponding vbranch
// - And it does NOT have a cooresponding remote
// It may also display details about a cooresponding pr if they exist
import FullviewLoading from '$lib/components/FullviewLoading.svelte';
import PullRequestPreview from '$lib/components/PullRequestPreview.svelte';
import { GitHubService } from '$lib/github/service';

View File

@ -1,4 +1,8 @@
<script lang="ts">
// This page is displayed when:
// - A remote branch is found
// - And it does NOT have a cooresponding vbranch
// It may also display details about a cooresponding pr if they exist
import FullviewLoading from '$lib/components/FullviewLoading.svelte';
import RemoteBranchPreview from '$lib/components/RemoteBranchPreview.svelte';
import { GitHubService } from '$lib/github/service';
@ -13,7 +17,7 @@
$: ({ error, branches } = data.remoteBranchService);
$: branch = $branches?.find((b) => b.sha == $page.params.sha);
$: pr = githubService.getListedPr(branch?.displayName);
$: pr = branch && githubService.getListedPr(branch.sha);
</script>
{#if $error}

View File

@ -1,4 +1,7 @@
<script lang="ts">
// This page is displayed when:
// - A vbranch is found
// It may also display details about a cooresponding remote and/or pr if they exist
import BranchLane from '$lib/components//BranchLane.svelte';
import Button from '$lib/components/Button.svelte';
import FullviewLoading from '$lib/components/FullviewLoading.svelte';

View File

@ -655,6 +655,11 @@ impl Repository {
})
.map_err(super::Error::Remotes)
}
pub fn add_remote(&self, name: &str, url: &str) -> Result<()> {
self.0.remote(name, url)?;
Ok(())
}
}
pub struct CheckoutTreeBuidler<'a> {

View File

@ -27,6 +27,7 @@ pub mod path;
pub mod project_repository;
pub mod projects;
pub mod reader;
pub mod remotes;
pub mod ssh;
pub mod storage;
pub mod synchronize;

View File

@ -611,6 +611,12 @@ impl Repository {
pub fn remotes(&self) -> Result<Vec<String>> {
self.git_repository.remotes().map_err(anyhow::Error::from)
}
pub fn add_remote(&self, name: &str, url: &str) -> Result<()> {
self.git_repository
.add_remote(name, url)
.map_err(anyhow::Error::from)
}
}
#[derive(Debug, thiserror::Error)]

View File

@ -0,0 +1,35 @@
use crate::{
error::Error,
project_repository,
projects::{self, ProjectId},
};
#[derive(Clone)]
pub struct Controller {
projects: projects::Controller,
}
impl Controller {
pub fn new(projects: projects::Controller) -> Self {
Self { projects }
}
pub async fn remotes(&self, project_id: &ProjectId) -> Result<Vec<String>, Error> {
let project = self.projects.get(project_id)?;
let project_repository = project_repository::Repository::open(&project)?;
project_repository.remotes().map_err(Into::into)
}
pub async fn add_remote(
&self,
project_id: &ProjectId,
name: &str,
url: &str,
) -> Result<(), Error> {
let project = self.projects.get(project_id)?;
let project_repository = project_repository::Repository::open(&project)?;
project_repository.add_remote(name, url).map_err(Into::into)
}
}

View File

@ -0,0 +1,2 @@
pub mod controller;
pub use controller::Controller;

View File

@ -388,14 +388,14 @@ impl Controller {
.await
}
pub async fn fetch_from_target(
pub async fn fetch_from_remotes(
&self,
project_id: &ProjectId,
askpass: Option<String>,
) -> Result<BaseBranch, Error> {
self.inner(project_id)
.await
.fetch_from_target(project_id, askpass)
.fetch_from_remotes(project_id, askpass)
.await
}
@ -880,7 +880,7 @@ impl ControllerInner {
})
}
pub async fn fetch_from_target(
pub async fn fetch_from_remotes(
&self,
project_id: &ProjectId,
askpass: Option<String>,

View File

@ -103,7 +103,7 @@ async fn integration() {
{
// should mark commits as integrated
controller
.fetch_from_target(project_id, None)
.fetch_from_remotes(project_id, None)
.await
.unwrap();

View File

@ -17,7 +17,7 @@ async fn should_update_last_fetched() {
assert!(before_fetch.last_fetched_ms.is_none());
let fetch = controller
.fetch_from_target(project_id, None)
.fetch_from_remotes(project_id, None)
.await
.unwrap();
assert!(fetch.last_fetched_ms.is_some());
@ -27,7 +27,7 @@ async fn should_update_last_fetched() {
assert_eq!(fetch.last_fetched_ms, after_fetch.last_fetched_ms);
let second_fetch = controller
.fetch_from_target(project_id, None)
.fetch_from_remotes(project_id, None)
.await
.unwrap();
assert!(second_fetch.last_fetched_ms.is_some());

View File

@ -56,7 +56,7 @@ mod cherry_pick;
mod create_commit;
mod create_virtual_branch_from_branch;
mod delete_virtual_branch;
mod fetch_from_target;
mod fetch_from_remotes;
mod init;
mod insert_blank_commit;
mod move_commit_file;

View File

@ -25,6 +25,7 @@ pub mod error;
pub mod github;
pub mod keys;
pub mod projects;
pub mod remotes;
pub mod undo;
pub mod users;
pub mod virtual_branches;

View File

@ -15,8 +15,8 @@
use gitbutler_core::{assets, git, storage};
use gitbutler_tauri::{
app, askpass, commands, github, keys, logs, menu, projects, undo, users, virtual_branches,
watcher, zip,
app, askpass, commands, github, keys, logs, menu, projects, remotes, undo, users,
virtual_branches, watcher, zip,
};
use tauri::{generate_context, Manager};
use tauri_plugin_log::LogTarget;
@ -134,6 +134,12 @@ fn main() {
git_credentials_controller.clone(),
));
let remotes_controller = gitbutler_core::remotes::controller::Controller::new(
projects_controller.clone(),
);
app_handle.manage(remotes_controller.clone());
let app = app::App::new(
projects_controller,
);
@ -200,7 +206,7 @@ fn main() {
virtual_branches::commands::list_remote_branches,
virtual_branches::commands::get_remote_branch_data,
virtual_branches::commands::squash_branch_commit,
virtual_branches::commands::fetch_from_target,
virtual_branches::commands::fetch_from_remotes,
virtual_branches::commands::move_commit,
undo::list_snapshots,
undo::restore_snapshot,
@ -210,6 +216,8 @@ fn main() {
github::commands::init_device_oauth,
github::commands::check_auth_status,
askpass::commands::submit_prompt_response,
remotes::list_remotes,
remotes::add_remote
])
.menu(menu::build(tauri_context.package_info()))
.on_menu_event(|event|menu::handle_event(&event))

View File

@ -0,0 +1,32 @@
use crate::error::Error;
use gitbutler_core::{projects::ProjectId, remotes::Controller};
use tauri::Manager;
use tracing::instrument;
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn list_remotes(
handle: tauri::AppHandle,
project_id: ProjectId,
) -> Result<Vec<String>, Error> {
handle
.state::<Controller>()
.remotes(&project_id)
.await
.map_err(Into::into)
}
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn add_remote(
handle: tauri::AppHandle,
project_id: ProjectId,
name: &str,
url: &str,
) -> Result<(), Error> {
handle
.state::<Controller>()
.add_remote(&project_id, name, url)
.await
.map_err(Into::into)
}

View File

@ -485,14 +485,14 @@ pub mod commands {
#[tauri::command(async)]
#[instrument(skip(handle), err(Debug))]
pub async fn fetch_from_target(
pub async fn fetch_from_remotes(
handle: tauri::AppHandle,
project_id: ProjectId,
action: Option<String>,
) -> Result<BaseBranch, Error> {
let base_branch = handle
.state::<Controller>()
.fetch_from_target(
.fetch_from_remotes(
&project_id,
Some(action.unwrap_or_else(|| "unknown".to_string())),
)