OPC # 0001: Gitea services
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -17,10 +17,13 @@ public static class GitEndpoints
|
||||
// GET /api/git/log?grep=OPC+%23+0001&limit=50&repo=all
|
||||
// repo can be "all" (default) or a named key from Git:Repos (e.g. "Clarity", "OPC", "Gateway").
|
||||
// All responses include a repoKey field so the caller knows which repo each commit came from.
|
||||
// GET /api/git/log?grep=...&limit=25&page=1&repo=all
|
||||
// page is 1-based. Each repo contributes up to limit*page commits before merge-sort+skip/take.
|
||||
private static IResult GetLog(
|
||||
IConfiguration config,
|
||||
string? grep = null,
|
||||
int limit = 50,
|
||||
int limit = 25,
|
||||
int page = 1,
|
||||
string repo = "all")
|
||||
{
|
||||
var repos = repo == "all"
|
||||
@@ -56,12 +59,13 @@ public static class GitEndpoints
|
||||
if (!string.IsNullOrWhiteSpace(grep))
|
||||
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var c in query.Take(limit))
|
||||
foreach (var c in query.Take(limit * page))
|
||||
bucket.Add((c.Author.When, repoKey, ToGitCommit(r, c)));
|
||||
}
|
||||
|
||||
var commits = bucket
|
||||
.OrderByDescending(x => x.When)
|
||||
.Skip((page - 1) * limit)
|
||||
.Take(limit)
|
||||
.Select(x => new
|
||||
{
|
||||
@@ -79,41 +83,48 @@ public static class GitEndpoints
|
||||
}
|
||||
|
||||
// GET /api/git/commits/{hash}
|
||||
// Searches all registered repos — hash may belong to any of the three repos.
|
||||
private static IResult GetCommit(string hash, IConfiguration config)
|
||||
{
|
||||
var repoPath = ResolveRepo(config);
|
||||
if (repoPath is null)
|
||||
return Results.Problem("Could not locate a git repository.");
|
||||
var repos = ResolveAllRepos(config);
|
||||
if (repos.Count == 0)
|
||||
return Results.Problem("Could not locate any git repositories.");
|
||||
|
||||
using var repo = new Repository(repoPath);
|
||||
var commit = repo.Lookup<Commit>(hash);
|
||||
if (commit is null) return Results.NotFound();
|
||||
|
||||
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
|
||||
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
|
||||
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
|
||||
|
||||
var files = changes.Select(c => new
|
||||
foreach (var (_, repoPath) in repos)
|
||||
{
|
||||
path = c.Path,
|
||||
oldPath = c.OldPath,
|
||||
status = c.Status.ToString(),
|
||||
additions = patch[c.Path]?.LinesAdded ?? 0,
|
||||
deletions = patch[c.Path]?.LinesDeleted ?? 0,
|
||||
patch = patch[c.Path]?.Patch ?? string.Empty,
|
||||
}).ToList();
|
||||
if (!Directory.Exists(repoPath)) continue;
|
||||
using var repo = new Repository(repoPath);
|
||||
var commit = repo.Lookup<Commit>(hash);
|
||||
if (commit is null) continue;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
hash = commit.Sha,
|
||||
shortHash = commit.Sha[..7],
|
||||
author = commit.Author.Name,
|
||||
email = commit.Author.Email,
|
||||
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
|
||||
subject = commit.MessageShort,
|
||||
body = commit.Message,
|
||||
files,
|
||||
});
|
||||
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
|
||||
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
|
||||
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
|
||||
|
||||
var files = changes.Select(c => new
|
||||
{
|
||||
path = c.Path,
|
||||
oldPath = c.OldPath,
|
||||
status = c.Status.ToString(),
|
||||
additions = patch[c.Path]?.LinesAdded ?? 0,
|
||||
deletions = patch[c.Path]?.LinesDeleted ?? 0,
|
||||
patch = patch[c.Path]?.Patch ?? string.Empty,
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
hash = commit.Sha,
|
||||
shortHash = commit.Sha[..7],
|
||||
author = commit.Author.Name,
|
||||
email = commit.Author.Email,
|
||||
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
|
||||
subject = commit.MessageShort,
|
||||
body = commit.Message,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// GET /api/git/branches
|
||||
|
||||
@@ -23,14 +23,22 @@ public static class GiteaEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRepo(GiteaService svc, CancellationToken ct)
|
||||
private static async Task<IResult> GetRepo(GiteaService svc, string? repo, CancellationToken ct)
|
||||
{
|
||||
var repo = await svc.GetRepoAsync(ct);
|
||||
return repo is null ? Results.StatusCode(503) : Results.Ok(repo);
|
||||
var result = await svc.GetRepoAsync(repo, ct);
|
||||
return result is null ? Results.StatusCode(503) : Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListBranches(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListBranchesAsync(ct));
|
||||
private static async Task<IResult> ListBranches(GiteaService svc, string? repo, CancellationToken ct)
|
||||
{
|
||||
// repo=all returns branches from all registered repos, each tagged with repoKey
|
||||
if (repo == "all")
|
||||
{
|
||||
var all = await svc.ListAllBranchesAsync(ct);
|
||||
return Results.Ok(all.Select(x => new { repoKey = x.RepoKey, x.Branch.Name, x.Branch.CommitSha, x.Branch.Protected }));
|
||||
}
|
||||
return Results.Ok(await svc.ListBranchesAsync(repo, ct));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBranch(
|
||||
CreateBranchRequest req, GiteaService svc, CancellationToken ct)
|
||||
@@ -40,40 +48,40 @@ public static class GiteaEndpoints
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListPulls(
|
||||
GiteaService svc, string state = "open", CancellationToken ct = default) =>
|
||||
Results.Ok(await svc.ListPullRequestsAsync(state, ct));
|
||||
GiteaService svc, string state = "open", string? repo = null, CancellationToken ct = default) =>
|
||||
Results.Ok(await svc.ListPullRequestsAsync(state, repo, ct));
|
||||
|
||||
private static async Task<IResult> GetPull(
|
||||
long number, GiteaService svc, CancellationToken ct)
|
||||
long number, GiteaService svc, string? repo = null, CancellationToken ct = default)
|
||||
{
|
||||
var pr = await svc.GetPullRequestAsync(number, ct);
|
||||
var pr = await svc.GetPullRequestAsync(number, repo, ct);
|
||||
return pr is null ? Results.NotFound() : Results.Ok(pr);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePull(
|
||||
CreatePullRequestRequest req, GiteaService svc, CancellationToken ct)
|
||||
CreatePullRequestRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
|
||||
{
|
||||
var pr = await svc.CreatePullRequestAsync(req, ct);
|
||||
var pr = await svc.CreatePullRequestAsync(req, repo, ct);
|
||||
return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTags(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListTagsAsync(ct));
|
||||
private static async Task<IResult> ListTags(GiteaService svc, string? repo = null, CancellationToken ct = default) =>
|
||||
Results.Ok(await svc.ListTagsAsync(repo, ct));
|
||||
|
||||
private static async Task<IResult> CreateTag(
|
||||
CreateTagRequest req, GiteaService svc, CancellationToken ct)
|
||||
CreateTagRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await svc.CreateTagAsync(req, ct);
|
||||
var tag = await svc.CreateTagAsync(req, repo, ct);
|
||||
return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListWebhooks(GiteaService svc, CancellationToken ct) =>
|
||||
Results.Ok(await svc.ListWebhooksAsync(ct));
|
||||
private static async Task<IResult> ListWebhooks(GiteaService svc, string? repo = null, CancellationToken ct = default) =>
|
||||
Results.Ok(await svc.ListWebhooksAsync(repo, ct));
|
||||
|
||||
private static async Task<IResult> RegisterWebhook(
|
||||
CreateWebhookRequest req, GiteaService svc, CancellationToken ct)
|
||||
CreateWebhookRequest req, GiteaService svc, string? repo = null, CancellationToken ct = default)
|
||||
{
|
||||
var hook = await svc.RegisterWebhookAsync(req, ct);
|
||||
var hook = await svc.RegisterWebhookAsync(req, repo, ct);
|
||||
return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,19 @@ namespace ControlPlane.Api.Services;
|
||||
public class GiteaService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly string _defaultOwner;
|
||||
private readonly string _defaultRepo;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ILogger<GiteaService> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger<GiteaService> log)
|
||||
{
|
||||
_log = log;
|
||||
_owner = cfg["Gitea:Owner"] ?? "Clarity";
|
||||
_repo = cfg["Gitea:Repo"] ?? "Clarity";
|
||||
_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;
|
||||
@@ -37,31 +39,66 @@ public class GiteaService
|
||||
_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<GiteaRepo?> GetRepoAsync(CancellationToken ct = default)
|
||||
public async Task<GiteaRepo?> GetRepoAsync(string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{_owner}/{_repo}", JsonOpts, ct);
|
||||
return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{owner}/{repo}", JsonOpts, ct);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; }
|
||||
}
|
||||
|
||||
// ── Branches ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaBranch>> ListBranchesAsync(CancellationToken ct = default)
|
||||
public async Task<List<GiteaBranch>> ListBranchesAsync(string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaBranch>>(
|
||||
$"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? [];
|
||||
$"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<List<(string RepoKey, GiteaBranch Branch)>> 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<GiteaBranch?> 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(" ", "");
|
||||
@@ -74,7 +111,7 @@ public class GiteaService
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/branches",
|
||||
$"repos/{owner}/{repo}/branches",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
@@ -90,29 +127,32 @@ public class GiteaService
|
||||
// ── Pull Requests ─────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaPullRequest>> ListPullRequestsAsync(
|
||||
string state = "open", CancellationToken ct = default)
|
||||
string state = "open", string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaPullRequest>>(
|
||||
$"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? [];
|
||||
$"repos/{owner}/{repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, CancellationToken ct = default)
|
||||
public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<GiteaPullRequest>(
|
||||
$"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct);
|
||||
$"repos/{owner}/{repo}/pulls/{number}", JsonOpts, ct);
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; }
|
||||
}
|
||||
|
||||
public async Task<GiteaPullRequest?> CreatePullRequestAsync(
|
||||
CreatePullRequestRequest req, CancellationToken ct = default)
|
||||
CreatePullRequestRequest req, string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
title = req.Title,
|
||||
@@ -122,7 +162,7 @@ public class GiteaService
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/pulls",
|
||||
$"repos/{owner}/{repo}/pulls",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
@@ -137,18 +177,20 @@ public class GiteaService
|
||||
|
||||
// ── Tags ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaTag>> ListTagsAsync(CancellationToken ct = default)
|
||||
public async Task<List<GiteaTag>> ListTagsAsync(string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaTag>>(
|
||||
$"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? [];
|
||||
$"repos/{owner}/{repo}/tags?limit=20", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, CancellationToken ct = default)
|
||||
public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
tag_name = req.TagName,
|
||||
@@ -157,7 +199,7 @@ public class GiteaService
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/tags",
|
||||
$"repos/{owner}/{repo}/tags",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
@@ -172,19 +214,21 @@ public class GiteaService
|
||||
|
||||
// ── Webhooks ──────────────────────────────────────────────────────────────
|
||||
|
||||
public async Task<List<GiteaWebhook>> ListWebhooksAsync(CancellationToken ct = default)
|
||||
public async Task<List<GiteaWebhook>> ListWebhooksAsync(string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
try
|
||||
{
|
||||
return await _http.GetFromJsonAsync<List<GiteaWebhook>>(
|
||||
$"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? [];
|
||||
$"repos/{owner}/{repo}/hooks", JsonOpts, ct) ?? [];
|
||||
}
|
||||
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; }
|
||||
}
|
||||
|
||||
public async Task<GiteaWebhook?> RegisterWebhookAsync(
|
||||
CreateWebhookRequest req, CancellationToken ct = default)
|
||||
CreateWebhookRequest req, string? repoKey = null, CancellationToken ct = default)
|
||||
{
|
||||
var (owner, repo) = ResolveOwnerRepo(repoKey);
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "gitea",
|
||||
@@ -194,7 +238,7 @@ public class GiteaService
|
||||
}, JsonOpts);
|
||||
|
||||
var res = await _http.PostAsync(
|
||||
$"repos/{_owner}/{_repo}/hooks",
|
||||
$"repos/{owner}/{repo}/hooks",
|
||||
new StringContent(body, Encoding.UTF8, "application/json"), ct);
|
||||
|
||||
if (!res.IsSuccessStatusCode)
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
"BaseUrl": "https://opc.clarity.test",
|
||||
"Owner": "ClarityStack",
|
||||
"Repo": "OPC",
|
||||
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646"
|
||||
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646",
|
||||
"Repos": {
|
||||
"Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" },
|
||||
"OPC": { "Owner": "ClarityStack", "Repo": "OPC" },
|
||||
"Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user