From e28b083dc34443c95df86122391b56a07517e465 Mon Sep 17 00:00:00 2001 From: Adam Frisby Date: Mon, 21 Sep 2026 22:46:49 +0000 Subject: [PATCH 1/3] Add Forgejo upstream remote plugin (codeybox.forgejo-upstream) First-class self-hosted forge target implementing IUpstreamRemote: push/open/auto-merge PR lifecycle, release-sync branch merge, base fetch, PR list/read, plus reviews, commit statuses, comments, repo webhooks and repository metadata against Forgejo API v1. Off unless allowlisted with Upstream.Kind=forgejo. Credentials from env only, never config or sandbox. CodeyBox-Prompt-Revision: 1 Co-Authored-By: CodeyBox --- CodeyBox.slnx | 1 + docs/extending/upstream-plugins.md | 12 + .../CodeyBox.ForgejoUpstreamPlugin.csproj | 40 + .../ForgejoApiModels.cs | 89 ++ .../ForgejoGitAuth.cs | 102 +++ .../ForgejoUpstreamException.cs | 38 + .../ForgejoUpstreamRemote.Extended.cs | 549 ++++++++++++ .../ForgejoUpstreamRemote.cs | 687 +++++++++++++++ .../CodeyBox.ForgejoUpstreamPlugin/README.md | 155 ++++ tests/CodeyBox.Tests/CodeyBox.Tests.csproj | 1 + .../Fixtures/Forgejo/branch-protections.json | 14 + .../Fixtures/Forgejo/combined-status.json | 27 + .../Fixtures/Forgejo/comments.json | 18 + .../Fixtures/Forgejo/hook-created.json | 13 + .../Fixtures/Forgejo/hooks.json | 15 + .../Fixtures/Forgejo/pull-list.json | 28 + .../Fixtures/Forgejo/pull-merged.json | 31 + .../Forgejo/pull-open-no-reviewers.json | 29 + .../Fixtures/Forgejo/pull-open.json | 31 + .../Fixtures/Forgejo/repository.json | 14 + .../Fixtures/Forgejo/reviews.json | 35 + .../ForgejoUpstreamRemoteTests.cs | 809 ++++++++++++++++++ 22 files changed, 2738 insertions(+) create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/CodeyBox.ForgejoUpstreamPlugin.csproj create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoApiModels.cs create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoGitAuth.cs create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamException.cs create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.Extended.cs create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.cs create mode 100644 plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/README.md create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/branch-protections.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/combined-status.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/comments.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/hook-created.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/hooks.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/pull-list.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/pull-merged.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/pull-open-no-reviewers.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/pull-open.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/repository.json create mode 100644 tests/CodeyBox.Tests/Fixtures/Forgejo/reviews.json create mode 100644 tests/CodeyBox.Tests/ForgejoUpstreamRemoteTests.cs diff --git a/CodeyBox.slnx b/CodeyBox.slnx index 346098eb5..d63bc4083 100644 --- a/CodeyBox.slnx +++ b/CodeyBox.slnx @@ -66,6 +66,7 @@ + diff --git a/docs/extending/upstream-plugins.md b/docs/extending/upstream-plugins.md index 4eadac2c9..48900bbfa 100644 --- a/docs/extending/upstream-plugins.md +++ b/docs/extending/upstream-plugins.md @@ -271,6 +271,18 @@ Then configure the orchestrator: } ``` +## Production Forgejo plugin + +Beyond the sample above, `plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/` +is a production-quality, first-class upstream remote for Forgejo +(`codeybox.forgejo-upstream`, `Upstream.Kind = "forgejo"`) implementing the +full `IUpstreamRemote` contract: push/open/auto-merge, release-sync branch +merges, base-branch fetch, PR listing/reads, plus the extended surfaces +Forgejo genuinely provides (reviews, commit statuses, plain comments, +repository webhooks, repository metadata). See its `README.md` for the +support matrix, configuration, and instance-version requirements. It is off +unless an operator allowlists it and selects the kind. + ## Registering your plugin 1. Add the plugin assembly path to `CodeyBox:Plugins:AssemblyPaths`. diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/CodeyBox.ForgejoUpstreamPlugin.csproj b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/CodeyBox.ForgejoUpstreamPlugin.csproj new file mode 100644 index 000000000..ae0550f18 --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/CodeyBox.ForgejoUpstreamPlugin.csproj @@ -0,0 +1,40 @@ + + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoApiModels.cs b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoApiModels.cs new file mode 100644 index 000000000..c794fd39d --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoApiModels.cs @@ -0,0 +1,89 @@ +using System.Text.Json.Serialization; + +namespace CodeyBox.ForgejoUpstreamPlugin; + +// JSON shapes for Forgejo API v1, verified against the live swagger +// (https://try.next.forgejo.org/swagger.v1.json, Forgejo 15 / API v1). +// Only the fields this provider reads are modelled; unknown fields are +// ignored so newer instances stay compatible. All ids stay numeric here +// and are rendered as strings at the contract boundary (forges differ). + +internal sealed record ForgejoUser( + [property: JsonPropertyName("login")] string? Login); + +internal sealed record ForgejoBranchInfo( + [property: JsonPropertyName("label")] string? Label, + [property: JsonPropertyName("ref")] string? Ref, + [property: JsonPropertyName("sha")] string? Sha); + +internal sealed record ForgejoPull( + [property: JsonPropertyName("number")] long Number, + [property: JsonPropertyName("index")] long Index, + [property: JsonPropertyName("html_url")] string? HtmlUrl, + [property: JsonPropertyName("state")] string? State, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("body")] string? Body, + [property: JsonPropertyName("merged")] bool Merged, + [property: JsonPropertyName("mergeable")] bool? Mergeable, + [property: JsonPropertyName("merge_commit_sha")] string? MergeCommitSha, + [property: JsonPropertyName("head")] ForgejoBranchInfo? Head, + [property: JsonPropertyName("base")] ForgejoBranchInfo? Base, + [property: JsonPropertyName("requested_reviewers")] IReadOnlyList? RequestedReviewers) +{ + // Pulls and issues share one numbering space; either field identifies the PR. + public long EffectiveNumber => Number > 0 ? Number : Index; +} + +internal sealed record ForgejoReview( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("user")] ForgejoUser? User, + [property: JsonPropertyName("state")] string? State, + [property: JsonPropertyName("body")] string? Body, + [property: JsonPropertyName("submitted_at")] DateTimeOffset? SubmittedAt, + [property: JsonPropertyName("dismissed")] bool Dismissed, + [property: JsonPropertyName("stale")] bool Stale); + +internal sealed record ForgejoCommitStatus( + [property: JsonPropertyName("context")] string? Context, + [property: JsonPropertyName("status")] string? Status, + [property: JsonPropertyName("target_url")] string? TargetUrl, + [property: JsonPropertyName("description")] string? Description); + +internal sealed record ForgejoCombinedStatus( + [property: JsonPropertyName("state")] string? State, + [property: JsonPropertyName("statuses")] IReadOnlyList? Statuses); + +internal sealed record ForgejoComment( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("user")] ForgejoUser? User, + [property: JsonPropertyName("body")] string? Body, + [property: JsonPropertyName("created_at")] DateTimeOffset? CreatedAt); + +internal sealed record ForgejoHook( + [property: JsonPropertyName("id")] long Id, + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("events")] IReadOnlyList? Events, + [property: JsonPropertyName("active")] bool Active, + [property: JsonPropertyName("url")] string? Url, + [property: JsonPropertyName("config")] Dictionary? Config) +{ + public string? TargetUrl => + !string.IsNullOrWhiteSpace(Url) ? Url + : Config is not null && Config.TryGetValue("url", out var u) && !string.IsNullOrWhiteSpace(u) ? u + : null; +} + +internal sealed record ForgejoRepository( + [property: JsonPropertyName("default_branch")] string? DefaultBranch, + [property: JsonPropertyName("private")] bool Private, + [property: JsonPropertyName("internal")] bool Internal); + +internal sealed record ForgejoBranchProtection( + [property: JsonPropertyName("rule_name")] string? RuleName, + [property: JsonPropertyName("branch_name")] string? BranchName, + [property: JsonPropertyName("required_approvals")] int RequiredApprovals, + [property: JsonPropertyName("enable_status_check")] bool EnableStatusCheck) +{ + // rule_name is current; branch_name is the deprecated equivalent kept for old instances. + public string? Pattern => !string.IsNullOrWhiteSpace(RuleName) ? RuleName : BranchName; +} diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoGitAuth.cs b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoGitAuth.cs new file mode 100644 index 000000000..807d505fd --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoGitAuth.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; + +namespace CodeyBox.ForgejoUpstreamPlugin; + +// Host-side git authentication for the plugin. Plain git has no +// GIT_USERNAME/GIT_PASSWORD convention, so (like the orchestrator's own +// GitCredentialHelper, which this plugin cannot reference across the plugin +// load-context boundary) credentials travel via a short-lived GIT_ASKPASS +// script: the token lives only in process environment, never on argv, and +// the script directory is removed on dispose. + +internal sealed class ForgejoGitAuthScope : IDisposable +{ + private readonly string _directory; + private bool _disposed; + + public IReadOnlyDictionary Environment { get; } + + private ForgejoGitAuthScope(string directory, IReadOnlyDictionary environment) + { + _directory = directory; + Environment = environment; + } + + public static ForgejoGitAuthScope Create(string? token) + { + if (string.IsNullOrEmpty(token)) + return new ForgejoGitAuthScope(string.Empty, new Dictionary()); + + var directory = Directory.CreateTempSubdirectory("codeybox-forgejo-askpass-").FullName; + var scriptPath = Path.Combine(directory, "askpass.sh"); + File.WriteAllText(scriptPath, "#!/bin/sh\nprintf '%s' \"$CODEYBOX_FORGEJO_GIT_PASS\"\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(directory, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(scriptPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return new ForgejoGitAuthScope(directory, new Dictionary + { + ["GIT_ASKPASS"] = scriptPath, + ["GIT_TERMINAL_PROMPT"] = "0", + ["CODEYBOX_FORGEJO_GIT_PASS"] = token, + }); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (string.IsNullOrEmpty(_directory)) + return; + try + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + catch + { + // Best-effort cleanup of a temp directory. + } + } +} + +// Minimal git runner for the release-sync merge path (merge one upstream +// branch into another). Mirrors the built-in generic-git's clone/fetch/ +// merge/push sequence; it lives here because IUpstreamRemote callers only +// reach this provider through the contract, which carries no project +// context, so the merge cannot be delegated to a per-project remote. +internal static class ForgejoGitRunner +{ + public static async Task<(int ExitCode, string Stdout, string Stderr)> RunAsync( + string workdir, + IReadOnlyDictionary extraEnv, + CancellationToken ct, + params string[] args) + { + var psi = new ProcessStartInfo + { + FileName = "git", + WorkingDirectory = workdir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var arg in args) + psi.ArgumentList.Add(arg); + foreach (var (key, value) in extraEnv) + psi.EnvironmentVariables[key] = value; + + using var process = new Process { StartInfo = psi }; + process.Start(); + var stdout = await process.StandardOutput.ReadToEndAsync(ct); + var stderr = await process.StandardError.ReadToEndAsync(ct); + await process.WaitForExitAsync(ct); + return (process.ExitCode, stdout, stderr); + } +} diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamException.cs b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamException.cs new file mode 100644 index 000000000..73b2ca8f0 --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamException.cs @@ -0,0 +1,38 @@ +using System.Net; + +namespace CodeyBox.ForgejoUpstreamPlugin; + +/// +/// The Forgejo instance was unreachable, refused the request, or answered +/// with an unexpected status. Forge-side failures are infrastructure: +/// they are never a verdict on the work item's diff. Throwing (rather than +/// returning a failure value) lets the orchestrator retry with backoff and +/// park the item as an infrastructure failure after its attempt budget. +/// +/// Soft outcomes that are part of normal operation — a PR that already +/// exists (409/422 on create), a PR that cannot be auto-merged (405/409 on +/// merge) — do not throw; they return partial results instead. +/// +public sealed class ForgejoUpstreamException : InvalidOperationException +{ + /// HTTP status from the Forgejo instance, when the failure was an HTTP response. + public HttpStatusCode? StatusCode { get; } + + /// + /// Value of the Retry-After response header in seconds, when the + /// instance supplied one (typically with 429 rate limiting). + /// + public int? RetryAfterSeconds { get; } + + public ForgejoUpstreamException(string message, Exception? inner = null) + : base(message, inner) + { + } + + public ForgejoUpstreamException(string message, HttpStatusCode statusCode, int? retryAfterSeconds = null, Exception? inner = null) + : base(message, inner) + { + StatusCode = statusCode; + RetryAfterSeconds = retryAfterSeconds; + } +} diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.Extended.cs b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.Extended.cs new file mode 100644 index 000000000..dafdc9898 --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.Extended.cs @@ -0,0 +1,549 @@ +using System.Globalization; +using System.Net; +using CodeyBox.Core; +using Microsoft.Extensions.Logging; + +namespace CodeyBox.ForgejoUpstreamPlugin; + +// Extended IUpstreamRemote surfaces genuinely supported by Forgejo API v1: +// review state, commit checks, plain issue comments, repository webhooks and +// repository metadata. Capability stays discoverable: unsupported returns +// null (or null-vs-empty per the contract), "supported and empty" returns a +// non-null empty result. Anything Forgejo cannot represent stays +// unimplemented rather than translated into another forge's shape: +// file-anchored and threaded comments, non-repository webhook scopes, and +// releases (see README.md). +public sealed partial class ForgejoUpstreamRemote +{ + /// + /// Open PRs whose head branch starts with + /// and whose mergeability Forgejo has computed. PRs with unknown + /// mergeability are skipped so the sweeper reconsiders them next tick. + /// Empty when the plugin has no scoped repository or nothing matches. + /// + public async Task> ListOpenPullRequestsAsync( + string branchPrefix, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(branchPrefix)) + throw new ArgumentException("branchPrefix must be non-empty", nameof(branchPrefix)); + var config = ResolveScopedConfig(); + if (config is null) + return []; + var token = ResolveToken(null); + + var pulls = await GetPagedAsync(config, token, "pulls?state=open", ct); + var result = new List(); + foreach (var pull in pulls) + { + if (pull.EffectiveNumber <= 0 || pull.EffectiveNumber > int.MaxValue) + continue; + if (string.Equals(pull.State, "closed", StringComparison.OrdinalIgnoreCase)) + continue; + var headBranch = BranchNameOf(pull.Head); + if (headBranch is null || !headBranch.StartsWith(branchPrefix, StringComparison.Ordinal)) + continue; + if (pull.Mergeable is null) + continue; + var headSha = pull.Head?.Sha; + var baseBranch = BranchNameOf(pull.Base); + if (string.IsNullOrEmpty(headSha) || string.IsNullOrEmpty(baseBranch)) + continue; + result.Add(new UpstreamPullRequest + { + Number = (int)pull.EffectiveNumber, + Url = pull.HtmlUrl ?? config.PullUrl(pull.EffectiveNumber), + HeadBranch = headBranch, + HeadSha = headSha, + BaseBranch = baseBranch, + HasMergeConflict = pull.Mergeable == false, + }); + } + return result; + } + + /// + /// Reads a PR by number. Null when the plugin has no scoped repository + /// or the PR is unavailable. Forgejo reports merged explicitly, + /// so merged PRs are not misread as merely closed. + /// + public async Task GetPullRequestAsync( + int number, CancellationToken ct = default) + { + if (number <= 0) + throw new ArgumentOutOfRangeException(nameof(number), "Pull request number must be positive."); + var config = ResolveScopedConfig(); + if (config is null) + return null; + var pull = await GetPullAsync(config, ResolveToken(null), number, ct); + if (pull is null) + return null; + var status = pull.Merged ? PullRequestStatus.Merged + : string.Equals(pull.State, "closed", StringComparison.OrdinalIgnoreCase) ? PullRequestStatus.Closed + : PullRequestStatus.Open; + return new UpstreamPullRequestState( + (int)pull.EffectiveNumber, + pull.HtmlUrl ?? config.PullUrl(pull.EffectiveNumber), + status, + pull.MergeCommitSha); + } + + /// + /// Review state from /pulls/{n}/reviews plus the still-requested + /// reviewers from the PR itself. RequirementsMet counts + /// non-dismissed, non-stale approvals against the scoped repository's + /// branch-protection quorum for the PR's base branch, and treats an + /// outstanding change request as blocking — the common Forgejo + /// configuration, documented in README.md. + /// + public async Task GetReviewStateAsync( + int number, CancellationToken ct = default) + { + if (number <= 0) + throw new ArgumentOutOfRangeException(nameof(number), "Pull request number must be positive."); + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var reviewsResponse = await SendForgejoAsync( + HttpMethod.Get, config.ReposPath($"pulls/{number}/reviews"), token, ct); + if (reviewsResponse.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(reviewsResponse, "list pull request reviews", ct); + var forgeReviews = await DeserializeAsync>(reviewsResponse, ct) ?? []; + + var pull = await GetPullAsync(config, token, number, ct); + if (pull is null) + return null; + + var reviews = forgeReviews + .Select(r => new UpstreamReview + { + Reviewer = r.User?.Login ?? "unknown", + Verdict = MapReviewVerdict(r.State, r.Dismissed), + SubmittedAt = r.SubmittedAt, + }) + .ToList(); + + var requiredReviewers = (pull.RequestedReviewers ?? []) + .Select(u => u.Login) + .Where(l => !string.IsNullOrWhiteSpace(l)) + .Select(l => l!) + .ToList(); + + var baseBranch = BranchNameOf(pull.Base); + var requiredApprovals = await GetRequiredApprovalsAsync(config, token, baseBranch, ct); + + var approvers = new HashSet(StringComparer.OrdinalIgnoreCase); + var blocked = false; + foreach (var (review, verdict) in forgeReviews.Zip(reviews, (f, m) => (f, m.Verdict))) + { + if (review.Dismissed || verdict == UpstreamReviewVerdict.Dismissed) + continue; + // A stale approval predates the latest push; with + // dismiss-stale-approvals protections it no longer counts. + if (verdict == UpstreamReviewVerdict.Approved) + { + if (!review.Stale && review.User?.Login is { } login) + approvers.Add(login); + } + else if (verdict == UpstreamReviewVerdict.ChangesRequested) + { + blocked = true; + } + } + + return new UpstreamReviewState + { + Reviews = reviews, + RequiredReviewers = requiredReviewers, + RequiredApprovalCount = requiredApprovals, + RequirementsMet = approvers.Count >= requiredApprovals && !blocked, + }; + } + + /// + /// Commit checks from /commits/{sha}/status (combined) with + /// fallback to the /statuses list on instances without the + /// combined endpoint. RequiredChecksPassed follows the forge's + /// combined state when present; otherwise no failing check means pass. + /// + public async Task GetCheckResultsAsync( + string headSha, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(headSha)) + throw new ArgumentException("headSha must be non-empty", nameof(headSha)); + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + var encoded = Uri.EscapeDataString(headSha.Trim()); + + IReadOnlyList statuses; + string? combinedState; + using (var combined = await SendForgejoAsync( + HttpMethod.Get, config.ReposPath($"commits/{encoded}/status"), token, ct)) + { + if (combined.StatusCode == HttpStatusCode.NotFound) + { + // Old instances lack the combined endpoint: fall back to the + // statuses list. A 404 there means the sha itself is unknown, + // which is "unavailable" (null), not infrastructure. + try + { + statuses = await GetPagedAsync( + config, token, $"commits/{encoded}/statuses", ct); + } + catch (ForgejoUpstreamException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + combinedState = null; + } + else + { + await EnsureSuccessAsync(combined, "read combined commit status", ct); + var body = await DeserializeAsync(combined, ct); + statuses = body?.Statuses ?? []; + combinedState = body?.State; + } + } + var checks = statuses + .Select(s => new UpstreamCheckResult + { + Name = string.IsNullOrWhiteSpace(s.Context) ? "unknown" : s.Context, + State = MapCheckState(s.Status), + DetailsUrl = string.IsNullOrWhiteSpace(s.TargetUrl) ? null : s.TargetUrl, + Description = string.IsNullOrWhiteSpace(s.Description) ? null : s.Description, + }) + .ToList(); + + var requiredPassed = combinedState is not null + ? string.Equals(combinedState, "success", StringComparison.OrdinalIgnoreCase) + : checks.All(c => c.State != UpstreamCheckState.Failing); + + return new UpstreamCheckSummary { Checks = checks, RequiredChecksPassed = requiredPassed }; + } + + /// + /// Plain issue comments on the PR (pulls and issues share Forgejo's + /// numbering), oldest first via pagination. Forgejo has no code-anchored + /// equivalent on this endpoint, so returned comments never carry a file + /// or line — see . + /// + public async Task?> ListCommentsAsync( + int number, CancellationToken ct = default) + { + if (number <= 0) + throw new ArgumentOutOfRangeException(nameof(number), "Pull request number must be positive."); + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + IReadOnlyList comments; + try + { + comments = await GetPagedAsync( + config, token, $"issues/{number}/comments", ct); + } + catch (ForgejoUpstreamException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + return comments + .Select(c => new UpstreamComment + { + Id = c.Id.ToString(CultureInfo.InvariantCulture), + Author = c.User?.Login ?? "unknown", + Body = c.Body ?? string.Empty, + CreatedAt = c.CreatedAt, + }) + .ToList(); + } + + /// + /// Posts a plain top-level comment. File-anchored threads and replies + /// have no Forgejo equivalent on this endpoint, so those shapes return + /// null (unsupported) rather than being mislabelled as plain comments. + /// + public async Task PostCommentAsync( + int number, NewUpstreamComment comment, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(comment); + if (number <= 0) + throw new ArgumentOutOfRangeException(nameof(number), "Pull request number must be positive."); + if (comment.FilePath is not null || comment.Line is not null || comment.ReplyToId is not null) + { + _host.Logger.LogInformation( + "Forgejo: file-anchored and threaded comments are not supported; declining post on PR #{Number}", + number); + return null; + } + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var response = await SendForgejoAsync( + HttpMethod.Post, config.ReposPath($"issues/{number}/comments"), token, ct, + new { body = comment.Body }); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(response, "post comment", ct); + var created = await DeserializeAsync(response, ct); + if (created is null) + throw new ForgejoUpstreamException("Forgejo returned an unusable comment after posting."); + return new UpstreamComment + { + Id = created.Id.ToString(CultureInfo.InvariantCulture), + Author = created.User?.Login ?? "unknown", + Body = created.Body ?? comment.Body, + CreatedAt = created.CreatedAt, + }; + } + + /// + /// Repository webhooks (forgejo-type hooks). Only the repository + /// scope exists at this endpoint; other scopes are separate Forgejo + /// resources an operator manages, not this provider. + /// + public async Task?> ListWebhookSubscriptionsAsync( + CancellationToken ct = default) + { + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var response = await SendForgejoAsync(HttpMethod.Get, config.ReposPath("hooks"), token, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(response, "list webhook subscriptions", ct); + var hooks = await DeserializeAsync>(response, ct) ?? []; + + var result = new List(); + foreach (var hook in hooks) + { + var target = hook.TargetUrl; + if (string.IsNullOrWhiteSpace(target)) + { + _host.Logger.LogDebug("Forgejo: skipping hook id {Id} without a delivery URL", hook.Id); + continue; + } + result.Add(new UpstreamWebhookSubscription + { + Id = hook.Id.ToString(CultureInfo.InvariantCulture), + Scope = UpstreamWebhookScopes.Repository, + Events = hook.Events ?? [], + TargetUrl = target, + }); + } + return result; + } + + /// + /// Creates a repository webhook. Scope travels as an opaque string; only + /// repository maps to this endpoint, anything else returns null + /// (unsupported) rather than being coerced. Event names are forge-native + /// and passed through untouched. + /// + public async Task CreateWebhookSubscriptionAsync( + NewUpstreamWebhookSubscription subscription, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(subscription); + if (!string.Equals(subscription.Scope, UpstreamWebhookScopes.Repository, StringComparison.OrdinalIgnoreCase)) + { + _host.Logger.LogInformation( + "Forgejo: webhook scope '{Scope}' is not supported; only '{Repository}' is", + subscription.Scope, UpstreamWebhookScopes.Repository); + return null; + } + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var response = await SendForgejoAsync( + HttpMethod.Post, config.ReposPath("hooks"), token, ct, + new + { + type = "forgejo", + active = true, + config = new { url = subscription.TargetUrl, content_type = "json" }, + events = subscription.Events, + }); + await EnsureSuccessAsync(response, "create webhook subscription", ct); + var created = await DeserializeAsync(response, ct); + var target = created?.TargetUrl; + if (created is null || string.IsNullOrWhiteSpace(target)) + throw new ForgejoUpstreamException("Forgejo returned an unusable hook after creation."); + return new UpstreamWebhookSubscription + { + Id = created.Id.ToString(CultureInfo.InvariantCulture), + Scope = UpstreamWebhookScopes.Repository, + Events = created.Events ?? subscription.Events, + TargetUrl = target, + }; + } + + /// + /// Deletes a repository webhook. Null when the plugin has no scoped + /// repository, true when removed, false when the id is unknown. + /// + public async Task DeleteWebhookSubscriptionAsync( + string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) + throw new ArgumentException("id must be non-empty", nameof(id)); + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var response = await SendForgejoAsync( + HttpMethod.Delete, config.ReposPath($"hooks/{Uri.EscapeDataString(id.Trim())}"), token, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + await EnsureSuccessAsync(response, "delete webhook subscription", ct); + return true; + } + + /// + /// Default branch, visibility and branch protection rules for the scoped + /// repository. Protection rules degrade honestly: instances without the + /// endpoint (404) yield metadata with no rules, not an error. + /// + public async Task GetRepositoryMetadataAsync( + CancellationToken ct = default) + { + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + + using var repoResponse = await SendForgejoAsync(HttpMethod.Get, config.ReposPath(string.Empty).TrimEnd('/'), token, ct); + if (repoResponse.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(repoResponse, "read repository", ct); + var repo = await DeserializeAsync(repoResponse, ct); + if (repo is null) + throw new ForgejoUpstreamException("Forgejo returned an unusable repository object."); + + var protections = new List(); + using (var protectionsResponse = await SendForgejoAsync( + HttpMethod.Get, config.ReposPath("branch_protections"), token, ct)) + { + if (protectionsResponse.StatusCode != HttpStatusCode.NotFound) + { + await EnsureSuccessAsync(protectionsResponse, "list branch protections", ct); + var rules = await DeserializeAsync>( + protectionsResponse, ct) ?? []; + foreach (var rule in rules) + { + if (string.IsNullOrWhiteSpace(rule.Pattern)) + continue; + protections.Add(new UpstreamBranchProtection( + rule.Pattern, + Math.Max(0, rule.RequiredApprovals), + rule.EnableStatusCheck)); + } + } + else + { + _host.Logger.LogDebug( + "Forgejo: branch_protections endpoint unavailable; returning metadata without rules"); + } + } + + return new UpstreamRepositoryMetadata + { + DefaultBranch = string.IsNullOrWhiteSpace(repo.DefaultBranch) ? null : repo.DefaultBranch, + Visibility = repo.Private ? UpstreamRepositoryVisibility.Private + : repo.Internal ? UpstreamRepositoryVisibility.Internal + : UpstreamRepositoryVisibility.Public, + BranchProtections = protections, + }; + } + + // ------------------------------------------------------------------ + // Forgejo-to-contract mappings (pure) + // ------------------------------------------------------------------ + + internal static string? BranchNameOf(ForgejoBranchInfo? branch) + { + if (branch is null) + return null; + if (!string.IsNullOrWhiteSpace(branch.Ref)) + return branch.Ref; + // label is "owner:branch"; the suffix is the branch in this repo. + if (!string.IsNullOrWhiteSpace(branch.Label)) + { + var label = branch.Label; + var colon = label.LastIndexOf(':'); + var name = colon >= 0 ? label[(colon + 1)..] : label; + if (!string.IsNullOrWhiteSpace(name)) + return name; + } + return null; + } + + internal static UpstreamReviewVerdict MapReviewVerdict(string? state, bool dismissed) + { + if (dismissed) + return UpstreamReviewVerdict.Dismissed; + return state?.ToUpperInvariant() switch + { + "APPROVED" => UpstreamReviewVerdict.Approved, + "CHANGES_REQUESTED" or "REQUEST_CHANGES" or "REJECTED" => UpstreamReviewVerdict.ChangesRequested, + "COMMENT" or "COMMENTED" => UpstreamReviewVerdict.Commented, + "PENDING" or "REQUEST_REVIEW" or null or "" => UpstreamReviewVerdict.Pending, + // A review that neither approves nor blocks is surfaced as a + // comment rather than dropped; the raw state is forge-native. + _ => UpstreamReviewVerdict.Commented, + }; + } + + internal static UpstreamCheckState MapCheckState(string? state) => + state?.ToLowerInvariant() switch + { + "success" => UpstreamCheckState.Passing, + "pending" => UpstreamCheckState.Pending, + "failure" or "error" => UpstreamCheckState.Failing, + "warning" or "skipped" => UpstreamCheckState.Neutral, + // Unknown strings carry no outcome: pending, not neutral. + _ => UpstreamCheckState.Pending, + }; + + private async Task GetRequiredApprovalsAsync( + ForgejoEndpointConfig config, string? token, string? baseBranch, CancellationToken ct) + { + if (string.IsNullOrEmpty(baseBranch)) + return 0; + using var response = await SendForgejoAsync( + HttpMethod.Get, config.ReposPath("branch_protections"), token, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return 0; + await EnsureSuccessAsync(response, "list branch protections", ct); + var rules = await DeserializeAsync>(response, ct) ?? []; + var quorum = 0; + foreach (var rule in rules) + { + if (rule.Pattern is null || !ProtectionMatchesBranch(rule.Pattern, baseBranch)) + continue; + quorum = Math.Max(quorum, Math.Max(0, rule.RequiredApprovals)); + } + return quorum; + } + + internal static bool ProtectionMatchesBranch(string pattern, string branch) + { + if (string.Equals(pattern, branch, StringComparison.Ordinal)) + return true; + // Forgejo rule names are usually exact; tolerate a trailing wildcard. + if (pattern.EndsWith('*')) + return branch.StartsWith(pattern[..^1], StringComparison.Ordinal); + return false; + } +} diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.cs b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.cs new file mode 100644 index 000000000..f55a07195 --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoUpstreamRemote.cs @@ -0,0 +1,687 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using CodeyBox.Core; +using CodeyBox.PluginSdk; +using Microsoft.Extensions.Logging; + +namespace CodeyBox.ForgejoUpstreamPlugin; + +/// +/// Upstream remote for Forgejo (self-hosted). One project, one plugin. +/// Off unless an operator enables it: the assembly must be allowlisted and a +/// project must set Upstream.Kind = "forgejo". +/// +/// Core lifecycle (via ): pushes the work +/// branch through the host git module, opens a pull request with Forgejo's +/// /api/v1/repos/{owner}/{repo}/pulls endpoint, optionally auto-merges +/// it, merges upstream branches for release sync, fetches base branches, and +/// lists/reads open PRs. Extended surfaces (reviews, checks, comments, +/// webhooks, metadata) live in ForgejoUpstreamRemote.Extended.cs. +/// +/// Configuration: per-project Upstream.PluginConfig keys +/// BaseUrl, Owner, Repository (optional PageSize, +/// MaxListPages), read via +/// IUpstreamPluginHost.GetProjectUpstreamConfig. Calls without a +/// project context (sweeps, reads, merges) fall back to the plugin-scoped +/// CodeyBox:Plugins:codeybox.forgejo-upstream section. Credentials +/// never come from configuration: the token is read from the environment +/// variable named by Upstream.TokenEnvVar (forwarded on the completion +/// request) and is held only by this remote — sandboxes never see it. +/// +[CodeyBoxPlugin( + id: "codeybox.forgejo-upstream", + displayName: "Forgejo Upstream Remote", + minHostApiVersion: "1.0")] +public sealed partial class ForgejoUpstreamRemote : IUpstreamRemote, IPluginInitializer +{ + public const string HttpClientName = "forgejo-upstream"; + + private const int DefaultPageSize = 50; + private const int DefaultMaxListPages = 10; + private const int MaxTitleLength = 512; + private const int MaxBodyLength = 100_000; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IGitHost _gitHost; + private readonly IHttpClientFactory _httpClientFactory; + + private IPluginHost _host = null!; + private IUpstreamPluginHost _upstreamHost = null!; + + public ForgejoUpstreamRemote(IGitHost gitHost, IHttpClientFactory httpClientFactory) + { + _gitHost = gitHost; + _httpClientFactory = httpClientFactory; + } + + public string Name => "forgejo"; + + public Task InitializeAsync(PluginContext context, CancellationToken ct = default) + { + _host = context.Host; + _upstreamHost = _host as IUpstreamPluginHost + ?? throw new InvalidOperationException( + "Forgejo upstream remote requires a host exposing IUpstreamPluginHost."); + context.Logger.LogInformation("ForgejoUpstreamRemote initialized"); + return Task.CompletedTask; + } + + // Push-only flows carry no project context, so per-project PluginConfig + // (BaseUrl/Owner/Repository) is unreachable here. The orchestrator's + // primary path is CompleteAsync, which pushes the work branch itself. + public Task PushAsync( + string repositoryId, string branch, CancellationToken ct = default) + => Task.FromResult(new UpstreamPushResult( + false, "push-only not supported by this plugin; use CompleteAsync")); + + /// + /// Full Forgejo completion flow: push work branch, open a PR (or reuse + /// from + /// a prior race-recovery attempt), optionally auto-merge it. + /// Transient forge failures throw so the orchestrator retries; soft + /// outcomes (PR already exists, merge blocked) return partial results. + /// + public async Task CompleteAsync( + UpstreamCompletionRequest request, CancellationToken ct = default) + { + ValidateBranch(request.WorkBranch, nameof(request.WorkBranch)); + ValidateBranch(request.BaseBranch, nameof(request.BaseBranch)); + + var config = ResolveProjectConfig(request.ProjectId); + var token = ResolveToken(request.TokenEnvVar); + if (token is null) + _host.Logger.LogWarning( + "Forgejo project {Project}: no token resolved; attempting anonymous access", + request.ProjectId); + + using var auth = ForgejoGitAuthScope.Create(token); + try + { + await _gitHost.PushToUpstreamAsync( + request.RepositoryId, + config.GitUrl, + request.WorkBranch, + auth.Environment, + ToReconcileStrategy(request.MergeMethod), + ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new ForgejoUpstreamException( + $"Failed to push work branch '{SanitizeForLog(request.WorkBranch)}': {Scrub(ex.Message, token)}", + ex); + } + + var mergeMethod = ToForgejoMergeAction(request.MergeMethod); + long prNumber; + string? prUrl; + if (request.ExistingPullRequestNumber is { } existing) + { + var reused = await GetPullAsync(config, token, existing, ct) + ?? throw new ForgejoUpstreamException( + $"Forgejo PR #{existing} from a prior attempt is no longer available."); + prNumber = reused.EffectiveNumber; + prUrl = reused.HtmlUrl ?? config.PullUrl(prNumber); + } + else + { + var created = await CreatePullAsync(config, token, request, ct); + if (created is null) + { + return new UpstreamCompletionOutcome + { + BranchPushed = true, + Notes = "PR creation skipped (branch already has an open PR); leaving it for a human", + }; + } + + prNumber = created.EffectiveNumber; + prUrl = created.HtmlUrl ?? config.PullUrl(prNumber); + _host.Logger.LogInformation("Forgejo PR #{Number} opened: {Url}", prNumber, prUrl); + } + + if (!request.AutoMerge) + { + return new UpstreamCompletionOutcome + { + BranchPushed = true, + PullRequestUrl = prUrl, + PullRequestNumber = (int)prNumber, + }; + } + + var (mergedSha, mergeNotes) = await MergePullAsync(config, token, prNumber, mergeMethod, ct); + if (mergedSha is not null) + _host.Logger.LogInformation("Forgejo PR #{Number} auto-merged: {Sha}", prNumber, mergedSha); + + return new UpstreamCompletionOutcome + { + BranchPushed = true, + PullRequestUrl = prUrl, + PullRequestNumber = (int)prNumber, + MergedSha = mergedSha, + Notes = mergeNotes, + }; + } + + /// + /// Merges into + /// on the Forgejo instance via a host-side temp clone (Forgejo exposes no + /// server-side branch-to-branch merge; the release flow only reaches this + /// provider through the contract, which carries no project context, so the + /// plugin-scoped repository is used). Returns false on merge conflict, + /// throws on infrastructure failures. + /// + public async Task TryMergeUpstreamBranchAsync( + string targetBranch, string sourceBranch, CancellationToken ct = default) + { + ValidateBranch(targetBranch, nameof(targetBranch)); + ValidateBranch(sourceBranch, nameof(sourceBranch)); + var config = ResolveScopedConfig() + ?? throw new InvalidOperationException( + "Forgejo upstream merge requires plugin-scoped BaseUrl/Owner/Repository " + + "under CodeyBox:Plugins:codeybox.forgejo-upstream."); + var token = ResolveToken(null); + + var stagingRoot = Path.Combine(Path.GetTempPath(), "codeybox-forgejo-sync-" + Guid.NewGuid().ToString("N")[..8]); + var cloneDir = Path.Combine(stagingRoot, "repo"); + try + { + Directory.CreateDirectory(cloneDir); + using var auth = ForgejoGitAuthScope.Create(token); + + var clone = await ForgejoGitRunner.RunAsync( + stagingRoot, auth.Environment, ct, + "clone", "--branch", targetBranch, "--single-branch", "--", config.GitUrl, cloneDir); + if (clone.ExitCode != 0) + throw new ForgejoUpstreamException( + $"Forgejo git clone of '{SanitizeForLog(targetBranch)}' failed: {Scrub(clone.Stderr, token)}"); + + var fetch = await ForgejoGitRunner.RunAsync( + cloneDir, auth.Environment, ct, "fetch", "origin", sourceBranch); + if (fetch.ExitCode != 0) + throw new ForgejoUpstreamException( + $"Forgejo git fetch of '{SanitizeForLog(sourceBranch)}' failed: {Scrub(fetch.Stderr, token)}"); + + var merge = await ForgejoGitRunner.RunAsync( + cloneDir, auth.Environment, ct, "merge", "FETCH_HEAD", "--no-edit", "--no-ff"); + if (merge.ExitCode != 0) + { + await ForgejoGitRunner.RunAsync(cloneDir, auth.Environment, ct, "merge", "--abort"); + return false; + } + + var push = await ForgejoGitRunner.RunAsync( + cloneDir, auth.Environment, ct, "push", "origin", targetBranch); + if (push.ExitCode != 0) + throw new ForgejoUpstreamException( + $"Forgejo git push of '{SanitizeForLog(targetBranch)}' failed: {Scrub(push.Stderr, token)}"); + + return true; + } + finally + { + try + { + if (Directory.Exists(stagingRoot)) + Directory.Delete(stagingRoot, recursive: true); + } + catch + { + // Best-effort cleanup of a temp directory. + } + } + } + + /// + /// Fetches from the plugin-scoped Forgejo + /// repository into the host bare repo. Returns null when the plugin is + /// not scoped to a repository (unsupported, non-fatal) or the branch is + /// not advertised upstream. + /// + public async Task FetchBaseBranchAsync( + string repositoryId, string baseBranch, CancellationToken ct = default) + { + var config = ResolveScopedConfig(); + if (config is null) + return null; + var token = ResolveToken(null); + using var auth = ForgejoGitAuthScope.Create(token); + try + { + return await _gitHost.FetchUpstreamBranchAsync( + repositoryId, config.GitUrl, baseBranch, auth.Environment, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new ForgejoUpstreamException( + $"Failed to fetch base branch '{SanitizeForLog(baseBranch)}' from Forgejo: {Scrub(ex.Message, token)}", + ex); + } + } + + // ------------------------------------------------------------------ + // Pull request core: create / read / merge / list + // ------------------------------------------------------------------ + + private async Task CreatePullAsync( + ForgejoEndpointConfig config, string? token, UpstreamCompletionRequest request, CancellationToken ct) + { + using var response = await SendForgejoAsync( + HttpMethod.Post, config.ReposPath("pulls"), token, ct, + new + { + title = Truncate(request.Title, MaxTitleLength), + body = Truncate(request.Description ?? string.Empty, MaxBodyLength), + head = request.WorkBranch, + @base = request.BaseBranch, + }); + + // The branch already has an open PR (or head==base / no diff): a soft + // outcome, not an infrastructure failure — leave it for a human. + if (response.StatusCode is HttpStatusCode.Conflict or HttpStatusCode.UnprocessableEntity) + { + _host.Logger.LogWarning( + "Forgejo: PR create for branch '{Branch}' returned {Status}; treating as already-exists", + SanitizeForLog(request.WorkBranch), (int)response.StatusCode); + return null; + } + + await EnsureSuccessAsync(response, "create pull request", ct); + return await ReadPullAsync(response, ct); + } + + private async Task GetPullAsync( + ForgejoEndpointConfig config, string? token, long number, CancellationToken ct) + { + using var response = await SendForgejoAsync( + HttpMethod.Get, config.ReposPath($"pulls/{number}"), token, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await EnsureSuccessAsync(response, "read pull request", ct); + return await ReadPullAsync(response, ct); + } + + private async Task<(string? Sha, string? Notes)> MergePullAsync( + ForgejoEndpointConfig config, string? token, long number, string doAction, CancellationToken ct) + { + using var response = await SendForgejoAsync( + HttpMethod.Post, config.ReposPath($"pulls/{number}/merge"), token, ct, + new { Do = doAction }); + + // Not mergeable right now (checks pending, conflicts, protection): + // soft outcome — leave the PR open for a human. + if (response.StatusCode is HttpStatusCode.MethodNotAllowed or HttpStatusCode.Conflict) + { + _host.Logger.LogWarning( + "Forgejo POST /pulls/{Number}/merge returned {Status}; leaving PR open", + number, (int)response.StatusCode); + return (null, "Auto-merge blocked (PR not mergeable or branch protection); PR left open"); + } + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ForgejoUpstreamException($"Forgejo PR #{number} was not found for merging."); + + await EnsureSuccessAsync(response, "merge pull request", ct); + + var refreshed = await GetPullAsync(config, token, number, ct); + if (refreshed?.Merged == true) + return (refreshed.MergeCommitSha, null); + return (null, "Merge call succeeded but the PR does not report merged; leaving it for a human"); + } + + // ------------------------------------------------------------------ + // Configuration + // ------------------------------------------------------------------ + + internal sealed record ForgejoEndpointConfig( + string ApiBaseUrl, string WebBaseUrl, string Owner, string Repository, int PageSize, int MaxListPages) + { + public string ReposPath(string relative) => + $"repos/{Uri.EscapeDataString(Owner)}/{Uri.EscapeDataString(Repository)}/{relative}"; + + public string GitUrl => $"{WebBaseUrl}/{Uri.EscapeDataString(Owner)}/{Uri.EscapeDataString(Repository)}.git"; + + public string PullUrl(long number) => + $"{WebBaseUrl}/{Owner}/{Repository}/pulls/{number}"; + } + + private ForgejoEndpointConfig ResolveProjectConfig(ProjectId projectId) + { + var project = _upstreamHost.GetProjectUpstreamConfig(projectId); + var scoped = _host.ScopedConfig; + string? Get(string key) => + project.TryGetValue(key, out var v) && !string.IsNullOrWhiteSpace(v) + ? v + : scoped[key]; + + if (!TryBuildConfig(Get("BaseUrl"), Get("Owner"), Get("Repository"), Get("PageSize"), Get("MaxListPages"), + out var config, out var error)) + throw new InvalidOperationException($"Project {projectId}: Forgejo upstream {error}"); + return config; + } + + private ForgejoEndpointConfig? ResolveScopedConfig() + { + var scoped = _host.ScopedConfig; + if (!TryBuildConfig(scoped["BaseUrl"], scoped["Owner"], scoped["Repository"], + scoped["PageSize"], scoped["MaxListPages"], out var config, out _)) + return null; + return config; + } + + private static bool TryBuildConfig( + string? baseUrl, string? owner, string? repository, string? pageSize, string? maxPages, + out ForgejoEndpointConfig config, out string error) + { + config = null!; + error = string.Empty; + if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(owner) || string.IsNullOrWhiteSpace(repository)) + { + error = "requires BaseUrl, Owner and Repository (Upstream.PluginConfig, falling back to plugin-scoped config)"; + return false; + } + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var apiUri) + || (apiUri.Scheme != Uri.UriSchemeHttp && apiUri.Scheme != Uri.UriSchemeHttps)) + { + error = $"BaseUrl '{baseUrl}' must be an absolute http(s) URL"; + return false; + } + if (!string.IsNullOrEmpty(apiUri.UserInfo)) + { + error = "BaseUrl must not embed credentials"; + return false; + } + + // Accept the instance root or the API root; normalize to the API base. + var apiBase = apiUri.ToString().TrimEnd('/'); + if (!apiBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase)) + apiBase += "/api/v1"; + var webBase = apiBase.EndsWith("/api/v1", StringComparison.OrdinalIgnoreCase) + ? apiBase[..^"/api/v1".Length] + : apiBase; + + if (owner.Any(c => char.IsWhiteSpace(c) || (char.IsControl(c) && c != '\t')) + || repository.Any(c => char.IsWhiteSpace(c) || (char.IsControl(c) && c != '\t'))) + { + error = "Owner and Repository must not contain whitespace or control characters"; + return false; + } + + if (!TryBoundedInt(pageSize, DefaultPageSize, 1, 100, out var pageSizeValue)) + { + error = "PageSize must be an integer 1..100"; + return false; + } + if (!TryBoundedInt(maxPages, DefaultMaxListPages, 1, 50, out var maxPagesValue)) + { + error = "MaxListPages must be an integer 1..50"; + return false; + } + + config = new ForgejoEndpointConfig(apiBase, webBase, owner.Trim(), repository.Trim(), pageSizeValue, maxPagesValue); + return true; + } + + private static bool TryBoundedInt(string? raw, int @default, int min, int max, out int value) + { + if (string.IsNullOrWhiteSpace(raw)) + { + value = @default; + return true; + } + if (!int.TryParse(raw.Trim(), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out value) + || value < min || value > max) + return false; + return true; + } + + private string? ResolveToken(string? requestTokenEnvVar) + { + var name = !string.IsNullOrWhiteSpace(requestTokenEnvVar) + ? requestTokenEnvVar + : _host.ScopedConfig["TokenEnvVar"]; + if (string.IsNullOrWhiteSpace(name)) + return null; + return Environment.GetEnvironmentVariable(name); + } + + // ------------------------------------------------------------------ + // HTTP plumbing: auth, failure classification, pagination + // ------------------------------------------------------------------ + + private async Task SendForgejoAsync( + HttpMethod method, string relativePath, string? token, CancellationToken ct, object? body = null) + { + var client = _httpClientFactory.CreateClient(HttpClientName); + using var request = new HttpRequestMessage(method, relativePath); + request.Headers.Accept.ParseAdd("application/json"); + if (!string.IsNullOrEmpty(token)) + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("token", token); + if (body is not null) + request.Content = JsonContent.Create(body, options: JsonOptions); + + HttpResponseMessage response; + try + { + response = await client.SendAsync(request, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or OperationCanceledException) + { + throw new ForgejoUpstreamException( + $"Forgejo instance unreachable: {Scrub(ex.Message, token)}", ex); + } + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + response.Dispose(); + throw new ForgejoUpstreamException( + "Forgejo rejected the request as unauthorised (401): check the token and its scopes.", + HttpStatusCode.Unauthorized); + } + if (response.StatusCode == HttpStatusCode.Forbidden && IsRateLimited(response)) + { + var retryAfter = ParseRetryAfter(response); + response.Dispose(); + throw new ForgejoUpstreamException( + $"Forgejo rate limit exceeded{(retryAfter is null ? string.Empty : $"; retry after {retryAfter}s")}.", + HttpStatusCode.Forbidden, retryAfter); + } + if (response.StatusCode == HttpStatusCode.Forbidden) + { + response.Dispose(); + throw new ForgejoUpstreamException( + "Forgejo forbade the request (403): the token lacks permission for this operation.", + HttpStatusCode.Forbidden); + } + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + var retryAfter = ParseRetryAfter(response); + response.Dispose(); + throw new ForgejoUpstreamException( + $"Forgejo rate limit exceeded{(retryAfter is null ? string.Empty : $"; retry after {retryAfter}s")}.", + HttpStatusCode.TooManyRequests, retryAfter); + } + if ((int)response.StatusCode >= 500) + { + response.Dispose(); + throw new ForgejoUpstreamException( + $"Forgejo instance failed with {(int)response.StatusCode} {response.StatusCode}.", + response.StatusCode); + } + + return response; + } + + private static bool IsRateLimited(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("X-RateLimit-Remaining", out var values)) + { + foreach (var value in values) + { + if (value.Trim() == "0") + return true; + } + } + return false; + } + + private static int? ParseRetryAfter(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Retry-After", out var values)) + return null; + foreach (var value in values) + { + if (int.TryParse(value.Trim(), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var seconds) && seconds >= 0) + return seconds; + if (DateTimeOffset.TryParse(value.Trim(), out var date)) + { + var delta = date - DateTimeOffset.UtcNow; + return delta.TotalSeconds > 0 ? (int)delta.TotalSeconds : 0; + } + } + return null; + } + + private async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + var detail = await ReadErrorDetailAsync(response, ct); + throw new ForgejoUpstreamException( + $"Forgejo {operation} failed with {(int)response.StatusCode} {response.StatusCode}{detail}.", + response.StatusCode); + } + + private static async Task ReadErrorDetailAsync(HttpResponseMessage response, CancellationToken ct) + { + try + { + var body = await response.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(body)) + return string.Empty; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("message", out var message) + && message.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(message.GetString())) + return $": {Truncate(message.GetString()!, 300)}"; + } + catch (JsonException) + { + // Fall through to the length-only detail below. + } + return $" (response body {body.Length} chars, not machine-readable)"; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + return string.Empty; + } + } + + private async Task> GetPagedAsync( + ForgejoEndpointConfig config, string? token, string pathAndQuery, CancellationToken ct) + { + var items = new List(); + for (var page = 1; page <= config.MaxListPages; page++) + { + var separator = pathAndQuery.Contains('?') ? "&" : "?"; + using var response = await SendForgejoAsync( + HttpMethod.Get, + $"{pathAndQuery}{separator}page={page}&limit={config.PageSize}", + token, ct); + await EnsureSuccessAsync(response, "list paged results", ct); + var pageItems = await DeserializeAsync>(response, ct) ?? []; + items.AddRange(pageItems); + // A short page means the forge has nothing more; without this the + // provider would either stop early (partial answer) or loop + // pointlessly. The MaxListPages cap bounds the total instead. + if (pageItems.Count < config.PageSize) + break; + } + return items; + } + + private static async Task DeserializeAsync(HttpResponseMessage response, CancellationToken ct) + { + var body = await response.Content.ReadAsStringAsync(ct); + if (string.IsNullOrWhiteSpace(body)) + return default; + try + { + return JsonSerializer.Deserialize(body, JsonOptions); + } + catch (JsonException ex) + { + throw new ForgejoUpstreamException( + $"Forgejo returned a response this provider cannot parse: {ex.Message}", ex); + } + } + + private static async Task ReadPullAsync(HttpResponseMessage response, CancellationToken ct) + { + var pull = await DeserializeAsync(response, ct); + if (pull is null || pull.EffectiveNumber <= 0) + throw new ForgejoUpstreamException("Forgejo returned a pull request without a usable number."); + return pull; + } + + // ------------------------------------------------------------------ + // Small pure helpers + // ------------------------------------------------------------------ + + private static void ValidateBranch(string branch, string paramName) + { + static bool HasInvalidChars(string s) => + s.Any(c => char.IsWhiteSpace(c) || (char.IsControl(c) && c != '\t')); + if (string.IsNullOrEmpty(branch) || HasInvalidChars(branch)) + throw new ArgumentException( + $"Branch contains invalid characters (whitespace/control chars not allowed): '{SanitizeForLog(branch)}'", + paramName); + } + + private static string ToForgejoMergeAction(string mergeMethod) => + mergeMethod.ToLowerInvariant() switch + { + "merge" => "merge", + "squash" => "squash", + "rebase" => "rebase", + _ => throw new InvalidOperationException( + $"Upstream MergeMethod '{SanitizeForLog(mergeMethod)}' is invalid for Forgejo; valid values: merge, squash, rebase"), + }; + + private static UpstreamPushReconcileStrategy ToReconcileStrategy(string mergeMethod) => + mergeMethod.Equals("rebase", StringComparison.OrdinalIgnoreCase) + ? UpstreamPushReconcileStrategy.Rebase + : UpstreamPushReconcileStrategy.Merge; + + private static string SanitizeForLog(string? value) + { + if (string.IsNullOrEmpty(value)) + return "(empty)"; + var scrubbed = new string(value.Select(c => char.IsControl(c) ? '?' : c).ToArray()); + return scrubbed.Length > 256 ? scrubbed[..256] + "…" : scrubbed; + } + + private static string Scrub(string message, string? token) => + string.IsNullOrEmpty(token) ? message : message.Replace(token, "[redacted]", StringComparison.Ordinal); + + private static string Truncate(string value, int maxLength) => + value.Length > maxLength ? value[..maxLength] : value; +} diff --git a/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/README.md b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/README.md new file mode 100644 index 000000000..c164f6ea7 --- /dev/null +++ b/plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/README.md @@ -0,0 +1,155 @@ +# CodeyBox: Forgejo Upstream (`codeybox.forgejo-upstream`) + +First-class upstream remote for [Forgejo](https://forgejo.org) (self-hosted). +One project, one plugin. **Off unless an operator enables it.** + +## Enablement + +1. Add this assembly to `CodeyBox:Plugins:AssemblyPaths`. +2. Add `codeybox.forgejo-upstream` to `CodeyBox:Plugins:Allowlist`. +3. Set `Upstream.Kind = "forgejo"` on the project. + +```json +{ + "CodeyBox": { + "Plugins": { + "Allowlist": ["codeybox.forgejo-upstream"], + "AssemblyPaths": ["/opt/codeybox/plugins/CodeyBox.ForgejoUpstreamPlugin.dll"] + }, + "Projects": [ + { + "Id": "my-app", + "RepositoryUrl": "https://forge.example.com/team/repo.git", + "Upstream": { + "Kind": "forgejo", + "TokenEnvVar": "FORGEJO_TOKEN", + "AutoMerge": true, + "MergeMethod": "squash", + "PluginConfig": { + "BaseUrl": "https://forge.example.com/api/v1", + "Owner": "team", + "Repository": "repo" + } + } + } + ] + } +} +``` + +## Configuration + +Per-project `Upstream.PluginConfig` keys (highest precedence): + +| Key | Required | Meaning | +|-----|----------|---------| +| `BaseUrl` | yes | Forgejo **API** base, e.g. `https://forge.example.com/api/v1`. The instance root is accepted too (`/api/v1` is appended). `http` is allowed — LAN instances are the norm for self-hosted forges. Must not embed credentials. | +| `Owner` | yes | Repository owner (user or organisation). | +| `Repository` | yes | Repository name. | +| `PageSize` | no | Items per API page, 1–100 (default 50, the Forgejo default). | +| `MaxListPages` | no | Page cap per listing, 1–50 (default 10). Bounds every list so a large repository yields a bounded answer, never an unbounded buffer. | + +Calls that carry no project context — base-branch fetch, PR listing/reads, +release-sync merges, and all extended read surfaces — fall back to the +plugin-scoped section `CodeyBox:Plugins:codeybox.forgejo-upstream` with the +same keys (plus `TokenEnvVar`, naming the env var that holds the token). +Single-instance operators can therefore configure the repository once; +multi-repository operators should use per-project `PluginConfig` for the +write path and point the scoped fallback at the repository they want swept. + +### Credentials + +The token **never** appears in configuration files. The operator puts a +Forgejo API token (scope `write:repository` for PR write/merge, `read` +scopes suffice for read-only surfaces) in an environment variable, names it +in `Upstream.TokenEnvVar`, and the orchestrator forwards only the *name*. +This remote reads the value with +`Environment.GetEnvironmentVariable(name)` at call time, sends it as an +`Authorization: token …` header (Forgejo's documented scheme) and via a +short-lived `GIT_ASKPASS` script for git pushes. The value is redacted from +every error message, never logged, and never mounted into a sandbox — the +plugin references no sandbox assembly at all (covered by test). + +## What is supported + +Core lifecycle (`IUpstreamRemote`): + +- Push work branch (inside `CompleteAsync`), open PR, optionally auto-merge. +- Reuse of `ExistingPullRequestNumber` for the orchestrator's race-recovery + re-run (create is skipped, the still-open PR is merged). +- Release-sync branch merge via a host-side temp clone (`TryMergeUpstreamBranchAsync`). +- Base-branch fetch (`FetchBaseBranchAsync`); PR listing with prefix filter + and forge-computed mergeability; PR state reads (open/closed/merged + + merge sha). + +Extended surfaces (all genuinely Forgejo API v1): + +- **Reviews** — individual verdicts plus requested reviewers and a + protection-derived quorum. `RequirementsMet` counts non-dismissed, + non-stale approvals against the base branch's `required_approvals` and + treats an outstanding change request as blocking (the common Forgejo + setup; see approximations below). +- **Checks** — commit statuses plus the forge-computed combined state, + which is what gates `RequiredChecksPassed`. +- **Comments** — plain issue comments, list and post. +- **Webhooks** — repository hooks, list/create/delete. +- **Repository metadata** — default branch, visibility + (`public`/`private`/`internal`), branch protection rules. + +Failure classification: unreachable / unauthorised / forbidden / +rate-limited / 5xx throw `ForgejoUpstreamException` (infrastructure — the +orchestrator retries, never a verdict on the diff). PR-already-exists +(409/422 on create) and merge-blocked (405/409 on merge) return partial +results with `Notes`. + +## What is NOT supported (by design) + +- **File-anchored and threaded comments** — Forgejo's issue-comment + endpoint has no code anchor or reply concept. `PostCommentAsync` with + `FilePath`/`Line`/`ReplyToId` returns `null` (unsupported), never a + mislabelled plain comment. +- **Non-repository webhook scopes** (`organization`, `user`, `system`) — + those are separate Forgejo resources; requests return `null`. +- **Releases/tags** — left on the contract default (`null`); out of scope + for this provider. +- **Push-only `PushAsync`** — carries no project context, so it reports + "use `CompleteAsync`", like the reference sample plugin. +- **`work_item.pull_request_opened` webhook emission** — the provider does + not publish orchestrator webhooks; operators subscribe on the Forgejo + side or via the created-PR URL in the work item record. + +Unsupported is always non-fatal: callers log and continue. `null` means +"this forge cannot tell you"; a non-null empty list means "supported and +empty" — never confuse the two when gating a merge. + +## Approximations (Forgejo concepts without a 1:1 contract field) + +- Review `RequirementsMet` assumes rejected reviews block (Forgejo's + `block_on_rejected_reviews`, commonly on) and approvals are non-stale. +- `RequiredChecksPassed` follows the forge's combined status; without a + combined state it means "no failing check". +- PR merge-style mapping: `merge`→`merge`, `squash`→`squash`, + `rebase`→`rebase` (Forgejo's `rebase-merge` is not used). + +## Instance version requirements + +Developed against Forgejo API v1 (verified against the live +`try.next.forgejo.org` swagger, Forgejo 15). The provider degrades honestly +on older instances: a missing endpoint (404) yields `null`/empty metadata, +never an error. Combined commit status (`/commits/{ref}/status`) and +`branch_protections` are the newest endpoints used; instances without them +fall back to the statuses list and protection-less metadata. Forgejo and +Gitea share ancestry but diverge — this provider targets Forgejo's own +surface (`type: "forgejo"` hooks, Forgejo token scopes) and is not tested +against Gitea. + +## Verification without a live instance + +No live Forgejo instance exists in CI, so integration coverage uses +recorded-shape fixtures (`tests/CodeyBox.Tests/Fixtures/Forgejo/`, +transcribed from the live swagger on 2026-09-21) asserting that real +response shapes parse into the provider's contract mappings, plus +queue-driven tests for every lifecycle and failure path. To run against a +real instance, point a scratch project at it and exercise +push → open → read → auto-merge; the provider logs PR numbers and URLs at +`Information` for correlation. diff --git a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj index 96833d250..e814e1151 100644 --- a/tests/CodeyBox.Tests/CodeyBox.Tests.csproj +++ b/tests/CodeyBox.Tests/CodeyBox.Tests.csproj @@ -59,6 +59,7 @@ +