Files
OPC/ControlPlane.Api/Endpoints/GitEndpoints.cs
T
amadzarak 65a6b4afaf OPC # 0001: Gitea services
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 19:35:46 -04:00

269 lines
10 KiB
C#

using ControlPlane.Core.Models;
using LibGit2Sharp;
namespace ControlPlane.Api.Endpoints;
public static class GitEndpoints
{
public static IEndpointRouteBuilder MapGitEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/git/log", GetLog);
app.MapGet("/api/git/commits/{hash}", GetCommit);
app.MapGet("/api/git/branches", GetBranches);
app.MapGet("/api/git/branch-coverage", GetBranchCoverage);
return app;
}
// 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 = 25,
int page = 1,
string repo = "all")
{
var repos = repo == "all"
? ResolveAllRepos(config)
: ResolveNamedRepo(config, repo) is { } p
? new Dictionary<string, string> { [repo] = p }
: null;
if (repos is null || repos.Count == 0)
return Results.Problem("Could not locate any git repositories. Set Git:Repos in appsettings.");
var bucket = new List<(DateTimeOffset When, string RepoKey, GitCommit Commit)>();
foreach (var (repoKey, repoPath) in repos)
{
if (!Directory.Exists(repoPath)) continue;
using var r = new Repository(repoPath);
var tips = r.Branches
.Where(b => b.Tip != null)
.Select(b => (GitObject)b.Tip)
.ToList();
var filter = new CommitFilter
{
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
IncludeReachableFrom = tips.Count > 0 ? tips : (object)r.Head,
};
IEnumerable<Commit> query = r.Commits.QueryBy(filter);
if (!string.IsNullOrWhiteSpace(grep))
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
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
{
repoKey = x.RepoKey,
hash = x.Commit.Hash,
shortHash = x.Commit.ShortHash,
author = x.Commit.Author,
date = x.Commit.Date,
subject = x.Commit.Subject,
files = x.Commit.Files,
})
.ToList();
return Results.Ok(commits);
}
// 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 repos = ResolveAllRepos(config);
if (repos.Count == 0)
return Results.Problem("Could not locate any git repositories.");
foreach (var (_, repoPath) in repos)
{
if (!Directory.Exists(repoPath)) continue;
using var repo = new Repository(repoPath);
var commit = repo.Lookup<Commit>(hash);
if (commit is null) continue;
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
private static IResult GetBranches(IConfiguration config)
{
var repoPath = ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository.");
using var repo = new Repository(repoPath);
var branches = repo.Branches
.Where(b => !b.IsRemote && b.Tip != null)
.Select(b => new
{
name = b.FriendlyName,
hash = b.Tip.Sha,
shortHash = b.Tip.Sha[..7],
subject = b.Tip.MessageShort,
author = b.Tip.Author.Name,
date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
isHead = b.IsCurrentRepositoryHead,
})
.OrderBy(b => b.name)
.ToList();
return Results.Ok(branches);
}
// GET /api/git/branch-coverage?commits=hash1,hash2,hash3&repo=OPC
// repo defaults to the single configured repo. Pass a named key from Git:Repos to
// query a specific repo — the backend silently ignores hashes it cannot find.
private static IResult GetBranchCoverage(IConfiguration config, string? commits = null, string? repo = null)
{
if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty<object>());
var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (hashes.Length == 0) return Results.Ok(Array.Empty<object>());
var repoPath = repo is not null
? ResolveNamedRepo(config, repo) ?? ResolveRepo(config)
: ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository.");
using var r = new Repository(repoPath);
var targetCommits = hashes
.Select(h => r.Lookup<Commit>(h))
.Where(c => c is not null)
.ToList();
if (targetCommits.Count == 0) return Results.Ok(Array.Empty<object>());
var result = r.Branches
.Where(b => !b.IsRemote && b.Tip != null)
.Select(b =>
{
var contains = targetCommits.All(tc =>
{
// If merge base of branch tip and target == target, then target is an ancestor
var mergeBase = r.ObjectDatabase.FindMergeBase(b.Tip, tc!);
return mergeBase?.Sha == tc!.Sha;
});
return new
{
branch = b.FriendlyName,
contains,
tipHash = b.Tip.Sha[..7],
isHead = b.IsCurrentRepositoryHead,
};
})
.OrderBy(b => b.branch)
.ToList();
return Results.Ok(result);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Resolves a named repo from the Git:Repos registry.
private static string? ResolveNamedRepo(IConfiguration config, string repoKey)
{
var path = config[$"Git:Repos:{repoKey}"];
return !string.IsNullOrWhiteSpace(path) && Directory.Exists(path) ? path : null;
}
/// Returns all repos from the Git:Repos registry that exist on disk.
/// Falls back to the single configured repo if the registry is empty.
private static IReadOnlyDictionary<string, string> ResolveAllRepos(IConfiguration config)
{
var result = new Dictionary<string, string>();
foreach (var child in config.GetSection("Git:Repos").GetChildren())
{
var path = child.Value;
if (!string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
result[child.Key] = path;
}
if (result.Count == 0 && ResolveRepo(config) is { } single)
result["default"] = single;
return result;
}
/// Resolves the repo root: explicit config overrides, otherwise auto-discover
/// from the running assembly directory upward via LibGit2Sharp.
private static string? ResolveRepo(IConfiguration config)
{
var configured = config["Git:RepoRoot"] ?? config["Docker:RepoRoot"];
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
return configured;
// Auto-discover: walk up from the app's own directory
var startPath = AppContext.BaseDirectory;
var discovered = Repository.Discover(startPath);
if (discovered is null) return null;
// Repository.Discover returns the .git directory path; get the working dir
using var probe = new Repository(discovered);
return probe.Info.WorkingDirectory;
}
private static GitCommit ToGitCommit(Repository repo, Commit c)
{
string[] files;
try
{
var parentTree = c.Parents.FirstOrDefault()?.Tree;
var changes = repo.Diff.Compare<TreeChanges>(parentTree, c.Tree);
files = changes.Select(ch => ch.Path).ToArray();
}
catch { files = []; }
return new GitCommit(
Hash: c.Sha,
ShortHash: c.Sha[..7],
Author: c.Author.Name,
Date: c.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
Subject: c.MessageShort,
Files: files
);
}
}