Merge branch 'feat/adapter-gitea' into feat/skeleton

This commit is contained in:
2026-09-24 11:41:33 +02:00
11 changed files with 745 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
namespace Wiki.Adapters.Gitea;
// Shapes of the Gitea API responses actually used. Verified against a live instance
// (GET /repos/{o}/{r}/branches/{b}, GET /repos/{o}/{r}/git/trees/{sha}) rather than the swagger doc.
internal sealed class BranchResponse
{
[JsonPropertyName("commit")]
public BranchCommit Commit { get; set; } = new();
}
internal sealed class BranchCommit
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; set; }
}
internal sealed class TreeResponse
{
[JsonPropertyName("tree")]
public List<TreeEntry>? Tree { get; set; }
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("truncated")]
public bool Truncated { get; set; }
}
internal sealed class TreeEntry
{
[JsonPropertyName("path")]
public string Path { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("sha")]
public string Sha { get; set; } = "";
}
/// <summary>The subset of Gitea's "create file" response (POST .../contents/{path}) that a fact sink needs.</summary>
internal sealed class ContentsCreateResponse
{
[JsonPropertyName("commit")]
public ContentsCommit Commit { get; set; } = new();
}
internal sealed class ContentsCommit
{
[JsonPropertyName("sha")]
public string Sha { get; set; } = "";
}
+157
View File
@@ -0,0 +1,157 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.Options;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
/// <summary>Deposits a fact as a new file under raw/ in Gitea. Never edits an existing file: a
/// title that would collide with an existing path at HEAD gets a numbered suffix instead.</summary>
public sealed class GiteaFactSink : IFactSink
{
private const int MaxSlugLength = 80;
private const int MaxSuffixAttempts = 1000;
private readonly HttpClient _http;
private readonly GiteaOptions _options;
public string Name => "gitea";
public GiteaFactSink(HttpClient httpClient, IOptions<GiteaOptions> options)
{
_http = httpClient;
_options = options.Value;
GiteaHttp.Configure(_http, _options);
}
public async Task<FactReceipt> AppendAsync(RawSource fact, Identity by, DateTimeOffset now, CancellationToken ct)
{
var errors = fact.Validate();
if (errors.Count > 0)
throw new ArgumentException($"Invalid raw source: {string.Join("; ", errors)}", nameof(fact));
var datePrefix = now.UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var slug = Slugify(fact.Title);
var path = await ReservePathAsync(datePrefix, slug, ct).ConfigureAwait(false);
var text = BuildFile(fact, by, now, datePrefix);
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var encodedPath = GiteaUrls.EncodeSegments(path);
var request = new
{
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(text)),
message = $"raw: {fact.Title}",
branch = _options.Branch,
};
using var response = await _http.PostAsJsonAsync($"api/v1/repos/{repo}/contents/{encodedPath}", request, GiteaHttp.Json, ct)
.ConfigureAwait(false);
await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
var created = await response.Content.ReadFromJsonAsync<ContentsCreateResponse>(GiteaHttp.Json, ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Gitea returned an empty body creating a file.");
return new FactReceipt(path, created.Commit.Sha, now);
}
public async Task<AdapterHealth> CheckAsync(CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var branch = Uri.EscapeDataString(_options.Branch);
var head = await GiteaHttp.GetJsonAsync<BranchResponse>(_http, $"api/v1/repos/{repo}/branches/{branch}", ct)
.ConfigureAwait(false);
return new AdapterHealth(Name, true, $"HEAD {head.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow);
}
}
/// <summary>The candidate path if free at HEAD, else the same with -2, -3, ... appended until one is free.</summary>
private async Task<string> ReservePathAsync(string datePrefix, string slug, CancellationToken ct)
{
var candidate = $"raw/{datePrefix}-{slug}.md";
for (var attempt = 2; await ExistsAtHeadAsync(candidate, ct).ConfigureAwait(false); attempt++)
{
if (attempt > MaxSuffixAttempts)
throw new InvalidOperationException($"Gitea has {MaxSuffixAttempts}+ raw files named like {datePrefix}-{slug}; giving up.");
candidate = $"raw/{datePrefix}-{slug}-{attempt}.md";
}
return candidate;
}
private async Task<bool> ExistsAtHeadAsync(string path, CancellationToken ct)
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var encodedPath = GiteaUrls.EncodeSegments(path);
var branch = Uri.EscapeDataString(_options.Branch);
using var response = await _http.GetAsync($"api/v1/repos/{repo}/contents/{encodedPath}?ref={branch}", ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound) return false;
await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
return true;
}
private static string BuildFile(RawSource fact, Identity by, DateTimeOffset now, string capturedDate)
{
var body = fact.Content;
if (!body.EndsWith('\n')) body += "\n";
var sb = new StringBuilder();
sb.Append("---\n");
sb.Append("title: ").Append(YamlScalar(fact.Title)).Append('\n');
sb.Append("type: raw\n");
sb.Append("source: ").Append(YamlScalar(fact.Source)).Append('\n');
sb.Append("captured: ").Append(capturedDate).Append('\n');
sb.Append("captured_by: ").Append(by.Name).Append('\n');
sb.Append("observed: ").Append(fact.ObservedAt.ToString("yyyy-MM-ddTHH:mm:ssK", CultureInfo.InvariantCulture)).Append('\n');
sb.Append("tags: [").Append(string.Join(", ", fact.Tags)).Append("]\n");
sb.Append("---\n");
sb.Append('\n');
sb.Append(body);
return sb.ToString();
}
/// <summary>Quotes a scalar that would otherwise be ambiguous or wrong in flow-less YAML: a colon
/// or a '#' can start a mapping value or a comment mid-line, and a leading quote needs escaping.</summary>
private static string YamlScalar(string value)
{
if (!value.Contains(':') && !value.Contains('#') && !value.StartsWith('"') && !value.StartsWith('\''))
return value;
return $"\"{value.Replace("\"", "\\\"")}\"";
}
private static string Slugify(string title)
{
var sb = new StringBuilder(title.Length);
foreach (var ch in title.ToLowerInvariant())
{
if (ch is (>= 'a' and <= 'z') or (>= '0' and <= '9')) sb.Append(ch);
else sb.Append('-');
}
var collapsed = new StringBuilder(sb.Length);
var lastWasDash = false;
foreach (var ch in sb.ToString())
{
if (ch == '-')
{
if (lastWasDash) continue;
lastWasDash = true;
}
else
{
lastWasDash = false;
}
collapsed.Append(ch);
}
var slug = collapsed.ToString().Trim('-');
if (slug.Length > MaxSlugLength) slug = slug[..MaxSlugLength].Trim('-');
return slug.Length == 0 ? "untitled" : slug;
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Net.Http.Headers;
using System.Text.Json;
namespace Wiki.Adapters.Gitea;
/// <summary>HTTP plumbing shared by <see cref="GiteaNoteStore"/> and <see cref="GiteaFactSink"/>:
/// client setup, JSON decoding and turning a failed response into a message worth reading.</summary>
internal static class GiteaHttp
{
public static readonly JsonSerializerOptions Json = new();
private const int ErrorBodyPreviewLength = 300;
/// <summary>Points the client at the configured Gitea and carries the token, unless the caller
/// (a test, typically) has already set these up on the HttpClient it handed in.</summary>
public static void Configure(HttpClient client, GiteaOptions options)
{
client.BaseAddress ??= new Uri(options.BaseUrl, UriKind.Absolute);
if (client.DefaultRequestHeaders.Authorization is null && !string.IsNullOrEmpty(options.Token))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", options.Token);
}
public static async Task<T> GetJsonAsync<T>(HttpClient client, string url, CancellationToken ct)
{
using var response = await client.GetAsync(url, ct).ConfigureAwait(false);
await EnsureSuccessAsync(response, ct).ConfigureAwait(false);
await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<T>(stream, Json, ct).ConfigureAwait(false)
?? throw new InvalidOperationException($"Gitea returned an empty body for {url}.");
}
public static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (body.Length > ErrorBodyPreviewLength) body = body[..ErrorBodyPreviewLength];
throw new HttpRequestException(
$"Gitea {(int)response.StatusCode} {response.ReasonPhrase} for {response.RequestMessage?.RequestUri}: {body}",
null,
response.StatusCode);
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Diagnostics;
using Microsoft.Extensions.Options;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
/// <summary>Reads the vault straight from a Gitea repository: HEAD, a recursive tree listing and raw
/// file contents, all at the commit the listing was taken from.</summary>
public sealed class GiteaNoteStore : INoteStore
{
// Gitea answers "tree": null past the last page, and may mark the last page truncated=true even
// though nothing follows; stop on a short/empty page or once total_count is reached either way.
private const int PageSize = 1000;
private const int MaxPages = 50;
private readonly HttpClient _http;
private readonly GiteaOptions _options;
public string Name => "gitea";
public GiteaNoteStore(HttpClient httpClient, IOptions<GiteaOptions> options)
{
_http = httpClient;
_options = options.Value;
GiteaHttp.Configure(_http, _options);
}
public async Task<StoreHead> GetHeadAsync(CancellationToken ct)
{
var branch = await FetchBranchAsync(ct).ConfigureAwait(false);
return new StoreHead(branch.Commit.Id, branch.Commit.Timestamp);
}
public async Task<IReadOnlyList<DocumentRef>> ListAsync(string commit, IReadOnlySet<DocumentKind> kinds, CancellationToken ct)
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var refs = new List<DocumentRef>();
var page = 1;
var seen = 0;
while (true)
{
var url = $"api/v1/repos/{repo}/git/trees/{Uri.EscapeDataString(commit)}?recursive=true&per_page={PageSize}&page={page}";
var tree = await GiteaHttp.GetJsonAsync<TreeResponse>(_http, url, ct).ConfigureAwait(false);
var entries = tree.Tree ?? [];
foreach (var entry in entries)
{
if (entry.Type != "blob") continue;
if (DocumentParser.Classify(entry.Path) is not { } classified) continue;
if (!kinds.Contains(classified.Kind)) continue;
refs.Add(new DocumentRef(classified.Kind, classified.Slug, entry.Path, entry.Sha));
}
seen += entries.Count;
// total_count is the reliable signal (confirmed live: Gitea reports it regardless of the
// per_page requested); a short page only means "done" when total_count isn't available,
// since a server may cap the effective page size below what was asked for.
var done = entries.Count == 0
|| (tree.TotalCount > 0 ? seen >= tree.TotalCount : entries.Count < PageSize);
if (done) break;
page++;
if (page > MaxPages) throw new InvalidOperationException("Gitea tree paging exceeded the safety cap.");
}
return refs;
}
public async Task<string> ReadAsync(DocumentRef reference, string commit, CancellationToken ct)
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var path = GiteaUrls.EncodeSegments(reference.Path);
var url = $"api/v1/repos/{repo}/raw/{path}?ref={Uri.EscapeDataString(commit)}";
using var response = await _http.GetAsync(url, ct).ConfigureAwait(false);
await GiteaHttp.EnsureSuccessAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
public async Task<AdapterHealth> CheckAsync(CancellationToken ct)
{
var sw = Stopwatch.StartNew();
try
{
var branch = await FetchBranchAsync(ct).ConfigureAwait(false);
return new AdapterHealth(Name, true, $"HEAD {branch.Commit.Id}", sw.Elapsed, DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
return new AdapterHealth(Name, false, ex.Message, sw.Elapsed, DateTimeOffset.UtcNow);
}
}
private Task<BranchResponse> FetchBranchAsync(CancellationToken ct)
{
var repo = GiteaUrls.EncodeSegments(_options.Repository);
var branch = Uri.EscapeDataString(_options.Branch);
return GiteaHttp.GetJsonAsync<BranchResponse>(_http, $"api/v1/repos/{repo}/branches/{branch}", ct);
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Wiki.Adapters.Gitea;
/// <summary>How to reach the notes repository on Gitea.</summary>
public sealed class GiteaOptions
{
public string BaseUrl { get; set; } = "https://gitea.renner.uno";
public string Repository { get; set; } = "k.renner/notes";
public string Branch { get; set; } = "main";
public string Token { get; set; } = "";
}
+9
View File
@@ -0,0 +1,9 @@
namespace Wiki.Adapters.Gitea;
/// <summary>Path building shared by the store and the sink: repo/branch/path segments are each
/// percent-encoded on their own so a literal '/' between segments survives, matching Gitea's routing.</summary>
internal static class GiteaUrls
{
public static string EncodeSegments(string value) =>
string.Join('/', value.Split('/').Select(Uri.EscapeDataString));
}
@@ -0,0 +1,19 @@
using Microsoft.Extensions.DependencyInjection;
using Wiki.Core.Ports;
namespace Wiki.Adapters.Gitea;
public static class ServiceCollectionExtensions
{
/// <summary>Registers the Gitea-backed <see cref="INoteStore"/> and <see cref="IFactSink"/>, each
/// with its own managed HttpClient from <see cref="IHttpClientFactory"/>.</summary>
public static IServiceCollection AddGiteaAdapters(this IServiceCollection services, Action<GiteaOptions> configure)
{
services.Configure(configure);
services.AddHttpClient<GiteaNoteStore>();
services.AddHttpClient<GiteaFactSink>();
services.AddTransient<INoteStore>(sp => sp.GetRequiredService<GiteaNoteStore>());
services.AddTransient<IFactSink>(sp => sp.GetRequiredService<GiteaFactSink>());
return services;
}
}
@@ -4,6 +4,11 @@
<ProjectReference Include="..\Wiki.Core\Wiki.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>