diff --git a/src/App.axaml.cs b/src/App.axaml.cs index 66882b4869..5f4c7217e6 100644 --- a/src/App.axaml.cs +++ b/src/App.axaml.cs @@ -227,6 +227,12 @@ public static ViewModels.Launcher GetLauncher() return Current is App app ? app._launcher : null; } + public static async Task CopyTextAsync(string text) + { + if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow.Clipboard: { } clipboard }) + await clipboard.SetTextAsync(text); + } + public static void Quit(int exitCode) { if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) diff --git a/src/Commands/QueryPullRequestTemplates.cs b/src/Commands/QueryPullRequestTemplates.cs new file mode 100644 index 0000000000..6e084b45dd --- /dev/null +++ b/src/Commands/QueryPullRequestTemplates.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + /// + /// Finds the pull/merge request templates (GitHub, GitLab, Gitea, Forgejo, Azure DevOps) in the given revision. + /// + public class QueryPullRequestTemplates + { + public QueryPullRequestTemplates(string repo, string revision) + { + _repo = repo; + _revision = revision; + } + + public async Task> GetResultAsync() + { + var templates = new List(); + + var lister = new QueryRevisionFileNames(_repo, _revision, LOOKUP_PATHS); + var files = await lister.GetResultAsync().ConfigureAwait(false); + foreach (var file in files) + { + if (!IsTemplate(file, out var isDefault)) + continue; + + using var stream = await QueryFileContent.RunAsync(_repo, _revision, file).ConfigureAwait(false); + using var reader = new StreamReader(stream); + var content = await reader.ReadToEndAsync().ConfigureAwait(false); + + var template = new Models.PullRequestTemplate() + { + Name = file, + Content = content.Trim(), + IsDefault = isDefault, + }; + + if (isDefault) + templates.Insert(0, template); + else + templates.Add(template); + } + + return templates; + } + + private static bool IsTemplate(string file, out bool isDefault) + { + isDefault = false; + + var path = file.ToLowerInvariant(); + if (!path.EndsWith(".md", StringComparison.Ordinal)) + return false; + + var name = Path.GetFileName(path); + var dir = Path.GetDirectoryName(path)?.Replace('\\', '/') ?? string.Empty; + + if (name.Equals("pull_request_template.md", StringComparison.Ordinal)) + { + isDefault = true; + return Array.IndexOf(SINGLE_TEMPLATE_DIRS, dir) >= 0; + } + + if (dir.Equals(".gitlab/merge_request_templates", StringComparison.Ordinal)) + { + isDefault = name.Equals("default.md", StringComparison.Ordinal); + return true; + } + + return Array.IndexOf(MULTIPLE_TEMPLATES_DIRS, dir) >= 0; + } + + private static readonly string[] LOOKUP_PATHS = [ + ".github", ".gitea", ".forgejo", ".gitlab/merge_request_templates", ".azuredevops", ".vsts", + "docs/pull_request_template.md", "docs/PULL_REQUEST_TEMPLATE.md", "pull_request_template.md", "PULL_REQUEST_TEMPLATE.md", + ]; + + private static readonly string[] SINGLE_TEMPLATE_DIRS = ["", ".github", ".gitea", ".forgejo", ".azuredevops", ".vsts", "docs"]; + private static readonly string[] MULTIPLE_TEMPLATES_DIRS = [".github/pull_request_template", ".gitea/pull_request_template", ".forgejo/pull_request_template"]; + + private readonly string _repo; + private readonly string _revision; + } +} diff --git a/src/Commands/QueryRemoteHead.cs b/src/Commands/QueryRemoteHead.cs new file mode 100644 index 0000000000..bdbeb0771c --- /dev/null +++ b/src/Commands/QueryRemoteHead.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace SourceGit.Commands +{ + public class QueryRemoteHead : Command + { + public QueryRemoteHead(string repo, string remote) + { + WorkingDirectory = repo; + Context = repo; + Args = $"symbolic-ref --short refs/remotes/{remote}/HEAD"; + + _remote = remote; + } + + /// + /// Returns the name of the remote's default branch (without the remote prefix), or null if unknown. + /// + public async Task GetResultAsync() + { + var rs = await ReadToEndAsync().ConfigureAwait(false); + if (!rs.IsSuccess) + return null; + + var output = rs.StdOut.Trim(); + var prefix = $"{_remote}/"; + if (output.StartsWith(prefix, StringComparison.Ordinal)) + return output.Substring(prefix.Length); + + return null; + } + + private readonly string _remote; + } +} diff --git a/src/Commands/QueryRemotes.cs b/src/Commands/QueryRemotes.cs index 6adff9b36d..96e74141b1 100644 --- a/src/Commands/QueryRemotes.cs +++ b/src/Commands/QueryRemotes.cs @@ -66,6 +66,9 @@ public QueryRemotes(string repo) DisableAutoFetch = disableAutoFetchRemotes.Contains(name) }; + if (Models.Remote.TryGetSSHHost(url, out var host)) + Models.HTTPSValidator.Add(host); + remotes.Add(r); } diff --git a/src/Commands/QueryRevisionFileNames.cs b/src/Commands/QueryRevisionFileNames.cs index e4dcc25c51..bba42ee642 100644 --- a/src/Commands/QueryRevisionFileNames.cs +++ b/src/Commands/QueryRevisionFileNames.cs @@ -13,6 +13,13 @@ public QueryRevisionFileNames(string repo, string revision) Args = $"ls-tree -r --name-only {revision}"; } + public QueryRevisionFileNames(string repo, string revision, string[] paths) + { + WorkingDirectory = repo; + Context = repo; + Args = $"ls-tree -r --name-only {revision} -- {string.Join(' ', paths)}"; + } + public async Task> GetResultAsync() { var outs = new List(); diff --git a/src/Models/HTTPSValidator.cs b/src/Models/HTTPSValidator.cs index 014207c21c..253e8634fa 100644 --- a/src/Models/HTTPSValidator.cs +++ b/src/Models/HTTPSValidator.cs @@ -21,13 +21,7 @@ public static void Add(string host) _hosts.Add(host, true); // Well-known hosts always support HTTPS - if (host.Contains("github.com", StringComparison.Ordinal) || - host.Contains("gitlab", StringComparison.Ordinal) || - host.Contains("azure.com", StringComparison.Ordinal) || - host.Equals("gitee.com", StringComparison.Ordinal) || - host.Equals("bitbucket.org", StringComparison.Ordinal) || - host.Equals("gitea.org", StringComparison.Ordinal) || - host.Equals("gitcode.com", StringComparison.Ordinal)) + if (IsWellKnownHost(host)) return; } @@ -67,6 +61,20 @@ public static void Add(string host) }); } + public static bool IsWellKnownHost(string host) + { + return host.Contains("github.com", StringComparison.Ordinal) || + host.Contains("gitlab", StringComparison.Ordinal) || + host.Contains("azure.com", StringComparison.Ordinal) || + host.Contains("visualstudio.com", StringComparison.Ordinal) || + host.Equals("gitee.com", StringComparison.Ordinal) || + host.Equals("bitbucket.org", StringComparison.Ordinal) || + host.Equals("gitea.org", StringComparison.Ordinal) || + host.Equals("gitea.com", StringComparison.Ordinal) || + host.Equals("codeberg.org", StringComparison.Ordinal) || + host.Equals("gitcode.com", StringComparison.Ordinal); + } + public static bool IsSupported(string host) { lock (_syncLock) diff --git a/src/Models/PullRequestPlatform.cs b/src/Models/PullRequestPlatform.cs new file mode 100644 index 0000000000..94c1e936f5 --- /dev/null +++ b/src/Models/PullRequestPlatform.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace SourceGit.Models +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum PullRequestPlatformType + { + None = 0, + GitHub, + GitLab, + Gitea, + Forgejo, + BitbucketCloud, + AzureDevOps, + Gitee, + } + + public class PullRequestPlatform + { + public const int MaxURLLength = 7000; + + public static readonly List Supported = [ + new(PullRequestPlatformType.GitHub, "GitHub", true, true, true), + new(PullRequestPlatformType.GitLab, "GitLab", true, true, true), + new(PullRequestPlatformType.Gitea, "Gitea", true, true, true), + new(PullRequestPlatformType.Forgejo, "Forgejo / Codeberg", false, false, true), + new(PullRequestPlatformType.BitbucketCloud, "Bitbucket Cloud", false, false, false), + new(PullRequestPlatformType.AzureDevOps, "Azure DevOps", false, false, false), + new(PullRequestPlatformType.Gitee, "Gitee", false, false, false), + ]; + + public PullRequestPlatformType Type { get; } + public string Name { get; } + public bool SupportsTitle { get; } + public bool SupportsBody { get; } + public bool SupportsCrossRepo { get; } + + public PullRequestPlatform(PullRequestPlatformType type, string name, bool supportsTitle, bool supportsBody, bool supportsCrossRepo) + { + Type = type; + Name = name; + SupportsTitle = supportsTitle; + SupportsBody = supportsBody; + SupportsCrossRepo = supportsCrossRepo; + } + + public static PullRequestPlatform Get(PullRequestPlatformType type) + { + return Supported.Find(x => x.Type == type); + } + + public static PullRequestPlatformType Detect(string host) + { + if (string.IsNullOrEmpty(host)) + return PullRequestPlatformType.None; + + host = host.ToLowerInvariant(); + + if (host.Contains("github", StringComparison.Ordinal)) + return PullRequestPlatformType.GitHub; + + if (host.Contains("gitlab", StringComparison.Ordinal)) + return PullRequestPlatformType.GitLab; + + if (host.Equals("codeberg.org", StringComparison.Ordinal) || host.Contains("forgejo", StringComparison.Ordinal)) + return PullRequestPlatformType.Forgejo; + + if (host.Contains("gitea", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitea; + + if (host.Equals("bitbucket.org", StringComparison.Ordinal)) + return PullRequestPlatformType.BitbucketCloud; + + if (host.Equals("dev.azure.com", StringComparison.Ordinal) || host.EndsWith("visualstudio.com", StringComparison.Ordinal)) + return PullRequestPlatformType.AzureDevOps; + + if (host.Equals("gitee.com", StringComparison.Ordinal)) + return PullRequestPlatformType.Gitee; + + return PullRequestPlatformType.None; + } + + public static PullRequestPlatform Resolve(Remote remote, RepositorySettings settings) + { + if (remote == null) + return null; + + if (settings != null && + settings.PullRequestPlatforms.TryGetValue(remote.Name, out var overrided) && + overrided != PullRequestPlatformType.None) + return Get(overrided); + + if (!remote.TryGetVisitURL(out var url)) + return null; + + return Get(Detect(new Uri(url).Host)); + } + + /// + /// Builds the URL of the platform's "create pull request" page. + /// + /// Web URL of the repository that receives the pull request + /// Web URL of the repository that contains the source branch (same as target if not a fork) + /// True if the title or body could not be passed by URL + public string BuildCreateURL(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body, out bool contentDropped) + { + contentDropped = (!string.IsNullOrEmpty(title) && !SupportsTitle) || (!string.IsNullOrEmpty(body) && !SupportsBody); + title = SupportsTitle ? title : null; + body = SupportsBody ? body : null; + + var url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, body); + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(body)) + { + contentDropped = true; + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, title, null); + } + + if (url.Length > MaxURLLength && !string.IsNullOrEmpty(title)) + url = BuildCreateURLWithContent(targetBaseURL, sourceBaseURL, sourceBranch, targetBranch, null, null); + + return url; + } + + private string BuildCreateURLWithContent(string targetBaseURL, string sourceBaseURL, string sourceBranch, string targetBranch, string title, string body) + { + targetBaseURL = targetBaseURL.TrimEnd('/'); + sourceBaseURL = string.IsNullOrEmpty(sourceBaseURL) ? targetBaseURL : sourceBaseURL.TrimEnd('/'); + + var isCrossRepo = !sourceBaseURL.Equals(targetBaseURL, StringComparison.OrdinalIgnoreCase); + var builder = new StringBuilder(1024); + + switch (Type) + { + case PullRequestPlatformType.GitHub: + case PullRequestPlatformType.Gitea: + case PullRequestPlatformType.Forgejo: + { + var head = EscapePath(sourceBranch); + if (isCrossRepo) + head = $"{Uri.EscapeDataString(GetOwner(sourceBaseURL))}:{head}"; + + builder.Append(targetBaseURL).Append("/compare/").Append(EscapePath(targetBranch)).Append("...").Append(head); + builder.Append(Type == PullRequestPlatformType.GitHub ? "?quick_pull=1" : "?expand=1"); + AppendQuery(builder, "title", title); + AppendQuery(builder, "body", body); + break; + } + case PullRequestPlatformType.GitLab: + // For forks, GitLab creates the merge request from the source project, and the target project defaults to the upstream one. + builder.Append(sourceBaseURL).Append("/-/merge_requests/new?"); + builder.Append("merge_request%5Bsource_branch%5D=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "merge_request%5Btarget_branch%5D", targetBranch); + AppendQuery(builder, "merge_request%5Btitle%5D", title); + AppendQuery(builder, "merge_request%5Bdescription%5D", body); + break; + case PullRequestPlatformType.BitbucketCloud: + builder.Append(targetBaseURL).Append("/pull-requests/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "dest", targetBranch); + break; + case PullRequestPlatformType.AzureDevOps: + builder.Append(targetBaseURL).Append("/pullrequestcreate?sourceRef=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "targetRef", targetBranch); + break; + case PullRequestPlatformType.Gitee: + builder.Append(targetBaseURL).Append("/pulls/new?source=").Append(Uri.EscapeDataString(sourceBranch)); + AppendQuery(builder, "target", targetBranch); + break; + default: + return targetBaseURL; + } + + return builder.ToString(); + } + + private static void AppendQuery(StringBuilder builder, string key, string value) + { + if (string.IsNullOrEmpty(value)) + return; + + builder.Append('&').Append(key).Append('=').Append(Uri.EscapeDataString(value)); + } + + private static string EscapePath(string branch) + { + var segments = branch.Split('/'); + for (int i = 0; i < segments.Length; i++) + segments[i] = Uri.EscapeDataString(segments[i]); + return string.Join('/', segments); + } + + private static string GetOwner(string baseURL) + { + var path = new Uri(baseURL).AbsolutePath.Trim('/'); + var idx = path.IndexOf('/'); + return idx > 0 ? path.Substring(0, idx) : path; + } + } +} diff --git a/src/Models/PullRequestTemplate.cs b/src/Models/PullRequestTemplate.cs new file mode 100644 index 0000000000..6973f2e84f --- /dev/null +++ b/src/Models/PullRequestTemplate.cs @@ -0,0 +1,9 @@ +namespace SourceGit.Models +{ + public class PullRequestTemplate + { + public string Name { get; set; } = string.Empty; + public string Content { get; set; } = string.Empty; + public bool IsDefault { get; set; } = false; + } +} diff --git a/src/Models/Remote.cs b/src/Models/Remote.cs index ab16874d26..51be0add94 100644 --- a/src/Models/Remote.cs +++ b/src/Models/Remote.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text.RegularExpressions; -using System.Web; namespace SourceGit.Models { @@ -19,8 +18,11 @@ public partial class Remote [GeneratedRegex(@"^ssh://([\w\-]+@)?[\w\.\-]+(\:[0-9]+)?/([a-zA-z0-9~%][\w\-\./~%]*)?[a-zA-Z0-9](\.git)?$")] private static partial Regex REG_SSH2(); - [GeneratedRegex(@"^git@([\w\.\-]+):([\w\.\-/~%]+/[\w\-\.%]+)\.git$")] - private static partial Regex REG_TO_VISIT_URL_CAPTURE(); + [GeneratedRegex(@"^[\w\-\.]+@([\w\.\-]+):/?([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SCP(); + + [GeneratedRegex(@"^ssh://([\w\-\.]+@)?([\w\.\-]+)(\:[0-9]+)?/([\w\.\-/~%]+/[\w\-\.%]+?)(\.git)?/?$")] + private static partial Regex REG_TO_VISIT_URL_CAPTURE_SSH(); private static readonly Regex[] URL_FORMATS = [ REG_HTTPS(), @@ -78,64 +80,64 @@ public bool TryGetVisitURL(out string url) return true; } - var match = REG_TO_VISIT_URL_CAPTURE().Match(URL); - if (match.Success) + if (!TryParseSSH(URL, out var host, out var path)) + return false; + + // Azure DevOps uses a different path layout for SSH remotes. + // git@ssh.dev.azure.com:v3/{org}/{project}/{repo} => https://dev.azure.com/{org}/{project}/_git/{repo} + // {org}@vs-ssh.visualstudio.com:v3/{org}/{project}/{repo} => https://{org}.visualstudio.com/{project}/_git/{repo} + if (path.StartsWith("v3/", StringComparison.Ordinal)) { - var host = match.Groups[1].Value; - var supportHTTPS = HTTPSValidator.IsSupported(host); - var scheme = supportHTTPS ? "https" : "http"; - url = $"{scheme}://{host}/{match.Groups[2].Value}"; - return true; + var parts = path.Split('/'); + if (parts.Length == 4) + { + if (host.Equals("ssh.dev.azure.com", StringComparison.Ordinal)) + { + url = $"https://dev.azure.com/{parts[1]}/{parts[2]}/_git/{parts[3]}"; + return true; + } + + if (host.Equals("vs-ssh.visualstudio.com", StringComparison.Ordinal)) + { + url = $"https://{parts[1]}.visualstudio.com/{parts[2]}/_git/{parts[3]}"; + return true; + } + } } - return false; + var supportHTTPS = HTTPSValidator.IsWellKnownHost(host) || HTTPSValidator.IsSupported(host); + var scheme = supportHTTPS ? "https" : "http"; + url = $"{scheme}://{host}/{path}"; + return true; } - public bool TryGetCreatePullRequestURL(out string url, string mergeBranch) + public static bool TryGetSSHHost(string url, out string host) { - url = null; - - if (!TryGetVisitURL(out var baseURL)) - return false; - - var uri = new Uri(baseURL); - var host = uri.Host; - var encodedBranch = HttpUtility.UrlEncode(mergeBranch); - - if (host.Contains("github.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/compare/{encodedBranch}?expand=1"; - return true; - } - - if (host.Contains("gitlab", StringComparison.Ordinal)) - { - url = $"{baseURL}/-/merge_requests/new?merge_request%5Bsource_branch%5D={encodedBranch}"; - return true; - } + return TryParseSSH(url, out host, out _); + } - if (host.Equals("gitee.com", StringComparison.Ordinal)) - { - url = $"{baseURL}/pulls/new?source={encodedBranch}"; - return true; - } + private static bool TryParseSSH(string url, out string host, out string path) + { + host = null; + path = null; - if (host.Equals("bitbucket.org", StringComparison.Ordinal)) - { - url = $"{baseURL}/pull-requests/new?source={encodedBranch}"; - return true; - } + if (string.IsNullOrEmpty(url)) + return false; - if (host.Equals("gitea.org", StringComparison.Ordinal)) + // NOTE: The port of SSH remotes is not used, since the port of web page is unknown. + var match = REG_TO_VISIT_URL_CAPTURE_SSH().Match(url); + if (match.Success) { - url = $"{baseURL}/compare/{encodedBranch}"; + host = match.Groups[2].Value; + path = match.Groups[4].Value; return true; } - if (host.Contains("azure.com", StringComparison.Ordinal) || - host.Contains("visualstudio.com", StringComparison.Ordinal)) + match = REG_TO_VISIT_URL_CAPTURE_SCP().Match(url); + if (match.Success) { - url = $"{baseURL}/pullrequestcreate?sourceRef={encodedBranch}"; + host = match.Groups[1].Value; + path = match.Groups[2].Value; return true; } diff --git a/src/Models/RepositorySettings.cs b/src/Models/RepositorySettings.cs index 506e6ded78..c171971e29 100644 --- a/src/Models/RepositorySettings.cs +++ b/src/Models/RepositorySettings.cs @@ -60,6 +60,12 @@ public AvaloniaList CustomActions set; } = []; + public Dictionary PullRequestPlatforms + { + get; + set; + } = []; + public static RepositorySettings Get(string gitCommonDir) { var fileInfo = new FileInfo(Path.Combine(gitCommonDir, "sourcegit.settings")); diff --git a/src/Resources/Locales/en_US.axaml b/src/Resources/Locales/en_US.axaml index d3ffb203a4..710c3b3800 100644 --- a/src/Resources/Locales/en_US.axaml +++ b/src/Resources/Locales/en_US.axaml @@ -64,7 +64,7 @@ Compare with HEAD Compare with ${0}$ Copy Branch Name - Create PR... + Create PR: ${0}$ → ${1}$... Create PR for upstream ${0}$... Custom Action Delete ${0}$... @@ -325,6 +325,21 @@ Enter branch name. Create Local Branch Overwrite existing branch + Create Pull Request + The source branch has not been pushed yet. + This platform can not prefill the title and description. They will be copied to the clipboard so you can paste them on the opened page. + The source branch has commits that have not been pushed yet. + Title and description have been copied to the clipboard. Paste them on the opened page. + Description: + (None) + Platform: + Push the source branch before opening the PR + Push the local commits before opening the PR + Push To: + Source: + Target: + Template: + Title: Create Tag... New Tag At: GPG signing diff --git a/src/ViewModels/CreatePullRequest.cs b/src/ViewModels/CreatePullRequest.cs new file mode 100644 index 0000000000..1e4ba1e3df --- /dev/null +++ b/src/ViewModels/CreatePullRequest.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; + +using Avalonia.Threading; + +namespace SourceGit.ViewModels +{ + public class CreatePullRequest : Popup + { + public Models.Branch Source + { + get; + } + + public bool NeedsPush + { + get; + } + + public bool PushFirst + { + get; + set; + } + + public bool IsPushRemoteSelectable + { + get => _upstream == null; + } + + public List PushRemotes + { + get; + } + + public Models.Remote SelectedPushRemote + { + get => _selectedPushRemote; + set + { + if (SetProperty(ref _selectedPushRemote, value)) + RefreshTargetBranches(); + } + } + + public List TargetBranches + { + get => _targetBranches; + private set => SetProperty(ref _targetBranches, value); + } + + [Required(ErrorMessage = "Target branch is required!!!")] + [CustomValidation(typeof(CreatePullRequest), nameof(ValidateTarget))] + public Models.Branch SelectedTarget + { + get => _selectedTarget; + set + { + if (SetProperty(ref _selectedTarget, value, true)) + PostTargetChanged(); + } + } + + public List Platforms + { + get => Models.PullRequestPlatform.Supported; + } + + public bool IsPlatformSelectable + { + get => _isPlatformSelectable; + private set => SetProperty(ref _isPlatformSelectable, value); + } + + [Required(ErrorMessage = "Platform is required!!!")] + public Models.PullRequestPlatform SelectedPlatform + { + get => _selectedPlatform; + set + { + if (SetProperty(ref _selectedPlatform, value, true)) + { + OnPropertyChanged(nameof(IsClipboardHintVisible)); + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + } + } + + public bool IsClipboardHintVisible + { + get => _selectedPlatform != null && (!_selectedPlatform.SupportsTitle || !_selectedPlatform.SupportsBody); + } + + public List Templates + { + get => _templates; + private set => SetProperty(ref _templates, value); + } + + public Models.PullRequestTemplate SelectedTemplate + { + get => _selectedTemplate; + set + { + if (SetProperty(ref _selectedTemplate, value)) + UpdateDefaultContent(); + } + } + + [Required(ErrorMessage = "Title is required!!!")] + public string Title + { + get => _title; + set + { + if (SetProperty(ref _title, value, true)) + _isTitleEdited = true; + } + } + + public string Description + { + get => _description; + set + { + // NOTE: TextBox may normalize line endings of generated content, which should not be treated as an edit. + if (SetProperty(ref _description, value) && !_generatedDescription.Equals(value?.ReplaceLineEndings("\n"), StringComparison.Ordinal)) + _isDescriptionEdited = true; + } + } + + public CreatePullRequest(Repository repo, Models.Branch source, Models.Branch target) + { + _repo = repo; + Source = source; + + if (!string.IsNullOrEmpty(source.Upstream) && !source.IsUpstreamGone) + _upstream = repo.Branches.Find(x => x.FullName.Equals(source.Upstream, StringComparison.Ordinal)); + + NeedsPush = _upstream == null || source.Ahead.Count > 0; + PushFirst = NeedsPush; + + PushRemotes = repo.Remotes; + _selectedPushRemote = _upstream != null ? repo.Remotes.Find(x => x.Name.Equals(_upstream.Remote, StringComparison.Ordinal)) : GetDefaultPushRemote(target); + + RefreshTargetBranches(); + + UpdateDefaultContent(); + + if (target != null) + SelectedTarget = target; + else + SelectDefaultTarget(); + } + + /// + /// Checks if a pull request can be created for the given local branch's upstream. + /// + public static bool CanCreateForUpstream(Repository repo, Models.Branch upstream) + { + return upstream != null && GetRemoteHost(repo, upstream.Remote) != null; + } + + /// + /// Checks if a pull request can be created from the current branch into the given remote branch. + /// is true when the remote branch is the upstream of current branch, + /// in which case the pull request is created for the current branch with the remote's default branch as target. + /// + public static bool CanCreateInto(Repository repo, Models.Branch head, Models.Branch target, out bool isUpstreamOfHead) + { + isUpstreamOfHead = false; + + if (head is not { IsDetachedHead: false } || target == null || target.IsLocal) + return false; + + var targetHost = GetRemoteHost(repo, target.Remote); + if (targetHost == null) + return false; + + isUpstreamOfHead = target.FullName.Equals(head.Upstream, StringComparison.Ordinal); + if (isUpstreamOfHead) + return true; + + // Only allows pull requests between repositories on the same server. + var headRemote = repo.Branches.Find(x => x.FullName.Equals(head.Upstream, StringComparison.Ordinal))?.Remote; + var headHost = string.IsNullOrEmpty(headRemote) ? null : GetRemoteHost(repo, headRemote); + return headHost == null || headHost.Equals(targetHost, StringComparison.OrdinalIgnoreCase); + } + + public static ValidationResult ValidateTarget(Models.Branch target, ValidationContext ctx) + { + if (ctx.ObjectInstance is CreatePullRequest { SelectedPlatform: { SupportsCrossRepo: false } } creator && + target != null && + !target.Remote.Equals(creator._selectedPushRemote?.Name, StringComparison.Ordinal)) + { + return new ValidationResult($"{creator.SelectedPlatform.Name} does not support pull requests between different repositories here!"); + } + + return ValidationResult.Success; + } + + public override async Task Sure() + { + var sourceRemote = _selectedPushRemote; + var targetRemote = _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)); + if (sourceRemote == null || targetRemote == null) + return false; + + if (PushFirst && NeedsPush) + { + using var lockWatcher = _repo.LockWatcher(); + ProgressDescription = $"Push {Source.Name} -> {sourceRemote.Name} ..."; + + var log = _repo.CreateLog("Push"); + Use(log); + + var remoteBranch = _upstream ?? new Models.Branch() { Name = Source.Name, Remote = sourceRemote.Name }; + var succ = await new Commands.Push( + _repo.FullPath, + Source, + sourceRemote, + remoteBranch, + false, + _repo.Submodules.Count > 0, + _upstream == null, + false, + false).Use(log).ExecAsync(); + + log.Complete(); + if (!succ) + return false; + } + + if (_isPlatformSelectable) + { + _repo.Settings.PullRequestPlatforms[targetRemote.Name] = _selectedPlatform.Type; + _repo.Settings.Save(); + } + + if (!targetRemote.TryGetVisitURL(out var targetURL) || !sourceRemote.TryGetVisitURL(out var sourceURL)) + return false; + + var sourceBranch = _upstream?.Name ?? Source.Name; + var url = _selectedPlatform.BuildCreateURL(targetURL, sourceURL, sourceBranch, _selectedTarget.Name, _title, _description, out var contentDropped); + if (contentDropped) + { + var content = string.IsNullOrEmpty(_description) ? _title : $"{_title}\n\n{_description}"; + await App.CopyTextAsync(content); + _repo.SendNotification(App.Text("CreatePR.CopiedToClipboard")); + } + + Native.OS.OpenBrowser(url); + return true; + } + + private Models.Remote GetDefaultPushRemote(Models.Branch target) + { + var remotes = _repo.Remotes; + if (remotes.Count == 0) + return null; + + if (!string.IsNullOrEmpty(_repo.Settings.DefaultRemote)) + { + var preferred = remotes.Find(x => x.Name.Equals(_repo.Settings.DefaultRemote, StringComparison.Ordinal)); + if (preferred != null) + return preferred; + } + + // Prefer a remote other than the target's (usually the fork of current user). + if (target != null) + { + var other = remotes.Find(x => !x.Name.Equals(target.Remote, StringComparison.Ordinal)); + if (other != null) + return other; + + return remotes.Find(x => x.Name.Equals(target.Remote, StringComparison.Ordinal)); + } + + return remotes[0]; + } + + private void RefreshTargetBranches() + { + var host = GetHost(_selectedPushRemote); + var branches = new List(); + foreach (var b in _repo.Branches) + { + if (b.IsLocal || b == _upstream) + continue; + + var remote = _repo.Remotes.Find(x => x.Name.Equals(b.Remote, StringComparison.Ordinal)); + if (host == null || host.Equals(GetHost(remote), StringComparison.OrdinalIgnoreCase)) + branches.Add(b); + } + + TargetBranches = branches; + + if (_selectedTarget == null) + return; + + if (!branches.Contains(_selectedTarget)) + SelectedTarget = null; + else + ValidateProperty(_selectedTarget, nameof(SelectedTarget)); + } + + private void SelectDefaultTarget() + { + var remote = _selectedPushRemote?.Name; + if (string.IsNullOrEmpty(remote)) + return; + + Task.Run(async () => + { + var head = await new Commands.QueryRemoteHead(_repo.FullPath, remote).GetResultAsync().ConfigureAwait(false); + Dispatcher.UIThread.Post(() => + { + if (_selectedTarget != null) + return; + + string[] candidates = string.IsNullOrEmpty(head) ? ["main", "master", "develop"] : [head]; + foreach (var name in candidates) + { + var found = _targetBranches.Find(x => x.Remote.Equals(remote, StringComparison.Ordinal) && x.Name.Equals(name, StringComparison.Ordinal)); + if (found != null) + { + SelectedTarget = found; + return; + } + } + }); + }); + } + + private void PostTargetChanged() + { + var targetRemote = _selectedTarget != null ? _repo.Remotes.Find(x => x.Name.Equals(_selectedTarget.Remote, StringComparison.Ordinal)) : null; + if (targetRemote != null) + { + IsPlatformSelectable = targetRemote.TryGetVisitURL(out var url) && + Models.PullRequestPlatform.Detect(new Uri(url).Host) == Models.PullRequestPlatformType.None; + SelectedPlatform = Models.PullRequestPlatform.Resolve(targetRemote, _repo.Settings); + } + + _commits = []; + _commitsVersion++; + + if (_selectedTarget == null) + { + Templates = []; + UpdateDefaultContent(); + return; + } + + var version = _commitsVersion; + var target = _selectedTarget.FullName; + var source = Source.FullName; + var repo = _repo.FullPath; + Task.Run(async () => + { + var commits = await new Commands.QueryCommits(repo, $"--no-merges -n 200 {target}..{source}", false).GetResultAsync().ConfigureAwait(false); + var singleCommitMessage = commits.Count == 1 ? await new Commands.QueryCommitFullMessage(repo, commits[0].SHA).GetResultAsync().ConfigureAwait(false) : null; + var templates = await new Commands.QueryPullRequestTemplates(repo, target).GetResultAsync().ConfigureAwait(false); + if (templates.Count > 0) + templates.Insert(0, new Models.PullRequestTemplate() { Name = App.Text("CreatePR.NoTemplate") }); + + Dispatcher.UIThread.Post(() => + { + if (version != _commitsVersion) + return; + + commits.Reverse(); + _commits = commits; + _singleCommitMessage = singleCommitMessage; + + var selected = _selectedTemplate != null ? templates.Find(x => x.Name.Equals(_selectedTemplate.Name, StringComparison.Ordinal)) : null; + selected ??= templates.Count > 1 ? templates[1] : null; + + Templates = templates; + _selectedTemplate = selected; + OnPropertyChanged(nameof(SelectedTemplate)); + UpdateDefaultContent(); + }); + }); + } + + private void UpdateDefaultContent() + { + if (!_isTitleEdited) + { + SetProperty(ref _title, _commits.Count == 1 ? _commits[0].Subject : HumanizeBranchName(Source.Name), nameof(Title)); + ValidateProperty(_title, nameof(Title)); + } + + if (_isDescriptionEdited) + return; + + var builder = new StringBuilder(); + if (!string.IsNullOrEmpty(_selectedTemplate?.Content)) + builder.Append(_selectedTemplate.Content).Append("\n\n"); + + if (_commits.Count == 1) + { + // Body of the only commit (without its subject). + var msg = _singleCommitMessage ?? string.Empty; + var idx = msg.IndexOf('\n'); + if (idx > 0) + builder.Append(msg.Substring(idx + 1).Trim()); + } + else if (_commits.Count > 1) + { + builder.Append("## Commits\n\n"); + foreach (var c in _commits) + builder.Append("- ").Append(c.Subject).Append('\n'); + } + + _generatedDescription = builder.ToString().Trim(); + SetProperty(ref _description, _generatedDescription, nameof(Description)); + } + + private static string HumanizeBranchName(string name) + { + var idx = name.LastIndexOf('/'); + if (idx >= 0) + name = name.Substring(idx + 1); + + name = name.Replace('-', ' ').Replace('_', ' ').Trim(); + if (name.Length == 0) + return name; + + return char.ToUpper(name[0], CultureInfo.CurrentCulture) + name.Substring(1); + } + + private static string GetRemoteHost(Repository repo, string remoteName) + { + return GetHost(repo.Remotes.Find(x => x.Name.Equals(remoteName, StringComparison.Ordinal))); + } + + private static string GetHost(Models.Remote remote) + { + if (remote == null || !remote.TryGetVisitURL(out var url)) + return null; + + return new Uri(url).Host; + } + + private readonly Repository _repo = null; + private readonly Models.Branch _upstream = null; + private Models.Remote _selectedPushRemote = null; + private List _targetBranches = []; + private Models.Branch _selectedTarget = null; + private bool _isPlatformSelectable = false; + private Models.PullRequestPlatform _selectedPlatform = null; + private List _templates = []; + private Models.PullRequestTemplate _selectedTemplate = null; + private List _commits = []; + private string _singleCommitMessage = null; + private int _commitsVersion = 0; + private string _title = string.Empty; + private string _description = string.Empty; + private string _generatedDescription = string.Empty; + private bool _isTitleEdited = false; + private bool _isDescriptionEdited = false; + } +} diff --git a/src/Views/BranchTree.axaml.cs b/src/Views/BranchTree.axaml.cs index 35d313e3a2..6451c313ce 100644 --- a/src/Views/BranchTree.axaml.cs +++ b/src/Views/BranchTree.axaml.cs @@ -1027,22 +1027,19 @@ private ContextMenu CreateContextMenuForLocalBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - if (upstream != null) + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstream)) { - var remote = repo.Remotes.Find(x => x.Name.Equals(upstream.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, upstream.Name)) + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => { - var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstream.FriendlyName); - createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); - createPR.Click += (_, e) => - { - Native.OS.OpenBrowser(prURL); - e.Handled = true; - }; + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, branch, null)); + e.Handled = true; + }; - menu.Items.Add(createPR); - } + menu.Items.Add(createPR); } menu.Items.Add(new MenuItem() { Header = "-" }); TryToAddCustomActionsToBranchContextMenu(repo, menu, branch); @@ -1352,15 +1349,17 @@ public ContextMenu CreateContextMenuForRemoteBranch(ViewModels.Repository repo, menu.Items.Add(createBranch); menu.Items.Add(createTag); - var remote = repo.Remotes.Find(x => x.Name.Equals(branch.Remote, StringComparison.Ordinal)); - if (remote != null && remote.TryGetCreatePullRequestURL(out var prURL, branch.Name)) + var head = repo.CurrentBranch; + if (ViewModels.CreatePullRequest.CanCreateInto(repo, head, branch, out var isUpstreamOfHead)) { + // Clicking the upstream of current branch creates a PR for it, otherwise creates a PR from current branch into the clicked one. var createPR = new MenuItem(); - createPR.Header = App.Text("BranchCM.CreatePR"); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", head.Name, name); createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); createPR.Click += (_, e) => { - Native.OS.OpenBrowser(prURL); + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, head, isUpstreamOfHead ? null : branch)); e.Handled = true; }; diff --git a/src/Views/CreatePullRequest.axaml b/src/Views/CreatePullRequest.axaml new file mode 100644 index 0000000000..4b62778471 --- /dev/null +++ b/src/Views/CreatePullRequest.axaml @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Views/CreatePullRequest.axaml.cs b/src/Views/CreatePullRequest.axaml.cs new file mode 100644 index 0000000000..13ec296151 --- /dev/null +++ b/src/Views/CreatePullRequest.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; + +namespace SourceGit.Views +{ + public partial class CreatePullRequest : UserControl + { + public CreatePullRequest() + { + InitializeComponent(); + } + } +} diff --git a/src/Views/Histories.axaml.cs b/src/Views/Histories.axaml.cs index 846c59d1b1..baf150dcd3 100644 --- a/src/Views/Histories.axaml.cs +++ b/src/Views/Histories.axaml.cs @@ -1608,6 +1608,22 @@ private void FillCurrentBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(rename); submenu.Items.Add(new MenuItem() { Header = "-" }); + var upstreamBranch = string.IsNullOrEmpty(current.Upstream) || current.IsUpstreamGone ? null : repo.Branches.Find(x => x.FullName.Equals(current.Upstream, StringComparison.Ordinal)); + if (ViewModels.CreatePullRequest.CanCreateForUpstream(repo, upstreamBranch)) + { + var createPR = new MenuItem(); + createPR.Header = App.Text("BranchCM.CreatePRForUpstream", upstreamBranch.FriendlyName); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, null)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + if (!repo.IsBare) { var type = repo.GetGitFlowType(current); @@ -1872,6 +1888,21 @@ private void FillRemoteBranchMenu(ContextMenu menu, ViewModels.Repository repo, submenu.Items.Add(compare); submenu.Items.Add(new MenuItem() { Header = "-" }); + if (ViewModels.CreatePullRequest.CanCreateInto(repo, current, branch, out var isUpstreamOfHead)) + { + var createPR = new MenuItem(); + createPR.Header = isUpstreamOfHead ? App.Text("BranchCM.CreatePRForUpstream", name) : App.Text("BranchCM.CreatePR", current.Name, name); + createPR.Icon = this.CreateMenuIcon("Icons.CreatePR"); + createPR.Click += (_, e) => + { + if (repo.CanCreatePopup()) + repo.ShowPopup(new ViewModels.CreatePullRequest(repo, current, isUpstreamOfHead ? null : branch)); + e.Handled = true; + }; + submenu.Items.Add(createPR); + submenu.Items.Add(new MenuItem() { Header = "-" }); + } + var copy = new MenuItem(); copy.Header = App.Text("BranchCM.CopyName"); copy.Icon = this.CreateMenuIcon("Icons.Copy");