Skip to content
Draft
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
31 changes: 31 additions & 0 deletions GitHubExtension.Test/Controls/SearchPagesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IRepository>();
var repo2 = new Mock<IRepository>();
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<IRepository> { 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)]
Expand Down
49 changes: 49 additions & 0 deletions GitHubExtension.Test/HelpersTests/SearchHelperTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using GitHubExtension.DataModel.Enums;
using GitHubExtension.Helpers;
using Octokit;

namespace GitHubExtension.Test.HelpersTests;

Expand Down Expand Up @@ -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);
}
}
7 changes: 7 additions & 0 deletions GitHubExtension/Controls/Commands/LinkCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions GitHubExtension/Controls/ICacheDataManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ public interface ICacheDataManager
Task<IEnumerable<IPullRequest>> GetPullRequests(ISearch search);

Task<IEnumerable<IIssue>> GetIssuesAndPullRequests(ISearch search);

Task<IEnumerable<IRepository>> GetRepositories(ISearch search);
}
26 changes: 26 additions & 0 deletions GitHubExtension/Controls/IRepository.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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<IRepository>(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<IEnumerable<IRepository>> LoadContentData()
{
return await CacheDataManager.GetRepositories(CurrentSearch);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand Down
10 changes: 10 additions & 0 deletions GitHubExtension/DataManager/CacheDataManagerFacade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ public async Task<IEnumerable<IPullRequest>> GetPullRequests(ISearch search)
return res;
}

public async Task<IEnumerable<IRepository>> GetRepositories(ISearch search)
{
await DownloadSearch(search);

var res = _dataRequester.GetRepositoriesForSearch(search.Name, search.SearchString);

_ = _cacheManager.RequestRefresh(search);
return res;
}

private List<IIssue> MergeIssuesAndPullRequests(IEnumerable<Issue> issues, IEnumerable<PullRequest> pullRequests)
{
var res = new List<IIssue>();
Expand Down
8 changes: 7 additions & 1 deletion GitHubExtension/DataManager/Data/GitHubDataManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -251,6 +251,12 @@ public IEnumerable<PullRequest> GetPullRequestsForSearch(string name, string sea
return GetSearch(name, searchString)?.PullRequests ?? [];
}

public IEnumerable<Repository> GetRepositoriesForSearch(string name, string searchString)
{
ValidateDataStore();
return GetSearch(name, searchString)?.Repositories ?? [];
}

// Removes unused data from the datastore.
private void PruneObsoleteData()
{
Expand Down
2 changes: 2 additions & 0 deletions GitHubExtension/DataManager/IDataRequester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public interface IDataRequester
IEnumerable<Issue> GetIssuesForSearch(string name, string searchString);

IEnumerable<PullRequest> GetPullRequestsForSearch(string name, string searchString);

IEnumerable<Repository> GetRepositoriesForSearch(string name, string searchString);
}
15 changes: 14 additions & 1 deletion GitHubExtension/DataModel/DataObjects/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@

using Dapper;
using Dapper.Contrib.Extensions;
using GitHubExtension.Controls;
using GitHubExtension.Helpers;
using Octokit;
using Serilog;

namespace GitHubExtension.DataModel.DataObjects;

[Table("Repository")]
public class Repository
public class Repository : IRepository
{
private static readonly Lazy<ILogger> _logger = new(() => Serilog.Log.ForContext("SourceContext", $"DataModel/{nameof(Repository)}"));

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions GitHubExtension/GitHubExtensionCommandsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions GitHubExtension/Helpers/GitHubIcon.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
};
}

Expand Down
24 changes: 24 additions & 0 deletions GitHubExtension/Helpers/GitHubRequestHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
53 changes: 53 additions & 0 deletions GitHubExtension/Helpers/SearchHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SearchType> SearchTypeMappings = new()
{
{ "issue", SearchType.Issues },
Expand Down
8 changes: 8 additions & 0 deletions GitHubExtension/Strings/en-US/Resources.resw
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@
<value>Copy source branch</value>
<comment>Title for command to copy source branch of a pull request</comment>
</data>
<data name="Commands_Copy_CloneUrl" xml:space="preserve">
<value>Copy clone URL</value>
<comment>Title for command to copy the clone URL of a repository</comment>
</data>
<data name="Commands_Copy_Checkout" xml:space="preserve">
<value>Copy checkout command</value>
<comment>Title for copy checkout command</comment>
Expand Down Expand Up @@ -321,6 +325,10 @@
<value>My pull requests</value>
<comment>Title for search "state:open is:pr author:{login} archived:false"</comment>
</data>
<data name="CommandsProvider_MyLatestRepositoriesCommandName" xml:space="preserve">
<value>My latest repositories</value>
<comment>Title for search "user:{login} sort:updated-desc type:repository"</comment>
</data>
<data name="ExtensionTitle" xml:space="preserve">
<value>GitHub Extension (Preview)</value>
<comment>Title for the extension</comment>
Expand Down
Loading