using System.Diagnostics; namespace w4c_workflows.Services; // Repo + clone lifecycle half of the Forgejo workflow repo service: creating the // Forgejo repo, cloning it locally, and pulling/committing/pushing source edits. // Configuration, naming and path layout live in ForgejoWorkflowRepoService.cs; // the bounded-timeout git calls in .Git.cs and the admin REST call in .AdminApi.cs. public sealed partial class ForgejoWorkflowRepoService { /// /// 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, string? repoName = null, 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 = Sanitize(repoName ?? WorkflowRepoName); 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 tenantId, string login, string? repoName = null, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login, repoName); if (Directory.Exists(Path.Combine(tenantDir, ".git"))) { // The clone already exists, but its remote `origin` may point at an old // Forgejo base (e.g. after the server location changed from // forgejo.wiz4chat.com to localhost). Pulling from a dead origin hangs // indefinitely (no timeout), blocking every request that runs the // post-auth middleware. Reconcile origin to the configured base so // subsequent pulls target the live server. Best-effort: never fail the // request over a remote rewrite. await ReconcileOriginAsync(tenantDir, login, repoName, ct); _logger.LogDebug("Local clone for login {Login} already exists at {Dir}", login, tenantDir); return tenantDir; } // Ensure the Forgejo repo exists. The selected repo name must be honored // here; otherwise switching to a non-default repo would clone the default // repo ("workflows") into the directory named after the selected repo. var fullName = await EnsureRepoAsync(login, repoName, 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"); // The per-user workflow repos are created and managed via the Forgejo // ADMIN token, and each is private (owned by the user's own account). // The plain AccessToken has no grant on those private repos (Forgejo // returns 404), so cloning with it makes git fail with "Repository not // found". Use the admin token (fallback to the access token) so the // clone — and the later pull/push — succeed against the private repo. if (!string.IsNullOrWhiteSpace(GitAuthToken)) { psi.ArgumentList.Add("-c"); psi.ArgumentList.Add($"http.extraHeader=Authorization: token {GitAuthToken}"); } 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"); using var cloneTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct); cloneTimeout.CancelAfter(GitOperationTimeout); var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); try { await process.WaitForExitAsync(cloneTimeout.Token); } catch (OperationCanceledException) { TryKill(process); _logger.LogWarning("Clone {FullName} timed out after {Timeout}s", fullName, GitOperationTimeout.TotalSeconds); throw new InvalidOperationException( $"git clone of {fullName} timed out after {GitOperationTimeout.TotalSeconds}s."); } var stdout = await stdoutTask; var stderr = await stderrTask; 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"); // Pin the remote to the configured Forgejo base (git infers origin from // the clone URL, but an explicit set-url guarantees it matches the current // configuration even if the base URL changed between clones). await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", cloneUrl); // NOTE: we deliberately do NOT seed shared example templates into a fresh // workflow repo. Workflows are strictly per-tenant: a tenant starts with // exactly the content of their own repo (empty for a freshly-created repo) // and builds up their own definitions. Seeding shared examples made every // tenant see the same "predefined" workflows and broke tenant isolation. return tenantDir; } /// /// Pulls the latest changes from the remote. Returns true on success. /// public async Task PullAsync(string tenantId, string login, string? repoName = null, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login, repoName); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) { _logger.LogWarning("No local clone for login {Login}, cannot pull", login); return false; } var token = GitAuthToken; var pull = !string.IsNullOrWhiteSpace(token) ? await RunGitExitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "pull", "--ff-only") : await RunGitExitAsync(tenantDir, ct, "pull", "--ff-only"); if (pull == null || pull.Value.ExitCode != 0) { var detail = pull.HasValue ? $"{pull.Value.Stdout}\n{pull.Value.Stderr}".Trim() : "git pull did not run"; _logger.LogWarning("Pull failed for login {Login}: {Out}", login, detail); return false; } _logger.LogInformation("Pull for login {Login}: ok", login); return true; } /// /// 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 login, string message, string? repoName = null, CancellationToken ct = default) { var tenantDir = TenantCloneDir(tenantId, login, repoName); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) return (false, "No local clone"); await RunGitAsync(tenantDir, ct, "add", "-A"); var commit = await RunGitExitAsync(tenantDir, ct, "commit", "-m", message); if (commit == null) return (false, "git commit could not be executed"); if (commit.Value.ExitCode != 0) { var commitText = $"{commit.Value.Stdout}\n{commit.Value.Stderr}"; if (commitText.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase) || commitText.Contains("no changes added to commit", StringComparison.OrdinalIgnoreCase) || commitText.Contains("working tree clean", StringComparison.OrdinalIgnoreCase)) return (true, "Nothing to commit"); return (false, commitText.Trim()); } var token = GitAuthToken; var push = !string.IsNullOrWhiteSpace(token) ? await RunGitExitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push") : await RunGitExitAsync(tenantDir, ct, "push"); if (push == null) return (false, "git push could not be executed"); // Success is the exit code, not a substring scan of stderr: a rejected // (non-fast-forward) push writes "! [rejected]" / "failed to push some // refs" and exits non-zero — the old heuristic reported that as success, // silently dropping the user's edits. var output = $"{push.Value.Stdout}\n{push.Value.Stderr}".Trim(); return push.Value.ExitCode == 0 ? (true, output.Length == 0 ? "Pushed" : output) : (false, output.Length == 0 ? "Push failed" : output); } }