345 lines
14 KiB
C#
345 lines
14 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.
|
|
// branch filters to commits reachable from the named local branch; requires a specific repo (not "all").
|
|
// Each result includes a branches[] array listing local branches that contain that commit.
|
|
private static IResult GetLog(
|
|
IConfiguration config,
|
|
string? grep = null,
|
|
int limit = 25,
|
|
int page = 1,
|
|
string repo = "all",
|
|
string? branch = null)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(branch) && repo == "all")
|
|
return Results.BadRequest("'branch' filter requires a specific 'repo' parameter.");
|
|
|
|
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);
|
|
|
|
CommitFilter filter;
|
|
if (!string.IsNullOrWhiteSpace(branch))
|
|
{
|
|
var branchRef = r.Branches[branch];
|
|
if (branchRef?.Tip is null)
|
|
return Results.BadRequest($"Branch '{branch}' not found in repo '{repoKey}'.");
|
|
filter = new CommitFilter
|
|
{
|
|
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
|
|
IncludeReachableFrom = branchRef.Tip,
|
|
};
|
|
}
|
|
else
|
|
{
|
|
var tips = r.Branches
|
|
.Where(b => b.Tip != null)
|
|
.Select(b => (GitObject)b.Tip)
|
|
.ToList();
|
|
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 pageEntries = bucket
|
|
.OrderByDescending(x => x.When)
|
|
.Skip((page - 1) * limit)
|
|
.Take(limit)
|
|
.ToList();
|
|
|
|
// Annotate each commit with the local branches whose tip is a descendant of that commit.
|
|
var branchMap = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
foreach (var grp in pageEntries.GroupBy(x => x.RepoKey))
|
|
{
|
|
if (!repos.TryGetValue(grp.Key, out var rPath) || !Directory.Exists(rPath)) continue;
|
|
using var annotRepo = new Repository(rPath);
|
|
var localBranches = annotRepo.Branches.Where(b => !b.IsRemote && b.Tip != null).ToList();
|
|
foreach (var (_, _, c) in grp)
|
|
{
|
|
var target = annotRepo.Lookup<Commit>(c.Hash);
|
|
if (target is null) { branchMap[c.Hash] = []; continue; }
|
|
branchMap[c.Hash] = localBranches
|
|
.Where(b => annotRepo.ObjectDatabase.FindMergeBase(b.Tip, target)?.Sha == target.Sha)
|
|
.Select(b => b.FriendlyName)
|
|
.ToList();
|
|
}
|
|
}
|
|
|
|
var commits = pageEntries.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,
|
|
branches = branchMap.TryGetValue(x.Commit.Hash, out var br) ? (IReadOnlyList<string>)br : Array.Empty<string>(),
|
|
}).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?repo=all
|
|
// repo defaults to "default" (single configured repo). Pass "all" to get branches from all
|
|
// registered repos. Each result includes repoKey so the caller can group branches by repo.
|
|
private static IResult GetBranches(IConfiguration config, string repo = "default")
|
|
{
|
|
if (repo == "all")
|
|
{
|
|
var allRepos = ResolveAllRepos(config);
|
|
if (allRepos.Count == 0)
|
|
return Results.Problem("Could not locate any git repositories.");
|
|
|
|
var allBranches = new List<object>();
|
|
foreach (var (repoKey, rPath) in allRepos)
|
|
{
|
|
if (!Directory.Exists(rPath)) continue;
|
|
using var gitRepo = new Repository(rPath);
|
|
allBranches.AddRange(gitRepo.Branches
|
|
.Where(b => !b.IsRemote && b.Tip != null)
|
|
.OrderBy(b => b.FriendlyName)
|
|
.Select(b => (object)new
|
|
{
|
|
repoKey,
|
|
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,
|
|
}));
|
|
}
|
|
return Results.Ok(allBranches);
|
|
}
|
|
|
|
var repoPath = repo == "default"
|
|
? ResolveRepo(config)
|
|
: ResolveNamedRepo(config, repo) ?? ResolveRepo(config);
|
|
|
|
if (repoPath is null)
|
|
return Results.Problem("Could not locate a git repository.");
|
|
|
|
var repoLabel = repo == "default" ? "default" : repo;
|
|
using var singleRepo = new Repository(repoPath);
|
|
var branches = singleRepo.Branches
|
|
.Where(b => !b.IsRemote && b.Tip != null)
|
|
.OrderBy(b => b.FriendlyName)
|
|
.Select(b => new
|
|
{
|
|
repoKey = repoLabel,
|
|
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,
|
|
})
|
|
.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
|
|
);
|
|
}
|
|
}
|