< Summary

Information
Class: NexusLabs.Needlr.Copilot.CopilotTokenResponse
Assembly: NexusLabs.Needlr.Copilot
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotTokenProvider.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 118
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Token()100%11100%
get_ExpiresAt()100%11100%

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;
 17    private readonly SemaphoreSlim _refreshLock = new(1, 1);
 18
 19    private string? _cachedToken;
 20    private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
 21
 22    public CopilotTokenProvider(CopilotChatClientOptions options, HttpClient? httpClient = null)
 23        : this(new GitHubOAuthTokenProvider(options), options, httpClient)
 24    {
 25    }
 26
 27    public CopilotTokenProvider(
 28        IGitHubOAuthTokenProvider oauthProvider,
 29        CopilotChatClientOptions options,
 30        HttpClient? httpClient = null)
 31    {
 32        _oauthProvider = oauthProvider ?? throw new ArgumentNullException(nameof(oauthProvider));
 33        _options = options ?? throw new ArgumentNullException(nameof(options));
 34        _ownsHttpClient = httpClient is null;
 35        _httpClient = httpClient ?? new HttpClient();
 36    }
 37
 38    public async Task<string> GetTokenAsync(CancellationToken cancellationToken = default)
 39    {
 40        if (_cachedToken is not null &&
 41            DateTimeOffset.UtcNow.AddSeconds(_options.TokenRefreshBufferSeconds) < _expiresAt)
 42        {
 43            return _cachedToken;
 44        }
 45
 46        await _refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
 47        try
 48        {
 49            // Double-check after acquiring the lock
 50            if (_cachedToken is not null &&
 51                DateTimeOffset.UtcNow.AddSeconds(_options.TokenRefreshBufferSeconds) < _expiresAt)
 52            {
 53                return _cachedToken;
 54            }
 55
 56            var oauthToken = _oauthProvider.GetOAuthToken();
 57            var response = await ExchangeTokenAsync(oauthToken, cancellationToken).ConfigureAwait(false);
 58
 59            _cachedToken = response.Token;
 60            _expiresAt = DateTimeOffset.FromUnixTimeSeconds(response.ExpiresAt);
 61
 62            return _cachedToken;
 63        }
 64        finally
 65        {
 66            _refreshLock.Release();
 67        }
 68    }
 69
 70    private async Task<CopilotTokenResponse> ExchangeTokenAsync(
 71        string oauthToken, CancellationToken cancellationToken)
 72    {
 73        var url = $"{_options.GitHubApiBaseUrl.TrimEnd('/')}/copilot_internal/v2/token";
 74
 75        using var request = new HttpRequestMessage(HttpMethod.Get, url);
 76        request.Headers.Add("Authorization", $"token {oauthToken}");
 77        request.Headers.Add("Accept", "application/json");
 78        request.Headers.Add("User-Agent", _options.IntegrationId);
 79
 80        using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 81
 82        if (!response.IsSuccessStatusCode)
 83        {
 84            var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 85            throw new HttpRequestException(
 86                $"Copilot token exchange failed ({response.StatusCode}): {body}");
 87        }
 88
 89        var tokenResponse = await response.Content
 90            .ReadFromJsonAsync(CopilotJsonContext.Default.CopilotTokenResponse, cancellationToken)
 91            .ConfigureAwait(false);
 92
 93        if (tokenResponse is null || string.IsNullOrWhiteSpace(tokenResponse.Token))
 94        {
 95            throw new InvalidOperationException("Copilot token exchange returned an empty token.");
 96        }
 97
 98        return tokenResponse;
 99    }
 100
 101    public void Dispose()
 102    {
 103        _refreshLock.Dispose();
 104        if (_ownsHttpClient)
 105        {
 106            _httpClient.Dispose();
 107        }
 108    }
 109}
 110
 111internal sealed record CopilotTokenResponse
 112{
 113    [JsonPropertyName("token")]
 52114    public string Token { get; init; } = "";
 115
 116    [JsonPropertyName("expires_at")]
 26117    public long ExpiresAt { get; init; }
 118}

Methods/Properties

get_Token()
get_ExpiresAt()