Skip to content

Commit cfc88dc

Browse files
helen229CopilotCopilot
authored
Don't require GitHub auth for public-repo PR status/label reads (#16210) (#16292)
* Don't require GitHub auth for public-repo PR status/label reads (#16210) * Try anonymous GitHub read first, fall back to auth only when required (#16210) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix indentation of ReadWithAnonymousFallbackAsync method in GitHubService.cs Co-authored-by: helen229 <19517014+helen229@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: helen229 <19517014+helen229@users.noreply.github.com>
1 parent e680274 commit cfc88dc

3 files changed

Lines changed: 133 additions & 3 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
using System.Net;
4+
using Azure.Sdk.Tools.Cli.Helpers;
5+
using Azure.Sdk.Tools.Cli.Services;
6+
using Moq;
7+
using Octokit;
8+
9+
namespace Azure.Sdk.Tools.Cli.Tests.Services;
10+
11+
[TestFixture]
12+
public class GitHubServiceTests
13+
{
14+
private string? originalGitHubToken;
15+
private string? originalGitHubPat;
16+
17+
// Exposes the protected anonymous-first read helper so it can be exercised in isolation.
18+
private sealed class TestableGitConnection : GitConnection
19+
{
20+
public TestableGitConnection(IProcessHelper processHelper) : base(processHelper) { }
21+
public Task<T> ReadWithAnonymousFallbackForTest<T>(Func<GitHubClient, Task<T>> operation)
22+
=> ReadWithAnonymousFallbackAsync(operation, CancellationToken.None);
23+
}
24+
25+
[SetUp]
26+
public void SetUp()
27+
{
28+
// Preserve and clear the auth environment variables so tests control token availability.
29+
originalGitHubToken = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
30+
originalGitHubPat = Environment.GetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN");
31+
Environment.SetEnvironmentVariable("GITHUB_TOKEN", null, EnvironmentVariableTarget.Process);
32+
Environment.SetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN", null, EnvironmentVariableTarget.Process);
33+
}
34+
35+
[TearDown]
36+
public void TearDown()
37+
{
38+
Environment.SetEnvironmentVariable("GITHUB_TOKEN", originalGitHubToken, EnvironmentVariableTarget.Process);
39+
Environment.SetEnvironmentVariable("GITHUB_PERSONAL_ACCESS_TOKEN", originalGitHubPat, EnvironmentVariableTarget.Process);
40+
}
41+
42+
[Test]
43+
public async Task ReadWithAnonymousFallback_UsesAnonymousClient_WhenPublicReadSucceeds()
44+
{
45+
var processHelperMock = new Mock<IProcessHelper>();
46+
var connection = new TestableGitConnection(processHelperMock.Object);
47+
48+
var usedAuthType = await connection.ReadWithAnonymousFallbackForTest(
49+
client => Task.FromResult(client.Credentials.AuthenticationType));
50+
51+
Assert.That(usedAuthType, Is.EqualTo(AuthenticationType.Anonymous));
52+
// No authentication was attempted for a successful anonymous read.
53+
processHelperMock.Verify(x => x.Run(It.IsAny<ProcessOptions>(), It.IsAny<CancellationToken>()), Times.Never);
54+
}
55+
56+
[Test]
57+
public async Task ReadWithAnonymousFallback_RetriesAuthenticated_WhenAnonymousRequiresAuth()
58+
{
59+
// A token is available in the environment, so the authenticated fallback can succeed.
60+
Environment.SetEnvironmentVariable("GITHUB_TOKEN", "fake-token", EnvironmentVariableTarget.Process);
61+
var processHelperMock = new Mock<IProcessHelper>();
62+
var connection = new TestableGitConnection(processHelperMock.Object);
63+
64+
// Simulate GitHub hiding a private resource as Not Found for the anonymous client.
65+
var usedAuthType = await connection.ReadWithAnonymousFallbackForTest(client =>
66+
{
67+
if (client.Credentials.AuthenticationType == AuthenticationType.Anonymous)
68+
{
69+
throw new NotFoundException("Not Found", HttpStatusCode.NotFound);
70+
}
71+
return Task.FromResult(client.Credentials.AuthenticationType);
72+
});
73+
74+
Assert.That(usedAuthType, Is.EqualTo(AuthenticationType.Bearer));
75+
}
76+
77+
[Test]
78+
public void ReadWithAnonymousFallback_PromptsForAuth_WhenRequiredAndNoToken()
79+
{
80+
// No token in the environment and `gh auth token` fails, so the authenticated fallback must
81+
// surface the standard authentication guidance instead of silently swallowing the failure.
82+
var processHelperMock = new Mock<IProcessHelper>();
83+
processHelperMock
84+
.Setup(x => x.Run(It.IsAny<ProcessOptions>(), It.IsAny<CancellationToken>()))
85+
.ReturnsAsync(new ProcessResult { ExitCode = 1 });
86+
var connection = new TestableGitConnection(processHelperMock.Object);
87+
88+
Assert.ThrowsAsync<InvalidOperationException>(async () =>
89+
await connection.ReadWithAnonymousFallbackForTest<int>(client =>
90+
{
91+
if (client.Credentials.AuthenticationType == AuthenticationType.Anonymous)
92+
{
93+
throw new NotFoundException("Not Found", HttpStatusCode.NotFound);
94+
}
95+
return Task.FromResult(0);
96+
}));
97+
}
98+
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
### Bugs Fixed
2020

21+
- 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.
22+
2123
- 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.
2224

2325
- 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.

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Services/GitHubService.cs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public class GitConnection
2323
private const int GitHubCliAuthTokenTimeoutMs = 120000;
2424
private readonly IProcessHelper processHelper;
2525
private GitHubClient? _gitHubClient; // Backing field for the property
26+
private GitHubClient? _anonymousGitHubClient; // Backing field for the anonymous client
2627

2728
protected GitConnection(IProcessHelper processHelper)
2829
{
@@ -45,6 +46,33 @@ public GitHubClient gitHubClient
4546
}
4647
}
4748

49+
/// <summary>
50+
/// An unauthenticated GitHub client suitable for read-only access to public repositories.
51+
/// </summary>
52+
protected GitHubClient anonymousGitHubClient =>
53+
_anonymousGitHubClient ??= new GitHubClient(new ProductHeaderValue("AzureSDKDevToolsMCP"));
54+
55+
/// <summary>
56+
/// Runs a read-only GitHub operation anonymously first so that public data (for example, the status
57+
/// or labels of a public spec pull request during SDK generation) can be read without authentication.
58+
/// If the anonymous request fails (the resource is private, access is forbidden, or the rate limit is
59+
/// exhausted), it is retried with an authenticated client, which prompts the user to run `gh auth login`
60+
/// when no token is available.
61+
/// </summary>
62+
protected async Task<T> ReadWithAnonymousFallbackAsync<T>(Func<GitHubClient, Task<T>> operation, CancellationToken ct)
63+
{
64+
ct.ThrowIfCancellationRequested();
65+
try
66+
{
67+
return await operation(anonymousGitHubClient);
68+
}
69+
catch (Octokit.ApiException)
70+
{
71+
ct.ThrowIfCancellationRequested();
72+
return await operation(gitHubClient);
73+
}
74+
}
75+
4876
protected string GetGitHubAuthToken()
4977
{
5078
var token = Environment.GetEnvironmentVariable("GITHUB_TOKEN");
@@ -149,7 +177,9 @@ public async Task<User> GetGitUserDetailsAsync(CancellationToken ct)
149177

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

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

497-
var checkResponse = await gitHubClient.Check.Run.GetAllForReference(repoOwner, repoName, pr.Head.Sha);
527+
var checkResponse = await ReadWithAnonymousFallbackAsync(client => client.Check.Run.GetAllForReference(repoOwner, repoName, pr.Head.Sha), ct);
498528
if (checkResponse == null || checkResponse.TotalCount == 0)
499529
{
500530
logger.LogError("No checkruns found for pull request.");

0 commit comments

Comments
 (0)