using System.Diagnostics; namespace w4c_workflows.Services; /// /// Manages per-tenant private Forgejo repositories for workflow source files. /// Each tenant gets a repo named workflows-{tenantId} under the configured /// Forgejo owner. The repo contains the tenant's workflow YAML definitions and /// their sibling code files (shell scripts, JS, Python, etc.). /// /// On first access the repo is created via the Forgejo admin API, cloned locally, /// and seeded with templates from WorkflowSource:SharedDir. Subsequent calls /// pull remote changes before the sync service reads the files. /// /// The local clone lives at {WorkflowSource:CopiesRoot}/{tenantId}/ — the /// same directory layout the legacy used, so /// the existing and worker runtime need no path /// changes when the backing store switches from plain filesystem to git. /// public sealed class ForgejoWorkflowRepoService { private readonly string _forgejoBase; private readonly string? _forgejoToken; private readonly string? _forgejoAdminToken; private readonly string _forgejoOwner; private readonly string _copiesRoot; private readonly string _sharedDir; private readonly ILogger _logger; public ForgejoWorkflowRepoService(IConfiguration config, ILogger logger) { _logger = logger; _forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/'); _forgejoToken = config["Forgejo:AccessToken"]?.Trim(); _forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim(); _forgejoOwner = config["Forgejo:WorkflowRepoOwner"] ?? config["Forgejo:Owner"] ?? string.Empty; _copiesRoot = Path.GetFullPath( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( "WorkflowSource:CopiesRoot is not configured. " + "Set it to a writable directory path (e.g. \"/data/workflow-tenants\").")); _sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows"; } /// True when Forgejo admin provisioning is configured. public bool IsConfigured => !string.IsNullOrWhiteSpace(_forgejoAdminToken) && !string.IsNullOrWhiteSpace(_forgejoOwner); /// The repo full name on Forgejo (owner/repo-name). public string RepoFullName(string tenantId) => $"{_forgejoOwner.ToLowerInvariant()}/{RepoName(tenantId)}"; /// The sanitized repo name for a tenant. public static string RepoName(string tenantId) => $"workflows-{Sanitize(tenantId)}"; /// /// Resolves the Forgejo repo full name for a user. The repo lives under /// the user's own Forgejo account: {login}/workflows-{login}. /// public static string RepoFullNameForLogin(string login) => $"{login.ToLowerInvariant()}/workflows-{Sanitize(login.ToLowerInvariant())}"; /// /// Ensures the user's Forgejo repo exists. Creates it via the admin API /// if missing (idempotent). Returns the repo full name (owner/repo). /// Throws on hard failures. /// public async Task EnsureRepoAsync(string login, CancellationToken ct = default) { if (!IsConfigured) throw new InvalidOperationException( "Forgejo workflow repo provisioning is not configured. " + "Set Forgejo:AdminToken and Forgejo:Owner (or Forgejo:WorkflowRepoOwner)."); var owner = login.ToLowerInvariant(); var name = $"workflows-{Sanitize(owner)}"; var fullName = $"{owner}/{name}"; try { // Check if repo already exists. using var exists = await SendAdminAsync(HttpMethod.Get, $"/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}", null, ct); if (exists.IsSuccessStatusCode) { _logger.LogDebug("Forgejo workflow repo {FullName} already exists", fullName); return fullName; } // Create the repo. using var create = await SendAdminAsync(HttpMethod.Post, $"/admin/users/{Uri.EscapeDataString(owner)}/repos", new { name, @private = true, auto_init = true, description = $"Workflow definitions for user {login}", }, ct); if (!create.IsSuccessStatusCode) { var body = await create.Content.ReadAsStringAsync(ct); _logger.LogWarning("Forgejo workflow repo create {FullName} -> {Status}: {Body}", fullName, (int)create.StatusCode, body); throw new InvalidOperationException( $"Could not create Forgejo workflow repo {fullName}: {(int)create.StatusCode}"); } _logger.LogInformation("Created Forgejo workflow repo {FullName}", fullName); return fullName; } catch (InvalidOperationException) { throw; } catch (Exception ex) { _logger.LogError(ex, "Forgejo workflow repo ensure failed for {FullName}", fullName); throw new InvalidOperationException( $"Could not create Forgejo workflow repo {fullName}: {ex.Message}"); } } /// /// Ensures a local clone of the user's workflow repo exists. If the repo /// doesn't exist on Forgejo yet, it is created. If the clone directory is /// missing, the repo is cloned. Returns the absolute path to the local clone. /// public async Task EnsureCloneAsync(string login, CancellationToken ct = default) { var tenantDir = TenantCloneDir(login); if (Directory.Exists(Path.Combine(tenantDir, ".git"))) { _logger.LogDebug("Local clone for login {Login} already exists at {Dir}", login, tenantDir); return tenantDir; } // Ensure the Forgejo repo exists. var fullName = await EnsureRepoAsync(login, ct); var cloneUrl = $"{_forgejoBase}/{fullName}.git"; Directory.CreateDirectory(tenantDir); // Clone the repo. var psi = new ProcessStartInfo("git") { WorkingDirectory = tenantDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add("clone"); if (!string.IsNullOrWhiteSpace(_forgejoToken)) { psi.ArgumentList.Add("-c"); psi.ArgumentList.Add($"http.extraHeader=Authorization: token {_forgejoToken}"); } psi.ArgumentList.Add("--"); psi.ArgumentList.Add(cloneUrl); psi.ArgumentList.Add("."); using var process = Process.Start(psi); if (process == null) throw new InvalidOperationException("git clone failed to start"); var stdout = await process.StandardOutput.ReadToEndAsync(ct); var stderr = await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); if (process.ExitCode != 0) { _logger.LogWarning("Clone {FullName} failed ({Code}): {Err}", fullName, process.ExitCode, stderr.Trim()); throw new InvalidOperationException( string.IsNullOrWhiteSpace(stderr) ? "git clone failed" : stderr.Trim()); } _logger.LogInformation("Cloned workflow repo {FullName} into {Dir}", fullName, tenantDir); // Clean up any baked-in extra header from clone config. UnsetLocalConfig(tenantDir, "http.extraheader"); // If the repo is empty (freshly created with auto_init), seed with templates. await SeedTemplatesIfEmptyAsync(tenantDir, ct); return tenantDir; } /// /// Pulls the latest changes from the remote. Returns true on success. /// public async Task PullAsync(string login, CancellationToken ct = default) { var tenantDir = TenantCloneDir(login); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) { _logger.LogWarning("No local clone for login {Login}, cannot pull", login); return false; } var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken; var output = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "pull", "--ff-only") : await RunGitAsync(tenantDir, ct, "pull", "--ff-only"); if (output == null) { _logger.LogWarning("Pull failed for login {Login}", login); return false; } var ok = !output.Contains("fatal:", StringComparison.OrdinalIgnoreCase) && !output.Contains("Could not resolve host", StringComparison.OrdinalIgnoreCase) && !output.Contains("Permission denied", StringComparison.OrdinalIgnoreCase); _logger.LogInformation("Pull for login {Login}: {Ok} ({Out})", login, ok, output.Trim()); return ok; } /// /// Commits and pushes all changes in the tenant's clone. Used after file /// edits via the source code API to persist changes to Forgejo. /// public async Task<(bool Success, string Output)> CommitAndPushAsync( string tenantId, string message, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) return (false, "No local clone"); await RunGitAsync(tenantDir, ct, "add", "-A"); var commitOutput = await RunGitAsync(tenantDir, ct, "commit", "-m", message); if (commitOutput == null || commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase)) return (true, "Nothing to commit"); var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken; var pushOutput = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push") : await RunGitAsync(tenantDir, ct, "push"); var ok = pushOutput != null && !pushOutput.Contains("fatal:", StringComparison.OrdinalIgnoreCase) && !pushOutput.Contains("Permission denied", StringComparison.OrdinalIgnoreCase); return (ok, ok ? pushOutput?.Trim() ?? "Pushed" : pushOutput?.Trim() ?? "Push failed"); } /// The absolute path to a tenant's local clone directory. public string TenantCloneDir(string tenantId) => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId))); /// /// Seeds the local clone with template files from the shared directory. /// Only runs when the repo is empty (no workflow YAML files yet). /// private async Task SeedTemplatesIfEmptyAsync(string cloneDir, CancellationToken ct) { // Check if the repo already has workflow YAML files. var yamlCount = Directory.EnumerateFiles(cloneDir, "*.yaml", SearchOption.AllDirectories) .Concat(Directory.EnumerateFiles(cloneDir, "*.yml", SearchOption.AllDirectories)) .Count(f => !f.Contains(".git", StringComparison.OrdinalIgnoreCase)); if (yamlCount > 0) { _logger.LogDebug("Clone already has {Count} YAML files, skipping seed", yamlCount); return; } var sharedDir = ResolveSharedDir(); if (!Directory.Exists(sharedDir)) { _logger.LogWarning("Shared workflow directory {Shared} does not exist; clone starts empty", sharedDir); return; } _logger.LogInformation("Seeding empty clone from {Shared}", sharedDir); // Copy ALL files (YAML + code) from the shared directory. CopyAllFiles(sharedDir, cloneDir); // Commit the seeded templates. await RunGitAsync(cloneDir, ct, "add", "-A"); var commitOutput = await RunGitAsync(cloneDir, ct, "commit", "-m", "Initial workflow templates"); if (commitOutput != null && !commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase)) { var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken; if (!string.IsNullOrWhiteSpace(token)) { await RunGitAsync(cloneDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push"); } else { await RunGitAsync(cloneDir, ct, "push"); } _logger.LogInformation("Pushed initial templates to Forgejo"); } } private string ResolveSharedDir() { var sharedRel = _sharedDir; if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel)) return Path.GetFullPath(sharedRel); // Try resolving relative to the repo root (walk up from the service dir). var start = AppContext.BaseDirectory; var walk = Path.GetFullPath(start); while (true) { var candidate = Path.Combine(walk, sharedRel); if (Directory.Exists(candidate)) return Path.GetFullPath(candidate); var parent = Directory.GetParent(walk)?.FullName; if (string.IsNullOrEmpty(parent) || parent == walk) break; walk = parent; } return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), sharedRel)); } private static void CopyAllFiles(string src, string dst) { foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories)) { var rel = Path.GetRelativePath(src, file); if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase)) continue; var destPath = Path.Combine(dst, rel); var destDir = Path.GetDirectoryName(destPath); if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir); try { File.Copy(file, destPath, overwrite: true); } catch (Exception) { // Best-effort — template files that fail to copy are non-fatal. } } } private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args) { try { var psi = new ProcessStartInfo("git") { WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; foreach (var a in args) psi.ArgumentList.Add(a); using var process = Process.Start(psi); if (process == null) return null; var stdout = await process.StandardOutput.ReadToEndAsync(ct); var stderr = await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); return process.ExitCode == 0 ? stdout : stderr; } catch (Exception ex) { _logger.LogDebug(ex, "git invocation failed in {Dir}", workDir); return null; } } private async Task SendAdminAsync( HttpMethod method, string path, object? body, CancellationToken ct) { using var request = new HttpRequestMessage(method, $"{_forgejoBase}/api/v1{path}"); request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue( "Bearer", _forgejoAdminToken); if (body != null) { request.Content = new StringContent( System.Text.Json.JsonSerializer.Serialize(body, new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, }), System.Text.Encoding.UTF8, "application/json"); } using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; return await client.SendAsync(request, ct); } private static void UnsetLocalConfig(string repoRoot, string key) { try { var psi = new ProcessStartInfo("git") { WorkingDirectory = repoRoot, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; psi.ArgumentList.Add("config"); psi.ArgumentList.Add("--local"); psi.ArgumentList.Add("--unset"); psi.ArgumentList.Add(key); using var p = Process.Start(psi); p?.WaitForExit(3000); } catch { // Best-effort cleanup. } } private static string Sanitize(string id) { if (string.IsNullOrWhiteSpace(id)) return "_"; var sb = new System.Text.StringBuilder(id.Length); foreach (var ch in id) sb.Append(char.IsLetterOrDigit(ch) || ch == '-' || ch == '_' ? ch : '_'); var result = sb.ToString(); return result.Length > 120 ? result[..120] : result; } }