< Summary

Information
Class: NexusLabs.Needlr.Copilot.CopilotTokenProvider
Assembly: NexusLabs.Needlr.Copilot
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotTokenProvider.cs
Line coverage
95%
Covered lines: 46
Uncovered lines: 2
Coverable lines: 48
Total lines: 118
Line coverage: 95.8%
Branch coverage
75%
Covered branches: 18
Total branches: 24
Branch coverage: 75%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)66.66%66100%
.ctor(...)100%11100%
GetTokenAsync()75%8892.85%
ExchangeTokenAsync()75%8894.11%
Dispose()100%22100%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotTokenProvider.cs

#LineLine coverage
 1using System.Net.Http.Json;
 2using System.Text.Json.Serialization;
 3
 4namespace NexusLabs.Needlr.Copilot;
 5
 6/// <summary>
 7/// Acquires and caches short-lived Copilot API tokens by exchanging a GitHub OAuth token
 8/// (from <see cref="IGitHubOAuthTokenProvider"/>) via the internal GitHub API endpoint.
 9/// Thread-safe: concurrent callers share a single refresh via <see cref="SemaphoreSlim"/>.
 10/// </summary>
 11internal sealed class CopilotTokenProvider : ICopilotTokenProvider, IDisposable
 12{
 13    private readonly HttpClient _httpClient;
 14    private readonly CopilotChatClientOptions _options;
 15    private readonly IGitHubOAuthTokenProvider _oauthProvider;
 16    private readonly bool _ownsHttpClient;
 1517    private readonly SemaphoreSlim _refreshLock = new(1, 1);
 18
 19    private string? _cachedToken;
 1520    private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
 21
 22    public CopilotTokenProvider(CopilotChatClientOptions options, HttpClient? httpClient = null)
 1523        : this(new GitHubOAuthTokenProvider(options), options, httpClient)
 24    {
 1525    }
 26
 1527    public CopilotTokenProvider(
 1528        IGitHubOAuthTokenProvider oauthProvider,
 1529        CopilotChatClientOptions options,
 1530        HttpClient? httpClient = null)
 31    {
 1532        _oauthProvider = oauthProvider ?? throw new ArgumentNullException(nameof(oauthProvider));
 1533        _options = options ?? throw new ArgumentNullException(nameof(options));
 1534        _ownsHttpClient = httpClient is null;
 1535        _httpClient = httpClient ?? new HttpClient();
 1536    }
 37
 38    public async Task<string> GetTokenAsync(CancellationToken cancellationToken = default)
 39    {
 1540        if (_cachedToken is not null &&
 1541            DateTimeOffset.UtcNow.AddSeconds(_options.TokenRefreshBufferSeconds) < _expiresAt)
 42        {
 143            return _cachedToken;
 44        }
 45
 1446        await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 47        try
 48        {
 49            // Double-check after acquiring the lock
 1450            if (_cachedToken is not null &&
 1451                DateTimeOffset.UtcNow.AddSeconds(_options.TokenRefreshBufferSeconds) < _expiresAt)
 52            {
 053                return _cachedToken;
 54            }
 55
 1456            var oauthToken = _oauthProvider.GetOAuthToken();
 1457            var response = await ExchangeTokenAsync(oauthToken, cancellationToken).ConfigureAwait(false);
 58
 1359            _cachedToken = response.Token;
 1360            _expiresAt = DateTimeOffset.FromUnixTimeSeconds(response.ExpiresAt);
 61
 1362            return _cachedToken;
 63        }
 64        finally
 65        {
 1466            _refreshLock.Release();
 67        }
 1468    }
 69
 70    private async Task<CopilotTokenResponse> ExchangeTokenAsync(
 71        string oauthToken, CancellationToken cancellationToken)
 72    {
 1473        var url = $"{_options.GitHubApiBaseUrl.TrimEnd('/')}/copilot_internal/v2/token";
 74
 1475        using var request = new HttpRequestMessage(HttpMethod.Get, url);
 1476        request.Headers.Add("Authorization", $"token {oauthToken}");
 1477        request.Headers.Add("Accept", "application/json");
 1478        request.Headers.Add("User-Agent", _options.IntegrationId);
 79
 1480        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 81
 1482        if (!response.IsSuccessStatusCode)
 83        {
 184            var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 185            throw new HttpRequestException(
 186                $"Copilot token exchange failed ({response.StatusCode}): {body}");
 87        }
 88
 1389        var tokenResponse = await response.Content
 1390            .ReadFromJsonAsync(CopilotJsonContext.Default.CopilotTokenResponse, cancellationToken)
 1391            .ConfigureAwait(false);
 92
 1393        if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.Token))
 94        {
 095            throw new InvalidOperationException("Copilot token exchange returned an empty token.");
 96        }
 97
 1398        return tokenResponse;
 1399    }
 100
 101    public void Dispose()
 102    {
 15103        _refreshLock.Dispose();
 15104        if (_ownsHttpClient)
 105        {
 1106            _httpClient.Dispose();
 107        }
 15108    }
 109}
 110
 111internal sealed record CopilotTokenResponse
 112{
 113    [JsonPropertyName("token")]
 114    public string Token { get; init; } = "";
 115
 116    [JsonPropertyName("expires_at")]
 117    public long ExpiresAt { get; init; }
 118}