Merge branch 'feat/adapter-gitea' into feat/skeleton
This commit is contained in:
@@ -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; } = "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user