using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using CVRManager.Models; namespace CVRManager.Services; public class GitHubService { readonly HttpClient http = new(); readonly Func token; public GitHubService(Func token) { this.token = token; http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("CVRManager", "1.0")); http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); } public static string? ParseRepo(string input) { input = input.Trim(); var m = Regex.Match(input, @"^(?:https?://github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?(?:/.*)?$"); return m.Success ? $"{m.Groups[1].Value}/{m.Groups[2].Value}" : null; } HttpRequestMessage Request(string url) { var req = new HttpRequestMessage(HttpMethod.Get, url); var t = token(); if (!string.IsNullOrWhiteSpace(t)) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", t); return req; } public async Task> GetReleasesAsync(string repo, CancellationToken ct) { using var resp = await http.SendAsync(Request($"https://api.github.com/repos/{repo}/releases?per_page=40"), ct); if (!resp.IsSuccessStatusCode) { var hint = (int)resp.StatusCode is 403 or 429 ? " (rate limited: add a GitHub token in Settings)" : ""; throw new InvalidOperationException($"GitHub returned {(int)resp.StatusCode} for {repo}{hint}."); } using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); var list = new List(); foreach (var r in doc.RootElement.EnumerateArray()) { if (r.GetProperty("draft").GetBoolean()) continue; var rel = new ReleaseInfo { Tag = r.GetProperty("tag_name").GetString() ?? "", Prerelease = r.GetProperty("prerelease").GetBoolean(), Published = r.TryGetProperty("published_at", out var p) && p.ValueKind == JsonValueKind.String ? p.GetDateTime() : default, }; foreach (var a in r.GetProperty("assets").EnumerateArray()) rel.Assets.Add(new AssetInfo { Name = a.GetProperty("name").GetString() ?? "", Url = a.GetProperty("browser_download_url").GetString() ?? "", Size = a.GetProperty("size").GetInt64(), }); list.Add(rel); } return list; } public async Task GetMelonLoaderNightlyAsync(CancellationToken ct) { using var resp = await http.SendAsync(Request( "https://api.github.com/repos/LavaGang/MelonLoader/actions/workflows/build.yml/runs?branch=master&status=success&per_page=1"), ct); if (!resp.IsSuccessStatusCode) return null; using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)); var runs = doc.RootElement.GetProperty("workflow_runs"); if (runs.GetArrayLength() == 0) return null; var run = runs[0]; var sha = run.GetProperty("head_sha").GetString() ?? ""; var id = run.GetProperty("id").GetInt64(); var rel = new ReleaseInfo { Tag = $"nightly-{sha[..Math.Min(7, sha.Length)]}", Prerelease = true, Published = run.GetProperty("created_at").GetDateTime(), }; rel.Assets.Add(new AssetInfo { Name = "MelonLoader.x64.zip", Url = $"https://nightly.link/LavaGang/MelonLoader/actions/runs/{id}/MelonLoader.Windows.x64.CI.Release.zip", }); return rel; } public async Task DownloadAsync(string url, string path, CancellationToken ct) { using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); resp.EnsureSuccessStatusCode(); Directory.CreateDirectory(Path.GetDirectoryName(path)!); await using var src = await resp.Content.ReadAsStreamAsync(ct); await using var dst = File.Create(path); await src.CopyToAsync(dst, ct); var last = resp.RequestMessage?.RequestUri?.Segments.LastOrDefault() ?? ""; return Uri.UnescapeDataString(last.TrimEnd('/')); } }