using Microsoft.AspNetCore.Hosting; 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. /// /// The class is split across partial files by responsibility: this file holds /// configuration, repo naming and the clone-directory layout; /// ForgejoWorkflowRepoService.Lifecycle.cs the repo/clone/pull/push /// lifecycle, .Git.cs the bounded-timeout git process calls and /// .AdminApi.cs the single admin REST call. /// public sealed partial class ForgejoWorkflowRepoService { /// /// Hard ceiling for any single git invocation. Without it, `git pull`/`git clone` /// against an unreachable remote (e.g. a stale Forgejo base URL) hangs forever, /// blocking the request that triggered it. See . /// private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20); /// Named HttpClient used for the Forgejo admin API (pooled, not per-call). public const string AdminClientName = "forgejo-admin"; private readonly string _forgejoBase; private readonly string? _forgejoToken; private readonly string? _forgejoAdminToken; private readonly string _forgejoOwner; private readonly string _copiesRoot; private readonly HttpClient _adminHttp; private readonly ILogger _logger; public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger logger, IHttpClientFactory? httpFactory = null) { _logger = logger; // One reused client for the service lifetime instead of `new HttpClient` // per admin call — the old pattern leaked sockets under load (the service // is a singleton, so this client is effectively shared anyway). _adminHttp = httpFactory?.CreateClient(AdminClientName) ?? new HttpClient(); _adminHttp.Timeout = TimeSpan.FromSeconds(30); _forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/'); _forgejoToken = config["Forgejo:AccessToken"]?.Trim(); _forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim(); // Prefer WorkflowRepoOwner, but treat an explicit empty string as // "not configured" and fall back to Forgejo:Owner. A plain `??` is not // enough: appsettings ships `"WorkflowRepoOwner": ""`, which is non-null // and would win over Forgejo:Owner, silently disabling Forgejo-backed // workflow mode (IsConfigured reads owner as empty). var workflowRepoOwner = config["Forgejo:WorkflowRepoOwner"]?.Trim(); _forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner) ? workflowRepoOwner : config["Forgejo:Owner"]?.Trim() ?? string.Empty; // Resolve CopiesRoot against the content root (not the process CWD) so a // relative value like "../source-copies" lands on the same wizard root // regardless of the working directory the service was launched from. _copiesRoot = WorkflowSourceFactory.ResolveCopiesRoot( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( "WorkflowSource:CopiesRoot is not configured. " + "Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."), env); // The workflow repo name (basename). Default "workflows" → {login}/workflows, // so a tenant's workflows live in a repo whose name contains "workflow". // A per-tenant selection overrides this at runtime. var repoName = config["WorkflowSource:WorkflowRepoName"]?.Trim(); WorkflowRepoName = string.IsNullOrWhiteSpace(repoName) ? "workflows" : repoName; } /// The Forgejo base URL (for building repo links). public string ForgejoBaseUrl => _forgejoBase; /// /// The token used for git transport (clone/pull/push) against the per-user /// private workflow repos. The repos are created and administered with the /// ADMIN token, so that is the credential that actually works against them; /// the plain AccessToken has no read/write grant on a user's private repo and /// Forgejo reports it as "Repository not found". Fall back to the access /// token when no admin token is configured. /// private string GitAuthToken => !string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken ?? string.Empty; /// 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). /// /// The repository name (basename) used for a tenant's workflow repo. Default /// is workflows (the repo full name {login}/workflows), so a /// tenant's workflows live in a repo whose name contains "workflow". A tenant /// may select a different repo (e.g. wiz4apps) to share the directory /// with the main-api Source Code explorer; the selection is persisted per /// tenant and overrides this default. /// public string WorkflowRepoName { get; } /// /// Consolidated repo-name resolver. Both the workflows-api engine and the /// webapi source-code explorer operate on a local clone whose directory name is /// the slug of the repo full name ({owner}_{repo}). Deriving full name AND /// slug from a single repo name is what keeps the two services pointing at the /// same directory. /// public static (string RepoFullName, string RepoSlug) ResolveWorkflowRepo(string login, string repoName) { var owner = Sanitize(login.ToLowerInvariant()); var repo = Sanitize(repoName); return ($"{owner}/{repo}", $"{owner}_{repo}"); } /// /// Resolves the Forgejo repo full name for a user given the configured/selected /// workflow repo name: {login}/{repoName}. /// public string RepoFullNameForLogin(string login, string? repoName = null) => ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName; /// /// The source-copies owner-repo slug for a user's workflow repo, matching the /// webapi SourceCodeCopyService layout ({owner}_{repo}, e.g. /// test_workflows). Sharing this exact directory with the webapi /// source-code explorer is what keeps workflow edits visible in the source-code /// view without a manual fetch/pull. /// public string OwnerRepoSlug(string login, string? repoName = null) => ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug; /// /// The absolute path to a tenant's local clone directory — shared with the /// webapi source-code explorer. Layout: /// {CopiesRoot}/{tenantId}/{ownerRepo} where ownerRepo is /// (e.g. source-copies/10/test_workflows-test). /// Both the w4c-workflows-api engine and the webapi SourceCodeController operate /// on this one working copy, so workflow edits and the source-code view stay /// consistent without a manual git pull. /// public string TenantCloneDir(string tenantId, string login, string? repoName = null) => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName))); 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; } }