diff --git a/Directory.Packages.props b/Directory.Packages.props index 4fcfad6..0cdca7b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/JitHub.Data.Caching/CacheEvent.cs b/JitHub.Data.Caching/CacheEvent.cs new file mode 100644 index 0000000..4faa6c2 --- /dev/null +++ b/JitHub.Data.Caching/CacheEvent.cs @@ -0,0 +1,9 @@ +namespace JitHub.Data.Caching; + +public sealed record CacheEvent( + CacheEventKind Kind, + CacheKey Key, + Type? ValueType, + object? Value, + Exception? Error, + DateTimeOffset TimestampUtc); diff --git a/JitHub.Data.Caching/CacheEventBus.cs b/JitHub.Data.Caching/CacheEventBus.cs new file mode 100644 index 0000000..95f3be7 --- /dev/null +++ b/JitHub.Data.Caching/CacheEventBus.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace JitHub.Data.Caching; + +public sealed class CacheEventBus : ICacheEventBus +{ + private readonly ConcurrentDictionary> _subscribers = new(); + private int _nextId; + + public IDisposable Subscribe(Action handler) + { + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + var id = Interlocked.Increment(ref _nextId); + _subscribers[id] = handler; + + return new Subscription(() => _subscribers.TryRemove(id, out _)); + } + + public void Publish(CacheEvent cacheEvent) + { + foreach (var subscriber in _subscribers.Values) + { + try + { + subscriber(cacheEvent); + } + catch + { + // Intentionally isolate subscribers. Cache publication should never fail because a consumer misbehaves. + } + } + } + + private sealed class Subscription(Action dispose) : IDisposable + { + private Action? _dispose = dispose; + + public void Dispose() + { + Interlocked.Exchange(ref _dispose, null)?.Invoke(); + } + } +} diff --git a/JitHub.Data.Caching/CacheEventKind.cs b/JitHub.Data.Caching/CacheEventKind.cs new file mode 100644 index 0000000..0f3b101 --- /dev/null +++ b/JitHub.Data.Caching/CacheEventKind.cs @@ -0,0 +1,8 @@ +namespace JitHub.Data.Caching; + +public enum CacheEventKind +{ + Updated = 0, + Removed = 1, + RefreshFailed = 2, +} diff --git a/JitHub.Data.Caching/CacheItemMetadata.cs b/JitHub.Data.Caching/CacheItemMetadata.cs new file mode 100644 index 0000000..6d97c60 --- /dev/null +++ b/JitHub.Data.Caching/CacheItemMetadata.cs @@ -0,0 +1,8 @@ +namespace JitHub.Data.Caching; + +public sealed record CacheItemMetadata( + DateTimeOffset FetchedAtUtc, + DateTimeOffset? ExpiresAtUtc = null, + string? ETag = null, + DateTimeOffset? LastModifiedUtc = null, + Exception? LastError = null); diff --git a/JitHub.Data.Caching/CacheKey.cs b/JitHub.Data.Caching/CacheKey.cs new file mode 100644 index 0000000..81d1349 --- /dev/null +++ b/JitHub.Data.Caching/CacheKey.cs @@ -0,0 +1,132 @@ +using System.Collections.Immutable; + +namespace JitHub.Data.Caching; + +public readonly record struct CacheKey +{ + private readonly ImmutableArray> _parameters; + + public CacheKey(string operation, string? userScope, IEnumerable>? parameters = null) + { + if (operation is null) + { + throw new ArgumentNullException(nameof(operation)); + } + + operation = operation.Trim(); + if (operation.Length == 0) + { + throw new ArgumentException("Operation must not be empty.", nameof(operation)); + } + + Operation = operation; + UserScope = string.IsNullOrWhiteSpace(userScope) ? null : userScope.Trim(); + + _parameters = Normalize(parameters); + } + + public string Operation { get; } + + public string? UserScope { get; } + + public IReadOnlyList> Parameters => _parameters; + + public static CacheKey Create(string operation, string? userScope = null, params (string Key, string Value)[] parameters) + => new(operation, userScope, parameters.Select(p => new KeyValuePair(p.Key, p.Value))); + + public bool Equals(CacheKey other) + { + if (!StringComparer.Ordinal.Equals(Operation, other.Operation)) + { + return false; + } + + if (!StringComparer.Ordinal.Equals(UserScope, other.UserScope)) + { + return false; + } + + if (_parameters.Length != other._parameters.Length) + { + return false; + } + + for (var i = 0; i < _parameters.Length; i++) + { + var a = _parameters[i]; + var b = other._parameters[i]; + + if (!StringComparer.Ordinal.Equals(a.Key, b.Key) || !StringComparer.Ordinal.Equals(a.Value, b.Value)) + { + return false; + } + } + + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Operation, StringComparer.Ordinal); + hash.Add(UserScope, StringComparer.Ordinal); + + for (var i = 0; i < _parameters.Length; i++) + { + var p = _parameters[i]; + hash.Add(p.Key, StringComparer.Ordinal); + hash.Add(p.Value, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + public override string ToString() + { + if (_parameters.IsDefaultOrEmpty) + { + return UserScope is null ? Operation : $"{Operation}@{UserScope}"; + } + + var scope = UserScope is null ? string.Empty : $"@{UserScope}"; + return $"{Operation}{scope}?{string.Join("&", _parameters.Select(p => $"{p.Key}={p.Value}"))}"; + } + + private static ImmutableArray> Normalize(IEnumerable>? parameters) + { + if (parameters is null) + { + return ImmutableArray>.Empty; + } + + var list = new List>(); + foreach (var (key, value) in parameters) + { + if (key is null) + { + throw new ArgumentNullException(nameof(parameters), "Parameter key must not be null."); + } + + if (value is null) + { + throw new ArgumentNullException(nameof(parameters), "Parameter value must not be null."); + } + + var normalizedKey = key.Trim(); + if (normalizedKey.Length == 0) + { + throw new ArgumentException("Parameter key must not be empty.", nameof(parameters)); + } + + list.Add(new KeyValuePair(normalizedKey, value.Trim())); + } + + list.Sort(static (a, b) => + { + var cmp = StringComparer.Ordinal.Compare(a.Key, b.Key); + return cmp != 0 ? cmp : StringComparer.Ordinal.Compare(a.Value, b.Value); + }); + + return list.ToImmutableArray(); + } +} diff --git a/JitHub.Data.Caching/CacheKeyExtensions.cs b/JitHub.Data.Caching/CacheKeyExtensions.cs new file mode 100644 index 0000000..fad5682 --- /dev/null +++ b/JitHub.Data.Caching/CacheKeyExtensions.cs @@ -0,0 +1,32 @@ +namespace JitHub.Data.Caching; + +public static class CacheKeyExtensions +{ + public static bool TryGetParameterValue(this CacheKey key, string parameterKey, out string? value) + { + if (parameterKey is null) + { + throw new ArgumentNullException(nameof(parameterKey)); + } + + // CacheKey parameters are normalized and sorted, but lookups are low-volume. + // Keep this simple and allocation-free. + foreach (var p in key.Parameters) + { + if (string.Equals(p.Key, parameterKey, StringComparison.Ordinal)) + { + value = p.Value; + return true; + } + } + + value = null; + return false; + } + + public static string? GetParameterValue(this CacheKey key, string parameterKey) + => key.TryGetParameterValue(parameterKey, out var value) ? value : null; + + public static string GetParameterValueOrEmpty(this CacheKey key, string parameterKey) + => key.GetParameterValue(parameterKey) ?? string.Empty; +} diff --git a/JitHub.Data.Caching/CacheRuntime.cs b/JitHub.Data.Caching/CacheRuntime.cs new file mode 100644 index 0000000..183e66d --- /dev/null +++ b/JitHub.Data.Caching/CacheRuntime.cs @@ -0,0 +1,290 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace JitHub.Data.Caching; + +public sealed class CacheRuntime +{ + private sealed class Inflight + { + public Inflight(Task task, CancellationTokenSource cts) + { + Task = task; + Cts = cts; + } + + public Task Task { get; } + public CancellationTokenSource Cts { get; } + public int RefCount; + } + + private readonly ICacheStore _store; + private readonly ICacheEventBus _events; + private readonly CacheRuntimeOptions _options; + private readonly SemaphoreSlim _refreshConcurrency; + private readonly ILogger? _logger; + + private readonly object _gate = new(); + private readonly Dictionary _inflight = new(); + + public CacheRuntime(ICacheStore store, ICacheEventBus events, CacheRuntimeOptions? options = null, ILogger? logger = null) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _events = events ?? throw new ArgumentNullException(nameof(events)); + _options = options ?? new CacheRuntimeOptions(); + _logger = logger; + + if (_options.MaxConcurrentRefreshes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), _options.MaxConcurrentRefreshes, "MaxConcurrentRefreshes must be greater than 0."); + } + + _refreshConcurrency = new SemaphoreSlim(_options.MaxConcurrentRefreshes); + } + + public CacheSnapshot GetCached(CacheKey key) + { + if (_store.TryGet(key, out var value, out var metadata)) + { + _logger?.LogDebug("Cache hit: {Operation} {Key}", key.Operation, key.ToString()); + return new CacheSnapshot(key, HasValue: true, value, metadata, IsFromCache: true); + } + + _logger?.LogDebug("Cache miss: {Operation} {Key}", key.Operation, key.ToString()); + return new CacheSnapshot(key, HasValue: false, Value: default, Metadata: null, IsFromCache: true); + } + + public Task> GetOrRefreshAsync( + CacheKey key, + bool preferCacheThenRefresh, + Func> fetchAsync, + CancellationToken ct) + { + if (fetchAsync is null) + { + throw new ArgumentNullException(nameof(fetchAsync)); + } + + if (!preferCacheThenRefresh) + { + _logger?.LogDebug("Cache refresh (forced): {Operation} {Key}", key.Operation, key.ToString()); + return RefreshAsync(key, fetchAsync, ct); + } + + var cached = GetCached(key); + if (cached.HasValue) + { + _logger?.LogDebug("Cache returning cached value and starting refresh in background: {Operation} {Key}", key.Operation, key.ToString()); + _ = StartRefreshInBackground(key, fetchAsync, ct); + return Task.FromResult(cached); + } + + _logger?.LogDebug("Cache empty; fetching: {Operation} {Key}", key.Operation, key.ToString()); + return RefreshAsync(key, fetchAsync, ct); + } + + public Task> RefreshAsync(CacheKey key, Func> fetchAsync, CancellationToken ct) + { + if (fetchAsync is null) + { + throw new ArgumentNullException(nameof(fetchAsync)); + } + + _logger?.LogDebug("Cache refresh requested: {Operation} {Key}", key.Operation, key.ToString()); + return JoinOrStartInflight(key, fetchAsync, ct, awaitResult: true); + } + + public Task StartPolling( + CacheKey key, + PollingOptions options, + Func> fetchAsync, + CancellationToken ct) + { + if (options.Interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), options.Interval, "Interval must be greater than 0."); + } + + if (fetchAsync is null) + { + throw new ArgumentNullException(nameof(fetchAsync)); + } + + _logger?.LogInformation( + "Polling started: {Operation} interval={IntervalMs}ms jitterMax={JitterMs}ms", + key.Operation, + (long)options.Interval.TotalMilliseconds, + (long)options.JitterMax.GetValueOrDefault(TimeSpan.Zero).TotalMilliseconds); + + return PollLoop(); + + async Task PollLoop() + { + var jitterMax = options.JitterMax.GetValueOrDefault(TimeSpan.Zero); + var rng = jitterMax > TimeSpan.Zero ? Random.Shared : null; + + using var timer = new PeriodicTimer(options.Interval); + try + { + while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false)) + { + _logger?.LogDebug("Polling tick: {Operation}", key.Operation); + + if (rng is not null) + { + var jitterMs = rng.NextDouble() * jitterMax.TotalMilliseconds; + if (jitterMs >= 1) + { + _logger?.LogDebug("Polling jitter: {Operation} jitterMs={JitterMs}", key.Operation, (long)jitterMs); + await Task.Delay(TimeSpan.FromMilliseconds(jitterMs), ct).ConfigureAwait(false); + } + } + + await RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false); + } + } + finally + { + _logger?.LogInformation("Polling stopped: {Operation}", key.Operation); + } + } + } + + private Task StartRefreshInBackground(CacheKey key, Func> fetchAsync, CancellationToken ct) + { + return JoinOrStartInflight(key, fetchAsync, ct, awaitResult: false); + } + + private Task> JoinOrStartInflight( + CacheKey key, + Func> fetchAsync, + CancellationToken callerToken, + bool awaitResult) + { + Inflight inflight; + lock (_gate) + { + if (!_inflight.TryGetValue(key, out inflight!)) + { + _logger?.LogDebug("Inflight start: {Operation} {Key} await={Await}", key.Operation, key.ToString(), awaitResult); + var cts = new CancellationTokenSource(); + var task = RunRefresh(key, fetchAsync, cts.Token); + + inflight = new Inflight(task, cts); + _inflight.Add(key, inflight); + + _ = task.ContinueWith( + _ => + { + lock (_gate) + { + _inflight.Remove(key); + } + cts.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + else + { + _logger?.LogDebug("Inflight join: {Operation} {Key} await={Await}", key.Operation, key.ToString(), awaitResult); + } + + inflight.RefCount++; + } + + var released = 0; + void ReleaseOnce() + { + if (Interlocked.Exchange(ref released, 1) == 0) + { + Release(key); + } + } + + var registration = callerToken.Register(ReleaseOnce); + + if (!awaitResult) + { + _ = inflight.Task.ContinueWith( + _ => + { + registration.Dispose(); + ReleaseOnce(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return Task.FromResult(GetCached(key)); + } + + return AwaitAndRelease(); + + async Task> AwaitAndRelease() + { + try + { + await inflight.Task.ConfigureAwait(false); + return GetCached(key); + } + finally + { + registration.Dispose(); + ReleaseOnce(); + } + } + } + + private void Release(CacheKey key) + { + lock (_gate) + { + if (!_inflight.TryGetValue(key, out var inflight)) + { + return; + } + + inflight.RefCount--; + if (inflight.RefCount <= 0) + { + inflight.Cts.Cancel(); + } + } + } + + private async Task RunRefresh(CacheKey key, Func> fetchAsync, CancellationToken ct) + { + _logger?.LogDebug("Refresh start: {Operation} {Key}", key.Operation, key.ToString()); + await _refreshConcurrency.WaitAsync(ct).ConfigureAwait(false); + try + { + var value = await fetchAsync(ct).ConfigureAwait(false); + var now = DateTimeOffset.UtcNow; + + DateTimeOffset? expiresAt = _options.DefaultTtl is null ? null : now.Add(_options.DefaultTtl.Value); + var metadata = new CacheItemMetadata(now, ExpiresAtUtc: expiresAt); + _store.Set(key, value, metadata); + + _events.Publish(new CacheEvent(CacheEventKind.Updated, key, typeof(T), value, Error: null, TimestampUtc: now)); + + _logger?.LogInformation("Refresh success: {Operation} {Key}", key.Operation, key.ToString()); + } + catch (OperationCanceledException) + { + // Silent; cancellation is expected for navigation/polling scopes. + _logger?.LogDebug("Refresh canceled: {Operation} {Key}", key.Operation, key.ToString()); + } + catch (Exception ex) + { + _events.Publish(new CacheEvent(CacheEventKind.RefreshFailed, key, typeof(T), Value: null, Error: ex, TimestampUtc: DateTimeOffset.UtcNow)); + + _logger?.LogWarning(ex, "Refresh failed: {Operation} {Key}", key.Operation, key.ToString()); + } + finally + { + _refreshConcurrency.Release(); + } + } +} diff --git a/JitHub.Data.Caching/CacheRuntimeOptions.cs b/JitHub.Data.Caching/CacheRuntimeOptions.cs new file mode 100644 index 0000000..d92f60f --- /dev/null +++ b/JitHub.Data.Caching/CacheRuntimeOptions.cs @@ -0,0 +1,5 @@ +namespace JitHub.Data.Caching; + +public sealed record CacheRuntimeOptions( + TimeSpan? DefaultTtl = null, + int MaxConcurrentRefreshes = 8); diff --git a/JitHub.Data.Caching/CacheSnapshot.cs b/JitHub.Data.Caching/CacheSnapshot.cs new file mode 100644 index 0000000..f206ec0 --- /dev/null +++ b/JitHub.Data.Caching/CacheSnapshot.cs @@ -0,0 +1,8 @@ +namespace JitHub.Data.Caching; + +public sealed record CacheSnapshot( + CacheKey Key, + bool HasValue, + T? Value, + CacheItemMetadata? Metadata, + bool IsFromCache); diff --git a/JitHub.Data.Caching/Class1.cs b/JitHub.Data.Caching/Class1.cs new file mode 100644 index 0000000..e71b0ed --- /dev/null +++ b/JitHub.Data.Caching/Class1.cs @@ -0,0 +1,5 @@ +namespace JitHub.Data.Caching; + +internal static class CachingAssembly +{ +} diff --git a/JitHub.Data.Caching/ICacheEventBus.cs b/JitHub.Data.Caching/ICacheEventBus.cs new file mode 100644 index 0000000..71ab0a1 --- /dev/null +++ b/JitHub.Data.Caching/ICacheEventBus.cs @@ -0,0 +1,8 @@ +namespace JitHub.Data.Caching; + +public interface ICacheEventBus +{ + IDisposable Subscribe(Action handler); + + void Publish(CacheEvent cacheEvent); +} diff --git a/JitHub.Data.Caching/ICacheStore.cs b/JitHub.Data.Caching/ICacheStore.cs new file mode 100644 index 0000000..ba85c1a --- /dev/null +++ b/JitHub.Data.Caching/ICacheStore.cs @@ -0,0 +1,10 @@ +namespace JitHub.Data.Caching; + +public interface ICacheStore +{ + bool TryGet(CacheKey key, out T? value, out CacheItemMetadata? metadata); + + void Set(CacheKey key, T value, CacheItemMetadata metadata); + + bool Remove(CacheKey key); +} diff --git a/JitHub.Data.Caching/InMemoryCacheStore.cs b/JitHub.Data.Caching/InMemoryCacheStore.cs new file mode 100644 index 0000000..24b836d --- /dev/null +++ b/JitHub.Data.Caching/InMemoryCacheStore.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; + +namespace JitHub.Data.Caching; + +public sealed class InMemoryCacheStore : ICacheStore +{ + private sealed class Entry(object value, Type valueType, CacheItemMetadata metadata) + { + public object Value { get; set; } = value; + + public Type ValueType { get; } = valueType; + + public CacheItemMetadata Metadata { get; set; } = metadata; + } + + private readonly int _maxEntries; + private readonly object _gate = new(); + private readonly Dictionary> _lruNodes = new(); + private readonly LinkedList _lru = new(); + private readonly ConcurrentDictionary _entries = new(); + + public InMemoryCacheStore(InMemoryCacheStoreOptions? options = null) + { + options ??= new InMemoryCacheStoreOptions(); + if (options.MaxEntries <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), options.MaxEntries, "MaxEntries must be greater than 0."); + } + + _maxEntries = options.MaxEntries; + } + + public bool TryGet(CacheKey key, out T? value, out CacheItemMetadata? metadata) + { + if (_entries.TryGetValue(key, out var entry) && entry.ValueType == typeof(T)) + { + Touch(key); + metadata = entry.Metadata; + value = (T)entry.Value; + return true; + } + + metadata = null; + value = default; + return false; + } + + public void Set(CacheKey key, T value, CacheItemMetadata metadata) + { + if (metadata is null) + { + throw new ArgumentNullException(nameof(metadata)); + } + + _entries[key] = new Entry(value!, typeof(T), metadata); + Touch(key); + EvictIfNeeded(); + } + + public bool Remove(CacheKey key) + { + var removed = _entries.TryRemove(key, out _); + if (removed) + { + lock (_gate) + { + if (_lruNodes.Remove(key, out var node)) + { + _lru.Remove(node); + } + } + } + + return removed; + } + + private void Touch(CacheKey key) + { + lock (_gate) + { + if (_lruNodes.TryGetValue(key, out var existing)) + { + _lru.Remove(existing); + } + + var node = _lru.AddFirst(key); + _lruNodes[key] = node; + } + } + + private void EvictIfNeeded() + { + while (_entries.Count > _maxEntries) + { + CacheKey? toRemove = null; + lock (_gate) + { + var last = _lru.Last; + if (last is not null) + { + toRemove = last.Value; + _lru.RemoveLast(); + _lruNodes.Remove(toRemove.Value); + } + } + + if (toRemove is null) + { + return; + } + + _entries.TryRemove(toRemove.Value, out _); + } + } +} diff --git a/JitHub.Data.Caching/InMemoryCacheStoreOptions.cs b/JitHub.Data.Caching/InMemoryCacheStoreOptions.cs new file mode 100644 index 0000000..6cf6b71 --- /dev/null +++ b/JitHub.Data.Caching/InMemoryCacheStoreOptions.cs @@ -0,0 +1,3 @@ +namespace JitHub.Data.Caching; + +public sealed record InMemoryCacheStoreOptions(int MaxEntries = 512); diff --git a/JitHub.Data.Caching/JitHub.Data.Caching.csproj b/JitHub.Data.Caching/JitHub.Data.Caching.csproj new file mode 100644 index 0000000..73862c6 --- /dev/null +++ b/JitHub.Data.Caching/JitHub.Data.Caching.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/JitHub.Data.Caching/ObservableCollectionSync.cs b/JitHub.Data.Caching/ObservableCollectionSync.cs new file mode 100644 index 0000000..b25d661 --- /dev/null +++ b/JitHub.Data.Caching/ObservableCollectionSync.cs @@ -0,0 +1,82 @@ +using System.Collections.ObjectModel; + +namespace JitHub.Data.Caching; + +public static class ObservableCollectionSync +{ + public static void SyncById( + ObservableCollection target, + IReadOnlyList source, + Func getId, + Func shouldReplace) + { + if (target is null) + { + throw new ArgumentNullException(nameof(target)); + } + + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (getId is null) + { + throw new ArgumentNullException(nameof(getId)); + } + + if (shouldReplace is null) + { + throw new ArgumentNullException(nameof(shouldReplace)); + } + + if (source.Count == 0) + { + if (target.Count != 0) + { + target.Clear(); + } + + return; + } + + // Build desired order without a full reset (avoids ListView flicker). + for (var desiredIndex = 0; desiredIndex < source.Count; desiredIndex++) + { + var desiredItem = source[desiredIndex]; + var desiredId = getId(desiredItem); + + var currentIndex = -1; + for (var i = desiredIndex; i < target.Count; i++) + { + if (getId(target[i]) == desiredId) + { + currentIndex = i; + break; + } + } + + if (currentIndex < 0) + { + target.Insert(desiredIndex, desiredItem); + continue; + } + + if (currentIndex != desiredIndex) + { + target.Move(currentIndex, desiredIndex); + } + + if (shouldReplace(target[desiredIndex], desiredItem)) + { + target[desiredIndex] = desiredItem; + } + } + + // Remove any trailing items not present in the source. + while (target.Count > source.Count) + { + target.RemoveAt(target.Count - 1); + } + } +} diff --git a/JitHub.Data.Caching/PollingOptions.cs b/JitHub.Data.Caching/PollingOptions.cs new file mode 100644 index 0000000..c38cce4 --- /dev/null +++ b/JitHub.Data.Caching/PollingOptions.cs @@ -0,0 +1,5 @@ +namespace JitHub.Data.Caching; + +public sealed record PollingOptions( + TimeSpan Interval, + TimeSpan? JitterMax = null); diff --git a/JitHub.GitHub.Abstractions/Class1.cs b/JitHub.GitHub.Abstractions/Class1.cs new file mode 100644 index 0000000..d714086 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Class1.cs @@ -0,0 +1,5 @@ +namespace JitHub.GitHub.Abstractions; + +internal static class AbstractionsAssembly +{ +} diff --git a/JitHub.GitHub.Abstractions/JitHub.GitHub.Abstractions.csproj b/JitHub.GitHub.Abstractions/JitHub.GitHub.Abstractions.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/JitHub.GitHub.Abstractions/JitHub.GitHub.Abstractions.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/JitHub.GitHub.Abstractions/Models/IssueComment.cs b/JitHub.GitHub.Abstractions/Models/IssueComment.cs new file mode 100644 index 0000000..49f861c --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/IssueComment.cs @@ -0,0 +1,8 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record IssueComment( + long Id, + string? AuthorLogin, + string? Body, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Abstractions/Models/IssueDetail.cs b/JitHub.GitHub.Abstractions/Models/IssueDetail.cs new file mode 100644 index 0000000..9a5d7e9 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/IssueDetail.cs @@ -0,0 +1,12 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record IssueDetail( + long Id, + int Number, + string Title, + IssueState State, + string? AuthorLogin, + string? Body, + int CommentCount, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Abstractions/Models/IssueState.cs b/JitHub.GitHub.Abstractions/Models/IssueState.cs new file mode 100644 index 0000000..04f364e --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/IssueState.cs @@ -0,0 +1,7 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public enum IssueState +{ + Open = 0, + Closed = 1, +} diff --git a/JitHub.GitHub.Abstractions/Models/IssueSummary.cs b/JitHub.GitHub.Abstractions/Models/IssueSummary.cs new file mode 100644 index 0000000..bd86ded --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/IssueSummary.cs @@ -0,0 +1,10 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record IssueSummary( + long Id, + int Number, + string Title, + IssueState State, + string? AuthorLogin, + int CommentCount, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Abstractions/Models/RepoKey.cs b/JitHub.GitHub.Abstractions/Models/RepoKey.cs new file mode 100644 index 0000000..c00280f --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/RepoKey.cs @@ -0,0 +1,39 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public readonly record struct RepoKey +{ + public RepoKey(string owner, string name) + { + if (owner is null) + { + throw new ArgumentNullException(nameof(owner)); + } + + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + owner = owner.Trim(); + name = name.Trim(); + + if (owner.Length == 0) + { + throw new ArgumentException("Owner must not be empty.", nameof(owner)); + } + + if (name.Length == 0) + { + throw new ArgumentException("Name must not be empty.", nameof(name)); + } + + Owner = owner; + Name = name; + } + + public string Owner { get; } + + public string Name { get; } + + public override string ToString() => $"{Owner}/{Name}"; +} diff --git a/JitHub.GitHub.Abstractions/Models/RepositorySummary.cs b/JitHub.GitHub.Abstractions/Models/RepositorySummary.cs new file mode 100644 index 0000000..d04de13 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Models/RepositorySummary.cs @@ -0,0 +1,10 @@ +namespace JitHub.GitHub.Abstractions.Models; + +public sealed record RepositorySummary( + long Id, + string Name, + string OwnerLogin, + bool IsPrivate, + string? DefaultBranch, + string? Description, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Abstractions/Paging/PageRequest.cs b/JitHub.GitHub.Abstractions/Paging/PageRequest.cs new file mode 100644 index 0000000..4a818d5 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Paging/PageRequest.cs @@ -0,0 +1,43 @@ +namespace JitHub.GitHub.Abstractions.Paging; + +public readonly record struct PageRequest +{ + private PageRequest(int pageSize, int? pageNumber, string? cursor) + { + if (pageSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "PageSize must be greater than 0."); + } + + if (pageNumber is not null && pageNumber <= 0) + { + throw new ArgumentOutOfRangeException(nameof(pageNumber), pageNumber, "PageNumber must be greater than 0."); + } + + PageSize = pageSize; + PageNumber = pageNumber; + Cursor = cursor; + } + + public int PageSize { get; } + + /// + /// Optional 1-based page number. Mutually exclusive with . + /// + public int? PageNumber { get; } + + /// + /// Optional cursor token for cursor-based pagination. Mutually exclusive with . + /// + public string? Cursor { get; } + + public static PageRequest FirstPage(int pageSize) => new(pageSize, pageNumber: 1, cursor: null); + + public static PageRequest FromPageNumber(int pageNumber, int pageSize) => new(pageSize, pageNumber, cursor: null); + + public static PageRequest FromCursor(string? cursor, int pageSize) + { + cursor = cursor?.Trim(); + return new(pageSize, pageNumber: null, cursor: string.IsNullOrEmpty(cursor) ? null : cursor); + } +} diff --git a/JitHub.GitHub.Abstractions/Paging/PagedResult.cs b/JitHub.GitHub.Abstractions/Paging/PagedResult.cs new file mode 100644 index 0000000..6016495 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Paging/PagedResult.cs @@ -0,0 +1,5 @@ +namespace JitHub.GitHub.Abstractions.Paging; + +public sealed record PagedResult( + T Items, + PageRequest? Next); diff --git a/JitHub.GitHub.Abstractions/Polling/PollingRequest.cs b/JitHub.GitHub.Abstractions/Polling/PollingRequest.cs new file mode 100644 index 0000000..77b79be --- /dev/null +++ b/JitHub.GitHub.Abstractions/Polling/PollingRequest.cs @@ -0,0 +1,5 @@ +namespace JitHub.GitHub.Abstractions.Polling; + +public sealed record PollingRequest( + TimeSpan Interval, + TimeSpan? JitterMax = null); diff --git a/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs new file mode 100644 index 0000000..fc4ef5c --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs @@ -0,0 +1,5 @@ +namespace JitHub.GitHub.Abstractions.Queries; + +public sealed record IssueQuery( + IssueStateFilter State, + string? SearchText = null); diff --git a/JitHub.GitHub.Abstractions/Queries/IssueState.cs b/JitHub.GitHub.Abstractions/Queries/IssueState.cs new file mode 100644 index 0000000..9f8372e --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueState.cs @@ -0,0 +1,7 @@ +namespace JitHub.GitHub.Abstractions.Queries; + +public enum IssueState +{ + Open = 0, + Closed = 1, +} diff --git a/JitHub.GitHub.Abstractions/Queries/IssueStateFilter.cs b/JitHub.GitHub.Abstractions/Queries/IssueStateFilter.cs new file mode 100644 index 0000000..d6a7f1f --- /dev/null +++ b/JitHub.GitHub.Abstractions/Queries/IssueStateFilter.cs @@ -0,0 +1,8 @@ +namespace JitHub.GitHub.Abstractions.Queries; + +public enum IssueStateFilter +{ + Open = 0, + Closed = 1, + All = 2, +} diff --git a/JitHub.GitHub.Abstractions/Refresh/RefreshMode.cs b/JitHub.GitHub.Abstractions/Refresh/RefreshMode.cs new file mode 100644 index 0000000..7c0436b --- /dev/null +++ b/JitHub.GitHub.Abstractions/Refresh/RefreshMode.cs @@ -0,0 +1,8 @@ +namespace JitHub.GitHub.Abstractions.Refresh; + +public enum RefreshMode +{ + PreferCacheThenRefresh = 0, + ForceRefresh = 1, + CacheOnly = 2, +} diff --git a/JitHub.GitHub.Abstractions/Security/IGitHubTokenProvider.cs b/JitHub.GitHub.Abstractions/Security/IGitHubTokenProvider.cs new file mode 100644 index 0000000..ed89e7d --- /dev/null +++ b/JitHub.GitHub.Abstractions/Security/IGitHubTokenProvider.cs @@ -0,0 +1,18 @@ +namespace JitHub.GitHub.Abstractions.Security; + +/// +/// Provides the current GitHub access token for API calls. +/// Implementations are responsible for retrieving the token from the app's authentication flow. +/// +public interface IGitHubTokenProvider +{ + /// + /// Raised when the access token may have changed (login/logout/token update). + /// Consumers can recreate clients or invalidate cached auth state. + /// + event EventHandler? TokenChanged; + + ValueTask GetAccessTokenAsync(CancellationToken cancellationToken); + + void NotifyTokenChanged(); +} diff --git a/JitHub.GitHub.Abstractions/Security/ISecretStore.cs b/JitHub.GitHub.Abstractions/Security/ISecretStore.cs new file mode 100644 index 0000000..6e68795 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Security/ISecretStore.cs @@ -0,0 +1,14 @@ +namespace JitHub.GitHub.Abstractions.Security; + +/// +/// Secure storage abstraction for secrets (e.g., OAuth tokens) when the app needs explicit control. +/// This is intentionally small so it can be backed by platform keychain/credential stores. +/// +public interface ISecretStore +{ + ValueTask GetAsync(string key, CancellationToken cancellationToken); + + ValueTask SetAsync(string key, string value, CancellationToken cancellationToken); + + ValueTask RemoveAsync(string key, CancellationToken cancellationToken); +} diff --git a/JitHub.GitHub.Abstractions/Security/InMemoryGitHubTokenProvider.cs b/JitHub.GitHub.Abstractions/Security/InMemoryGitHubTokenProvider.cs new file mode 100644 index 0000000..229ac66 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Security/InMemoryGitHubTokenProvider.cs @@ -0,0 +1,34 @@ +using System.Threading; + +namespace JitHub.GitHub.Abstractions.Security; + +/// +/// Simple token provider for unit tests and local harnesses. +/// +public sealed class InMemoryGitHubTokenProvider : IGitHubTokenProvider +{ + private readonly object _gate = new(); + private string? _token; + + public event EventHandler? TokenChanged; + + public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) + { + lock (_gate) + { + return ValueTask.FromResult(_token); + } + } + + public void NotifyTokenChanged() => TokenChanged?.Invoke(this, EventArgs.Empty); + + public void SetToken(string? token) + { + lock (_gate) + { + _token = token; + } + + NotifyTokenChanged(); + } +} diff --git a/JitHub.GitHub.Abstractions/Security/InMemorySecretStore.cs b/JitHub.GitHub.Abstractions/Security/InMemorySecretStore.cs new file mode 100644 index 0000000..d6998d2 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Security/InMemorySecretStore.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; + +namespace JitHub.GitHub.Abstractions.Security; + +/// +/// In-memory secret store for unit tests. Not secure. +/// +public sealed class InMemorySecretStore : ISecretStore +{ + private readonly ConcurrentDictionary _values = new(StringComparer.Ordinal); + + public ValueTask GetAsync(string key, CancellationToken cancellationToken) + { + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + _values.TryGetValue(key, out var value); + return ValueTask.FromResult(value); + } + + public ValueTask SetAsync(string key, string value, CancellationToken cancellationToken) + { + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + _values[key] = value; + return ValueTask.CompletedTask; + } + + public ValueTask RemoveAsync(string key, CancellationToken cancellationToken) + { + if (key is null) + { + throw new ArgumentNullException(nameof(key)); + } + + _values.TryRemove(key, out _); + return ValueTask.CompletedTask; + } +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubIssueConversationService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubIssueConversationService.cs new file mode 100644 index 0000000..5daa34d --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubIssueConversationService.cs @@ -0,0 +1,19 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubIssueConversationService +{ + Task GetIssueAsync( + RepoKey repo, + int issueNumber, + RefreshMode refresh, + CancellationToken ct); + + Task> GetCommentsAsync( + RepoKey repo, + int issueNumber, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubIssuePollingService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubIssuePollingService.cs new file mode 100644 index 0000000..56d6602 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubIssuePollingService.cs @@ -0,0 +1,16 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Polling; +using JitHub.GitHub.Abstractions.Queries; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubIssuePollingService +{ + Task StartIssuesPollingAsync( + RepoKey repo, + IssueQuery query, + PageRequest page, + PollingRequest polling, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubIssueService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubIssueService.cs new file mode 100644 index 0000000..314f82d --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubIssueService.cs @@ -0,0 +1,16 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubIssueService +{ + Task>> GetIssuesAsync( + RepoKey repo, + IssueQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct); +} diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryService.cs new file mode 100644 index 0000000..9f28df5 --- /dev/null +++ b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryService.cs @@ -0,0 +1,9 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; + +namespace JitHub.GitHub.Abstractions.Services; + +public interface IGitHubRepositoryService +{ + Task> GetMyRepositoriesAsync(RefreshMode refresh, CancellationToken ct); +} diff --git a/JitHub.GitHub.Octokit/Class1.cs b/JitHub.GitHub.Octokit/Class1.cs new file mode 100644 index 0000000..1194d23 --- /dev/null +++ b/JitHub.GitHub.Octokit/Class1.cs @@ -0,0 +1,6 @@ +namespace JitHub.GitHub.Octokit; + +public class Class1 +{ + +} diff --git a/JitHub.GitHub.Octokit/IOctokitClientFactory.cs b/JitHub.GitHub.Octokit/IOctokitClientFactory.cs new file mode 100644 index 0000000..b5a8d2b --- /dev/null +++ b/JitHub.GitHub.Octokit/IOctokitClientFactory.cs @@ -0,0 +1,8 @@ +using Octokit; + +namespace JitHub.GitHub.Octokit; + +public interface IOctokitClientFactory +{ + ValueTask CreateAsync(CancellationToken cancellationToken); +} diff --git a/JitHub.GitHub.Octokit/InternalsVisibleTo.cs b/JitHub.GitHub.Octokit/InternalsVisibleTo.cs new file mode 100644 index 0000000..c350909 --- /dev/null +++ b/JitHub.GitHub.Octokit/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("JitHub.GitHub.Tests")] diff --git a/JitHub.GitHub.Octokit/JitHub.GitHub.Octokit.csproj b/JitHub.GitHub.Octokit/JitHub.GitHub.Octokit.csproj new file mode 100644 index 0000000..1af322e --- /dev/null +++ b/JitHub.GitHub.Octokit/JitHub.GitHub.Octokit.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitIssueCommentData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitIssueCommentData.cs new file mode 100644 index 0000000..a5703d5 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitIssueCommentData.cs @@ -0,0 +1,8 @@ +namespace JitHub.GitHub.Octokit.Mapping; + +public sealed record OctokitIssueCommentData( + long Id, + string? AuthorLogin, + string? Body, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitIssueData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitIssueData.cs new file mode 100644 index 0000000..359d154 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitIssueData.cs @@ -0,0 +1,10 @@ +namespace JitHub.GitHub.Octokit.Mapping; + +public sealed record OctokitIssueData( + long Id, + int Number, + string Title, + string? State, + string? AuthorLogin, + int CommentCount, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitIssueDetailData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitIssueDetailData.cs new file mode 100644 index 0000000..1fb266d --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitIssueDetailData.cs @@ -0,0 +1,12 @@ +namespace JitHub.GitHub.Octokit.Mapping; + +public sealed record OctokitIssueDetailData( + long Id, + int Number, + string Title, + string? State, + string? AuthorLogin, + string? Body, + int CommentCount, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs new file mode 100644 index 0000000..d78b406 --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs @@ -0,0 +1,84 @@ +using JitHub.GitHub.Abstractions.Models; + +namespace JitHub.GitHub.Octokit.Mapping; + +public static class OctokitMappings +{ + public static RepositorySummary ToRepositorySummary(OctokitRepositoryData repo) + { + if (repo is null) + { + throw new ArgumentNullException(nameof(repo)); + } + + return new RepositorySummary( + Id: repo.Id, + Name: repo.Name ?? string.Empty, + OwnerLogin: repo.OwnerLogin ?? string.Empty, + IsPrivate: repo.IsPrivate, + DefaultBranch: repo.DefaultBranch, + Description: repo.Description, + UpdatedAt: repo.UpdatedAt); + } + + public static IssueSummary ToIssueSummary(OctokitIssueData issue) + { + if (issue is null) + { + throw new ArgumentNullException(nameof(issue)); + } + + return new IssueSummary( + Id: issue.Id, + Number: issue.Number, + Title: issue.Title ?? string.Empty, + State: ParseIssueState(issue.State), + AuthorLogin: issue.AuthorLogin, + CommentCount: issue.CommentCount, + UpdatedAt: issue.UpdatedAt); + } + + public static IssueDetail ToIssueDetail(OctokitIssueDetailData issue) + { + if (issue is null) + { + throw new ArgumentNullException(nameof(issue)); + } + + return new IssueDetail( + Id: issue.Id, + Number: issue.Number, + Title: issue.Title ?? string.Empty, + State: ParseIssueState(issue.State), + AuthorLogin: issue.AuthorLogin, + Body: issue.Body, + CommentCount: issue.CommentCount, + CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt); + } + + public static IssueComment ToIssueComment(OctokitIssueCommentData comment) + { + if (comment is null) + { + throw new ArgumentNullException(nameof(comment)); + } + + return new IssueComment( + Id: comment.Id, + AuthorLogin: comment.AuthorLogin, + Body: comment.Body, + CreatedAt: comment.CreatedAt, + UpdatedAt: comment.UpdatedAt); + } + + private static IssueState ParseIssueState(string? state) + { + if (string.Equals(state, "closed", StringComparison.OrdinalIgnoreCase)) + { + return IssueState.Closed; + } + + return IssueState.Open; + } +} diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryData.cs new file mode 100644 index 0000000..c7ed87c --- /dev/null +++ b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryData.cs @@ -0,0 +1,10 @@ +namespace JitHub.GitHub.Octokit.Mapping; + +public sealed record OctokitRepositoryData( + long Id, + string Name, + string? OwnerLogin, + bool IsPrivate, + string? DefaultBranch, + string? Description, + DateTimeOffset? UpdatedAt); diff --git a/JitHub.GitHub.Octokit/OctokitClientFactory.cs b/JitHub.GitHub.Octokit/OctokitClientFactory.cs new file mode 100644 index 0000000..a8b887f --- /dev/null +++ b/JitHub.GitHub.Octokit/OctokitClientFactory.cs @@ -0,0 +1,51 @@ +using JitHub.GitHub.Abstractions.Security; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace JitHub.GitHub.Octokit; + +public sealed class OctokitClientFactory : IOctokitClientFactory +{ + private static readonly Uri DefaultApiBaseAddress = new("https://api.github.com/"); + + private readonly IGitHubTokenProvider _tokenProvider; + private readonly OctokitClientOptions _options; + private readonly ILogger _logger; + + public OctokitClientFactory(IGitHubTokenProvider tokenProvider, OctokitClientOptions options, ILogger logger) + { + _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger; + + if (string.IsNullOrWhiteSpace(_options.ProductName)) + { + throw new ArgumentException("ProductName must not be empty.", nameof(options)); + } + } + + public async ValueTask CreateAsync(CancellationToken cancellationToken) + { + var token = await _tokenProvider.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogWarning("Octokit client creation failed: missing access token"); + throw new InvalidOperationException("Missing access token. Please login first."); + } + + var userAgent = _options.ProductVersion is null + ? new ProductHeaderValue(_options.ProductName) + : new ProductHeaderValue(_options.ProductName, _options.ProductVersion); + + var baseAddress = _options.ApiBaseAddress ?? DefaultApiBaseAddress; + + _logger.LogInformation("Creating Octokit client (BaseAddress={BaseAddress})", baseAddress); + var client = new GitHubClient(userAgent, baseAddress) + { + Credentials = new Credentials(token) + }; + + _options.OnClientCreated?.Invoke(new OctokitClientCreatedEvent(baseAddress, _options.ProductName, _options.ProductVersion)); + return client; + } +} diff --git a/JitHub.GitHub.Octokit/OctokitClientOptions.cs b/JitHub.GitHub.Octokit/OctokitClientOptions.cs new file mode 100644 index 0000000..b4f49be --- /dev/null +++ b/JitHub.GitHub.Octokit/OctokitClientOptions.cs @@ -0,0 +1,12 @@ +namespace JitHub.GitHub.Octokit; + +public sealed record OctokitClientOptions( + string ProductName, + string? ProductVersion = null, + Uri? ApiBaseAddress = null, + Action? OnClientCreated = null); + +public sealed record OctokitClientCreatedEvent( + Uri ApiBaseAddress, + string ProductName, + string? ProductVersion); diff --git a/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0a36f91 --- /dev/null +++ b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace JitHub.GitHub.Octokit; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddJitHubGitHubServices(this IServiceCollection services) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddSingleton(_ => new InMemoryCacheStore()); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubIssueConversationService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueConversationService.cs new file mode 100644 index 0000000..f050def --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueConversationService.cs @@ -0,0 +1,68 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubIssueConversationService : IGitHubIssueConversationService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubIssueConversationService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task GetIssueAsync(RepoKey repo, int issueNumber, RefreshMode refresh, CancellationToken ct) + { + var key = GitHubCacheKeys.Issue(repo, issueNumber); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached(key); + return cached.HasValue ? cached.Value : null; + } + + Func> fetchAsync = async token => + { + var data = await _dataSource.GetIssueAsync(repo, issueNumber, token).ConfigureAwait(false); + return data is null ? null : OctokitMappings.ToIssueDetail(data); + }; + + var snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue ? snapshot.Value : null; + } + + public async Task> GetCommentsAsync(RepoKey repo, int issueNumber, RefreshMode refresh, CancellationToken ct) + { + // POC: first page only. + var page = PageRequest.FirstPage(pageSize: 50); + var key = GitHubCacheKeys.IssueComments(repo, issueNumber, page); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>(key); + return cached.HasValue ? (cached.Value ?? Array.Empty()) : Array.Empty(); + } + + Func>> fetchAsync = async token => + { + var data = await _dataSource.GetIssueCommentsAsync(repo, issueNumber, page, token).ConfigureAwait(false); + return data.Select(OctokitMappings.ToIssueComment).ToArray(); + }; + + var snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue ? (snapshot.Value ?? Array.Empty()) : Array.Empty(); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubIssuePollingService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubIssuePollingService.cs new file mode 100644 index 0000000..cf2378e --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubIssuePollingService.cs @@ -0,0 +1,61 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Polling; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; +using Microsoft.Extensions.Logging; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubIssuePollingService : IGitHubIssuePollingService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + private readonly ILogger _logger; + + public CachedGitHubIssuePollingService(CacheRuntime cache, IGitHubDataSource dataSource, ILogger logger) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartIssuesPollingAsync( + RepoKey repo, + IssueQuery query, + PageRequest page, + PollingRequest polling, + CancellationToken ct) + { + _logger.LogInformation( + "Start issues polling: {Repo} state={State} interval={IntervalMs}ms jitterMax={JitterMs}ms", + $"{repo.Owner}/{repo.Name}", + query.State, + (long)polling.Interval.TotalMilliseconds, + (long)polling.JitterMax.GetValueOrDefault(TimeSpan.Zero).TotalMilliseconds); + + var key = GitHubCacheKeys.Issues(repo, query, page); + var options = new PollingOptions(polling.Interval, polling.JitterMax); + + return _cache.StartPolling( + key, + options, + fetchAsync: async token => + { + _logger.LogDebug("Polling fetch: {Repo} state={State}", $"{repo.Owner}/{repo.Name}", query.State); + var data = await _dataSource.GetIssuesAsync(repo, query, page, token).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToIssueSummary).ToArray(); + + PageRequest? next = null; + if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize) + { + next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return new PagedResult>(items, next); + }, + ct); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubIssueService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueService.cs new file mode 100644 index 0000000..7b0507e --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueService.cs @@ -0,0 +1,59 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubIssueService : IGitHubIssueService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + + public CachedGitHubIssueService(CacheRuntime cache, IGitHubDataSource dataSource) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + } + + public async Task>> GetIssuesAsync( + RepoKey repo, + IssueQuery query, + PageRequest page, + RefreshMode refresh, + CancellationToken ct) + { + var key = GitHubCacheKeys.Issues(repo, query, page); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>>(key); + return cached.HasValue ? cached.Value! : new PagedResult>(Array.Empty(), Next: null); + } + + Func>>> fetchAsync = async token => + { + var data = await _dataSource.GetIssuesAsync(repo, query, page, token).ConfigureAwait(false); + var items = data.Select(OctokitMappings.ToIssueSummary).ToArray(); + + PageRequest? next = null; + if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize) + { + next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize); + } + + return new PagedResult>(items, next); + }; + + CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + return snapshot.HasValue + ? snapshot.Value! + : new PagedResult>(Array.Empty(), Next: null); + } +} diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryService.cs new file mode 100644 index 0000000..688919c --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryService.cs @@ -0,0 +1,53 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Octokit.Mapping; +using Microsoft.Extensions.Logging; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class CachedGitHubRepositoryService : IGitHubRepositoryService +{ + private readonly CacheRuntime _cache; + private readonly IGitHubDataSource _dataSource; + private readonly ILogger _logger; + + public CachedGitHubRepositoryService(CacheRuntime cache, IGitHubDataSource dataSource, ILogger logger) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource)); + _logger = logger; + } + + public async Task> GetMyRepositoriesAsync(RefreshMode refresh, CancellationToken ct) + { + var key = GitHubCacheKeys.MyRepositories(); + + _logger.LogInformation("GetMyRepositoriesAsync (RefreshMode={RefreshMode})", refresh); + + if (refresh == RefreshMode.CacheOnly) + { + var cached = _cache.GetCached>(key); + _logger.LogInformation("Repositories cache-only: HasValue={HasValue}", cached.HasValue); + return cached.HasValue ? cached.Value! : Array.Empty(); + } + + Func>> fetchAsync = async token => + { + var data = await _dataSource.GetMyRepositoriesAsync(token).ConfigureAwait(false); + return data.Select(OctokitMappings.ToRepositorySummary).ToArray(); + }; + + CacheSnapshot> snapshot = refresh == RefreshMode.ForceRefresh + ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false) + : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false); + + _logger.LogInformation( + "Repositories snapshot: HasValue={HasValue} FromCache={IsFromCache}", + snapshot.HasValue, + snapshot.IsFromCache); + + return snapshot.HasValue ? snapshot.Value! : Array.Empty(); + } +} diff --git a/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs new file mode 100644 index 0000000..91999d7 --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs @@ -0,0 +1,56 @@ +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; + +namespace JitHub.GitHub.Octokit.Services; + +internal static class GitHubCacheKeys +{ + public static CacheKey MyRepositories() + => CacheKey.Create("github.repos.mine"); + + public static CacheKey Issues(RepoKey repo, IssueQuery query, PageRequest page) + { + var state = query.State.ToString(); + var search = string.IsNullOrWhiteSpace(query.SearchText) ? string.Empty : query.SearchText.Trim(); + + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.issues.list", + userScope: null, + ("owner", repo.Owner), + ("repo", repo.Name), + ("state", state), + ("search", search), + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } + + public static CacheKey Issue(RepoKey repo, int issueNumber) + => CacheKey.Create( + "github.issue.get", + userScope: null, + ("owner", repo.Owner), + ("repo", repo.Name), + ("issueNumber", issueNumber.ToString())); + + public static CacheKey IssueComments(RepoKey repo, int issueNumber, PageRequest page) + { + var pageNumber = page.PageNumber?.ToString() ?? string.Empty; + var cursor = page.Cursor ?? string.Empty; + + return CacheKey.Create( + "github.issue.comments.list", + userScope: null, + ("owner", repo.Owner), + ("repo", repo.Name), + ("issueNumber", issueNumber.ToString()), + ("pageSize", page.PageSize.ToString()), + ("pageNumber", pageNumber), + ("cursor", cursor)); + } +} diff --git a/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs new file mode 100644 index 0000000..fdd98fe --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs @@ -0,0 +1,17 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Octokit.Mapping; + +namespace JitHub.GitHub.Octokit.Services; + +internal interface IGitHubDataSource +{ + Task> GetMyRepositoriesAsync(CancellationToken ct); + + Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct); + + Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct); + + Task> GetIssueCommentsAsync(RepoKey repo, int issueNumber, PageRequest page, CancellationToken ct); +} diff --git a/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs new file mode 100644 index 0000000..572e3a3 --- /dev/null +++ b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs @@ -0,0 +1,175 @@ +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Octokit.Mapping; +using Octokit; + +namespace JitHub.GitHub.Octokit.Services; + +internal sealed class OctokitGitHubDataSource : IGitHubDataSource +{ + private readonly IOctokitClientFactory _clientFactory; + + public OctokitGitHubDataSource(IOctokitClientFactory clientFactory) + { + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + } + + public async Task> GetMyRepositoriesAsync(CancellationToken ct) + { + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var request = new RepositoryRequest + { + // Align with the POC: show repositories the user can access. + Affiliation = RepositoryAffiliation.Owner | RepositoryAffiliation.Collaborator | RepositoryAffiliation.OrganizationMember, + }; + + var repos = await client.Repository.GetAllForCurrent(request).ConfigureAwait(false); + + return repos + .Select(r => new OctokitRepositoryData( + Id: r.Id, + Name: r.Name, + OwnerLogin: r.Owner?.Login, + IsPrivate: r.Private, + DefaultBranch: r.DefaultBranch, + Description: r.Description, + UpdatedAt: r.UpdatedAt)) + .ToArray(); + } + + public async Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var apiOptions = new ApiOptions + { + PageCount = 1, + PageSize = page.PageSize, + StartPage = page.PageNumber.GetValueOrDefault(1), + }; + + if (!string.IsNullOrWhiteSpace(query.SearchText)) + { + var q = BuildSearchQuery(repo, query); + var request = new SearchIssuesRequest(q) + { + PerPage = page.PageSize, + Page = page.PageNumber.GetValueOrDefault(1), + }; + + var result = await client.Search.SearchIssues(request).ConfigureAwait(false); + + return result.Items + .Select(i => new OctokitIssueData( + Id: i.Id, + Number: i.Number, + Title: i.Title, + State: i.State.ToString(), + AuthorLogin: i.User?.Login, + CommentCount: i.Comments, + UpdatedAt: i.UpdatedAt)) + .ToArray(); + } + + var issueRequest = new RepositoryIssueRequest + { + State = query.State switch + { + IssueStateFilter.Open => ItemStateFilter.Open, + IssueStateFilter.Closed => ItemStateFilter.Closed, + IssueStateFilter.All => ItemStateFilter.All, + _ => ItemStateFilter.Open, + }, + }; + + var issues = await client.Issue.GetAllForRepository(repo.Owner, repo.Name, issueRequest, apiOptions).ConfigureAwait(false); + + return issues + .Select(i => new OctokitIssueData( + Id: i.Id, + Number: i.Number, + Title: i.Title, + State: i.State.ToString(), + AuthorLogin: i.User?.Login, + CommentCount: i.Comments, + UpdatedAt: i.UpdatedAt)) + .ToArray(); + } + + public async Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct) + { + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var issue = await client.Issue.Get(repo.Owner, repo.Name, issueNumber).ConfigureAwait(false); + + return new OctokitIssueDetailData( + Id: issue.Id, + Number: issue.Number, + Title: issue.Title, + State: issue.State.ToString(), + AuthorLogin: issue.User?.Login, + Body: issue.Body, + CommentCount: issue.Comments, + CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt); + } + + public async Task> GetIssueCommentsAsync(RepoKey repo, int issueNumber, PageRequest page, CancellationToken ct) + { + if (page.Cursor is not null) + { + throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider."); + } + + var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false); + + var apiOptions = new ApiOptions + { + PageCount = 1, + PageSize = page.PageSize, + StartPage = page.PageNumber.GetValueOrDefault(1), + }; + + var comments = await client.Issue.Comment.GetAllForIssue(repo.Owner, repo.Name, issueNumber, apiOptions).ConfigureAwait(false); + + return comments + .Select(c => new OctokitIssueCommentData( + Id: c.Id, + AuthorLogin: c.User?.Login, + Body: c.Body, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt)) + .ToArray(); + } + + private static string BuildSearchQuery(RepoKey repo, IssueQuery query) + { + var terms = new List + { + $"repo:{repo.Owner}/{repo.Name}", + "is:issue", + }; + + terms.Add(query.State switch + { + IssueStateFilter.Open => "state:open", + IssueStateFilter.Closed => "state:closed", + IssueStateFilter.All => "state:open state:closed", + _ => "state:open", + }); + + if (!string.IsNullOrWhiteSpace(query.SearchText)) + { + terms.Add(query.SearchText.Trim()); + } + + return string.Join(" ", terms); + } +} diff --git a/JitHub.GitHub.Tests/CacheEventBusHardeningTests.cs b/JitHub.GitHub.Tests/CacheEventBusHardeningTests.cs new file mode 100644 index 0000000..62efa73 --- /dev/null +++ b/JitHub.GitHub.Tests/CacheEventBusHardeningTests.cs @@ -0,0 +1,29 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CacheEventBusHardeningTests +{ + [Test] + public void Publish_isolates_subscriber_exceptions() + { + var bus = new CacheEventBus(); + + var called = 0; + using var sub1 = bus.Subscribe(_ => throw new InvalidOperationException("boom")); + using var sub2 = bus.Subscribe(_ => called++); + + var evt = new CacheEvent( + Kind: CacheEventKind.Updated, + Key: CacheKey.Create("op"), + ValueType: typeof(string), + Value: "v", + Error: null, + TimestampUtc: DateTimeOffset.UtcNow); + + bus.Invoking(b => b.Publish(evt)).Should().NotThrow(); + called.Should().Be(1); + } +} diff --git a/JitHub.GitHub.Tests/CacheKeyExtensionsTests.cs b/JitHub.GitHub.Tests/CacheKeyExtensionsTests.cs new file mode 100644 index 0000000..f159386 --- /dev/null +++ b/JitHub.GitHub.Tests/CacheKeyExtensionsTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CacheKeyExtensionsTests +{ + [Test] + public void GetParameterValue_returns_null_when_missing() + { + var key = CacheKey.Create("op", userScope: null, ("a", "1")); + + key.GetParameterValue("missing").Should().BeNull(); + key.GetParameterValueOrEmpty("missing").Should().BeEmpty(); + } + + [Test] + public void GetParameterValue_returns_value_when_present() + { + var key = new CacheKey( + operation: "op", + userScope: null, + parameters: + [ + new KeyValuePair(" owner ", " octo "), + new KeyValuePair("repo", "hello"), + ]); + + key.GetParameterValue("owner").Should().Be("octo"); + key.GetParameterValueOrEmpty("repo").Should().Be("hello"); + } +} diff --git a/JitHub.GitHub.Tests/CacheKeyTests.cs b/JitHub.GitHub.Tests/CacheKeyTests.cs new file mode 100644 index 0000000..37021b1 --- /dev/null +++ b/JitHub.GitHub.Tests/CacheKeyTests.cs @@ -0,0 +1,45 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CacheKeyTests +{ + [Test] + public void Equal_when_parameters_are_provided_in_different_orders() + { + var a = CacheKey.Create( + operation: "issues.list", + userScope: "user1", + ("repo", "runtime"), + ("owner", "dotnet")); + + var b = CacheKey.Create( + operation: "issues.list", + userScope: "user1", + ("owner", "dotnet"), + ("repo", "runtime")); + + a.Should().Be(b); + a.ToString().Should().Be(b.ToString()); + } + + [Test] + public void Trims_operation_and_scope_and_parameters() + { + var key = new CacheKey( + operation: " repos.list ", + userScope: " me ", + parameters: new[] + { + new KeyValuePair(" owner ", " dotnet "), + }); + + key.Operation.Should().Be("repos.list"); + key.UserScope.Should().Be("me"); + key.Parameters.Should().ContainSingle(); + key.Parameters[0].Key.Should().Be("owner"); + key.Parameters[0].Value.Should().Be("dotnet"); + } +} diff --git a/JitHub.GitHub.Tests/CacheRuntimeHardeningTests.cs b/JitHub.GitHub.Tests/CacheRuntimeHardeningTests.cs new file mode 100644 index 0000000..7e28c56 --- /dev/null +++ b/JitHub.GitHub.Tests/CacheRuntimeHardeningTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CacheRuntimeHardeningTests +{ + [Test] + public async Task Canceling_one_waiter_does_not_cancel_shared_inflight_refresh() + { + var events = new CacheEventBus(); + var cache = new CacheRuntime(new InMemoryCacheStore(), events); + var key = CacheKey.Create("op.shared"); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowComplete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var callCount = 0; + + async Task FetchAsync(CancellationToken ct) + { + Interlocked.Increment(ref callCount); + started.TrySetResult(); + await allowComplete.Task.WaitAsync(ct); + return 123; + } + + using var cts1 = new CancellationTokenSource(); + using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + var t1 = cache.RefreshAsync(key, FetchAsync, cts1.Token); + var t2 = cache.RefreshAsync(key, FetchAsync, cts2.Token); + + await started.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + // One waiter cancels while another remains active. + cts1.Cancel(); + + // Allow the shared fetch to finish. + allowComplete.TrySetResult(); + + var r2 = await t2; + r2.HasValue.Should().BeTrue(); + + var cached = cache.GetCached(key); + cached.HasValue.Should().BeTrue(); + cached.Value.Should().Be(123); + + callCount.Should().Be(1); + + // Ensure the canceled waiter does not hang. + await t1.WaitAsync(TimeSpan.FromSeconds(2)); + } +} diff --git a/JitHub.GitHub.Tests/CacheRuntimeTests.cs b/JitHub.GitHub.Tests/CacheRuntimeTests.cs new file mode 100644 index 0000000..0951bae --- /dev/null +++ b/JitHub.GitHub.Tests/CacheRuntimeTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CacheRuntimeTests +{ + [Test] + public async Task PreferCacheThenRefresh_returns_cached_and_refreshes_in_background() + { + var store = new InMemoryCacheStore(new InMemoryCacheStoreOptions(MaxEntries: 10)); + var bus = new CacheEventBus(); + var runtime = new CacheRuntime(store, bus); + + var key = CacheKey.Create("repos.list", userScope: "me"); + + store.Set(key, 1, new CacheItemMetadata(DateTimeOffset.UtcNow)); + + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var sub = bus.Subscribe(e => + { + if (e.Kind == CacheEventKind.Updated && e.Key.Equals(key) && e.Value is int v) + { + updated.TrySetResult(v); + } + }); + + var fetchCalls = 0; + Task Fetch(CancellationToken ct) + { + Interlocked.Increment(ref fetchCalls); + return Task.FromResult(2); + } + + var snapshot = await runtime.GetOrRefreshAsync(key, preferCacheThenRefresh: true, Fetch, CancellationToken.None); + snapshot.IsFromCache.Should().BeTrue(); + snapshot.Value.Should().Be(1); + + var published = await updated.Task.WaitAsync(TimeSpan.FromSeconds(2)); + published.Should().Be(2); + + store.TryGet(key, out var stored, out _).Should().BeTrue(); + stored.Should().Be(2); + fetchCalls.Should().Be(1); + } + + [Test] + public async Task Concurrent_refresh_deduplicates_inflight_fetch() + { + var store = new InMemoryCacheStore(new InMemoryCacheStoreOptions(MaxEntries: 10)); + var runtime = new CacheRuntime(store, new CacheEventBus()); + var key = CacheKey.Create("issues.list", userScope: "me", ("owner", "dotnet"), ("repo", "runtime")); + + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + + async Task Fetch(CancellationToken ct) + { + Interlocked.Increment(ref calls); + await gate.Task; + return 123; + } + + var t1 = runtime.RefreshAsync(key, Fetch, CancellationToken.None); + var t2 = runtime.RefreshAsync(key, Fetch, CancellationToken.None); + + gate.SetResult(); + await Task.WhenAll(t1, t2); + + calls.Should().Be(1); + store.TryGet(key, out var stored, out _).Should().BeTrue(); + stored.Should().Be(123); + } + + [Test] + public async Task Polling_stops_on_cancellation() + { + var store = new InMemoryCacheStore(new InMemoryCacheStoreOptions(MaxEntries: 10)); + var runtime = new CacheRuntime(store, new CacheEventBus()); + var key = CacheKey.Create("repos.list", userScope: "me"); + + var calls = 0; + Task Fetch(CancellationToken ct) + { + Interlocked.Increment(ref calls); + return Task.FromResult(1); + } + + using var cts = new CancellationTokenSource(); + var pollTask = runtime.StartPolling(key, new PollingOptions(TimeSpan.FromMilliseconds(20), JitterMax: TimeSpan.Zero), Fetch, cts.Token); + + await Task.Delay(60); + cts.Cancel(); + + await pollTask.ContinueWith(_ => { }, TaskScheduler.Default); + calls.Should().BeGreaterThan(0); + } +} diff --git a/JitHub.GitHub.Tests/CachedServicesTests.cs b/JitHub.GitHub.Tests/CachedServicesTests.cs new file mode 100644 index 0000000..8c0d07f --- /dev/null +++ b/JitHub.GitHub.Tests/CachedServicesTests.cs @@ -0,0 +1,228 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Paging; +using JitHub.GitHub.Abstractions.Polling; +using JitHub.GitHub.Abstractions.Queries; +using JitHub.GitHub.Abstractions.Refresh; +using JitHub.GitHub.Octokit.Mapping; +using JitHub.GitHub.Octokit.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class CachedServicesTests +{ + [Test] + public async Task RepoService_cache_only_returns_cached_value() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var dataSource = new FakeGitHubDataSource + { + Repositories = + [ + new OctokitRepositoryData(1, "repo1", "me", true, "main", null, DateTimeOffset.UtcNow), + ] + }; + + var sut = new CachedGitHubRepositoryService(cache, dataSource, NullLogger.Instance); + + var first = await sut.GetMyRepositoriesAsync(RefreshMode.ForceRefresh, CancellationToken.None); + first.Should().HaveCount(1); + dataSource.GetMyRepositoriesCallCount.Should().Be(1); + + var cached = await sut.GetMyRepositoriesAsync(RefreshMode.CacheOnly, CancellationToken.None); + cached.Should().HaveCount(1); + dataSource.GetMyRepositoriesCallCount.Should().Be(1); + } + + [Test] + public async Task IssueService_cache_key_varies_by_state() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var dataSource = new FakeGitHubDataSource + { + IssuesFactory = (_, query, _, _) => + { + var state = query.State == IssueStateFilter.Closed ? "closed" : "open"; + return Task.FromResult>( + [ + new OctokitIssueData(10, 1, $"{state} issue", state, "me", 0, DateTimeOffset.UtcNow), + ]); + } + }; + + var sut = new CachedGitHubIssueService(cache, dataSource); + var repo = new RepoKey("octo", "hello"); + var page = PageRequest.FirstPage(30); + + var open = await sut.GetIssuesAsync(repo, new IssueQuery(IssueStateFilter.Open), page, RefreshMode.ForceRefresh, CancellationToken.None); + open.Items.Should().ContainSingle(); + + var closed = await sut.GetIssuesAsync(repo, new IssueQuery(IssueStateFilter.Closed), page, RefreshMode.ForceRefresh, CancellationToken.None); + closed.Items.Should().ContainSingle(); + + dataSource.GetIssuesCallCount.Should().Be(2); + + // CacheOnly should not call datasource again + _ = await sut.GetIssuesAsync(repo, new IssueQuery(IssueStateFilter.Open), page, RefreshMode.CacheOnly, CancellationToken.None); + dataSource.GetIssuesCallCount.Should().Be(2); + } + + [Test] + public async Task IssueService_cache_key_varies_by_page_number() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + + var now = DateTimeOffset.UtcNow; + var dataSource = new FakeGitHubDataSource + { + IssuesFactory = (_, _, page, _) => + { + var n = page.PageNumber ?? 0; + return Task.FromResult>( + [ + new OctokitIssueData(Id: n, Number: n, Title: $"page {n}", State: "open", AuthorLogin: "me", CommentCount: 0, UpdatedAt: now), + ]); + } + }; + + var sut = new CachedGitHubIssueService(cache, dataSource); + var repo = new RepoKey("octo", "hello"); + var query = new IssueQuery(IssueStateFilter.Open); + + var p1 = PageRequest.FromPageNumber(1, pageSize: 30); + var p2 = PageRequest.FromPageNumber(2, pageSize: 30); + + var r1 = await sut.GetIssuesAsync(repo, query, p1, RefreshMode.ForceRefresh, CancellationToken.None); + var r2 = await sut.GetIssuesAsync(repo, query, p2, RefreshMode.ForceRefresh, CancellationToken.None); + + dataSource.GetIssuesCallCount.Should().Be(2); + + var cached1 = await sut.GetIssuesAsync(repo, query, p1, RefreshMode.CacheOnly, CancellationToken.None); + cached1.Items.Should().ContainSingle(); + + cached1.Items[0].Id.Should().Be(r1.Items[0].Id); + cached1.Items[0].Id.Should().NotBe(r2.Items[0].Id); + } + + [Test] + public void IssueService_cursor_paging_is_not_supported() + { + var cache = new CacheRuntime(new InMemoryCacheStore(), new CacheEventBus()); + var dataSource = new FakeGitHubDataSource(); + var sut = new CachedGitHubIssueService(cache, dataSource); + + var repo = new RepoKey("octo", "hello"); + var query = new IssueQuery(IssueStateFilter.Open); + var page = PageRequest.FromCursor("abc", pageSize: 30); + + Func act = () => sut.GetIssuesAsync(repo, query, page, RefreshMode.ForceRefresh, CancellationToken.None); + act.Should().ThrowAsync(); + } + + [Test] + public async Task Polling_populates_same_cache_entry_as_issue_service() + { + var events = new CacheEventBus(); + var cache = new CacheRuntime(new InMemoryCacheStore(), events); + + var dataSource = new FakeGitHubDataSource + { + IssuesFactory = (_, _, _, _) => + Task.FromResult>( + [ + new OctokitIssueData(1, 42, "Hello", "open", "me", 1, DateTimeOffset.UtcNow), + ]) + }; + + var issueService = new CachedGitHubIssueService(cache, dataSource); + var pollingService = new CachedGitHubIssuePollingService(cache, dataSource, NullLogger.Instance); + + var repo = new RepoKey("octo", "hello"); + var query = new IssueQuery(IssueStateFilter.Open); + var page = PageRequest.FirstPage(30); + + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var sub = events.Subscribe(e => + { + if (e.Kind == CacheEventKind.Updated && e.Key.Operation == "github.issues.list") + { + updated.TrySetResult(); + } + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + + var pollingTask = pollingService.StartIssuesPollingAsync( + repo, + query, + page, + new PollingRequest(TimeSpan.FromMilliseconds(1)), + cts.Token); + + await updated.Task; + cts.Cancel(); + + // Polling may complete as canceled. + try { await pollingTask; } catch (OperationCanceledException) { } + + var cached = await issueService.GetIssuesAsync(repo, query, page, RefreshMode.CacheOnly, CancellationToken.None); + cached.Items.Should().ContainSingle(i => i.Number == 42); + } + + internal sealed class FakeGitHubDataSource : IGitHubDataSource + { + public IReadOnlyList Repositories { get; init; } = Array.Empty(); + + public Func>>? IssuesFactory { get; init; } + + public Func>? IssueFactory { get; init; } + + public Func>>? IssueCommentsFactory { get; init; } + + public int GetMyRepositoriesCallCount { get; private set; } + public int GetIssuesCallCount { get; private set; } + + public Task> GetMyRepositoriesAsync(CancellationToken ct) + { + GetMyRepositoriesCallCount++; + return Task.FromResult(Repositories); + } + + public Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct) + { + GetIssuesCallCount++; + + if (IssuesFactory is not null) + { + return IssuesFactory(repo, query, page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct) + { + if (IssueFactory is not null) + { + return IssueFactory(repo, issueNumber, ct); + } + + return Task.FromResult(null); + } + + public Task> GetIssueCommentsAsync(RepoKey repo, int issueNumber, PageRequest page, CancellationToken ct) + { + if (IssueCommentsFactory is not null) + { + return IssueCommentsFactory(repo, issueNumber, page, ct); + } + + return Task.FromResult>(Array.Empty()); + } + } +} diff --git a/JitHub.GitHub.Tests/InMemoryCacheStoreTests.cs b/JitHub.GitHub.Tests/InMemoryCacheStoreTests.cs new file mode 100644 index 0000000..cc978d9 --- /dev/null +++ b/JitHub.GitHub.Tests/InMemoryCacheStoreTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class InMemoryCacheStoreTests +{ + [Test] + public void Evicts_least_recently_used_entries() + { + var store = new InMemoryCacheStore(new InMemoryCacheStoreOptions(MaxEntries: 2)); + var meta = new CacheItemMetadata(DateTimeOffset.UtcNow); + + var k1 = CacheKey.Create("op", userScope: null, ("k", "1")); + var k2 = CacheKey.Create("op", userScope: null, ("k", "2")); + var k3 = CacheKey.Create("op", userScope: null, ("k", "3")); + + store.Set(k1, 1, meta); + store.Set(k2, 2, meta); + + store.TryGet(k1, out _, out _).Should().BeTrue(); + + store.Set(k3, 3, meta); + + store.TryGet(k2, out _, out _).Should().BeFalse(); + store.TryGet(k1, out var v1, out _).Should().BeTrue(); + v1.Should().Be(1); + store.TryGet(k3, out var v3, out _).Should().BeTrue(); + v3.Should().Be(3); + } +} diff --git a/JitHub.GitHub.Tests/JitHub.GitHub.Tests.csproj b/JitHub.GitHub.Tests/JitHub.GitHub.Tests.csproj new file mode 100644 index 0000000..913ee48 --- /dev/null +++ b/JitHub.GitHub.Tests/JitHub.GitHub.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs b/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs new file mode 100644 index 0000000..be892ce --- /dev/null +++ b/JitHub.GitHub.Tests/ObservableCollectionSyncTests.cs @@ -0,0 +1,56 @@ +using System.Collections.ObjectModel; +using FluentAssertions; +using JitHub.Data.Caching; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class ObservableCollectionSyncTests +{ + private sealed record Item(long Id, int Revision); + + [Test] + public void SyncById_clears_when_source_empty() + { + var target = new ObservableCollection { new(1, 0) }; + + ObservableCollectionSync.SyncById( + target, + Array.Empty(), + getId: x => x.Id, + shouldReplace: (_, _) => true); + + target.Should().BeEmpty(); + } + + [Test] + public void SyncById_inserts_moves_replaces_and_trims() + { + var target = new ObservableCollection + { + new(1, 0), + new(2, 0), + new(3, 0), + new(4, 0), + }; + + // Desired: reorder, replace item 2, remove item 4, insert item 5. + var source = new[] + { + new Item(3, 0), + new Item(2, 1), + new Item(1, 0), + new Item(5, 0), + }; + + ObservableCollectionSync.SyncById( + target, + source, + getId: x => x.Id, + shouldReplace: (current, next) => current.Revision != next.Revision); + + target.Select(x => x.Id).Should().Equal(3, 2, 1, 5); + target.Single(x => x.Id == 2).Revision.Should().Be(1); + target.Any(x => x.Id == 4).Should().BeFalse(); + } +} diff --git a/JitHub.GitHub.Tests/OctokitAdapterTests.cs b/JitHub.GitHub.Tests/OctokitAdapterTests.cs new file mode 100644 index 0000000..bd8277f --- /dev/null +++ b/JitHub.GitHub.Tests/OctokitAdapterTests.cs @@ -0,0 +1,84 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Security; +using JitHub.GitHub.Octokit; +using JitHub.GitHub.Octokit.Mapping; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class OctokitAdapterTests +{ + [Test] + public async Task OctokitClientFactory_sets_user_agent_base_address_and_token() + { + var tokenProvider = new InMemoryGitHubTokenProvider(); + tokenProvider.SetToken("t"); + + var created = new List(); + var options = new OctokitClientOptions( + ProductName: "JitHubV3", + ProductVersion: "test", + ApiBaseAddress: new Uri("https://api.github.com/"), + OnClientCreated: e => created.Add(e)); + + var factory = new OctokitClientFactory(tokenProvider, options, NullLogger.Instance); + var client = await factory.CreateAsync(CancellationToken.None); + + client.Credentials.Should().NotBeNull(); + client.Connection.BaseAddress.Should().Be(options.ApiBaseAddress); + + created.Should().ContainSingle(); + created[0].ApiBaseAddress.Should().Be(options.ApiBaseAddress); + } + + [Test] + public void Mapping_repository_handles_null_owner_login() + { + var data = new OctokitRepositoryData( + Id: 1, + Name: "repo", + OwnerLogin: null, + IsPrivate: true, + DefaultBranch: "main", + Description: null, + UpdatedAt: null); + + var mapped = OctokitMappings.ToRepositorySummary(data); + mapped.Should().BeEquivalentTo(new RepositorySummary( + Id: 1, + Name: "repo", + OwnerLogin: string.Empty, + IsPrivate: true, + DefaultBranch: "main", + Description: null, + UpdatedAt: null)); + } + + [Test] + public void Mapping_issue_maps_state_case_insensitively() + { + var open = OctokitMappings.ToIssueSummary(new OctokitIssueData( + Id: 1, + Number: 10, + Title: "t", + State: "OPEN", + AuthorLogin: "me", + CommentCount: 3, + UpdatedAt: null)); + + open.State.Should().Be(IssueState.Open); + + var closed = OctokitMappings.ToIssueSummary(new OctokitIssueData( + Id: 2, + Number: 11, + Title: "t", + State: "closed", + AuthorLogin: "me", + CommentCount: 0, + UpdatedAt: null)); + + closed.State.Should().Be(IssueState.Closed); + } +} diff --git a/JitHub.GitHub.Tests/PagingTests.cs b/JitHub.GitHub.Tests/PagingTests.cs new file mode 100644 index 0000000..e051933 --- /dev/null +++ b/JitHub.GitHub.Tests/PagingTests.cs @@ -0,0 +1,27 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Paging; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class PagingTests +{ + [Test] + public void FirstPage_sets_page_number_1() + { + var page = PageRequest.FirstPage(pageSize: 30); + + page.PageSize.Should().Be(30); + page.PageNumber.Should().Be(1); + page.Cursor.Should().BeNull(); + } + + [Test] + public void FromCursor_normalizes_empty_to_null() + { + var page = PageRequest.FromCursor(" ", pageSize: 30); + + page.PageNumber.Should().BeNull(); + page.Cursor.Should().BeNull(); + } +} diff --git a/JitHub.GitHub.Tests/RepoKeyTests.cs b/JitHub.GitHub.Tests/RepoKeyTests.cs new file mode 100644 index 0000000..ab14de8 --- /dev/null +++ b/JitHub.GitHub.Tests/RepoKeyTests.cs @@ -0,0 +1,32 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Models; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class RepoKeyTests +{ + [Test] + public void Ctor_trims_owner_and_name() + { + var key = new RepoKey(" dotnet ", " runtime "); + + key.Owner.Should().Be("dotnet"); + key.Name.Should().Be("runtime"); + key.ToString().Should().Be("dotnet/runtime"); + } + + [Test] + public void Ctor_rejects_empty_owner() + { + var act = () => _ = new RepoKey(" ", "repo"); + act.Should().Throw(); + } + + [Test] + public void Ctor_rejects_empty_name() + { + var act = () => _ = new RepoKey("owner", " "); + act.Should().Throw(); + } +} diff --git a/JitHub.GitHub.Tests/SecurityAbstractionsTests.cs b/JitHub.GitHub.Tests/SecurityAbstractionsTests.cs new file mode 100644 index 0000000..51619fa --- /dev/null +++ b/JitHub.GitHub.Tests/SecurityAbstractionsTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using JitHub.GitHub.Abstractions.Security; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class SecurityAbstractionsTests +{ + [Test] + public async Task InMemoryGitHubTokenProvider_returns_null_when_not_set() + { + var provider = new InMemoryGitHubTokenProvider(); + var token = await provider.GetAccessTokenAsync(CancellationToken.None); + token.Should().BeNull(); + } + + [Test] + public async Task InMemoryGitHubTokenProvider_raises_TokenChanged_on_set() + { + var provider = new InMemoryGitHubTokenProvider(); + var raised = 0; + provider.TokenChanged += (_, _) => Interlocked.Increment(ref raised); + + provider.SetToken("abc"); + (await provider.GetAccessTokenAsync(CancellationToken.None)).Should().Be("abc"); + raised.Should().Be(1); + + provider.SetToken(null); + (await provider.GetAccessTokenAsync(CancellationToken.None)).Should().BeNull(); + raised.Should().Be(2); + } + + [Test] + public async Task InMemorySecretStore_set_get_remove_roundtrip() + { + var store = new InMemorySecretStore(); + var ct = CancellationToken.None; + + (await store.GetAsync("k", ct)).Should().BeNull(); + + await store.SetAsync("k", "v", ct); + (await store.GetAsync("k", ct)).Should().Be("v"); + + await store.RemoveAsync("k", ct); + (await store.GetAsync("k", ct)).Should().BeNull(); + } +} diff --git a/JitHub.GitHub.Tests/SmokeTests.cs b/JitHub.GitHub.Tests/SmokeTests.cs new file mode 100644 index 0000000..ae1825e --- /dev/null +++ b/JitHub.GitHub.Tests/SmokeTests.cs @@ -0,0 +1,13 @@ +using FluentAssertions; +using NUnit.Framework; + +namespace JitHub.GitHub.Tests; + +public sealed class SmokeTests +{ + [Test] + public void True_is_true() + { + true.Should().BeTrue(); + } +} diff --git a/JitHub.GitHub.Tests/UnitTest1.cs b/JitHub.GitHub.Tests/UnitTest1.cs new file mode 100644 index 0000000..bd45b78 --- /dev/null +++ b/JitHub.GitHub.Tests/UnitTest1.cs @@ -0,0 +1,15 @@ +namespace JitHub.GitHub.Tests; + +public class Tests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void Test1() + { + Assert.Pass(); + } +} diff --git a/JitHub.Markdown.Skia/Rendering/SkiaMarkdownRenderer.cs b/JitHub.Markdown.Skia/Rendering/SkiaMarkdownRenderer.cs index 2abe785..64e5cea 100644 --- a/JitHub.Markdown.Skia/Rendering/SkiaMarkdownRenderer.cs +++ b/JitHub.Markdown.Skia/Rendering/SkiaMarkdownRenderer.cs @@ -787,12 +787,9 @@ private static void DrawImageRun(InlineRunLayout run, RenderContext context) } var image = (uri is not null && context.ImageResolver is not null) ? context.ImageResolver(uri) : null; - if (image is not null) - { - context.Canvas.DrawImage(image, bounds); - return; - } + // Always paint the placeholder surface first so the area reads as an image block even + // before the image loads (or when it fails to load). using var paint = new SKPaint { IsAntialias = true, @@ -808,6 +805,27 @@ private static void DrawImageRun(InlineRunLayout run, RenderContext context) { context.Canvas.DrawRoundRect(bounds, radius, radius, paint); } + + if (image is null) + { + return; + } + + // User preference: render images at their intrinsic size (no fit-to-width scaling). + // If they overflow the placeholder surface, they will be clipped. + context.Canvas.Save(); + if (radius <= 0) + { + context.Canvas.ClipRect(bounds, antialias: true); + } + else + { + var rr = new SKRoundRect(bounds, radius, radius); + context.Canvas.ClipRoundRect(rr, antialias: true); + } + + context.Canvas.DrawImage(image, bounds.Left, bounds.Top); + context.Canvas.Restore(); } private static ColorRgba Multiply(ColorRgba c, float factor) diff --git a/JitHub.Markdown.Uno/MarkdownView.Shared.cs b/JitHub.Markdown.Uno/MarkdownView.Shared.cs index 13f9286..b34cea3 100644 --- a/JitHub.Markdown.Uno/MarkdownView.Shared.cs +++ b/JitHub.Markdown.Uno/MarkdownView.Shared.cs @@ -1,11 +1,18 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using System.Globalization; +using JitHub.Markdown; namespace JitHub.Markdown.Uno; public sealed partial class MarkdownView : UserControl { + public static readonly DependencyProperty AutoThemeEnabledProperty = DependencyProperty.Register( + nameof(AutoThemeEnabled), + typeof(bool), + typeof(MarkdownView), + new PropertyMetadata(true, OnAutoThemeEnabledChanged)); + public static readonly DependencyProperty GitHubBaseUrlProperty = DependencyProperty.Register( nameof(GitHubBaseUrl), typeof(string), @@ -48,6 +55,19 @@ public sealed partial class MarkdownView : UserControl typeof(MarkdownView), new PropertyMetadata(true, OnSelectionEnabledChanged)); + private MarkdownPlatformThemeWatcher? _autoThemeWatcher; + private bool _autoThemeEligible; + + /// + /// When enabled (default), and when is not explicitly set by the consumer, + /// the control will track platform Light/Dark/HighContrast and apply the matching markdown theme. + /// + public bool AutoThemeEnabled + { + get => (bool)GetValue(AutoThemeEnabledProperty); + set => SetValue(AutoThemeEnabledProperty, value); + } + public bool IsRightToLeft { get => (bool)GetValue(IsRightToLeftProperty); @@ -104,6 +124,11 @@ private static void OnMarkdownChanged(DependencyObject d, DependencyPropertyChan ((MarkdownView)d).SyncMarkdown(); } + private static void OnAutoThemeEnabledChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((MarkdownView)d).SyncAutoTheme(); + } + private static void OnThemeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { ((MarkdownView)d).SyncTheme(); @@ -131,6 +156,7 @@ private void SyncAll() SyncSelectionEnabled(); SyncIsRightToLeft(); SyncGitHubEnrichments(); + SyncAutoTheme(); } private void SyncMarkdown() @@ -160,6 +186,50 @@ private void SyncGitHubEnrichments() _host.GitHubRepositorySlug = GitHubRepositorySlug; } + private void InitializeAutoThemeSupport() + { + Loaded += (_, __) => + { + // Only auto-theme when the consumer hasn't set Theme explicitly (binding/local value). + // This prevents overriding pages that intentionally drive Theme (e.g., MarkdownTestPage). + _autoThemeEligible = ReadLocalValue(ThemeProperty) == DependencyProperty.UnsetValue; + SyncAutoTheme(); + }; + + Unloaded += (_, __) => + { + _autoThemeWatcher?.Dispose(); + _autoThemeWatcher = null; + }; + } + + private void SyncAutoTheme() + { + if (!IsLoaded) + { + return; + } + + if (!AutoThemeEnabled || !_autoThemeEligible) + { + _autoThemeWatcher?.Dispose(); + _autoThemeWatcher = null; + return; + } + + if (_autoThemeWatcher is null) + { + _autoThemeWatcher = new MarkdownPlatformThemeWatcher(this); + _autoThemeWatcher.VariantChanged += (_, variant) => + { + // Apply via Theme DP so the existing SyncTheme pipeline updates the renderer. + Theme = MarkdownThemeEngine.Resolve(variant); + }; + } + + Theme = MarkdownThemeEngine.Resolve(_autoThemeWatcher.CurrentVariant); + } + public Task CopySelectionToClipboardAsync(bool includePlainText = true) => _host.CopySelectionToClipboardAsync(includePlainText); } diff --git a/JitHub.Markdown.Uno/MarkdownView.cs b/JitHub.Markdown.Uno/MarkdownView.cs index 3c741ad..899f868 100644 --- a/JitHub.Markdown.Uno/MarkdownView.cs +++ b/JitHub.Markdown.Uno/MarkdownView.cs @@ -30,6 +30,8 @@ public MarkdownView() // Use handledEventsToo so this still runs when a parent ScrollViewer handles panning/manipulation. AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler((_, __) => _host.Focus(FocusState.Pointer)), true); + InitializeAutoThemeSupport(); + SyncAll(); } } diff --git a/JitHub.Markdown.Uno/Xaml/MarkdownView.xaml.cs b/JitHub.Markdown.Uno/Xaml/MarkdownView.xaml.cs index 407e16f..1f3661d 100644 --- a/JitHub.Markdown.Uno/Xaml/MarkdownView.xaml.cs +++ b/JitHub.Markdown.Uno/Xaml/MarkdownView.xaml.cs @@ -27,6 +27,8 @@ public MarkdownView() // Use handledEventsToo so this still runs when a parent ScrollViewer handles panning/manipulation. AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler((_, __) => _host.Focus(FocusState.Pointer)), true); + InitializeAutoThemeSupport(); + SyncAll(); } } diff --git a/JitHubV3.Server/Apis/AuthApi.cs b/JitHubV3.Server/Apis/AuthApi.cs index d21a013..9458a03 100644 --- a/JitHubV3.Server/Apis/AuthApi.cs +++ b/JitHubV3.Server/Apis/AuthApi.cs @@ -131,6 +131,23 @@ private static IResult Start( return "/auth/complete?client=windows"; } + // Desktop loopback flow: accept an explicit loopback redirect_uri (http://127.0.0.1:{port}/oauth2/callback). + if (string.Equals(client, AuthClientKinds.Desktop, StringComparison.Ordinal)) + { + if (string.IsNullOrWhiteSpace(redirectUriFromQuery)) + { + return null; + } + + var allowed = redirectPolicy.TryGetAllowedRedirectUri(redirectUriFromQuery); + if (allowed is null || !allowed.IsLoopback) + { + return null; + } + + return allowed.ToString(); + } + // For native broker-based clients (iOS/Android/macOS/etc), accept an explicit redirect_uri // but only when it matches our allowlist rules. if (!string.IsNullOrWhiteSpace(redirectUriFromQuery)) diff --git a/JitHubV3.Server/Services/Auth/AuthClientKinds.cs b/JitHubV3.Server/Services/Auth/AuthClientKinds.cs index 1863f84..3aabba3 100644 --- a/JitHubV3.Server/Services/Auth/AuthClientKinds.cs +++ b/JitHubV3.Server/Services/Auth/AuthClientKinds.cs @@ -14,6 +14,9 @@ internal static class AuthClientKinds // Windows App SDK: system browser + protocol activation back into the app. internal const string Windows = "windows"; + // Desktop (WinAppSDK + Skia): system browser + loopback redirect (http://127.0.0.1:{port}/...). + internal const string Desktop = "desktop"; + // iOS/Android/macOS/etc: broker-based flow using redirect_uri. internal const string Native = "native"; } diff --git a/JitHubV3.Server/Services/Auth/OAuthRedirectPolicy.cs b/JitHubV3.Server/Services/Auth/OAuthRedirectPolicy.cs index 72f0978..88f13c5 100644 --- a/JitHubV3.Server/Services/Auth/OAuthRedirectPolicy.cs +++ b/JitHubV3.Server/Services/Auth/OAuthRedirectPolicy.cs @@ -36,6 +36,20 @@ public OAuthRedirectPolicy(IOptions options) return null; } + // Desktop loopback redirects (used by the app for local callback capture). + // Allow only loopback and only a fixed callback path to avoid open-redirect issues. + if (uri.IsLoopback) + { + if (string.IsNullOrWhiteSpace(uri.Fragment) + && (string.Equals(uri.AbsolutePath, "/oauth2/callback", StringComparison.Ordinal) + || string.Equals(uri.AbsolutePath, "/oauth2/callback/", StringComparison.Ordinal))) + { + return uri; + } + + return null; + } + // Disallow fragments for HTTP(S) redirects; caller may append query params (handoffCode). if (!string.IsNullOrWhiteSpace(uri.Fragment)) { diff --git a/JitHubV3.Tests/AppInfoTests.cs b/JitHubV3.Tests/AppInfoTests.cs index dfeeaaa..3e87830 100644 --- a/JitHubV3.Tests/AppInfoTests.cs +++ b/JitHubV3.Tests/AppInfoTests.cs @@ -1,5 +1,7 @@ namespace JitHubV3.Tests; +public sealed record AppConfig(string? Environment); + public class AppInfoTests { [SetUp] @@ -10,7 +12,7 @@ public void Setup() [Test] public void AppInfoCreation() { - var appInfo = new AppConfig { Environment = "Test" }; + var appInfo = new AppConfig(Environment: "Test"); appInfo.Should().NotBeNull(); appInfo.Environment.Should().Be("Test"); diff --git a/JitHubV3.Tests/GlobalUsings.cs b/JitHubV3.Tests/GlobalUsings.cs index 4ba0ce5..91a038c 100644 --- a/JitHubV3.Tests/GlobalUsings.cs +++ b/JitHubV3.Tests/GlobalUsings.cs @@ -1,3 +1,2 @@ global using FluentAssertions; -global using JitHubV3.Models; global using NUnit.Framework; diff --git a/JitHubV3.Tests/JitHubV3.Tests.csproj b/JitHubV3.Tests/JitHubV3.Tests.csproj index 95ddfcd..2121a33 100644 --- a/JitHubV3.Tests/JitHubV3.Tests.csproj +++ b/JitHubV3.Tests/JitHubV3.Tests.csproj @@ -24,6 +24,5 @@ - diff --git a/JitHubV3.slnx b/JitHubV3.slnx index f556414..1a88d66 100644 --- a/JitHubV3.slnx +++ b/JitHubV3.slnx @@ -16,6 +16,21 @@ + + + + + + + + + + + + + + + diff --git a/JitHubV3/App.xaml.cs b/JitHubV3/App.xaml.cs index efecbd5..7501735 100644 --- a/JitHubV3/App.xaml.cs +++ b/JitHubV3/App.xaml.cs @@ -1,6 +1,11 @@ using Uno.Resizetizer; using JitHubV3.Authentication; using JitHubV3.Services.GitHub; +using JitHub.GitHub.Abstractions.Security; +using JitHub.GitHub.Abstractions.Services; +using JitHub.GitHub.Abstractions.Models; +using JitHub.GitHub.Abstractions.Polling; +using JitHub.GitHub.Octokit; namespace JitHubV3; @@ -17,7 +22,7 @@ public App() // Ensure the broker callback URI matches the app's registered custom scheme. // See: https://platform.uno/docs/articles/features/web-authentication-broker.html // Windows uses ms-app:// (handled by default); non-Windows native platforms use custom schemes. -#if __IOS__ || __ANDROID__ || __MACOS__ || __MACCATALYST__ || __SKIA__ +#if __IOS__ || __ANDROID__ || __MACOS__ || __MACCATALYST__ Uno.WinRTFeatureConfiguration.WebAuthenticationBroker.DefaultReturnUri = new Uri(GitHubAuthFlow.DefaultCallbackUri); #endif } @@ -41,7 +46,7 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) return; } - RegisterWindowsProtocolActivation(); + // Auth flow uses loopback redirect on desktop; protocol activation not required. #endif var builder = this.CreateBuilder(args) // Add navigation support for toolkit controls such as TabBar and NavigationView @@ -75,7 +80,10 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) //// Storage messages //logBuilder.StorageLogLevel(LogLevel.Debug); //// Binding related messages - //logBuilder.XamlBindingLogLevel(LogLevel.Debug); + if (context.HostingEnvironment.IsDevelopment()) + { + logBuilder.XamlBindingLogLevel(LogLevel.Debug); + } //// Binder memory references tracking //logBuilder.BinderMemoryReferenceLogLevel(LogLevel.Debug); //// DevServer and HotReload related @@ -98,6 +106,12 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) .Login(async (services, dispatcher, credentials, ct) => { var resultTokens = await GitHubAuthFlow.LoginAsync(services, credentials); + + if (services.GetService() is UnoTokenCacheGitHubTokenProvider provider) + { + provider.UpdateFromTokens(resultTokens); + } + return resultTokens; }) .Refresh((services, tokens, ct) => @@ -114,6 +128,12 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) .Logout(async (services, dispatcher, tokenCache, tokens, ct) => { await tokenCache.ClearAsync(ct); + + if (services.GetService() is UnoTokenCacheGitHubTokenProvider provider) + { + provider.UpdateFromTokens(null); + } + return true; }); }, name: "GitHub") @@ -123,7 +143,33 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) // TODO: Register your services //services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(sp => + { + var baseUrl = context.Configuration["GitHub:ApiBaseUrl"]; + Uri? apiBase = null; + if (!string.IsNullOrWhiteSpace(baseUrl) && Uri.TryCreate(baseUrl, UriKind.Absolute, out var parsed)) + { + apiBase = parsed; + } + + return new OctokitClientOptions( + ProductName: "JitHubV3", + ProductVersion: null, + ApiBaseAddress: apiBase); + }); + + services.AddSingleton(sp => + new OctokitClientFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + services.AddJitHubGitHubServices(); }) .UseNavigation(RegisterRoutes) ); @@ -155,20 +201,6 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) } else { -#if WINDOWS - // If the app was activated via protocol (handoffCode), try to complete login immediately - // so we can skip the login screen and navigate to the authenticated route. - if (GitHubAuthFlow.HasPendingProtocolHandoff()) - { - var dispatcher = services.GetRequiredService(); - var protocolLoginSuccess = await auth.LoginAsync(dispatcher); - if (protocolLoginSuccess) - { - await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); - return; - } - } -#endif await navigator.NavigateViewModelAsync(this, qualifier: Qualifiers.Nested); } }); @@ -190,39 +222,7 @@ private static async Task EnsureSingleWindowsInstanceAsync() return true; } - private void RegisterWindowsProtocolActivation() - { - // WinUI 3 (Windows App SDK) doesn't support overriding Application.OnActivated like UWP. - // Use AppInstance activation events instead. - var instance = Microsoft.Windows.AppLifecycle.AppInstance.GetCurrent(); - - HandleWindowsActivation(instance.GetActivatedEventArgs()); - instance.Activated += (_, e) => HandleWindowsActivation(e); - } - - private void HandleWindowsActivation(Microsoft.Windows.AppLifecycle.AppActivationArguments args) - { - if (args.Kind != Microsoft.Windows.AppLifecycle.ExtendedActivationKind.Protocol) - { - return; - } - - if (args.Data is Windows.ApplicationModel.Activation.IProtocolActivatedEventArgs protocolArgs && protocolArgs.Uri is not null) - { - GitHubAuthFlow.TryHandleProtocolActivation(protocolArgs.Uri); - - // Bring the existing window to foreground. - // AppInstance.Activated can come in on a non-UI thread; marshal activation to the window dispatcher. - if (MainWindow is null) - { - _pendingWindowActivation = true; - } - else - { - TryActivateMainWindow(); - } - } - } + // Protocol activation was previously used for OAuth handoff; desktop now uses a loopback redirect. private void TryActivateMainWindow() { @@ -267,6 +267,8 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) new ViewMap(ViewModel: typeof(ShellViewModel)), new ViewMap(), new ViewMap(), + new DataViewMap(), + new DataViewMap(), new ViewMap(), new DataViewMap() ); @@ -277,6 +279,8 @@ private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes) [ new ("Login", View: views.FindByViewModel()), new ("Main", View: views.FindByViewModel(), IsDefault:true), + new ("Issues", View: views.FindByViewModel()), + new ("IssueConversation", View: views.FindByViewModel()), new ("MarkdownTest", View: views.FindByViewModel()), new ("Second", View: views.FindByViewModel()), ] diff --git a/JitHubV3/Authentication/GitHubAuthFlow.cs b/JitHubV3/Authentication/GitHubAuthFlow.cs index 4333dca..2b8c5e7 100644 --- a/JitHubV3/Authentication/GitHubAuthFlow.cs +++ b/JitHubV3/Authentication/GitHubAuthFlow.cs @@ -1,4 +1,5 @@ using System.Net.Http.Json; +using System.Diagnostics; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -12,66 +13,9 @@ namespace JitHubV3.Authentication; internal static class GitHubAuthFlow { // Default callback URI for native broker-based platforms. - // Windows does NOT use WebAuthenticationBroker in this app (see below); it uses browser + protocol activation. + // Desktop uses a loopback redirect; mobile uses WebAuthenticationBroker. internal const string DefaultCallbackUri = "jithubv3://authentication-callback"; -#if WINDOWS || __WINDOWS__ - private static TaskCompletionSource? _protocolHandoffTcs; - private static string? _pendingHandoffCode; - - internal static bool HasPendingProtocolHandoff() => !string.IsNullOrWhiteSpace(_pendingHandoffCode); - - internal static void TryHandleProtocolActivation(Uri uri) - { - // Expected: jithubv3://auth?handoffCode=... - if (!string.Equals(uri.Scheme, "jithubv3", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - var handoffCode = GetQueryParameter(uri, "handoffCode"); - if (string.IsNullOrWhiteSpace(handoffCode)) - { - return; - } - - // If a login is currently waiting, complete it. - // Otherwise store it so startup/login can consume it and skip the login screen. - if (!(_protocolHandoffTcs?.TrySetResult(handoffCode) ?? false)) - { - _pendingHandoffCode = handoffCode; - } - } - - private static Task BeginWindowsProtocolWait(TimeSpan timeout) - { - _protocolHandoffTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - if (!string.IsNullOrWhiteSpace(_pendingHandoffCode)) - { - var handoff = _pendingHandoffCode; - _pendingHandoffCode = null; - _protocolHandoffTcs.TrySetResult(handoff); - return _protocolHandoffTcs.Task; - } - - _ = Task.Run(async () => - { - try - { - await Task.Delay(timeout); - _protocolHandoffTcs.TrySetException(new TimeoutException("Timed out waiting for protocol callback.")); - } - catch - { - // ignored - } - }); - - return _protocolHandoffTcs.Task; - } -#endif - internal static async Task> LoginAsync(IServiceProvider services, IDictionary? credentials) { var config = services.GetRequiredService(); @@ -92,25 +36,22 @@ internal static async Task> LoginAsync(IServiceProvi ? scopeFromCreds : "repo"; -#if WINDOWS || __WINDOWS__ - // Windows: follow the old app's proven pattern. - // - Open the server auth start URL in the system browser - // - Server completes OAuth then redirects back into the app via protocol activation - // This avoids WebAuthenticationBroker callback URI restrictions that caused COMException 0x8007007D. +#if WINDOWS || __WINDOWS__ || __SKIA__ + // Desktop (WinAppSDK + Skia): system browser + loopback redirect. + // This avoids WebAuthenticationBroker (not available on Skia) and avoids protocol activation races. + await using var listener = new LoopbackHandoffListener("/oauth2/callback"); + var loopbackUri = listener.Start(); + var startUri = new Uri(AppendQuery(startUriRaw, new Dictionary { - ["client"] = "windows", + ["client"] = "desktop", + ["redirect_uri"] = loopbackUri.ToString(), ["scope"] = scope, })); - var handoffWait = BeginWindowsProtocolWait(TimeSpan.FromMinutes(2)); - var launched = await Launcher.LaunchUriAsync(startUri); - if (!launched) - { - throw new InvalidOperationException("Failed to launch system browser for authentication."); - } + await LaunchBrowserAsync(startUri); - var handoffCode = await handoffWait; + var handoffCode = await listener.WaitForHandoffCodeAsync(TimeSpan.FromMinutes(2), CancellationToken.None); var exchangeUri = new Uri($"{startUri.GetLeftPart(UriPartial.Authority)}/auth/exchange"); #else // Other platforms: use WebAuthenticationBroker. @@ -177,6 +118,32 @@ internal static async Task> LoginAsync(IServiceProvi return tokens; } + private static async Task LaunchBrowserAsync(Uri uri) + { +#if WINDOWS || __WINDOWS__ + var launched = await Launcher.LaunchUriAsync(uri); + if (!launched) + { + throw new InvalidOperationException("Failed to launch system browser for authentication."); + } +#else + try + { + Process.Start(new ProcessStartInfo + { + FileName = uri.ToString(), + UseShellExecute = true, + }); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to launch system browser for authentication.", ex); + } + + await Task.CompletedTask; +#endif + } + private static string AppendQuery(string baseUri, IReadOnlyDictionary query) { var separator = baseUri.Contains('?', StringComparison.Ordinal) ? "&" : "?"; diff --git a/JitHubV3/Authentication/LoopbackHandoffListener.cs b/JitHubV3/Authentication/LoopbackHandoffListener.cs new file mode 100644 index 0000000..7726996 --- /dev/null +++ b/JitHubV3/Authentication/LoopbackHandoffListener.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace JitHubV3.Authentication; + +internal sealed class LoopbackHandoffListener : IAsyncDisposable +{ + private readonly string _callbackPath; + private TcpListener? _listener; + + public LoopbackHandoffListener(string callbackPath) + { + if (string.IsNullOrWhiteSpace(callbackPath)) + { + throw new ArgumentException("Callback path is required.", nameof(callbackPath)); + } + + _callbackPath = callbackPath.StartsWith("/", StringComparison.Ordinal) ? callbackPath : "/" + callbackPath; + } + + public Uri Start() + { + if (_listener is not null) + { + throw new InvalidOperationException("Listener already started."); + } + + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + + var port = ((IPEndPoint)_listener.LocalEndpoint).Port; + return new Uri($"http://127.0.0.1:{port}{_callbackPath}"); + } + + public async Task WaitForHandoffCodeAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + if (_listener is null) + { + throw new InvalidOperationException("Listener not started."); + } + + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); + + try + { + using var client = await _listener.AcceptTcpClientAsync(linkedCts.Token); + await using var stream = client.GetStream(); + + var request = await ReadHeadersAsync(stream, linkedCts.Token); + var (path, query) = ParseRequestTarget(request); + + var handoffCode = string.IsNullOrWhiteSpace(query) ? null : GetQueryParameter(query, "handoffCode"); + + // Return a small page so the browser doesn't show a connection reset. + var html = "Signed in" + + "

Signed in

You can return to the app.

"; + + var bodyBytes = Encoding.UTF8.GetBytes(html); + var response = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + $"Content-Length: {bodyBytes.Length}\r\n" + + "Connection: close\r\n\r\n"; + + var headerBytes = Encoding.ASCII.GetBytes(response); + await stream.WriteAsync(headerBytes, linkedCts.Token); + await stream.WriteAsync(bodyBytes, linkedCts.Token); + await stream.FlushAsync(linkedCts.Token); + + if (!string.Equals(path, _callbackPath, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unexpected callback path: '{path}'."); + } + + if (string.IsNullOrWhiteSpace(handoffCode)) + { + throw new InvalidOperationException("Missing handoffCode in loopback callback."); + } + + return handoffCode; + } + catch (OperationCanceledException) + { + if (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException("Timed out waiting for authentication callback."); + } + + throw; + } + } + + public ValueTask DisposeAsync() + { + try + { + _listener?.Stop(); + } + catch + { + // ignored + } + + _listener = null; + return ValueTask.CompletedTask; + } + + private static async Task ReadHeadersAsync(NetworkStream stream, CancellationToken cancellationToken) + { + // Read until end of headers (CRLF CRLF). Limit size to avoid abuse. + var buffer = new byte[4096]; + var sb = new StringBuilder(); + + while (sb.Length < 32_768) + { + var read = await stream.ReadAsync(buffer, cancellationToken); + if (read <= 0) + { + break; + } + + sb.Append(Encoding.ASCII.GetString(buffer, 0, read)); + + if (sb.ToString().Contains("\r\n\r\n", StringComparison.Ordinal)) + { + break; + } + } + + return sb.ToString(); + } + + private static (string path, string? query) ParseRequestTarget(string requestHeaders) + { + // First line: GET /path?query HTTP/1.1 + var firstLineEnd = requestHeaders.IndexOf("\r\n", StringComparison.Ordinal); + var firstLine = firstLineEnd >= 0 ? requestHeaders[..firstLineEnd] : requestHeaders; + + var parts = firstLine.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + throw new InvalidOperationException("Invalid HTTP request received."); + } + + var target = parts[1]; + var qIndex = target.IndexOf('?', StringComparison.Ordinal); + if (qIndex < 0) + { + return (target, null); + } + + return (target[..qIndex], target[(qIndex + 1)..]); + } + + private static string? GetQueryParameter(string query, string key) + { + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = part.Split('=', 2); + if (kv.Length == 0) + { + continue; + } + + var name = Uri.UnescapeDataString(kv[0]); + if (!string.Equals(name, key, StringComparison.Ordinal)) + { + continue; + } + + return kv.Length == 2 ? Uri.UnescapeDataString(kv[1]) : string.Empty; + } + + return null; + } +} diff --git a/JitHubV3/GlobalUsings.cs b/JitHubV3/GlobalUsings.cs index a88f089..0f88eef 100644 --- a/JitHubV3/GlobalUsings.cs +++ b/JitHubV3/GlobalUsings.cs @@ -8,4 +8,7 @@ global using Microsoft.Extensions.Localization; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; +global using Uno.Extensions; +global using Uno.Extensions.Authentication; +global using Uno.Extensions.Navigation; global using ApplicationExecutionState = Windows.ApplicationModel.Activation.ApplicationExecutionState; diff --git a/JitHubV3/JitHubV3.csproj b/JitHubV3/JitHubV3.csproj index d7f780f..a711da0 100644 --- a/JitHubV3/JitHubV3.csproj +++ b/JitHubV3/JitHubV3.csproj @@ -9,14 +9,27 @@ This keeps `dotnet build` on Windows from attempting Apple targets. --> - net10.0-windows10.0.26100;net10.0-desktop;net10.0-android - net10.0-windows10.0.26100;net10.0-desktop;net10.0 + + false + + net10.0-desktop;net10.0-windows10.0.26100;net10.0-android + net10.0-desktop;net10.0-windows10.0.26100 net10.0-ios;net10.0-maccatalyst;net10.0-desktop net10.0-desktop Exe true + + true + JitHubV3 @@ -87,6 +100,8 @@ + + diff --git a/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs b/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs new file mode 100644 index 0000000..62ed1e9 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/ActivatablePage.cs @@ -0,0 +1,51 @@ +namespace JitHubV3.Presentation; + +public class ActivatablePage : Page +{ + private IActivatableViewModel? _activeVm; + + protected ActivatablePage() + { + DataContextChanged += (_, __) => TryActivateFromDataContext(); + } + + protected override void OnNavigatedTo(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + base.OnNavigatedTo(e); + TryActivateFromDataContext(); + } + + protected override void OnNavigatedFrom(Microsoft.UI.Xaml.Navigation.NavigationEventArgs e) + { + DeactivateCurrent(); + base.OnNavigatedFrom(e); + } + + private void TryActivateFromDataContext() + { + if (DataContext is not IActivatableViewModel vm) + { + return; + } + + if (ReferenceEquals(_activeVm, vm)) + { + return; + } + + DeactivateCurrent(); + _activeVm = vm; + + _ = vm.ActivateAsync(); + } + + private void DeactivateCurrent() + { + if (_activeVm is not null) + { + _activeVm.Deactivate(); + } + + _activeVm = null; + } +} diff --git a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs new file mode 100644 index 0000000..f7e9f0f --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace JitHubV3.Presentation; + +public sealed class BoolToVisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, string language) + { + var visible = value is bool b && b; + + if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase)) + { + visible = !visible; + } + + return visible ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + if (value is not Visibility v) + { + return false; + } + + var result = v == Visibility.Visible; + + if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase)) + { + result = !result; + } + + return result; + } +} diff --git a/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs b/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs new file mode 100644 index 0000000..6ee6ab5 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/DataFreshnessState.cs @@ -0,0 +1,8 @@ +namespace JitHubV3.Presentation; + +public enum DataFreshnessState +{ + Unknown = 0, + Cached = 1, + Fresh = 2, +} diff --git a/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs b/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs new file mode 100644 index 0000000..1189c24 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/IActivatableViewModel.cs @@ -0,0 +1,8 @@ +namespace JitHubV3.Presentation; + +public interface IActivatableViewModel +{ + Task ActivateAsync(); + + void Deactivate(); +} diff --git a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs new file mode 100644 index 0000000..8f89cd6 --- /dev/null +++ b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs @@ -0,0 +1,155 @@ +namespace JitHubV3.Presentation; + +public sealed partial class StatusBarViewModel : ObservableObject +{ + private SynchronizationContext? _uiContext; + + private string? _message; + private bool _isBusy; + private bool _isRefreshing; + private DataFreshnessState _freshness = DataFreshnessState.Unknown; + private DateTimeOffset? _lastUpdatedAt; + + public void AttachToCurrentThread() + { + _uiContext ??= SynchronizationContext.Current; + } + + public string? Message + { + get => _message; + set => SetProperty(ref _message, value); + } + + public bool IsBusy + { + get => _isBusy; + set => SetProperty(ref _isBusy, value); + } + + public bool IsRefreshing + { + get => _isRefreshing; + set => SetProperty(ref _isRefreshing, value); + } + + public DataFreshnessState Freshness + { + get => _freshness; + set + { + if (SetProperty(ref _freshness, value)) + { + OnPropertyChanged(nameof(FreshnessLabel)); + } + } + } + + public DateTimeOffset? LastUpdatedAt + { + get => _lastUpdatedAt; + set + { + if (SetProperty(ref _lastUpdatedAt, value)) + { + OnPropertyChanged(nameof(LastUpdatedLabel)); + } + } + } + + public string FreshnessLabel => Freshness switch + { + DataFreshnessState.Cached => "Cached", + DataFreshnessState.Fresh => "Fresh", + _ => string.Empty, + }; + + public string LastUpdatedLabel + => LastUpdatedAt is null ? string.Empty : $"Updated {LastUpdatedAt:HH:mm:ss}"; + + public void Set( + string? message = null, + bool? isBusy = null, + bool? isRefreshing = null, + DataFreshnessState? freshness = null, + DateTimeOffset? lastUpdatedAt = null) + { + var ui = _uiContext; + if (ui is null) + { + // Best effort: if the caller is on UI thread and a context exists, capture it. + AttachToCurrentThread(); + ui = _uiContext; + } + + if (ui is null || ReferenceEquals(SynchronizationContext.Current, ui)) + { + ApplySet(message, isBusy, isRefreshing, freshness, lastUpdatedAt); + return; + } + + ui.Post(_ => ApplySet(message, isBusy, isRefreshing, freshness, lastUpdatedAt), null); + } + + private void ApplySet( + string? message, + bool? isBusy, + bool? isRefreshing, + DataFreshnessState? freshness, + DateTimeOffset? lastUpdatedAt) + { + if (message is not null) + { + Message = message; + } + + if (isBusy is not null) + { + IsBusy = isBusy.Value; + } + + if (isRefreshing is not null) + { + IsRefreshing = isRefreshing.Value; + } + + if (freshness is not null) + { + Freshness = freshness.Value; + } + + if (lastUpdatedAt is not null) + { + LastUpdatedAt = lastUpdatedAt; + } + } + + public void Clear() + { + var ui = _uiContext; + if (ui is null) + { + AttachToCurrentThread(); + ui = _uiContext; + } + + if (ui is null || ReferenceEquals(SynchronizationContext.Current, ui)) + { + Message = null; + IsBusy = false; + IsRefreshing = false; + Freshness = DataFreshnessState.Unknown; + LastUpdatedAt = null; + return; + } + + ui.Post(_ => + { + Message = null; + IsBusy = false; + IsRefreshing = false; + Freshness = DataFreshnessState.Unknown; + LastUpdatedAt = null; + }, null); + } +} diff --git a/JitHubV3/Presentation/IssueConversationPage.xaml b/JitHubV3/Presentation/IssueConversationPage.xaml new file mode 100644 index 0000000..e746060 --- /dev/null +++ b/JitHubV3/Presentation/IssueConversationPage.xaml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + +