using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using ControlPlane.Core.Models; namespace ControlPlane.Api.Services; /// /// Thin wrapper around the Gitea REST API v1. /// Configured via Gitea__BaseUrl, Gitea__Owner, and Gitea__Token in appsettings. /// public class GiteaService { private readonly HttpClient _http; private readonly string _defaultOwner; private readonly string _defaultRepo; private readonly IConfiguration _cfg; private readonly ILogger _log; private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger log) { _log = log; _cfg = cfg; _defaultOwner = cfg["Gitea:Owner"] ?? "ClarityStack"; _defaultRepo = cfg["Gitea:Repo"] ?? "OPC"; var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test"; var token = cfg["Gitea:Token"] ?? string.Empty; _http = factory.CreateClient("gitea"); _http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/api/v1/"); _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); if (!string.IsNullOrWhiteSpace(token)) _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); } // ── Repo resolution ─────────────────────────────────────────────────────── /// Returns (owner, repo) for the given key, falling back to the defaults. private (string Owner, string Repo) ResolveOwnerRepo(string? repoKey = null) { if (!string.IsNullOrWhiteSpace(repoKey)) { var owner = _cfg[$"Gitea:Repos:{repoKey}:Owner"]; var repo = _cfg[$"Gitea:Repos:{repoKey}:Repo"]; if (!string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repo)) return (owner, repo); } return (_defaultOwner, _defaultRepo); } // ── Repos ───────────────────────────────────────────────────────────────── public async Task GetRepoAsync(string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync($"repos/{owner}/{repo}", JsonOpts, ct); } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; } } // ── Branches ────────────────────────────────────────────────────────────── public async Task> ListBranchesAsync(string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( $"repos/{owner}/{repo}/branches?limit=50", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; } } /// Returns branches from all registered repos, tagged with repoKey. public async Task> ListAllBranchesAsync(CancellationToken ct = default) { var result = new List<(string, GiteaBranch)>(); var section = _cfg.GetSection("Gitea:Repos"); var keys = section.GetChildren().Select(c => c.Key).ToList(); if (keys.Count == 0) keys = ["default"]; await Task.WhenAll(keys.Select(async key => { var branches = await ListBranchesAsync(key == "default" ? null : key, ct); lock (result) result.AddRange(branches.Select(b => (key, b))); })); return result; } public async Task CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(req.RepoKey); // Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration" var slug = SlugifyTitle(req.OpcTitle); var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", ""); var branchName = $"feature/{num}-{slug}"; var body = JsonSerializer.Serialize(new { new_branch_name = branchName, old_branch_name = req.From, }, JsonOpts); var res = await _http.PostAsync( $"repos/{owner}/{repo}/branches", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) { var err = await res.Content.ReadAsStringAsync(ct); _log.LogWarning("Gitea CreateBranch failed {Status}: {Error}", res.StatusCode, err); return null; } return await res.Content.ReadFromJsonAsync(JsonOpts, ct); } // ── Pull Requests ───────────────────────────────────────────────────────── public async Task> ListPullRequestsAsync( string state = "open", string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( $"repos/{owner}/{repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; } } public async Task GetPullRequestAsync(long number, string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync( $"repos/{owner}/{repo}/pulls/{number}", JsonOpts, ct); } catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; } } public async Task CreatePullRequestAsync( CreatePullRequestRequest req, string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { title = req.Title, head = req.Head, @base = req.Base, body = req.Body, }, JsonOpts); var res = await _http.PostAsync( $"repos/{owner}/{repo}/pulls", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) { var err = await res.Content.ReadAsStringAsync(ct); _log.LogWarning("Gitea CreatePR failed {Status}: {Error}", res.StatusCode, err); return null; } return await res.Content.ReadFromJsonAsync(JsonOpts, ct); } // ── Tags ────────────────────────────────────────────────────────────────── public async Task> ListTagsAsync(string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( $"repos/{owner}/{repo}/tags?limit=20", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; } } public async Task CreateTagAsync(CreateTagRequest req, string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { tag_name = req.TagName, message = req.Message, target = req.CommitSha, }, JsonOpts); var res = await _http.PostAsync( $"repos/{owner}/{repo}/tags", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) { var err = await res.Content.ReadAsStringAsync(ct); _log.LogWarning("Gitea CreateTag failed {Status}: {Error}", res.StatusCode, err); return null; } return await res.Content.ReadFromJsonAsync(JsonOpts, ct); } // ── Webhooks ────────────────────────────────────────────────────────────── public async Task> ListWebhooksAsync(string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); try { return await _http.GetFromJsonAsync>( $"repos/{owner}/{repo}/hooks", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; } } public async Task RegisterWebhookAsync( CreateWebhookRequest req, string? repoKey = null, CancellationToken ct = default) { var (owner, repo) = ResolveOwnerRepo(repoKey); var body = JsonSerializer.Serialize(new { type = "gitea", active = true, config = new { url = req.TargetUrl, content_type = "json" }, events = req.Events, }, JsonOpts); var res = await _http.PostAsync( $"repos/{owner}/{repo}/hooks", new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!res.IsSuccessStatusCode) { var err = await res.Content.ReadAsStringAsync(ct); _log.LogWarning("Gitea RegisterWebhook failed {Status}: {Error}", res.StatusCode, err); return null; } return await res.Content.ReadFromJsonAsync(JsonOpts, ct); } // ── Helpers ─────────────────────────────────────────────────────────────── private static string SlugifyTitle(string title) => System.Text.RegularExpressions.Regex .Replace(title.ToLowerInvariant(), @"[^a-z0-9]+", "-") .Trim('-')[..Math.Min(40, title.Length)]; }