Skip to content
Open
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
14 changes: 14 additions & 0 deletions crates/git/src/hosting_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ impl<'a> BuildPermalinkParams<'a> {
}
}

pub struct BuildCreatePullRequestParams<'a> {
pub source_branch: &'a str,
pub target_branch: Option<&'a str>,
}

/// A Git hosting provider.
#[async_trait]
pub trait GitHostingProvider {
Expand All @@ -92,6 +97,15 @@ pub trait GitHostingProvider {
/// Returns a permalink to a file and/or selection on this hosting provider.
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url;

/// Returns a URL to create a pull request on this hosting provider.
fn build_create_pull_request_url(
&self,
_remote: &ParsedGitRemote,
_params: BuildCreatePullRequestParams,
) -> Option<Url> {
None
}

/// Returns whether this provider supports avatars.
fn supports_avatars(&self) -> bool;

Expand Down
71 changes: 69 additions & 2 deletions crates/git_hosting_providers/src/providers/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use regex::Regex;
use serde::Deserialize;
use url::Url;
use urlencoding::encode;

use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
PullRequest, RemoteUrl,
BuildCommitPermalinkParams, BuildCreatePullRequestParams, BuildPermalinkParams,
GitHostingProvider, ParsedGitRemote, PullRequest, RemoteUrl,
};

use crate::get_host_from_git_remote_url;
Expand Down Expand Up @@ -224,6 +225,32 @@ impl GitHostingProvider for Github {
permalink
}

fn build_create_pull_request_url(
&self,
remote: &ParsedGitRemote,
params: BuildCreatePullRequestParams,
) -> Option<Url> {
let ParsedGitRemote { owner, repo } = remote;
let BuildCreatePullRequestParams {
source_branch,
target_branch,
} = params;

let encoded_source = encode(source_branch);

let mut url = self
.base_url()
.join(&format!("{owner}/{repo}/pull/new/{encoded_source}"))
.ok()?;

if let Some(target_branch) = target_branch {
let encoded_target = encode(target_branch);
url.set_query(Some(&format!("base={encoded_target}")));
}

Some(url)
}

fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
let line = message.lines().next()?;
let capture = pull_request_number_regex().captures(line)?;
Expand Down Expand Up @@ -466,6 +493,46 @@ mod tests {
assert_eq!(permalink.to_string(), expected_url.to_string())
}

#[test]
fn test_build_github_create_pr_url() {
let remote = ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
};

let provider = Github::public_instance();

let url = provider
.build_create_pull_request_url(
&remote,
BuildCreatePullRequestParams {
source_branch: "feature/something cool",
target_branch: Some("main"),
},
)
.expect("url should be constructed");

assert_eq!(
url.as_str(),
"https://github.com/zed-industries/zed/pull/new/feature%2Fsomething%20cool?base=main"
);

let url_without_target = provider
.build_create_pull_request_url(
&remote,
BuildCreatePullRequestParams {
source_branch: "feature/only-source",
target_branch: None,
},
)
.expect("url should be constructed");

assert_eq!(
url_without_target.as_str(),
"https://github.com/zed-industries/zed/pull/new/feature%2Fonly-source"
);
}

#[test]
fn test_github_pull_requests() {
let remote = ParsedGitRemote {
Expand Down
77 changes: 75 additions & 2 deletions crates/git_hosting_providers/src/providers/gitlab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ use gpui::SharedString;
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
use serde::Deserialize;
use url::Url;
use urlencoding::encode;

use git::{
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
RemoteUrl,
BuildCommitPermalinkParams, BuildCreatePullRequestParams, BuildPermalinkParams,
GitHostingProvider, ParsedGitRemote, RemoteUrl,
};

use crate::get_host_from_git_remote_url;
Expand Down Expand Up @@ -209,6 +210,38 @@ impl GitHostingProvider for Gitlab {
permalink
}

fn build_create_pull_request_url(
&self,
remote: &ParsedGitRemote,
params: BuildCreatePullRequestParams,
) -> Option<Url> {
let BuildCreatePullRequestParams {
source_branch,
target_branch,
} = params;

let mut url = self
.base_url()
.join(&format!(
"{}/{}/-/merge_requests/new",
remote.owner, remote.repo
))
.ok()?;

let mut query = format!("merge_request%5Bsource_branch%5D={}", encode(source_branch));

if let Some(target_branch) = target_branch {
query.push('&');
query.push_str(&format!(
"merge_request%5Btarget_branch%5D={}",
encode(target_branch)
));
}

url.set_query(Some(&query));
Some(url)
}

async fn commit_author_avatar_url(
&self,
repo_owner: &str,
Expand Down Expand Up @@ -376,6 +409,46 @@ mod tests {
assert_eq!(permalink.to_string(), expected_url.to_string())
}

#[test]
fn test_build_gitlab_create_pr_url() {
let remote = ParsedGitRemote {
owner: "zed-industries".into(),
repo: "zed".into(),
};

let provider = Gitlab::public_instance();

let url = provider
.build_create_pull_request_url(
&remote,
BuildCreatePullRequestParams {
source_branch: "feature/cool stuff",
target_branch: Some("main"),
},
)
.expect("create PR url should be constructed");

assert_eq!(
url.as_str(),
"https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcool%20stuff&merge_request%5Btarget_branch%5D=main"
);

let url_without_target = provider
.build_create_pull_request_url(
&remote,
BuildCreatePullRequestParams {
source_branch: "feature/only-source",
target_branch: None,
},
)
.expect("create PR url should be constructed without target");

assert_eq!(
url_without_target.as_str(),
"https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fonly-source"
);
}

#[test]
fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
let gitlab =
Expand Down
7 changes: 6 additions & 1 deletion crates/git_ui/src/git_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2924,7 +2924,12 @@ impl GitPanel {
}
}

fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
fn show_remote_output(
&mut self,
action: RemoteAction,
info: RemoteCommandOutput,
cx: &mut Context<Self>,
) {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
Expand Down
82 changes: 81 additions & 1 deletion crates/git_ui/src/git_ui.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::any::Any;

use anyhow::anyhow;
use command_palette_hooks::CommandPaletteFilter;
use commit_modal::CommitModal;
use editor::{Editor, actions::DiffClipboardWithSelectionData};
Expand All @@ -11,6 +12,7 @@ use ui::{
mod blame_ui;

use git::{
BuildCreatePullRequestParams, GitHostingProviderRegistry, parse_git_remote_url,
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
};
Expand Down Expand Up @@ -77,6 +79,13 @@ pub fn init(cx: &mut App) {
return;
}
if !project.is_via_collab() {
workspace.register_action(
|workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
if let Err(error) = open_create_pull_request(workspace, window, cx) {
workspace.show_error(&error, cx);
}
},
);
workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
return;
Expand Down Expand Up @@ -231,6 +240,77 @@ pub fn init(cx: &mut App) {
.detach();
}

fn open_create_pull_request(
workspace: &mut Workspace,
_window: &mut Window,
cx: &mut Context<Workspace>,
) -> anyhow::Result<()> {
let project = workspace.project();
let Some(active_repository) = project.read(cx).active_repository(cx) else {
return Err(anyhow!("No active repository"));
};

let (branch, remote_origin, remote_upstream) = {
let repository = active_repository.read(cx);
(
repository.branch.clone(),
repository.remote_origin_url.clone(),
repository.remote_upstream_url.clone(),
)
};

let branch = branch.ok_or_else(|| anyhow!("No active branch"))?;
let source_branch = branch.name().to_string();

let remote_url = branch
.upstream
.as_ref()
.and_then(|upstream| {
let remote_name = upstream.remote_name();
match remote_name {
Some("upstream") => remote_upstream.as_deref(),
Some(_) => remote_origin.as_deref(),
None => None,
}
})
.or_else(|| remote_origin.as_deref())
.or_else(|| remote_upstream.as_deref())
.ok_or_else(|| anyhow!("No remote configured for repository"))?;
let remote_url = remote_url.to_string();

let target_branch = branch.upstream.as_ref().and_then(|upstream| {
if let UpstreamTracking::Tracked(_) = upstream.tracking {
let ref_name = upstream.ref_name.as_ref();
let stripped = ref_name.strip_prefix("refs/remotes/").unwrap_or(ref_name);
let branch_name = stripped
.split_once('/')
.map(|(_, name)| name)
.unwrap_or(stripped);
Some(branch_name.to_string())
} else {
None
}
});

let provider_registry = GitHostingProviderRegistry::global(cx);
let Some((provider, parsed_remote)) = parse_git_remote_url(provider_registry, &remote_url)
else {
return Err(anyhow!("Unsupported remote URL: {}", remote_url));
};

let params = BuildCreatePullRequestParams {
source_branch: source_branch.as_str(),
target_branch: target_branch.as_deref(),
};

let Some(url) = provider.build_create_pull_request_url(&parsed_remote, params) else {
return Err(anyhow!("Unable to construct pull request URL"));
};

cx.open_url(url.as_str());
Ok(())
}

fn open_modified_files(
workspace: &mut Workspace,
window: &mut Window,
Expand Down Expand Up @@ -310,7 +390,7 @@ impl RenameBranchModal {
{
Ok(Ok(_)) => Ok(()),
Ok(Err(error)) => Err(error),
Err(_) => Err(anyhow::anyhow!("Operation was canceled")),
Err(_) => Err(anyhow!("Operation was canceled")),
}
})
.detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
Expand Down
4 changes: 3 additions & 1 deletion crates/zed_actions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ pub mod git {
/// Opens the git stash selector.
ViewStash,
/// Opens the git worktree selector.
Worktree
Worktree,
/// Creates a pull request for the current branch.
CreatePullRequest
]
);
}
Expand Down
Loading