diff --git a/.gitignore b/.gitignore index 8f11a4e8ab..d053335d43 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ tools/xunit.runner.console *.ncrunch* *.GhostDoc.xml +# FAKE temporary files +.fake/ + # New VS Test Runner creates arbitrary folders with PDBs *.pdb pingme.txt \ No newline at end of file diff --git a/Octokit.Tests.Integration/Clients/SearchClientTests.cs b/Octokit.Tests.Integration/Clients/SearchClientTests.cs index cb7a1305b3..ba6f4d89fd 100644 --- a/Octokit.Tests.Integration/Clients/SearchClientTests.cs +++ b/Octokit.Tests.Integration/Clients/SearchClientTests.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; using System.Threading.Tasks; using Octokit; using Octokit.Tests.Integration; @@ -34,8 +36,8 @@ public async Task SearchForGitHub() [Fact] public async Task SearchForFunctionInCode() { - var request = new SearchCodeRequest("addClass"); - request.Repo = "jquery/jquery"; + var request = new SearchCodeRequest("addClass", "jquery", "jquery"); + var repos = await _gitHubClient.Search.SearchCode(request); Assert.NotEmpty(repos.Items); @@ -45,6 +47,11 @@ public async Task SearchForFunctionInCode() public async Task SearchForWordInCode() { var request = new SearchIssuesRequest("windows"); + request.Repos = new RepositoryCollection { + { "aspnet", "dnx" }, + { "aspnet", "dnvm" } + }; + request.SortField = IssueSearchSort.Created; request.Order = SortDirection.Descending; @@ -57,7 +64,7 @@ public async Task SearchForWordInCode() public async Task SearchForOpenIssues() { var request = new SearchIssuesRequest("phone"); - request.Repo = "caliburn-micro/caliburn.micro"; + request.Repos.Add("caliburn-micro", "caliburn.micro"); request.State = ItemState.Open; var issues = await _gitHubClient.Search.SearchIssues(request); @@ -66,10 +73,25 @@ public async Task SearchForOpenIssues() } [Fact] - public async Task SearchForAllIssues() + public async Task SearchForAllIssuesWithouTaskUsingTerm() + { + var request = new SearchIssuesRequest(); + request.Repos.Add("caliburn-micro/caliburn.micro"); + + var issues = await _gitHubClient.Search.SearchIssues(request); + + var closedIssues = issues.Items.Where(x => x.State == ItemState.Closed); + var openedIssues = issues.Items.Where(x => x.State == ItemState.Open); + + Assert.NotEmpty(closedIssues); + Assert.NotEmpty(openedIssues); + } + + [Fact] + public async Task SearchForAllIssuesUsingTerm() { var request = new SearchIssuesRequest("phone"); - request.Repo = "caliburn-micro/caliburn.micro"; + request.Repos.Add("caliburn-micro", "caliburn.micro"); var issues = await _gitHubClient.Search.SearchIssues(request); diff --git a/Octokit.Tests/Clients/SearchClientTests.cs b/Octokit.Tests/Clients/SearchClientTests.cs index c4eb248ce5..ab4cd108e9 100644 --- a/Octokit.Tests/Clients/SearchClientTests.cs +++ b/Octokit.Tests/Clients/SearchClientTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using NSubstitute; using Xunit; using System.Threading.Tasks; @@ -1150,13 +1151,31 @@ public void TestingTheRepoQualifier() var connection = Substitute.For(); var client = new SearchClient(connection); var request = new SearchIssuesRequest("something"); - request.Repo = "octokit.net"; + request.Repos.Add("octokit", "octokit.net"); client.SearchIssues(request); connection.Received().Get( Arg.Is(u => u.ToString() == "search/issues"), - Arg.Is>(d => d["q"] == "something+repo:octokit.net")); + Arg.Is>(d => d["q"] == "something+repo:octokit/octokit.net")); + } + + [Fact] + public async Task ErrorOccursWhenSpecifyingInvalidFormatForRepos() + { + var connection = Substitute.For(); + var client = new SearchClient(connection); + + var request = new SearchIssuesRequest("windows"); + request.Repos = new RepositoryCollection { + "haha-business" + }; + + request.SortField = IssueSearchSort.Created; + request.Order = SortDirection.Descending; + + await Assert.ThrowsAsync( + async () => await client.SearchIssues(request)); } [Fact] @@ -1165,7 +1184,7 @@ public void TestingTheRepoAndUserAndLabelQualifier() var connection = Substitute.For(); var client = new SearchClient(connection); var request = new SearchIssuesRequest("something"); - request.Repo = "octokit.net"; + request.Repos.Add("octokit/octokit.net"); request.User = "alfhenrik"; request.Labels = new[] { "bug" }; @@ -1174,7 +1193,7 @@ public void TestingTheRepoAndUserAndLabelQualifier() connection.Received().Get( Arg.Is(u => u.ToString() == "search/issues"), Arg.Is>(d => d["q"] == - "something+label:bug+user:alfhenrik+repo:octokit.net")); + "something+label:bug+user:alfhenrik+repo:octokit/octokit.net")); } } @@ -1445,14 +1464,13 @@ public void TestingTheRepoQualifier() { var connection = Substitute.For(); var client = new SearchClient(connection); - var request = new SearchCodeRequest("something"); - request.Repo = "octokit.net"; + var request = new SearchCodeRequest("something", "octokit", "octokit.net"); client.SearchCode(request); connection.Received().Get( Arg.Is(u => u.ToString() == "search/code"), - Arg.Is>(d => d["q"] == "something+repo:octokit.net")); + Arg.Is>(d => d["q"] == "something+repo:octokit/octokit.net")); } [Fact] @@ -1475,8 +1493,7 @@ public void TestingTheRepoAndPathAndExtensionQualifiers() { var connection = Substitute.For(); var client = new SearchClient(connection); - var request = new SearchCodeRequest("something"); - request.Repo = "octokit.net"; + var request = new SearchCodeRequest("something", "octokit", "octokit.net"); request.Path = "tools/FAKE.core"; request.Extension = "fs"; @@ -1485,7 +1502,24 @@ public void TestingTheRepoAndPathAndExtensionQualifiers() connection.Received().Get( Arg.Is(u => u.ToString() == "search/code"), Arg.Is>(d => - d["q"] == "something+path:tools/FAKE.core+extension:fs+repo:octokit.net")); + d["q"] == "something+path:tools/FAKE.core+extension:fs+repo:octokit/octokit.net")); + } + + [Fact] + public async Task ErrorOccursWhenSpecifyingInvalidFormatForRepos() + { + var connection = Substitute.For(); + var client = new SearchClient(connection); + + var request = new SearchCodeRequest("windows"); + request.Repos = new RepositoryCollection { + "haha-business" + }; + + request.Order = SortDirection.Descending; + + await Assert.ThrowsAsync( + async () => await client.SearchCode(request)); } } } diff --git a/Octokit/Exceptions/RepositoryFormatException.cs b/Octokit/Exceptions/RepositoryFormatException.cs new file mode 100644 index 0000000000..941a166916 --- /dev/null +++ b/Octokit/Exceptions/RepositoryFormatException.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Runtime.Serialization; +using System.Text; +using System.Threading.Tasks; + +namespace Octokit +{ +#if !NETFX_CORE + [Serializable] +#endif + [SuppressMessage("Microsoft.Design", "CA1032:ImplementStandardExceptionConstructors", + Justification = "These exceptions are specific to the GitHub API and not general purpose exceptions")] + public class RepositoryFormatException : Exception + { + readonly string message; + + public RepositoryFormatException(IEnumerable invalidRepositories) + { + var parameterList = string.Join(", ", invalidRepositories); + message = string.Format( + CultureInfo.InvariantCulture, + "The list of repositories must be formatted as 'owner/name' - these values don't match this rule: {0}", + parameterList); + + } + + public override string Message + { + get + { + return message; + } + } + + #if !NETFX_CORE + /// + /// Constructs an instance of LoginAttemptsExceededException + /// + /// + /// The that holds the + /// serialized object data about the exception being thrown. + /// + /// + /// The that contains + /// contextual information about the source or destination. + /// + protected RepositoryFormatException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + if (info == null) return; + message = info.GetString("Message"); + } + + public override void GetObjectData(SerializationInfo info, StreamingContext context) + { + base.GetObjectData(info, context); + info.AddValue("Message", Message); + } +#endif + } +} diff --git a/Octokit/Helpers/StringExtensions.cs b/Octokit/Helpers/StringExtensions.cs index 1823dc811d..670eddf244 100644 --- a/Octokit/Helpers/StringExtensions.cs +++ b/Octokit/Helpers/StringExtensions.cs @@ -101,5 +101,19 @@ static IEnumerable SplitUpperCase(this string source) //We need to have the last word. yield return new String(letters, wordStartIndex, letters.Length - wordStartIndex); } + + // the rule: + // Username may only contain alphanumeric characters or single hyphens + // and cannot begin or end with a hyphen + static readonly Regex nameWithOwner = new Regex("[a-z0-9.-]{1,}/[a-z0-9.-]{1,}", +#if (!PORTABLE && !NETFX_CORE) + RegexOptions.Compiled | +#endif + RegexOptions.IgnoreCase); + + internal static bool IsNameWithOwnerFormat(this string input) + { + return nameWithOwner.IsMatch(input); + } } } diff --git a/Octokit/Models/Request/SearchCodeRequest.cs b/Octokit/Models/Request/SearchCodeRequest.cs index 0de5c1f97a..570b5fb814 100644 --- a/Octokit/Models/Request/SearchCodeRequest.cs +++ b/Octokit/Models/Request/SearchCodeRequest.cs @@ -16,7 +16,10 @@ namespace Octokit [DebuggerDisplay("{DebuggerDisplay,nq}")] public class SearchCodeRequest : BaseSearchRequest { - public SearchCodeRequest(string term) : base(term) { } + public SearchCodeRequest(string term) : base(term) + { + Repos = new RepositoryCollection(); + } public SearchCodeRequest(string term, string owner, string name) : this(term) @@ -24,7 +27,7 @@ public SearchCodeRequest(string term, string owner, string name) Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); Ensure.ArgumentNotNullOrEmptyString(name, "name"); - this.Repo = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", owner, name); + Repos.Add(owner, name); } /// @@ -117,7 +120,8 @@ public IEnumerable In /// /// https://help.github.com/articles/searching-code#users-organizations-and-repositories /// - public string Repo { get; set; } + [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] + public RepositoryCollection Repos { get; set; } [SuppressMessage("Microsoft.Globalization", "CA1304:SpecifyCultureInfo", MessageId = "System.String.ToLower")] public override IReadOnlyList MergedQualifiers() @@ -162,9 +166,16 @@ public override IReadOnlyList MergedQualifiers() parameters.Add(String.Format(CultureInfo.InvariantCulture, "user:{0}", User)); } - if (Repo.IsNotBlank()) + if (Repos.Any()) { - parameters.Add(String.Format(CultureInfo.InvariantCulture, "repo:{0}", Repo)); + var invalidFormatRepos = Repos.Where(x => !x.IsNameWithOwnerFormat()); + if (invalidFormatRepos.Any()) + { + throw new RepositoryFormatException(invalidFormatRepos); + } + + parameters.Add( + string.Join("+", Repos.Select(x => "repo:" + x))); } return new ReadOnlyCollection(parameters); diff --git a/Octokit/Models/Request/SearchIssuesRequest.cs b/Octokit/Models/Request/SearchIssuesRequest.cs index 49d0d5e01b..bf18fb1c2c 100644 --- a/Octokit/Models/Request/SearchIssuesRequest.cs +++ b/Octokit/Models/Request/SearchIssuesRequest.cs @@ -15,15 +15,31 @@ namespace Octokit [DebuggerDisplay("{DebuggerDisplay,nq}")] public class SearchIssuesRequest : BaseSearchRequest { - public SearchIssuesRequest(string term) : base(term) { } + /// + /// Search without specifying a keyword + /// + public SearchIssuesRequest() + { + Repos = new RepositoryCollection(); + } + /// + /// Search using a specify keyword + /// + /// The term to filter on + public SearchIssuesRequest(string term) : base(term) + { + Repos = new RepositoryCollection(); + } + + [Obsolete("this will be deprecated in a future version")] public SearchIssuesRequest(string term, string owner, string name) : this(term) { Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); Ensure.ArgumentNotNullOrEmptyString(name, "name"); - this.Repo = string.Format(CultureInfo.InvariantCulture, "{0}/{1}", owner, name); + Repos.Add(owner, name); } /// @@ -177,13 +193,8 @@ public IEnumerable Labels /// public string User { get; set; } - /// - /// Limits searches to a specific repository. - /// - /// - /// https://help.github.com/articles/searching-issues#users-organizations-and-repositories - /// - public string Repo { get; set; } + [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] + public RepositoryCollection Repos { get; set; } public override IReadOnlyList MergedQualifiers() { @@ -264,9 +275,16 @@ public override IReadOnlyList MergedQualifiers() parameters.Add(String.Format(CultureInfo.InvariantCulture, "user:{0}", User)); } - if (Repo.IsNotBlank()) + if (Repos.Any()) { - parameters.Add(String.Format(CultureInfo.InvariantCulture, "repo:{0}", Repo)); + var invalidFormatRepos = Repos.Where(x => !x.IsNameWithOwnerFormat()); + if (invalidFormatRepos.Any()) + { + throw new RepositoryFormatException(invalidFormatRepos); + } + + parameters.Add( + string.Join("+", Repos.Select(x => "repo:" + x))); } return new ReadOnlyCollection(parameters); @@ -317,4 +335,39 @@ public enum IssueTypeQualifier [Parameter(Value = "issue")] Issue } + + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class RepositoryCollection : Collection + { + public void Add(string owner, string name) + { + Add(GetRepositoryName(owner, name)); + } + + public bool Contains(string owner, string name) + { + return Contains(GetRepositoryName(owner, name)); + } + + public bool Remove(string owner, string name) + { + return Remove(GetRepositoryName(owner, name)); + } + + static string GetRepositoryName(string owner, string name) + { + Ensure.ArgumentNotNullOrEmptyString(owner, "owner"); + Ensure.ArgumentNotNullOrEmptyString(name, "name"); + + return string.Format(CultureInfo.InvariantCulture, "{0}/{1}", owner, name); + } + + internal string DebuggerDisplay + { + get + { + return String.Format(CultureInfo.InvariantCulture, "Repositories: {0}", Count); + } + } + } } diff --git a/Octokit/Octokit-Mono.csproj b/Octokit/Octokit-Mono.csproj index e01e6b2c03..caef5dfcc5 100644 --- a/Octokit/Octokit-Mono.csproj +++ b/Octokit/Octokit-Mono.csproj @@ -396,6 +396,7 @@ + \ No newline at end of file diff --git a/Octokit/Octokit-MonoAndroid.csproj b/Octokit/Octokit-MonoAndroid.csproj index 8077c4c47e..7c7c420b3a 100644 --- a/Octokit/Octokit-MonoAndroid.csproj +++ b/Octokit/Octokit-MonoAndroid.csproj @@ -412,6 +412,7 @@ + \ No newline at end of file diff --git a/Octokit/Octokit-Monotouch.csproj b/Octokit/Octokit-Monotouch.csproj index 61fb07ffa9..810d8272fb 100644 --- a/Octokit/Octokit-Monotouch.csproj +++ b/Octokit/Octokit-Monotouch.csproj @@ -405,6 +405,7 @@ + diff --git a/Octokit/Octokit-Portable.csproj b/Octokit/Octokit-Portable.csproj index 9467bbc575..428ac9c1ef 100644 --- a/Octokit/Octokit-Portable.csproj +++ b/Octokit/Octokit-Portable.csproj @@ -395,6 +395,7 @@ + diff --git a/Octokit/Octokit-netcore45.csproj b/Octokit/Octokit-netcore45.csproj index e1c7bdd1fc..104e805289 100644 --- a/Octokit/Octokit-netcore45.csproj +++ b/Octokit/Octokit-netcore45.csproj @@ -399,6 +399,7 @@ + diff --git a/Octokit/Octokit.csproj b/Octokit/Octokit.csproj index 6df2548663..3a394da8f8 100644 --- a/Octokit/Octokit.csproj +++ b/Octokit/Octokit.csproj @@ -74,6 +74,7 @@ + diff --git a/build.cmd b/build.cmd index 5358f51d77..4cf5c3dbcb 100644 --- a/build.cmd +++ b/build.cmd @@ -1,7 +1,7 @@ @echo off "tools\nuget\nuget.exe" "install" "xunit.runner.console" "-OutputDirectory" "tools" "-ExcludeVersion" "-version" "2.0.0" -"tools\nuget\nuget.exe" "install" "FAKE.Core" "-OutputDirectory" "tools" "-ExcludeVersion" "-version" "3.12.2" +"tools\nuget\nuget.exe" "install" "FAKE.Core" "-OutputDirectory" "tools" "-ExcludeVersion" "-version" "3.37.1" "tools\nuget\nuget.exe" "install" "SourceLink.Fake" "-OutputDirectory" "tools" "-ExcludeVersion" "-version" "1.0.0" :Build diff --git a/build.fsx b/build.fsx index 77893c299a..c226326da4 100644 --- a/build.fsx +++ b/build.fsx @@ -29,7 +29,10 @@ let releaseNotes = let buildMode = getBuildParamOrDefault "buildMode" "Release" -MSBuildDefaults <- { MSBuildDefaults with Verbosity = Some MSBuildVerbosity.Minimal } +MSBuildDefaults <- { + MSBuildDefaults with + ToolsVersion = Some "12.0" + Verbosity = Some MSBuildVerbosity.Minimal } Target "Clean" (fun _ -> CleanDirs [buildDir; reactiveBuildDir; testResultsDir; packagingRoot; packagingDir; reactivePackagingDir] @@ -62,9 +65,19 @@ Target "FixProjects" (fun _ -> |> Fake.MSBuild.ProjectSystem.FixProjectFiles "./Octokit.Reactive/Octokit.Reactive.csproj" ) +let setParams defaults = { + defaults with + ToolsVersion = Some("12.0") + Targets = ["Build"] + Properties = + [ + "Configuration", buildMode + ] + } + Target "BuildApp" (fun _ -> - MSBuild null "Build" ["Configuration", buildMode] ["./Octokit.sln"] - |> Log "AppBuild-Output: " + build setParams "./Octokit.sln" + |> DoNothing ) Target "ConventionTests" (fun _ -> diff --git a/docs/search.md b/docs/search.md new file mode 100644 index 0000000000..c8cd57e55b --- /dev/null +++ b/docs/search.md @@ -0,0 +1,130 @@ +# Search + +You can use Octokit to search for different sorts of data available +on the GitHub or GitHub Enterprise server: + + - issues + - repositories + - code + - users + +## Search Issues + +A common scenario is to search for issues to triage: + +```csharp +// you can also specify a search term here +var request = new SearchIssuesRequest(); + +// you can add individual repos to focus your search +request.Repos.Add("aspnet/dnx"); +request.Repos.Add("aspnet", "dnvm"); + +// or use a series of repositories +request.Repos = new RepositoryCollection { + "aspnet/dnx", + "aspnet/dnvm" +}; + +request.Repos = new RepositoryCollection { + { "aspnet", "dnx" }, + { "aspnet", "dnvm" } +}; +``` + +There's many other options available here to tweak +your search criteria: + +```csharp +// if you're searching for a specific term, you can +// focus your search on specific criteria +request.In = new[] { + IssueInQualifier.Title, + IssueInQualifier.Body +}; + +// you can restrict your search to issues or pull requests +request.Type = IssueTypeQualifier.Issue; + +// you can filter on when the issue was created or updated +var aWeekAgo = DateTime.Now.Subtract(TimeSpan.FromDays(7)); +request.Created = new DateRange(aWeekAgo, SearchQualifierOperator.GreaterThan) + +// you can search for issues created by, assigned to +// or mentioning a specific user +request.Author = "davidfowl"; +request.Assignee = "damianedwards"; +request.Mentions = "shiftkey"; +request.Commenter = "haacked"; + +// rather than setting all these, you can use this to find +// all the above for a specific user with this one-liner +request.Involves = "davidfowl"; + +// by default this will search on open issues, set this if +// you want to get all issues +request.State = ItemState.All; +// or to just search closed issues +request.State = ItemState.Closed; +``` + +There's other options available to control how the results are returned: + +```csharp +request.SortField = IssueSearchSort.Created; +request.Order = SortDirection.Descending; + +// 100 results per page as default +request.PerPage = 30; + +// set this when you want to fetch subsequent pages +request.Page = 2; +``` + +Once you've set the right parameters, execute the request: + +```csharp +var repos = await client.Search.SearchIssues(request); + +Console.WriteLine("Query has {0} matches.", repos.TotalCount); +Console.WriteLine("Response has {0} items.", repos.Items.Count); +``` + +## Search Pull Requests + +Another scenario to consider is how to search broadly: + +```csharp +var threeMonthsAgoIsh = DateTime.Now.Subtract(TimeSpan.FromDays(90)); + +// search for a specific term +var request = new SearchIssuesRequest("linux") +{ + // only search pull requests + Type = IssueTypeQualifier.PR, + + // search across open and closed PRs + State = ItemState.All, + + // search repositories which contain code + // matching a given language + Language = Language.CSharp, + + // focus on pull requests updated recently + Updated = new DateRange(threeMonthsAgoIsh, SearchQualifierOperator.GreaterThan) +}; +``` + + + +## Search Repositories + +**TODO** + +## Search Code + +**TODO** + +## Search Users + +**TODO**