w4c-workflows-api/Services/ForgejoWorkflowRepoService.Lifecycle.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

257 lines
11 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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}");
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Pulls the latest changes from the remote. Returns true on success.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Commits and pushes all changes in the tenant's clone. Used after file
/// edits via the source code API to persist changes to Forgejo.
/// </summary>
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);
}
}