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 _owner; private readonly string _repo; private readonly ILogger _log; private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger log) { _log = log; _owner = cfg["Gitea:Owner"] ?? "Clarity"; _repo = cfg["Gitea:Repo"] ?? "Clarity"; 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); } // ── Repos ───────────────────────────────────────────────────────────────── public async Task GetRepoAsync(CancellationToken ct = default) { 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(CancellationToken ct = default) { try { return await _http.GetFromJsonAsync>( $"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? []; } catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; } } public async Task CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default) { // 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", CancellationToken ct = default) { 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, CancellationToken ct = default) { 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, CancellationToken ct = default) { 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(CancellationToken ct = default) { 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, CancellationToken ct = default) { 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(CancellationToken ct = default) { 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, CancellationToken ct = default) { 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)]; }