Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CodeyBox.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
<Project Path="plugins/credentials/CodeyBox.InfisicalPlugin/CodeyBox.InfisicalPlugin.csproj" />
<Project Path="plugins/upstream/CodeyBox.AzureDevOpsUpstreamPlugin/CodeyBox.AzureDevOpsUpstreamPlugin.csproj" />
<Project Path="plugins/upstream/CodeyBox.GiteaUpstreamPlugin/CodeyBox.GiteaUpstreamPlugin.csproj" />
<Project Path="plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/CodeyBox.ForgejoUpstreamPlugin.csproj" />
<Project Path="plugins/notifications/CodeyBox.SlackPlugin/CodeyBox.SlackPlugin.csproj" />
</Folder>
<Folder Name="/tests/">
Expand Down
12 changes: 12 additions & 0 deletions docs/extending/upstream-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,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`.
Expand Down
2 changes: 1 addition & 1 deletion plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ sit alongside them rather than buried among them.
| `quota` | Quota probes, reset notifiers, and quota usage telemetry (e.g. `CodeyBox.OpencodeGoQuotaPlugin`, `CodeyBox.QuotaResetNotifier`, `CodeyBox.StatisticsPlugin`) |
| `telemetry` | Metric samplers (`IMetricSampler`) outside the quota domain |
| `test-runners` | Test-execution plugins (`ITestRunnerAuditor`, e.g. `CodeyBox.DotnetTestRunnerPlugin`) |
| `upstream` | Upstream-remote forge providers (`IUpstreamRemote`, e.g. `CodeyBox.GiteaUpstreamPlugin`, `CodeyBox.AzureDevOpsUpstreamPlugin`) |
| `upstream` | Upstream-remote forge providers (`IUpstreamRemote`, e.g. `CodeyBox.GiteaUpstreamPlugin`, `CodeyBox.AzureDevOpsUpstreamPlugin`, `CodeyBox.ForgejoUpstreamPlugin`) |
| `work-sync` | External work-tracker sync (e.g. `CodeyBox.LinearWorkSyncPlugin`, `CodeyBox.PlaneWorkSyncPlugin`) |

The authoritative list is the `RecognisedGroups` set in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
First-class upstream remote for Forgejo (self-hosted).

Rules (same as all CodeyBox plugins):
- Reference CodeyBox.PluginSdk only (which transitively pulls CodeyBox.Core).
- Never reference CodeyBox.Orchestrator, CodeyBox.Api, or implementation
assemblies such as CodeyBox.Git (the plugin load context is isolated).
- Additional NuGet packages are allowed for HTTP client functionality.

One project, one plugin. Off unless an operator enables it: the assembly
must be listed in CodeyBox:Plugins:AssemblyPaths, the plugin id
"codeybox.forgejo-upstream" must pass the allowlist, and a project must
set Upstream.Kind = "forgejo".

See README.md for operator documentation.
-->

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="CodeyBox.Tests" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CodeyBox.Core\CodeyBox.Core.csproj" />
<ProjectReference Include="..\..\..\src\CodeyBox.PluginSdk\CodeyBox.PluginSdk.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<ForgejoUser>? 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<ForgejoCommitStatus>? 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<string>? Events,
[property: JsonPropertyName("active")] bool Active,
[property: JsonPropertyName("url")] string? Url,
[property: JsonPropertyName("config")] Dictionary<string, string>? 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;
}
102 changes: 102 additions & 0 deletions plugins/upstream/CodeyBox.ForgejoUpstreamPlugin/ForgejoGitAuth.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> Environment { get; }

private ForgejoGitAuthScope(string directory, IReadOnlyDictionary<string, string> environment)
{
_directory = directory;
Environment = environment;
}

public static ForgejoGitAuthScope Create(string? token)
{
if (string.IsNullOrEmpty(token))
return new ForgejoGitAuthScope(string.Empty, new Dictionary<string, string>());

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<string, string>
{
["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<string, string> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Net;

namespace CodeyBox.ForgejoUpstreamPlugin;

/// <summary>
/// The Forgejo instance was unreachable, refused the request, or answered
/// with an unexpected status. Forge-side failures are <em>infrastructure</em>:
/// 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.
///
/// <para>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.</para>
/// </summary>
public sealed class ForgejoUpstreamException : InvalidOperationException
{
/// <summary>HTTP status from the Forgejo instance, when the failure was an HTTP response.</summary>
public HttpStatusCode? StatusCode { get; }

/// <summary>
/// Value of the <c>Retry-After</c> response header in seconds, when the
/// instance supplied one (typically with 429 rate limiting).
/// </summary>
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;
}
}
Loading
Loading