feat(installer): add IReleaseClient + Gitea ReleaseClient
This commit is contained in:
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
15
src/ClaudeDo.Installer/Core/IReleaseClient.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
public sealed record ReleaseAsset(string Name, string BrowserDownloadUrl, long Size);
|
||||||
|
|
||||||
|
public sealed record GiteaRelease(
|
||||||
|
string TagName,
|
||||||
|
string Name,
|
||||||
|
IReadOnlyList<ReleaseAsset> Assets);
|
||||||
|
|
||||||
|
public interface IReleaseClient
|
||||||
|
{
|
||||||
|
Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct);
|
||||||
|
|
||||||
|
Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct);
|
||||||
|
}
|
||||||
82
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
82
src/ClaudeDo.Installer/Core/ReleaseClient.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
public sealed class ReleaseClient : IReleaseClient
|
||||||
|
{
|
||||||
|
public const string DefaultApiBase = "https://git.kuns.dev/api/v1/repos/releases/ClaudeDo";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly string _apiBase;
|
||||||
|
|
||||||
|
public ReleaseClient(HttpClient http, string apiBase = DefaultApiBase)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_apiBase = apiBase.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _http.GetAsync($"{_apiBase}/releases/latest", ct);
|
||||||
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return ParseRelease(json);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException) { return null; }
|
||||||
|
catch (TaskCanceledException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await using var input = await response.Content.ReadAsStreamAsync(ct);
|
||||||
|
await using var output = File.Create(destPath);
|
||||||
|
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long total = 0;
|
||||||
|
int read;
|
||||||
|
while ((read = await input.ReadAsync(buffer, ct)) > 0)
|
||||||
|
{
|
||||||
|
await output.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||||
|
total += read;
|
||||||
|
progress.Report(total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GiteaRelease? ParseRelease(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("tag_name", out var tagEl)) return null;
|
||||||
|
|
||||||
|
var tag = tagEl.GetString() ?? "";
|
||||||
|
var name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
|
||||||
|
|
||||||
|
var assets = new List<ReleaseAsset>();
|
||||||
|
if (root.TryGetProperty("assets", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var item in arr.EnumerateArray())
|
||||||
|
{
|
||||||
|
var aName = item.GetProperty("name").GetString() ?? "";
|
||||||
|
var aUrl = item.GetProperty("browser_download_url").GetString() ?? "";
|
||||||
|
var aSize = item.TryGetProperty("size", out var s) ? s.GetInt64() : 0L;
|
||||||
|
assets.Add(new ReleaseAsset(aName, aUrl, aSize));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GiteaRelease(tag, name, assets);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
109
tests/ClaudeDo.Installer.Tests/ReleaseClientTests.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using ClaudeDo.Installer.Core;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Installer.Tests;
|
||||||
|
|
||||||
|
public sealed class ReleaseClientTests
|
||||||
|
{
|
||||||
|
private const string ApiBase = "https://git.example.test/api/v1/repos/releases/ClaudeDo";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestReleaseAsync_ParsesTagAndAssets()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"tag_name": "v0.2.0",
|
||||||
|
"name": "v0.2.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "ClaudeDo-0.2.0-win-x64.zip",
|
||||||
|
"browser_download_url": "https://git.example.test/dl/zip",
|
||||||
|
"size": 12345
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "checksums.txt",
|
||||||
|
"browser_download_url": "https://git.example.test/dl/checksums",
|
||||||
|
"size": 128
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, json);
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var client = new ReleaseClient(http, ApiBase);
|
||||||
|
|
||||||
|
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(release);
|
||||||
|
Assert.Equal("v0.2.0", release!.TagName);
|
||||||
|
Assert.Equal(2, release.Assets.Count);
|
||||||
|
Assert.Equal("ClaudeDo-0.2.0-win-x64.zip", release.Assets[0].Name);
|
||||||
|
Assert.Equal("https://git.example.test/dl/zip", release.Assets[0].BrowserDownloadUrl);
|
||||||
|
Assert.Equal(12345, release.Assets[0].Size);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestReleaseAsync_Returns_Null_On404()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.NotFound, "");
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var client = new ReleaseClient(http, ApiBase);
|
||||||
|
|
||||||
|
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(release);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestReleaseAsync_Returns_Null_OnNetworkError()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("boom"));
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var client = new ReleaseClient(http, ApiBase);
|
||||||
|
|
||||||
|
var release = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(release);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLatestReleaseAsync_Hits_CorrectUrl()
|
||||||
|
{
|
||||||
|
var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, "{\"tag_name\":\"v0.1.0\",\"assets\":[]}");
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var client = new ReleaseClient(http, ApiBase);
|
||||||
|
|
||||||
|
_ = await client.GetLatestReleaseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(handler.Requests);
|
||||||
|
Assert.Equal($"{ApiBase}/releases/latest", handler.Requests[0].RequestUri!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DownloadAsync_WritesBytesToDisk()
|
||||||
|
{
|
||||||
|
var payload = new byte[] { 1, 2, 3, 4, 5 };
|
||||||
|
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(payload)
|
||||||
|
});
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var client = new ReleaseClient(http, ApiBase);
|
||||||
|
|
||||||
|
var tempPath = Path.Combine(Path.GetTempPath(), "ClaudeDoDlTest-" + Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.DownloadAsync("https://example/foo", tempPath,
|
||||||
|
new Progress<long>(_ => { }), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(File.Exists(tempPath));
|
||||||
|
Assert.Equal(payload, File.ReadAllBytes(tempPath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user