257 lines
11 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|