Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Net;
using Azure.Sdk.Tools.Cli.Helpers;
using Azure.Sdk.Tools.Cli.Services;
using Moq;
using Octokit;

namespace Azure.Sdk.Tools.Cli.Tests.Services;

[TestFixture]
public class GitHubServiceTests
{
private string? originalGitHubToken;
private string? originalGitHubPat;

// Exposes the protected anonymous-first read helper so it can be exercised in isolation.
private sealed class TestableGitConnection : GitConnection
{
public TestableGitConnection(IProcessHelper processHelper) : base(processHelper) { }
public Task<T> ReadWithAnonymousFallbackForTest<T>(Func<GitHubClient, Task<T>> operation)
=> ReadWithAnonymousFallbackAsync(operation, CancellationToken.None);
}

[SetUp]
public void SetUp()
{
// Preserve and clear the auth environment variables so tests control token availability.
originalGitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
originalGitHubPat = Environment.GetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN");
Environment.SetEnvironmentVariable("GITHUB_TOKEN", null, EnvironmentVariableTarget.Process);
Environment.SetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN", null, EnvironmentVariableTarget.Process);
}

[TearDown]
public void TearDown()
{
Environment.SetEnvironmentVariable("GITHUB_TOKEN", originalGitHubToken, EnvironmentVariableTarget.Process);
Environment.SetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN", originalGitHubPat, EnvironmentVariableTarget.Process);
}

[Test]
public async Task ReadWithAnonymousFallback_UsesAnonymousClient_WhenPublicReadSucceeds()
{
var processHelperMock = new Mock<IProcessHelper>();
var connection = new TestableGitConnection(processHelperMock.Object);

var usedAuthType = await connection.ReadWithAnonymousFallbackForTest(
client => Task.FromResult(client.Credentials.AuthenticationType));

Assert.That(usedAuthType, Is.EqualTo(AuthenticationType.Anonymous));
// No authentication was attempted for a successful anonymous read.
processHelperMock.Verify(x => x.Run(It.IsAny<ProcessOptions>(), It.IsAny<CancellationToken>()), Times.Never);
}

[Test]
public async Task ReadWithAnonymousFallback_RetriesAuthenticated_WhenAnonymousRequiresAuth()
{
// A token is available in the environment, so the authenticated fallback can succeed.
Environment.SetEnvironmentVariable("GITHUB_TOKEN", "fake-token", EnvironmentVariableTarget.Process);
var processHelperMock = new Mock<IProcessHelper>();
var connection = new TestableGitConnection(processHelperMock.Object);

// Simulate GitHub hiding a private resource as Not Found for the anonymous client.
var usedAuthType = await connection.ReadWithAnonymousFallbackForTest(client =>
{
if (client.Credentials.AuthenticationType == AuthenticationType.Anonymous)
{
throw new NotFoundException("Not Found", HttpStatusCode.NotFound);
}
return Task.FromResult(client.Credentials.AuthenticationType);
});

Assert.That(usedAuthType, Is.EqualTo(AuthenticationType.Bearer));
}

[Test]
public void ReadWithAnonymousFallback_PromptsForAuth_WhenRequiredAndNoToken()
{
// No token in the environment and `gh auth token` fails, so the authenticated fallback must
// surface the standard authentication guidance instead of silently swallowing the failure.
var processHelperMock = new Mock<IProcessHelper>();
processHelperMock
.Setup(x => x.Run(It.IsAny<ProcessOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ProcessResult { ExitCode = 1 });
var connection = new TestableGitConnection(processHelperMock.Object);

Assert.ThrowsAsync<InvalidOperationException>(async () =>
await connection.ReadWithAnonymousFallbackForTest<int>(client =>
{
if (client.Credentials.AuthenticationType == AuthenticationType.Anonymous)
{
throw new NotFoundException("Not Found", HttpStatusCode.NotFound);
}
return Task.FromResult(0);
}));
}
}
2 changes: 2 additions & 0 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

### Bugs Fixed

- Reading the status, checks, and labels of a public pull request (for example, the spec pull request during SDK generation) no longer requires GitHub authentication. These read-only operations are now attempted anonymously first, and only fall back to an authenticated request (prompting the user to run `gh auth login`) when GitHub indicates authentication is required, such as for a private repository.

- The create release plan tool no longer accepts an `--sdk-type` parameter. The SDK release type is now always derived from the API release type (GA maps to a stable SDK release, preview maps to a beta SDK release), preventing a stable SDK release from a preview API version.

- The create and update release plan tools now give clear guidance when run from a language SDK repository (or any directory that is not the `azure-rest-api-specs`/`azure-rest-api-specs-pr` repo). Instead of incorrectly reporting that the spec is in a private repository, they now ask the user to provide the absolute path to the TypeSpec project or to run the command from within the `Azure/azure-rest-api-specs` repository.
Expand Down
36 changes: 33 additions & 3 deletions tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public class GitConnection
private const int GitHubCliAuthTokenTimeoutMs = 120000;
private readonly IProcessHelper processHelper;
private GitHubClient? _gitHubClient; // Backing field for the property
private GitHubClient? _anonymousGitHubClient; // Backing field for the anonymous client

protected GitConnection(IProcessHelper processHelper)
{
Expand All @@ -45,6 +46,33 @@ public GitHubClient gitHubClient
}
}

/// <summary>
/// An unauthenticated GitHub client suitable for read-only access to public repositories.
/// </summary>
protected GitHubClient anonymousGitHubClient =>
_anonymousGitHubClient ??= new GitHubClient(new ProductHeaderValue("AzureSDKDevToolsMCP"));

/// <summary>
/// Runs a read-only GitHub operation anonymously first so that public data (for example, the status
/// or labels of a public spec pull request during SDK generation) can be read without authentication.
/// If the anonymous request fails (the resource is private, access is forbidden, or the rate limit is
/// exhausted), it is retried with an authenticated client, which prompts the user to run `gh auth login`
/// when no token is available.
/// </summary>
protected async Task<T> ReadWithAnonymousFallbackAsync<T>(Func<GitHubClient, Task<T>> operation, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
return await operation(anonymousGitHubClient);
}
catch (Octokit.ApiException)
{
ct.ThrowIfCancellationRequested();
return await operation(gitHubClient);
}
}

protected string GetGitHubAuthToken()
{
var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
Expand Down Expand Up @@ -149,7 +177,9 @@ public async Task<User> GetGitUserDetailsAsync(CancellationToken ct)

public async Task<PullRequest> GetPullRequestAsync(string repoOwner, string repoName, int pullRequestNumber, CancellationToken ct)
{
var pullRequest = await gitHubClient.PullRequest.Get(repoOwner, repoName, pullRequestNumber);
// Reading a pull request (including its status and labels) is a read-only operation that works
// anonymously for public repositories, so try anonymously first and only prompt for auth if needed.
var pullRequest = await ReadWithAnonymousFallbackAsync(client => client.PullRequest.Get(repoOwner, repoName, pullRequestNumber), ct);
return pullRequest;
}

Expand Down Expand Up @@ -189,7 +219,7 @@ public async Task<Issue> CreateIssueAsync(string repoOwner, string repoName, str
try
{
logger.LogInformation("Getting head SHA for PR #{PullRequestNumber} in {Owner}/{Repo}", pullRequestNumber, repoOwner, repoName);
var pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, pullRequestNumber);
var pr = await ReadWithAnonymousFallbackAsync(client => client.PullRequest.Get(repoOwner, repoName, pullRequestNumber), ct);
return pr?.Head?.Sha;
}
catch (Exception ex)
Expand Down Expand Up @@ -494,7 +524,7 @@ public async Task<List<String>> GetPullRequestChecksAsync(int pullRequestNumber,
throw new NotFoundException($"Pull request {pullRequestNumber} not found.", System.Net.HttpStatusCode.NotFound);
}

var checkResponse = await gitHubClient.Check.Run.GetAllForReference(repoOwner, repoName, pr.Head.Sha);
var checkResponse = await ReadWithAnonymousFallbackAsync(client => client.Check.Run.GetAllForReference(repoOwner, repoName, pr.Head.Sha), ct);
if (checkResponse == null || checkResponse.TotalCount == 0)
{
logger.LogError("No checkruns found for pull request.");
Expand Down
Loading