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
6 changes: 6 additions & 0 deletions src/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 87 additions & 0 deletions src/Commands/QueryPullRequestTemplates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;

namespace SourceGit.Commands
{
/// <summary>
/// Finds the pull/merge request templates (GitHub, GitLab, Gitea, Forgejo, Azure DevOps) in the given revision.
/// </summary>
public class QueryPullRequestTemplates
{
public QueryPullRequestTemplates(string repo, string revision)
{
_repo = repo;
_revision = revision;
}

public async Task<List<Models.PullRequestTemplate>> GetResultAsync()
{
var templates = new List<Models.PullRequestTemplate>();

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;
}
}
36 changes: 36 additions & 0 deletions src/Commands/QueryRemoteHead.cs
Original file line number Diff line number Diff line change
@@ -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;
}

/// <summary>
/// Returns the name of the remote's default branch (without the remote prefix), or null if unknown.
/// </summary>
public async Task<string> 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;
}
}
3 changes: 3 additions & 0 deletions src/Commands/QueryRemotes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
7 changes: 7 additions & 0 deletions src/Commands/QueryRevisionFileNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<string>> GetResultAsync()
{
var outs = new List<string>();
Expand Down
22 changes: 15 additions & 7 deletions src/Models/HTTPSValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)
Expand Down
201 changes: 201 additions & 0 deletions src/Models/PullRequestPlatform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.Json.Serialization;

namespace SourceGit.Models
{
[JsonConverter(typeof(JsonStringEnumConverter<PullRequestPlatformType>))]
public enum PullRequestPlatformType
{
None = 0,
GitHub,
GitLab,
Gitea,
Forgejo,
BitbucketCloud,
AzureDevOps,
Gitee,
}

public class PullRequestPlatform
{
public const int MaxURLLength = 7000;

public static readonly List<PullRequestPlatform> 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));
}

/// <summary>
/// Builds the URL of the platform's "create pull request" page.
/// </summary>
/// <param name="targetBaseURL">Web URL of the repository that receives the pull request</param>
/// <param name="sourceBaseURL">Web URL of the repository that contains the source branch (same as target if not a fork)</param>
/// <param name="contentDropped">True if the title or body could not be passed by URL</param>
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;
}
}
}
Loading