w4c-workflows-api/Services/WorkflowSource.cs

129 lines
4.5 KiB
C#

using System.Diagnostics;
namespace w4c_workflows.Services;
/// <summary>A workflow definition file discovered in the git source.</summary>
public sealed record WorkflowFile(string Path);
/// <summary>Current git state of the workflow source (for "sync status vs git").</summary>
public sealed record WorkflowSourceState(string? HeadSha, bool Dirty);
/// <summary>
/// Reads workflow YAML definitions from the authoritative git source. v1 uses
/// the same local monorepo checkout the Source Code explorer reads
/// (<c>SourceCode:RepoRoot</c>); a Forgejo API transport can be swapped in
/// behind this interface later.
/// </summary>
public interface IWorkflowSource
{
Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct);
Task<string> ReadAsync(string path, CancellationToken ct);
WorkflowSourceState GetState();
}
public class LocalRepoWorkflowSource : IWorkflowSource
{
private const string WorkflowsDir = "workflows";
private readonly string _repoRoot;
private readonly ILogger<LocalRepoWorkflowSource> _logger;
public LocalRepoWorkflowSource(IConfiguration config, IWebHostEnvironment env, ILogger<LocalRepoWorkflowSource> logger)
{
_logger = logger;
_repoRoot = ResolveRepoRoot(config, env);
_logger.LogInformation("Workflow source repo root resolved to {Root}", _repoRoot);
}
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var dir = Path.Combine(_repoRoot, WorkflowsDir);
var files = new List<WorkflowFile>();
if (!Directory.Exists(dir))
return Task.FromResult<IReadOnlyList<WorkflowFile>>(files);
foreach (var full in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(full);
if (ext is not (".yaml" or ".yml"))
continue;
var rel = Path.GetRelativePath(_repoRoot, full).Replace(Path.DirectorySeparatorChar, '/');
files.Add(new WorkflowFile(rel));
}
return Task.FromResult<IReadOnlyList<WorkflowFile>>(files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList());
}
public async Task<string> ReadAsync(string path, CancellationToken ct)
=> await File.ReadAllTextAsync(Path.Combine(_repoRoot, path.Replace('/', Path.DirectorySeparatorChar)), ct);
public WorkflowSourceState GetState()
{
var head = RunGit("rev-parse", "HEAD");
var status = RunGit("status", "--porcelain");
return new WorkflowSourceState(
string.IsNullOrWhiteSpace(head) ? null : head,
!string.IsNullOrEmpty(status));
}
private string? RunGit(params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = _repoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in args)
psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null)
return null;
var output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(5000))
{
process.Kill(entireProcessTree: true);
return null;
}
return process.ExitCode == 0 ? output.Trim() : null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed");
return null;
}
}
private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env)
{
var configured = config["SourceCode:RepoRoot"];
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
return Path.GetFullPath(configured);
var start = AppContext.BaseDirectory;
if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath))
start = env.ContentRootPath;
var walk = Path.GetFullPath(start);
while (true)
{
if (Directory.Exists(Path.Combine(walk, ".git")))
return Path.GetFullPath(walk);
var parent = Directory.GetParent(walk)?.FullName;
if (string.IsNullOrEmpty(parent) || parent == walk)
break;
walk = parent;
}
return Path.GetFullPath(Directory.GetCurrentDirectory());
}
}