using ControlPlane.Core.Models; using ControlPlane.Core.Services; using LibGit2Sharp; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Diagnostics; namespace ControlPlane.Api.Services; /// /// Runs dotnet build or npm run build for individual projects in the repo. /// Used by the Build Monitor tab in the control plane UI. /// public class ProjectBuildService( IConfiguration config, BuildHistoryService history, ILogger logger) { public string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty; /// Known projects in the solution, returned to the UI for the build monitor grid. public IReadOnlyList GetProjects() { if (string.IsNullOrWhiteSpace(RepoRoot)) return []; return [ // ── Solution-level builds (primary targets) ────────────────────── new("Clarity Solution", BuildKind.SolutionBuild, "Clarity/Clarity.slnx"), new("ControlPlane Solution", BuildKind.SolutionBuild, "OPC/ControlPlane.slnx"), // ── Individual Clarity projects ─────────────────────────────────── new("Clarity.Server", BuildKind.DotnetProject, "Clarity/Clarity.Server/Clarity.Server.csproj"), new("Clarity.ServiceDefaults", BuildKind.DotnetProject, "Clarity/Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj"), new("frontend (Clarity.Server)", BuildKind.NpmProject, "Clarity/frontend"), ]; } /// /// Builds a single project and streams output to . /// public async Task BuildProjectAsync( string projectName, Action onLine, CancellationToken ct) { var projects = GetProjects(); var def = projects.FirstOrDefault(p => p.Name == projectName); if (def is null) { var err = new BuildRecord { Kind = BuildKind.DotnetProject, Target = projectName, Status = BuildStatus.Failed }; err.Log.Add($"Unknown project: {projectName}"); return err; } var record = await history.CreateBuildAsync(def.Kind, def.RelativePath); record.Log.Add($"▶ Building {def.Name} [{def.Kind}]"); record.Log.Add($" Path: {def.RelativePath}"); record.Log.Add("──────────────────────────────────────"); onLine($"▶ Building {def.Name}"); // Capture HEAD SHA so the build is traceable to a specific commit try { using var gitRepo = new Repository(RepoRoot); record.CommitSha = gitRepo.Head.Tip?.Sha; if (record.CommitSha is not null) record.Log.Add($" Commit: {record.CommitSha[..8]}"); } catch { /* not a git repo or no commits yet */ } try { var (exe, args, workDir) = def.Kind == BuildKind.NpmProject ? BuildNpmCommand(def.RelativePath) : BuildDotnetCommand(def.RelativePath); var psi = new ProcessStartInfo(exe, args) { WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; void HandleLine(string? line) { if (line is null) return; record.Log.Add(line); onLine(line); // Non-blocking fire-and-forget flush _ = history.AppendBuildLogAsync(record, line); } proc.OutputDataReceived += (_, e) => HandleLine(e.Data); proc.ErrorDataReceived += (_, e) => HandleLine(e.Data); proc.Start(); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); await proc.WaitForExitAsync(ct); var status = proc.ExitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed; var summary = proc.ExitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {proc.ExitCode})."; onLine("──────────────────────────────────────"); onLine(summary); record.Log.Add(summary); await history.CompleteBuildAsync(record, status); logger.LogInformation("Project build [{Name}] {Status}", def.Name, status); return record; } catch (Exception ex) { onLine($"✖ Exception: {ex.Message}"); record.Log.Add($"Exception: {ex.Message}"); await history.CompleteBuildAsync(record, BuildStatus.Failed); logger.LogError(ex, "Project build [{Name}] threw.", def.Name); return record; } } private (string exe, string args, string workDir) BuildDotnetCommand(string relativePath) { var fullPath = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); return ("dotnet", $"build \"{fullPath}\" --configuration Release --nologo", RepoRoot); } private (string exe, string args, string workDir) BuildNpmCommand(string relativePath) { var workDir = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); // npm on Windows needs cmd /c return (OperatingSystem.IsWindows() ? "cmd" : "sh", OperatingSystem.IsWindows() ? "/c npm run build" : "-c \"npm run build\"", workDir); } } public record ProjectDefinition(string Name, BuildKind Kind, string RelativePath);