diff --git a/GitHubExtension.Test/Controls/SearchPagesTests.cs b/GitHubExtension.Test/Controls/SearchPagesTests.cs index 5daa919..edc11b8 100644 --- a/GitHubExtension.Test/Controls/SearchPagesTests.cs +++ b/GitHubExtension.Test/Controls/SearchPagesTests.cs @@ -39,6 +39,37 @@ public void SearchPagesCreate_CreatesPagesForBothTypes() search.Setup(x => x.Type).Returns(SearchType.PullRequests); var pullRequestsSearchPage = new PullRequestsSearchPage(search.Object, cacheDataManager.Object, resources.Object); Assert.IsNotNull(pullRequestsSearchPage); + + search.Setup(x => x.Type).Returns(SearchType.Repositories); + var repositoriesSearchPage = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object); + Assert.IsNotNull(repositoriesSearchPage); + } + + [TestMethod] + [TestCategory("Unit")] + public void GetItemsFromRepositoriesSearchPage_ReturnsExpectedItems() + { + var (cacheDataManager, resources, search) = CreateCommonMocks(SearchType.Repositories, "test search string type:repository"); + + var page = new RepositoriesSearchPage(search.Object, cacheDataManager.Object, resources.Object); + + var repo1 = new Mock(); + var repo2 = new Mock(); + repo1.Setup(x => x.FullName).Returns("owner/repo1"); + repo1.Setup(x => x.Description).Returns("Description1"); + repo1.Setup(x => x.HtmlUrl).Returns("mock/url1"); + repo1.Setup(x => x.CloneUrl).Returns("mock/url1.git"); + repo2.Setup(x => x.FullName).Returns("owner/repo2"); + repo2.Setup(x => x.Description).Returns("Description2"); + repo2.Setup(x => x.HtmlUrl).Returns("mock/url2"); + repo2.Setup(x => x.CloneUrl).Returns("mock/url2.git"); + var repositories = new List { repo1.Object, repo2.Object }; + cacheDataManager.Setup(x => x.GetRepositories(search.Object)).ReturnsAsync(repositories); + + var items = page.GetItems(); + Assert.AreEqual(repositories.Count, items.Length); + Assert.AreEqual(repositories[0].FullName, items[0].Title); + Assert.AreEqual(repositories[1].FullName, items[1].Title); } [DataRow(SearchType.PullRequests)] diff --git a/GitHubExtension.Test/HelpersTests/SearchHelperTests.cs b/GitHubExtension.Test/HelpersTests/SearchHelperTests.cs index 634396b..832af27 100644 --- a/GitHubExtension.Test/HelpersTests/SearchHelperTests.cs +++ b/GitHubExtension.Test/HelpersTests/SearchHelperTests.cs @@ -4,6 +4,7 @@ using GitHubExtension.DataModel.Enums; using GitHubExtension.Helpers; +using Octokit; namespace GitHubExtension.Test.HelpersTests; @@ -45,4 +46,52 @@ public void ParseSearchStringFromUri_ValidInput_ReturnsExpectedSearchString(stri var result = SearchHelper.ParseSearchStringFromUri(uri); Assert.AreEqual(expected, result); } + + [DataRow("user:octocat type:repository", "user:octocat")] + [DataRow("type:repo user:octocat", "user:octocat")] + [DataRow("user:octocat", "user:octocat")] + [DataRow("", "")] + [TestMethod] + public void RemoveTypeQualifier_StripsTypeToken(string input, string expected) + { + var result = SearchHelper.RemoveTypeQualifier(input); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void ParseRepoSortFromTerm_ParsesFieldDirectionAndStripsToken() + { + var result = SearchHelper.ParseRepoSortFromTerm("user:octocat sort:updated-desc type:repository"); + Assert.IsNotNull(result); + Assert.AreEqual(RepoSearchSort.Updated, result.Value.SortField); + Assert.AreEqual(SortDirection.Descending, result.Value.Direction); + Assert.AreEqual("user:octocat type:repository", result.Value.UpdatedTerm); + } + + [DataRow("stars", RepoSearchSort.Stars)] + [DataRow("forks", RepoSearchSort.Forks)] + [DataRow("updated", RepoSearchSort.Updated)] + [TestMethod] + public void ParseRepoSortFromTerm_MapsKnownFields(string field, RepoSearchSort expected) + { + var result = SearchHelper.ParseRepoSortFromTerm($"sort:{field}-asc"); + Assert.IsNotNull(result); + Assert.AreEqual(expected, result.Value.SortField); + Assert.AreEqual(SortDirection.Ascending, result.Value.Direction); + } + + [TestMethod] + public void ParseRepoSortFromTerm_NoSort_ReturnsNull() + { + Assert.IsNull(SearchHelper.ParseRepoSortFromTerm("user:octocat")); + } + + [TestMethod] + public void GetSearchRepositoriesRequest_AppliesSortFromTerm() + { + var request = GitHubRequestHelper.GetSearchRepositoriesRequest("user:octocat sort:updated-desc type:repository"); + Assert.AreEqual(RepoSearchSort.Updated, request.SortField); + Assert.AreEqual(SortDirection.Descending, request.Order); + Assert.AreEqual(ExtensionConstants.PerPage, request.PerPage); + } } diff --git a/GitHubExtension/Controls/Commands/LinkCommand.cs b/GitHubExtension/Controls/Commands/LinkCommand.cs index 9a0a24d..d672936 100644 --- a/GitHubExtension/Controls/Commands/LinkCommand.cs +++ b/GitHubExtension/Controls/Commands/LinkCommand.cs @@ -26,6 +26,13 @@ internal LinkCommand(IPullRequest pullRequest, IResources resources) Icon = new IconInfo("\uE8A7"); } + internal LinkCommand(IRepository repository, IResources resources) + { + _htmlUrl = repository.HtmlUrl; + Name = resources.GetResource("Commands_Open_Link"); + Icon = new IconInfo("\uE8A7"); + } + public override CommandResult Invoke() { Process.Start(new ProcessStartInfo(_htmlUrl) { UseShellExecute = true }); diff --git a/GitHubExtension/Controls/ICacheDataManager.cs b/GitHubExtension/Controls/ICacheDataManager.cs index 3b65781..1e2d489 100644 --- a/GitHubExtension/Controls/ICacheDataManager.cs +++ b/GitHubExtension/Controls/ICacheDataManager.cs @@ -16,4 +16,6 @@ public interface ICacheDataManager Task> GetPullRequests(ISearch search); Task> GetIssuesAndPullRequests(ISearch search); + + Task> GetRepositories(ISearch search); } diff --git a/GitHubExtension/Controls/IRepository.cs b/GitHubExtension/Controls/IRepository.cs new file mode 100644 index 0000000..34a1893 --- /dev/null +++ b/GitHubExtension/Controls/IRepository.cs @@ -0,0 +1,26 @@ +// 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. + +namespace GitHubExtension.Controls; + +public interface IRepository +{ + string Name { get; } + + string FullName { get; } + + string Description { get; } + + string HtmlUrl { get; } + + string CloneUrl { get; } + + string OwnerLogin { get; } + + bool IsPrivate { get; } + + bool IsFork { get; } + + string DefaultBranch { get; } +} diff --git a/GitHubExtension/Controls/Pages/SearchPages/RepositoriesSearchPage.cs b/GitHubExtension/Controls/Pages/SearchPages/RepositoriesSearchPage.cs new file mode 100644 index 0000000..f056d5a --- /dev/null +++ b/GitHubExtension/Controls/Pages/SearchPages/RepositoriesSearchPage.cs @@ -0,0 +1,33 @@ +// 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 GitHubExtension.Controls.Commands; +using GitHubExtension.Helpers; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace GitHubExtension.Controls.Pages; + +public sealed partial class RepositoriesSearchPage(ISearch search, ICacheDataManager cacheDataManager, IResources resources) + : SearchPage(search, cacheDataManager, resources) +{ + protected override ListItem GetListItem(IRepository item) + { + return new ListItem(new LinkCommand(item, Resources)) + { + Title = item.FullName, + Icon = GitHubIcon.IconDictionary["repo"], + Subtitle = item.Description, + MoreCommands = new CommandContextItem[] + { + new(new CopyCommand(item.HtmlUrl, $"{Resources.GetResource("Commands_CopyURL")}", Resources)), + new(new CopyCommand(item.CloneUrl, $"{Resources.GetResource("Commands_Copy_CloneUrl")}", Resources)), + }, + }; + } + + protected override async Task> LoadContentData() + { + return await CacheDataManager.GetRepositories(CurrentSearch); + } +} diff --git a/GitHubExtension/Controls/Pages/SearchPages/SearchPageFactory.cs b/GitHubExtension/Controls/Pages/SearchPages/SearchPageFactory.cs index f82c1bb..8202e11 100644 --- a/GitHubExtension/Controls/Pages/SearchPages/SearchPageFactory.cs +++ b/GitHubExtension/Controls/Pages/SearchPages/SearchPageFactory.cs @@ -32,6 +32,7 @@ private ListPage CreatePageForSearch(ISearch search) { SearchType.PullRequests => new PullRequestsSearchPage(search, _cacheDataManager, _resources), SearchType.Issues => new IssuesSearchPage(search, _cacheDataManager, _resources), + SearchType.Repositories => new RepositoriesSearchPage(search, _cacheDataManager, _resources), _ => new CombinedSearchPage(search, _cacheDataManager, _resources), }; } diff --git a/GitHubExtension/DataManager/CacheDataManagerFacade.cs b/GitHubExtension/DataManager/CacheDataManagerFacade.cs index f35c775..0e5d8d4 100644 --- a/GitHubExtension/DataManager/CacheDataManagerFacade.cs +++ b/GitHubExtension/DataManager/CacheDataManagerFacade.cs @@ -87,6 +87,16 @@ public async Task> GetPullRequests(ISearch search) return res; } + public async Task> GetRepositories(ISearch search) + { + await DownloadSearch(search); + + var res = _dataRequester.GetRepositoriesForSearch(search.Name, search.SearchString); + + _ = _cacheManager.RequestRefresh(search); + return res; + } + private List MergeIssuesAndPullRequests(IEnumerable issues, IEnumerable pullRequests) { var res = new List(); diff --git a/GitHubExtension/DataManager/Data/GitHubDataManager.cs b/GitHubExtension/DataManager/Data/GitHubDataManager.cs index d8f6dce..78be182 100644 --- a/GitHubExtension/DataManager/Data/GitHubDataManager.cs +++ b/GitHubExtension/DataManager/Data/GitHubDataManager.cs @@ -162,7 +162,7 @@ private async Task UpdateRepositoriesForSearchAsync(ISearch search, RequestOptio var searchString = search.SearchString; _log.Information($"Updating repositories for: {name}"); - var searchRepoRequest = new Octokit.SearchRepositoriesRequest(searchString); + var searchRepoRequest = GitHubRequestHelper.GetSearchRepositoriesRequest(searchString); var reposResult = await _gitHubClientProvider.GetClient().Search.SearchRepo(searchRepoRequest); if (reposResult == null) @@ -251,6 +251,12 @@ public IEnumerable GetPullRequestsForSearch(string name, string sea return GetSearch(name, searchString)?.PullRequests ?? []; } + public IEnumerable GetRepositoriesForSearch(string name, string searchString) + { + ValidateDataStore(); + return GetSearch(name, searchString)?.Repositories ?? []; + } + // Removes unused data from the datastore. private void PruneObsoleteData() { diff --git a/GitHubExtension/DataManager/IDataRequester.cs b/GitHubExtension/DataManager/IDataRequester.cs index 76d8468..cabd94e 100644 --- a/GitHubExtension/DataManager/IDataRequester.cs +++ b/GitHubExtension/DataManager/IDataRequester.cs @@ -14,4 +14,6 @@ public interface IDataRequester IEnumerable GetIssuesForSearch(string name, string searchString); IEnumerable GetPullRequestsForSearch(string name, string searchString); + + IEnumerable GetRepositoriesForSearch(string name, string searchString); } diff --git a/GitHubExtension/DataModel/DataObjects/Repository.cs b/GitHubExtension/DataModel/DataObjects/Repository.cs index f719d18..fe6c5b9 100644 --- a/GitHubExtension/DataModel/DataObjects/Repository.cs +++ b/GitHubExtension/DataModel/DataObjects/Repository.cs @@ -4,6 +4,7 @@ using Dapper; using Dapper.Contrib.Extensions; +using GitHubExtension.Controls; using GitHubExtension.Helpers; using Octokit; using Serilog; @@ -11,7 +12,7 @@ namespace GitHubExtension.DataModel.DataObjects; [Table("Repository")] -public class Repository +public class Repository : IRepository { private static readonly Lazy _logger = new(() => Serilog.Log.ForContext("SourceContext", $"DataModel/{nameof(Repository)}")); @@ -65,6 +66,18 @@ private DataStore? DataStore [Computed] public string FullName => Owner.Login + '/' + Name; + [Write(false)] + [Computed] + public string OwnerLogin => Owner.Login; + + [Write(false)] + [Computed] + public bool IsPrivate => Private == 1; + + [Write(false)] + [Computed] + public bool IsFork => Fork == 1; + [Write(false)] [Computed] public User Owner diff --git a/GitHubExtension/GitHubExtensionCommandsProvider.cs b/GitHubExtension/GitHubExtensionCommandsProvider.cs index be5a83f..819dd3d 100644 --- a/GitHubExtension/GitHubExtensionCommandsProvider.cs +++ b/GitHubExtension/GitHubExtensionCommandsProvider.cs @@ -115,6 +115,7 @@ public async Task UpdateSignInStatus(bool isSignedIn) new SearchCandidate($"is:open mentions:{login} archived:false sort:created-desc", _resources.GetResource("CommandsProvider_MentionsMeCommandName")), new SearchCandidate($"is:open is:issue archived:false author:{login} sort:created-desc", _resources.GetResource("CommandsProvider_CreatedIssuesCommandName")), new SearchCandidate($"is:open is:pr author:{login} archived:false sort:created-desc", _resources.GetResource("CommandsProvider_MyPullRequestsCommandName")), + new SearchCandidate($"user:{login} sort:updated-desc type:repository", _resources.GetResource("CommandsProvider_MyLatestRepositoriesCommandName")), }; try diff --git a/GitHubExtension/Helpers/GitHubIcon.cs b/GitHubExtension/Helpers/GitHubIcon.cs index 4661759..8666de8 100644 --- a/GitHubExtension/Helpers/GitHubIcon.cs +++ b/GitHubExtension/Helpers/GitHubIcon.cs @@ -22,6 +22,8 @@ static GitHubIcon() { "PullRequests", IconHelpers.FromRelativePath("Assets\\pulls.svg") }, { "IssuesAndPullRequests", IconHelpers.FromRelativePaths("Assets\\github.light.svg", "Assets\\github.dark.svg") }, { "Search", new IconInfo("\ue721") }, + { "repo", new IconInfo("\uE8F1") }, + { "Repositories", new IconInfo("\uE8F1") }, }; } diff --git a/GitHubExtension/Helpers/GitHubRequestHelper.cs b/GitHubExtension/Helpers/GitHubRequestHelper.cs index a8e915b..6d8e6e9 100644 --- a/GitHubExtension/Helpers/GitHubRequestHelper.cs +++ b/GitHubExtension/Helpers/GitHubRequestHelper.cs @@ -51,4 +51,28 @@ public static SearchIssuesRequest GetSearchPullRequestsRequest(string term) Order = sortDirection, }; } + + public static SearchRepositoriesRequest GetSearchRepositoriesRequest(string term) + { + RepoSearchSort? sortField = null; + var sortDirection = SortDirection.Descending; + var result = SearchHelper.ParseRepoSortFromTerm(term); + if (result is (var sortFieldResult, var directionResult, var updatedTerm)) + { + sortField = sortFieldResult; + sortDirection = directionResult; + term = updatedTerm; + } + + // Strip the internal "type:" classification hint; it is not a valid repository qualifier. + term = SearchHelper.RemoveTypeQualifier(term); + + return new SearchRepositoriesRequest(term) + { + PerPage = ExtensionConstants.PerPage, + Page = 1, + SortField = sortField, + Order = sortDirection, + }; + } } diff --git a/GitHubExtension/Helpers/SearchHelper.cs b/GitHubExtension/Helpers/SearchHelper.cs index 70e9405..697a6a6 100644 --- a/GitHubExtension/Helpers/SearchHelper.cs +++ b/GitHubExtension/Helpers/SearchHelper.cs @@ -219,6 +219,59 @@ public static (IssueSearchSort SortField, SortDirection Direction, string Update return (sortField, order, updatedTerm); } + // Repository search does not support the internal "type:" hint we use to classify + // searches, so it must be stripped before the term is sent to the GitHub API. + public static string RemoveTypeQualifier(string term) + { + if (string.IsNullOrWhiteSpace(term)) + { + return term; + } + + var updatedTerm = Regex.Replace(term, @"\s*type:\w+\s*", " ", RegexOptions.IgnoreCase).Trim(); + return Regex.Replace(updatedTerm, @"\s{2,}", " "); + } + + public static (RepoSearchSort SortField, SortDirection Direction, string UpdatedTerm)? ParseRepoSortFromTerm(string term) + { + if (string.IsNullOrWhiteSpace(term)) + { + return null; + } + + var match = Regex.Match(term, @"sort:(\w+)-(asc|desc)", RegexOptions.IgnoreCase); + if (!match.Success) + { + return null; + } + + var field = match.Groups[1].Value.ToLowerInvariant(); + var direction = match.Groups[2].Value.ToLowerInvariant(); + + RepoSearchSort sortField; + switch (field) + { + case "stars": + sortField = RepoSearchSort.Stars; + break; + case "forks": + sortField = RepoSearchSort.Forks; + break; + case "updated": + sortField = RepoSearchSort.Updated; + break; + default: + return null; + } + + var order = direction == "asc" ? SortDirection.Ascending : SortDirection.Descending; + + var updatedTerm = Regex.Replace(term, @"\s*sort:\w+-\w+\s*", " ", RegexOptions.IgnoreCase).Trim(); + updatedTerm = Regex.Replace(updatedTerm, @"\s{2,}", " "); + + return (sortField, order, updatedTerm); + } + private static readonly Dictionary SearchTypeMappings = new() { { "issue", SearchType.Issues }, diff --git a/GitHubExtension/Strings/en-US/Resources.resw b/GitHubExtension/Strings/en-US/Resources.resw index 5a5c6ab..0937251 100644 --- a/GitHubExtension/Strings/en-US/Resources.resw +++ b/GitHubExtension/Strings/en-US/Resources.resw @@ -121,6 +121,10 @@ Copy source branch Title for command to copy source branch of a pull request + + Copy clone URL + Title for command to copy the clone URL of a repository + Copy checkout command Title for copy checkout command @@ -321,6 +325,10 @@ My pull requests Title for search "state:open is:pr author:{login} archived:false" + + My latest repositories + Title for search "user:{login} sort:updated-desc type:repository" + GitHub Extension (Preview) Title for the extension