diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/GitHubServiceTests.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/GitHubServiceTests.cs new file mode 100644 index 00000000000..e84d2888597 --- /dev/null +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli.Tests/Services/GitHubServiceTests.cs @@ -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 ReadWithAnonymousFallbackForTest(Func> 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(); + 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(), It.IsAny()), 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(); + 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(); + processHelperMock + .Setup(x => x.Run(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult { ExitCode = 1 }); + var connection = new TestableGitConnection(processHelperMock.Object); + + Assert.ThrowsAsync(async () => + await connection.ReadWithAnonymousFallbackForTest(client => + { + if (client.Credentials.AuthenticationType == AuthenticationType.Anonymous) + { + throw new NotFoundException("Not Found", HttpStatusCode.NotFound); + } + return Task.FromResult(0); + })); + } +} diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md index 5558b0f4edc..f482b025d5c 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md @@ -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. diff --git a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs index 825fa0f7aab..0fa09938342 100644 --- a/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs +++ b/tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs @@ -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) { @@ -45,6 +46,33 @@ public GitHubClient gitHubClient } } + /// + /// An unauthenticated GitHub client suitable for read-only access to public repositories. + /// + protected GitHubClient anonymousGitHubClient => + _anonymousGitHubClient ??= new GitHubClient(new ProductHeaderValue("AzureSDKDevToolsMCP")); + + /// + /// 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. + /// + protected async Task ReadWithAnonymousFallbackAsync(Func> 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"); @@ -149,7 +177,9 @@ public async Task GetGitUserDetailsAsync(CancellationToken ct) public async Task 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; } @@ -189,7 +219,7 @@ public async Task 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) @@ -494,7 +524,7 @@ public async Task> 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.");