Skip to content
Closed
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
187 changes: 187 additions & 0 deletions GitHubExtension.Test/Controls/DiscussionsAndProjectsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json.Nodes;
using GitHubExtension.Client;
using GitHubExtension.Controls;
using GitHubExtension.Controls.Pages;
using GitHubExtension.DataManager;
using GitHubExtension.DataManager.Data;
using GitHubExtension.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Moq;

namespace GitHubExtension.Test.Controls;

[TestClass]
public class DiscussionsAndProjectsTests
{
private static Mock<IResources> CreateResources()
{
var resources = new Mock<IResources>();
resources.Setup(x => x.GetResource(It.IsAny<string>(), null)).Returns<string, object>((key, _) => key);
return resources;
}

[TestMethod]
[TestCategory("Unit")]
public void ResolveGraphQLEndpoint_GitHubCom_ReturnsApiGraphQL()
{
var endpoint = GitHubGraphQLClient.ResolveGraphQLEndpoint(new Uri("https://api.github.com/"));
Assert.AreEqual("https://api.github.com/graphql", endpoint.ToString());
}

[TestMethod]
[TestCategory("Unit")]
public void ResolveGraphQLEndpoint_EnterpriseServer_ReturnsHostApiGraphQL()
{
var endpoint = GitHubGraphQLClient.ResolveGraphQLEndpoint(new Uri("https://ghe.example.com/api/v3/"));
Assert.AreEqual("https://ghe.example.com/api/graphql", endpoint.ToString());
}

[TestMethod]
[TestCategory("Unit")]
public async Task DiscussionsDataManager_MapsSearchNodes()
{
var data = JsonNode.Parse(@"{
""search"": {
""nodes"": [
{ ""title"": ""First"", ""url"": ""https://github.com/o/r/discussions/1"", ""number"": 1, ""updatedAt"": ""2024-01-01T00:00:00Z"", ""repository"": { ""nameWithOwner"": ""o/r"" }, ""author"": { ""login"": ""octocat"" } },
{ ""title"": ""Second"", ""url"": ""https://github.com/o/r/discussions/2"", ""number"": 2, ""updatedAt"": ""2024-01-02T00:00:00Z"", ""repository"": { ""nameWithOwner"": ""o/r"" }, ""author"": { ""login"": ""hubber"" } }
]
}
}");

var client = new Mock<IGitHubGraphQLClient>();
client.Setup(x => x.QueryAsync(It.IsAny<string>(), It.IsAny<object?>())).ReturnsAsync(data);

var manager = new DiscussionsDataManager(client.Object);
var discussions = (await manager.SearchDiscussionsAsync("author:@me")).ToList();

Assert.AreEqual(2, discussions.Count);
Assert.AreEqual("First", discussions[0].Title);
Assert.AreEqual("o/r", discussions[0].RepositoryFullName);
Assert.AreEqual("octocat", discussions[0].Author);
Assert.AreEqual(2, discussions[1].Number);
}

[TestMethod]
[TestCategory("Unit")]
public async Task DiscussionsDataManager_NoNodes_ReturnsEmpty()
{
var client = new Mock<IGitHubGraphQLClient>();
client.Setup(x => x.QueryAsync(It.IsAny<string>(), It.IsAny<object?>())).ReturnsAsync(JsonNode.Parse("{}"));

var manager = new DiscussionsDataManager(client.Object);
var discussions = await manager.SearchDiscussionsAsync("author:@me");

Assert.AreEqual(0, discussions.Count());
}

[TestMethod]
[TestCategory("Unit")]
public async Task ProjectsDataManager_MapsViewerProjects()
{
var data = JsonNode.Parse(@"{
""viewer"": {
""projectsV2"": {
""nodes"": [
{ ""title"": ""Roadmap"", ""url"": ""https://github.com/users/o/projects/1"", ""number"": 1, ""closed"": false },
{ ""title"": ""Archive"", ""url"": ""https://github.com/users/o/projects/2"", ""number"": 2, ""closed"": true }
]
}
}
}");

var client = new Mock<IGitHubGraphQLClient>();
client.Setup(x => x.QueryAsync(It.IsAny<string>(), It.IsAny<object?>())).ReturnsAsync(data);

var manager = new ProjectsDataManager(client.Object);
var projects = (await manager.GetMyProjectsAsync()).ToList();

Assert.AreEqual(2, projects.Count);
Assert.AreEqual("Roadmap", projects[0].Title);
Assert.IsFalse(projects[0].Closed);
Assert.IsTrue(projects[1].Closed);
}

[TestMethod]
[TestCategory("Unit")]
public async Task AutoMergeManager_Enable_ResolvesIdThenMutates()
{
var idData = JsonNode.Parse(@"{ ""repository"": { ""pullRequest"": { ""id"": ""PR_kabc123"" } } }");

var client = new Mock<IGitHubGraphQLClient>();
var capturedIds = new List<string?>();
client.Setup(x => x.QueryAsync(It.Is<string>(q => q.Contains("pullRequest(number")), It.IsAny<object?>()))
.ReturnsAsync(idData);
client.Setup(x => x.QueryAsync(It.Is<string>(q => q.Contains("enablePullRequestAutoMerge")), It.IsAny<object?>()))
.Callback<string, object?>((_, vars) =>
{
var dict = (IDictionary<string, object?>)vars!;
capturedIds.Add(dict["id"]?.ToString());
})
.ReturnsAsync((JsonNode?)null);

var pr = new Mock<IPullRequest>();
pr.Setup(x => x.HtmlUrl).Returns("https://github.com/owner/repo/pull/7");
pr.Setup(x => x.Number).Returns(7);

var manager = new GitHubAutoMergeManager(client.Object);
await manager.EnableAutoMergeAsync(pr.Object);

var expectedIds = new[] { "PR_kabc123" };
CollectionAssert.AreEqual(expectedIds, capturedIds);
client.Verify(x => x.QueryAsync(It.Is<string>(q => q.Contains("enablePullRequestAutoMerge")), It.IsAny<object?>()), Times.Once);
}

[TestMethod]
[TestCategory("Unit")]
public void DiscussionsPage_EmptyQuery_ShowsPrompt()
{
var manager = new Mock<IDiscussionsDataManager>();
var page = new DiscussionsPage(manager.Object, CreateResources().Object, "Search discussions", string.Empty);

var items = page.GetItems();

Assert.AreEqual(1, items.Length);
Assert.AreEqual("Pages_Discussions_Prompt", items[0].Title);
manager.Verify(x => x.SearchDiscussionsAsync(It.IsAny<string>()), Times.Never);
}

[TestMethod]
[TestCategory("Unit")]
public void DiscussionsPage_MyDiscussions_UsesBaseQuery()
{
var manager = new Mock<IDiscussionsDataManager>();
manager.Setup(x => x.SearchDiscussionsAsync(It.IsAny<string>()))
.ReturnsAsync(new[] { new Discussion { Title = "D", HtmlUrl = "https://github.com/o/r/discussions/1" } });

var page = new DiscussionsPage(manager.Object, CreateResources().Object, "My discussions", "author:@me");

var items = page.GetItems();

Assert.AreEqual(1, items.Length);
manager.Verify(x => x.SearchDiscussionsAsync("author:@me"), Times.Once);
}

[TestMethod]
[TestCategory("Unit")]
public void ProjectsPage_WithProjects_ReturnsItems()
{
var manager = new Mock<IProjectsDataManager>();
manager.Setup(x => x.GetMyProjectsAsync())
.ReturnsAsync(new[]
{
new Project { Title = "A", HtmlUrl = "https://github.com/users/o/projects/1", Closed = false },
new Project { Title = "B", HtmlUrl = "https://github.com/users/o/projects/2", Closed = true },
});

var page = new ProjectsPage(manager.Object, CreateResources().Object);

var items = page.GetItems();

Assert.AreEqual(2, items.Length);
}
}
9 changes: 7 additions & 2 deletions GitHubExtension.Test/Controls/MutationCommandsFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ private static (MutationCommandsFactory Factory, Mock<IGitHubMutationManager> Ma
{
var manager = new Mock<IGitHubMutationManager>();
var createManager = new Mock<IGitHubCreateManager>();
var autoMergeManager = new Mock<IGitHubAutoMergeManager>();
var mediator = new MutationMediator();
var resources = new Mock<IResources>();
resources.Setup(x => x.GetResource(It.IsAny<string>(), null)).Returns<string, object>((key, _) => key);
var factory = new MutationCommandsFactory(manager.Object, createManager.Object, mediator, resources.Object);
var factory = new MutationCommandsFactory(manager.Object, createManager.Object, autoMergeManager.Object, mediator, resources.Object);
return (factory, manager, mediator);
}

Expand Down Expand Up @@ -76,11 +77,15 @@ public void GetPullRequestCommands_OpenPullRequest_ReturnsMergeAndCloseCommands(
var (factory, _, _) = CreateFactory();
var commands = factory.GetPullRequestCommands(CreatePullRequest("Open").Object).ToList();

Assert.AreEqual(3, commands.Count);
Assert.AreEqual(5, commands.Count);
Assert.AreEqual("Commands_MergePullRequest", commands[0].Command!.Name);
Assert.AreEqual("Commands_ClosePullRequest", commands[1].Command!.Name);
Assert.AreEqual("Commands_EnableAutoMerge", commands[2].Command!.Name);
Assert.AreEqual("Commands_DisableAutoMerge", commands[3].Command!.Name);
Assert.IsTrue(commands[0].IsCritical);
Assert.IsTrue(commands[1].IsCritical);
Assert.IsFalse(commands[2].IsCritical);
Assert.IsFalse(commands[3].IsCritical);
}

[TestMethod]
Expand Down
3 changes: 2 additions & 1 deletion GitHubExtension.Test/Controls/SearchPagesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ private static (MutationCommandsFactory Factory, MutationMediator Mediator) Crea
{
var mutationManager = new Mock<IGitHubMutationManager>();
var createManager = new Mock<IGitHubCreateManager>();
var autoMergeManager = new Mock<IGitHubAutoMergeManager>();
var mediator = new MutationMediator();
var factory = new MutationCommandsFactory(mutationManager.Object, createManager.Object, mediator, resources.Object);
var factory = new MutationCommandsFactory(mutationManager.Object, createManager.Object, autoMergeManager.Object, mediator, resources.Object);
return (factory, mediator);
}

Expand Down
3 changes: 2 additions & 1 deletion GitHubExtension.Test/Controls/TopLevelSearchesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,9 @@ public async Task Integration_AddNewTopLevelCommand(string searchString, string
var mockCacheDataManager = new Mock<ICacheDataManager>().Object;
var mutationManager = new Mock<IGitHubMutationManager>().Object;
var createManager = new Mock<IGitHubCreateManager>().Object;
var autoMergeManager = new Mock<IGitHubAutoMergeManager>().Object;
var mutationMediator = new MutationMediator();
var mutationCommandsFactory = new MutationCommandsFactory(mutationManager, createManager, mutationMediator, resources);
var mutationCommandsFactory = new MutationCommandsFactory(mutationManager, createManager, autoMergeManager, mutationMediator, resources);
var searchPageFactory = new SearchPageFactory(mockCacheDataManager, persistentDataManager, resources, mediator, mutationCommandsFactory, mutationMediator);

var addSearchForm = new SaveSearchForm(persistentDataManager, resources, mediator);
Expand Down
7 changes: 6 additions & 1 deletion GitHubExtension.Test/Helpers/TestSetupHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ public static GitHubExtensionCommandsProvider CreateGitHubExtensionCommandsProvi
var workflowRunsMediator = new WorkflowRunsMediator();
var mockWorkflowRunsDataManager = new Mock<IWorkflowRunsDataManager>();
var workflowRunsPage = new WorkflowRunsPage(mockWorkflowRunsDataManager.Object, workflowRunsMediator, mockResources);
return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager, workflowRunsPage, mockWorkflowRunsDataManager.Object);
var mockDiscussionsDataManager = new Mock<IDiscussionsDataManager>();
var myDiscussionsPage = new DiscussionsPage(mockDiscussionsDataManager.Object, mockResources, "My discussions", "author:@me");
var searchDiscussionsPage = new DiscussionsPage(mockDiscussionsDataManager.Object, mockResources, "Search discussions", string.Empty);
var mockProjectsDataManager = new Mock<IProjectsDataManager>();
var projectsPage = new ProjectsPage(mockProjectsDataManager.Object, mockResources);
return new GitHubExtensionCommandsProvider(savedSearchesPage, signOutPage, signInPage, notificationsPage, mockDeveloperIdProvider, persistentDataManager, mockResources, searchPageFactory, savedSearchesMediator, mockAuthenticationMediator, notificationsMediator, mockCreateManager, workflowRunsPage, mockWorkflowRunsDataManager.Object, myDiscussionsPage, searchDiscussionsPage, projectsPage);
}
}
94 changes: 94 additions & 0 deletions GitHubExtension/Client/GitHubGraphQLClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Serilog;

namespace GitHubExtension.Client;

// Sends GraphQL queries and mutations to GitHub using the logged-in developer's
// OAuth token. The GraphQL endpoint is derived from the REST client's base
// address so the same code path works for github.com and (later) GitHub
// Enterprise Server.
public sealed class GitHubGraphQLClient : IGitHubGraphQLClient
{
private static readonly Lazy<ILogger> _logger = new(() => Serilog.Log.ForContext("SourceContext", nameof(GitHubGraphQLClient)));

private static readonly ILogger _log = _logger.Value;

private static readonly HttpClient _httpClient = new();

private readonly GitHubClientProvider _gitHubClientProvider;

public GitHubGraphQLClient(GitHubClientProvider gitHubClientProvider)
{
_gitHubClientProvider = gitHubClientProvider;
}

public async Task<JsonNode?> QueryAsync(string query, object? variables = null)
{
var client = await _gitHubClientProvider.GetClientForLoggedInDeveloper(false);
var token = client.Connection.Credentials?.Password
?? throw new InvalidOperationException("No authenticated GitHub token is available for GraphQL.");

var endpoint = ResolveGraphQLEndpoint(client.Connection.BaseAddress);

var payload = new Dictionary<string, object?>
{
["query"] = query,
};

if (variables != null)
{
payload["variables"] = variables;
}

var body = JsonSerializer.Serialize(payload);

using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.UserAgent.Add(new ProductInfoHeaderValue(Constants.CMDPAL_APPLICATION_NAME, "1.0"));

using var response = await _httpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
{
_log.Error($"GraphQL request failed with status {(int)response.StatusCode}: {content}");
throw new HttpRequestException($"GraphQL request failed with status {(int)response.StatusCode}.");
}

var root = JsonNode.Parse(content);
var errors = root?["errors"];
if (errors is JsonArray errorArray && errorArray.Count > 0)
{
var message = errorArray[0]?["message"]?.ToString() ?? "Unknown GraphQL error.";
_log.Error($"GraphQL response returned errors: {content}");
throw new InvalidOperationException(message);
}

return root?["data"];
}

internal static Uri ResolveGraphQLEndpoint(Uri baseAddress)
{
var host = baseAddress.Host;

// github.com REST lives at api.github.com; GraphQL is api.github.com/graphql.
if (host.Equals("api.github.com", StringComparison.OrdinalIgnoreCase)
|| host.Equals("github.com", StringComparison.OrdinalIgnoreCase))
{
return new Uri("https://api.github.com/graphql");
}

// GitHub Enterprise Server: https://HOST/api/graphql.
return new Uri($"{baseAddress.Scheme}://{baseAddress.Authority}/api/graphql");
}
}
15 changes: 15 additions & 0 deletions GitHubExtension/Client/IGitHubGraphQLClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Text.Json.Nodes;

namespace GitHubExtension.Client;

// Minimal GraphQL access for capabilities not covered by the Octokit REST
// client (Discussions, Projects v2, and auto-merge mutations). Returns the
// "data" node of the GraphQL response.
public interface IGitHubGraphQLClient
{
Task<JsonNode?> QueryAsync(string query, object? variables = null);
}
Loading
Loading