btcpayserver-plugin-builder/PluginBuilder/Services/GitLabHostingProvider.cs

326 lines
13 KiB
C#

using System.Text.RegularExpressions;
using System.Xml.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PluginBuilder.APIModels;
using PluginBuilder.DataModels;
namespace PluginBuilder.Services;
public class GitLabHostingProvider : IGitHostingProvider
{
private static readonly Regex HostRegex = new(
@"^https?://(www\.)?gitlab\.com/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly IHttpClientFactory _httpClientFactory;
private readonly IEnumerable<string> _additionalHosts;
public GitLabHostingProvider(IHttpClientFactory httpClientFactory, IEnumerable<string>? additionalHosts = null)
{
_httpClientFactory = httpClientFactory;
_additionalHosts = additionalHosts ?? Enumerable.Empty<string>();
}
public bool CanHandle(string repoUrl)
{
if (HostRegex.IsMatch(repoUrl))
return true;
// Support self-hosted GitLab instances via configured additional hosts
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri))
return false;
return _additionalHosts.Any(h => uri.Authority.Equals(h, StringComparison.OrdinalIgnoreCase)
|| uri.Host.Equals(h, StringComparison.OrdinalIgnoreCase));
}
public (string Owner, string RepoName)? ParseRepository(string repoUrl)
{
if (string.IsNullOrEmpty(repoUrl))
return null;
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
return null;
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
return null;
// Last segment is the repo name, everything before is the namespace/owner
var repoName = segments[^1];
var owner = string.Join("/", segments[..^1]);
return (owner, repoName);
}
public string? GetSourceUrl(string repoUrl, string? commit, string? pluginDir)
{
if (commit is null)
return null;
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
return null;
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
var baseUrl = $"{uri.Scheme}://{uri.Authority}/{path}";
var link = $"{baseUrl}/-/tree/{commit}";
if (!string.IsNullOrEmpty(pluginDir))
link += $"/{pluginDir}";
return link;
}
public async Task<string> FetchIdentifierFromCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null)
{
var client = CreateClientForRepo(repoUrl);
var projectId = GetProjectId(repoUrl);
var dir = string.IsNullOrWhiteSpace(pluginDir) ? "" : pluginDir.Trim('/');
// List files in the directory using the Repository Tree API
var apiUrl = $"projects/{Uri.EscapeDataString(projectId)}/repository/tree";
var queryParams = new List<string>();
if (!string.IsNullOrEmpty(dir))
queryParams.Add($"path={Uri.EscapeDataString(dir)}");
if (!string.IsNullOrWhiteSpace(gitRef))
queryParams.Add($"ref={Uri.EscapeDataString(gitRef.Trim())}");
if (queryParams.Count > 0)
apiUrl += "?" + string.Join("&", queryParams);
using var resp = await client.GetAsync(apiUrl);
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new BuildServiceException(
$"GitLab error ({(int)resp.StatusCode}) listing '{(string.IsNullOrEmpty(dir) ? "/" : dir)}': {apiUrl}\nBody: {body}");
var items = JsonConvert.DeserializeObject<List<GitLabTreeItem>>(body);
if (items == null)
throw new BuildServiceException(
$"Failed to parse GitLab tree response for '{(string.IsNullOrEmpty(dir) ? "/" : dir)}'.");
var csprojs = items
.Where(i => string.Equals(i.Type, "blob", StringComparison.OrdinalIgnoreCase)
&& i.Name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)).ToList();
if (csprojs.Count == 0)
throw new BuildServiceException(
$"No .csproj found in '{(string.IsNullOrEmpty(dir) ? "/" : dir)}' at {(string.IsNullOrWhiteSpace(gitRef) ? "default branch" : gitRef)}.");
if (csprojs.Count > 1)
throw new BuildServiceException("Multiple .csproj found. Keep exactly one.");
// Download the .csproj file content using the Repository Files API
var filePath = csprojs[0].Path;
var fileApiUrl = $"projects/{Uri.EscapeDataString(projectId)}/repository/files/{Uri.EscapeDataString(filePath)}/raw";
if (!string.IsNullOrWhiteSpace(gitRef))
fileApiUrl += $"?ref={Uri.EscapeDataString(gitRef.Trim())}";
using var fileResp = await client.GetAsync(fileApiUrl);
var csprojBody = await fileResp.Content.ReadAsStringAsync();
if (!fileResp.IsSuccessStatusCode || string.IsNullOrWhiteSpace(csprojBody))
throw new BuildServiceException(
$"GitLab error downloading '{csprojs[0].Name}' (HTTP {(int)fileResp.StatusCode}).\nBody: {csprojBody}");
XDocument doc;
try
{
doc = XDocument.Parse(csprojBody);
}
catch (System.Xml.XmlException ex)
{
throw new BuildServiceException($"Failed to parse '{csprojs[0].Name}' as XML: {ex.Message}");
}
var assemblyName = doc.Descendants("AssemblyName").FirstOrDefault()?.Value ?? Path.GetFileNameWithoutExtension(csprojs[0].Name);
return assemblyName;
}
public async Task<List<GitHubContributor>> GetContributorsAsync(string repoUrl, string pluginDir)
{
var repo = ParseRepository(repoUrl);
if (repo == null)
return new List<GitHubContributor>();
var client = CreateClientForRepo(repoUrl);
var projectId = GetProjectId(repoUrl);
var contributors = new Dictionary<string, GitHubContributor>(StringComparer.OrdinalIgnoreCase);
int page = 1;
const int perPage = 100;
try
{
while (true)
{
var apiPath = $"projects/{Uri.EscapeDataString(projectId)}/repository/commits?per_page={perPage}&page={page}";
if (!string.IsNullOrEmpty(pluginDir))
apiPath += $"&path={Uri.EscapeDataString(pluginDir)}";
using var response = await client.GetAsync(apiPath);
if (!response.IsSuccessStatusCode)
break;
var json = await response.Content.ReadAsStringAsync();
var commits = JsonConvert.DeserializeObject<List<GitLabCommit>>(json);
if (commits == null || commits.Count == 0)
break;
foreach (var commit in commits)
{
var name = commit.AuthorName;
if (string.IsNullOrEmpty(name))
continue;
// Use author email as key since GitLab commits don't have user login
var key = commit.AuthorEmail ?? name;
if (contributors.TryGetValue(key, out var existing))
{
existing.Contributions++;
}
else
{
contributors[key] = new GitHubContributor
{
Login = name,
AvatarUrl = null,
HtmlUrl = null,
Contributions = 1
};
}
}
// Check for next page via header
if (!response.Headers.TryGetValues("x-next-page", out var nextPageHeaders))
break;
var nextPage = nextPageHeaders.FirstOrDefault();
if (string.IsNullOrEmpty(nextPage) || !int.TryParse(nextPage, out var np) || np <= page)
break;
page = np;
}
var result = contributors.Values.OrderByDescending(c => c.Contributions).ToList();
await ResolveUsersAsync(client, contributors);
return result;
}
catch (Exception)
{
return new List<GitHubContributor>();
}
}
private static async Task ResolveUsersAsync(
HttpClient client,
Dictionary<string, GitHubContributor> contributors)
{
foreach (var (key, contributor) in contributors)
{
// key is the email when available
if (!key.Contains('@'))
continue;
try
{
var user = await TryResolveGitLabUser(client, key);
if (user is not null)
{
if (!string.IsNullOrWhiteSpace(user.WebUrl))
contributor.HtmlUrl = user.WebUrl;
if (!string.IsNullOrWhiteSpace(user.AvatarUrl))
contributor.AvatarUrl = user.AvatarUrl;
// Prefer the canonical username if we found one
if (!string.IsNullOrWhiteSpace(user.Username))
contributor.Login = user.Username;
continue;
}
// Fallback: avatar-only lookup via /avatar
var avatarUrl = await TryResolveAvatarOnly(client, key);
if (!string.IsNullOrWhiteSpace(avatarUrl))
contributor.AvatarUrl = avatarUrl;
}
catch
{
// Best effort — skip if anything goes wrong
}
}
}
private static async Task<GitLabUser?> TryResolveGitLabUser(HttpClient client, string email)
{
// Try exact public_email match first (only returns users who made their email public)
var user = await QueryUser(client, $"users?public_email={Uri.EscapeDataString(email)}");
if (user is not null)
return user;
// Fall back to search (matches any field including email substring)
return await QueryUser(client, $"users?search={Uri.EscapeDataString(email)}");
}
private static async Task<GitLabUser?> QueryUser(HttpClient client, string apiPath)
{
using var resp = await client.GetAsync(apiPath);
if (!resp.IsSuccessStatusCode)
return null;
var json = await resp.Content.ReadAsStringAsync();
var users = JsonConvert.DeserializeObject<List<GitLabUser>>(json);
// Only accept unambiguous matches
return users is { Count: 1 } ? users[0] : null;
}
private static async Task<string?> TryResolveAvatarOnly(HttpClient client, string email)
{
var apiUrl = $"avatar?email={Uri.EscapeDataString(email)}&size=48";
using var resp = await client.GetAsync(apiUrl);
if (!resp.IsSuccessStatusCode)
return null;
var json = await resp.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
return obj["avatar_url"]?.ToString();
}
private HttpClient CreateClientForRepo(string repoUrl)
{
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
throw new BuildServiceException("Invalid repository URL");
// Use the named GitLab client so GITLAB_TOKEN config is applied
var client = _httpClientFactory.CreateClient(HttpClientNames.GitLab);
// Override base address to target the repo's actual host (self-hosted support)
client.BaseAddress = new Uri($"{uri.Scheme}://{uri.Authority}/api/v4/");
return client;
}
private static string GetProjectId(string repoUrl)
{
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
throw new BuildServiceException("Invalid repository URL");
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
path = path[..^4];
if (string.IsNullOrEmpty(path))
throw new BuildServiceException("Invalid repository URL");
// GitLab project ID is the URL-encoded full path (e.g., "group/subgroup/repo")
return path;
}
private record GitLabTreeItem(
[property: JsonProperty("name")] string Name,
[property: JsonProperty("type")] string Type,
[property: JsonProperty("path")] string Path);
private record GitLabCommit(
[property: JsonProperty("author_name")] string AuthorName,
[property: JsonProperty("author_email")] string? AuthorEmail);
private record GitLabUser(
[property: JsonProperty("username")] string? Username,
[property: JsonProperty("web_url")] string? WebUrl,
[property: JsonProperty("avatar_url")] string? AvatarUrl);
}