From 143f16e9b49b752f425d0d1698ac0bf7fc865919 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Wed, 22 Dec 2021 23:00:55 -0400 Subject: [PATCH 01/28] fix: config would fail because no providers were configured (#129) --- Peer/Program.cs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Peer/Program.cs b/Peer/Program.cs index bcd73bb..fa80941 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -16,6 +16,7 @@ using Peer.Parsing; using Peer.Verbs; using wimm.Secundatives; +using wimm.Secundatives.Extensions; namespace Peer { @@ -44,12 +45,6 @@ public static async Task Main(string[] args) var setupResult = SetupServices(); - if (setupResult.IsError) - { - Console.Error.WriteLine(_configErrorMap[setupResult.Error]); - return; - } - var services = setupResult.Value; var parser = new Parser(config => { config.AutoHelp = true; @@ -60,18 +55,25 @@ public static async Task Main(string[] args) var parseResult = parser.ParseArguments(args); + if (setupResult.IsError && !parseResult.Is()) + { + Console.Error.WriteLine(_configErrorMap[setupResult.Error]); + return; + } + if (parseResult.Tag == ParserResultType.Parsed) { await parseResult.MapResult( - (ShowOptions x) => ShowAsync(x, services, _tcs.Token), - (OpenOptions x) => OpenAsync(x, services, _tcs.Token), + (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), + (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), (ConfigOptions x) => ConfigAsync(x), - (DetailsOptions x) => DetailsAsync(x, services, _tcs.Token), + (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), err => Task.CompletedTask); return; } + var services = setupResult.Value; var text = parseResult switch { var v when v.Is() => GetHelpText(parseResult, services.BuildServiceProvider()), From 6cdf60d3ca30631b24741d67572261c03d49030c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Dec 2021 15:55:23 -0400 Subject: [PATCH 02/28] chore: release 1.5.2 (#130) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd0da7..1d87071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.5.2](https://www.github.com/wareismymind/peer/compare/v1.5.1...v1.5.2) (2021-12-23) + + +### Bug Fixes + +* config would fail because no providers were configured ([#129](https://www.github.com/wareismymind/peer/issues/129)) ([143f16e](https://www.github.com/wareismymind/peer/commit/143f16e9b49b752f425d0d1698ac0bf7fc865919)) + ### [1.5.1](https://www.github.com/wareismymind/peer/compare/v1.5.0...v1.5.1) (2021-12-19) diff --git a/version.txt b/version.txt index 26ca594..4cda8f1 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.1 +1.5.2 From 44a062087bf98145fcc6b2221eaa34a89a26c430 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Thu, 23 Dec 2021 21:22:12 -0400 Subject: [PATCH 03/28] feat: async enumerable and lazy paging (#128) * fix: details formatter throws on empty collection of checks * first shot (without addl tests) of the move to IAsyncEnumerable Also added paging and a cli flag for count on show (that we can default when we get around to the peer config feature) * initial tests for detailsformatter * Make github PRs use lazy evaluation where possible by swapping to fully IAsyncEnumerable implementations * cleanup and formatting * whitespace and formatting * fix pagesize on query (should remove param) and fixed infinite loop when sorting * fix: nullability of check conclusion state * Cleanup of pull request, factored out ScalarExtensions. General cleanup * use page size again, add provider, author, title to sorts * fix tests that needed author * skip return of prs with 'has more comments' until end of the loop * spent a few more letters so ttd will eat with the group * formatting and usings * remove extra nullable annotation * accidentally removed linq using * Change EndCursor to after in pr query --- Peer.Domain/Commands/Details.cs | 2 +- Peer.Domain/Commands/DetailsFormatter.cs | 13 ++- Peer.Domain/Commands/IPullRequestService.cs | 4 +- Peer.Domain/Commands/Open.cs | 2 +- Peer.Domain/Commands/Show.cs | 27 ++--- Peer.Domain/Commands/ShowArguments.cs | 5 + Peer.Domain/IPullRequestFetcher.cs | 2 +- Peer.Domain/ISorter.cs | 4 +- Peer.Domain/Identifier.cs | 5 +- Peer.Domain/Peer.Domain.csproj | 1 + Peer.Domain/PullRequestService.cs | 12 +- Peer.GitHub/GitHubRequestFetcher.cs | 106 +++++++++++++----- Peer.GitHub/GitHubWebRegistrationHandler.cs | 2 +- .../GraphQL/PullRequestSearch/CheckRun.cs | 4 +- .../GraphQL/PullRequestSearch/Commits.cs | 11 -- .../GraphQL/PullRequestSearch/PullRequest.cs | 26 +++-- .../PullRequestSearch/ScalarExtensions.cs | 13 +++ .../GraphQL/PullRequestSearch/Search.cs | 74 ++++++------ .../GraphQL/PullRequestSearch/SearchParams.cs | 6 +- Peer.GitHub/Peer.GitHub.csproj | 4 + .../Formatters/DetailsFormatterTests.cs | 39 +++++++ Peer.UnitTests/IdentifierTests.cs | 4 +- Peer.UnitTests/Peer.UnitTests.csproj | 1 + Peer.UnitTests/PullRequestTests.cs | 2 +- Peer.UnitTests/SortParserTests.cs | 5 +- Peer.UnitTests/Util/PrimitiveGenerators.cs | 2 +- Peer/Parsing/SortParser.cs | 3 + Peer/Program.cs | 7 +- Peer/Properties/launchSettings.json | 2 +- Peer/Verbs/ShowOptions.cs | 3 + 30 files changed, 256 insertions(+), 135 deletions(-) delete mode 100644 Peer.GitHub/GraphQL/PullRequestSearch/Commits.cs create mode 100644 Peer.GitHub/GraphQL/PullRequestSearch/ScalarExtensions.cs create mode 100644 Peer.UnitTests/Formatters/DetailsFormatterTests.cs diff --git a/Peer.Domain/Commands/Details.cs b/Peer.Domain/Commands/Details.cs index a7f5c34..77e12b9 100644 --- a/Peer.Domain/Commands/Details.cs +++ b/Peer.Domain/Commands/Details.cs @@ -22,7 +22,7 @@ public Details( public async Task> DetailsAsync(DetailsArguments args, CancellationToken token = default) { - var findResult = await _prService.FindByPartial(args.Partial, token); + var findResult = await _prService.FindSingleByPartial(args.Partial, token); var formatted = findResult.Map(pr => _formatter.Format(pr)); if (formatted.IsError) diff --git a/Peer.Domain/Commands/DetailsFormatter.cs b/Peer.Domain/Commands/DetailsFormatter.cs index 68bcda8..a0d1eac 100644 --- a/Peer.Domain/Commands/DetailsFormatter.cs +++ b/Peer.Domain/Commands/DetailsFormatter.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using Peer.Domain.Util; using wimm.Secundatives.Extensions; - namespace Peer.Domain.Commands { //TODO:CN -- Ansi term code support @@ -20,6 +20,8 @@ public DetailsFormatter(ICheckSymbolProvider symbolProvider) public IList Format(PullRequest pullRequest) { + Validators.ArgIsNotNull(pullRequest); + var lines = new List { "---", @@ -32,8 +34,13 @@ public IList Format(PullRequest pullRequest) lines.Add("Url:"); lines.Add($"{_pad}{pullRequest.Url}"); lines.Add(string.Empty); - lines.Add("Checks:"); + if (!pullRequest.Checks.Any()) + { + return lines; + } + + lines.Add("Checks:"); var titleWidth = pullRequest.Checks.Max(x => x.Name.Length); //Checks here @@ -41,7 +48,7 @@ public IList Format(PullRequest pullRequest) { var symbol = _symbolProvider.GetSymbol(check.Status, check.Result) .UnwrapOr("\u25EF\uFE0F"); //Large white circle - + lines.Add($"{_pad}{symbol,4} {check.Name.PadRight(titleWidth)} -- {check.Url}"); } diff --git a/Peer.Domain/Commands/IPullRequestService.cs b/Peer.Domain/Commands/IPullRequestService.cs index 7f6fb3a..7b75fd8 100644 --- a/Peer.Domain/Commands/IPullRequestService.cs +++ b/Peer.Domain/Commands/IPullRequestService.cs @@ -7,7 +7,7 @@ namespace Peer.Domain.Commands { public interface IPullRequestService { - Task> FetchAllPullRequests(CancellationToken token = default); - Task> FindByPartial(PartialIdentifier partial, CancellationToken token = default); + Task> FetchAllPullRequests(CancellationToken token = default); + Task> FindSingleByPartial(PartialIdentifier partial, CancellationToken token = default); } } diff --git a/Peer.Domain/Commands/Open.cs b/Peer.Domain/Commands/Open.cs index 4ed561b..c4995b5 100644 --- a/Peer.Domain/Commands/Open.cs +++ b/Peer.Domain/Commands/Open.cs @@ -21,7 +21,7 @@ public Open(IPullRequestService prService, IOSInfoProvider infoProvider) public async Task> OpenAsync(OpenArguments openOptions, CancellationToken token = default) { - var res = await _prService.FindByPartial(openOptions.Partial, token) + var res = await _prService.FindSingleByPartial(openOptions.Partial, token) .MapError(err => err switch { FindError.AmbiguousMatch => OpenError.AmbiguousPattern, diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index 1ce7711..dcfb900 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; -using System.Linq; +using System.Linq; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -8,38 +8,31 @@ namespace Peer.Domain.Commands { public class Show { - private readonly List _fetchers; + private readonly IPullRequestService _pullRequestService; private readonly IListFormatter _formatter; private readonly IConsoleWriter _writer; private readonly ISorter? _sorter; public Show( - IEnumerable fetchers, + IPullRequestService prService, IListFormatter formatter, IConsoleWriter writer, ISorter? sorter = null) { - _fetchers = fetchers.ToList(); + _pullRequestService = prService; _formatter = formatter; _writer = writer; _sorter = sorter; } - public async Task> ShowAsync(ShowArguments _, CancellationToken token = default) + public async Task> ShowAsync(ShowArguments args, CancellationToken token = default) { - var prs = await FetchAllSources(token); - var sorted = _sorter?.Sort(prs) ?? prs; - _writer.Display(_formatter.FormatLines(sorted).ToList(), token); + var prs = await _pullRequestService.FetchAllPullRequests(token); + var sorted = await (_sorter?.Sort(prs) ?? prs).Take(args.Count).ToListAsync(token); + var lines = _formatter.FormatLines(sorted).ToList(); + _writer.Display(lines, token); return Maybe.None; } - - private async Task> FetchAllSources(CancellationToken token) - { - var tasks = _fetchers.Select(async x => await x.GetPullRequestsAsync(token)); - var prs = await Task.WhenAll(tasks); - var combined = prs.SelectMany(x => x); - return combined; - } } public enum ShowError diff --git a/Peer.Domain/Commands/ShowArguments.cs b/Peer.Domain/Commands/ShowArguments.cs index 38f4861..6c271ef 100644 --- a/Peer.Domain/Commands/ShowArguments.cs +++ b/Peer.Domain/Commands/ShowArguments.cs @@ -2,5 +2,10 @@ { public class ShowArguments { + public int Count { get; } + public ShowArguments(int count) + { + Count = count; + } } } diff --git a/Peer.Domain/IPullRequestFetcher.cs b/Peer.Domain/IPullRequestFetcher.cs index 7a4083d..8465b1d 100644 --- a/Peer.Domain/IPullRequestFetcher.cs +++ b/Peer.Domain/IPullRequestFetcher.cs @@ -6,6 +6,6 @@ namespace Peer.Domain { public interface IPullRequestFetcher { - Task> GetPullRequestsAsync(CancellationToken token = default); + Task> GetPullRequestsAsync(CancellationToken token = default); } } diff --git a/Peer.Domain/ISorter.cs b/Peer.Domain/ISorter.cs index 757cf39..e04c6b6 100644 --- a/Peer.Domain/ISorter.cs +++ b/Peer.Domain/ISorter.cs @@ -7,7 +7,7 @@ namespace Peer.Domain { public interface ISorter { - IEnumerable Sort(IEnumerable input); + IAsyncEnumerable Sort(IAsyncEnumerable input); } public class SelectorSorter : ISorter @@ -21,7 +21,7 @@ public SelectorSorter(Func selector, SortDirection direction) _direction = direction; } - public IEnumerable Sort(IEnumerable input) + public IAsyncEnumerable Sort(IAsyncEnumerable input) { return _direction switch { diff --git a/Peer.Domain/Identifier.cs b/Peer.Domain/Identifier.cs index 265dc09..2575283 100644 --- a/Peer.Domain/Identifier.cs +++ b/Peer.Domain/Identifier.cs @@ -10,13 +10,14 @@ public class Identifier public string Repo { get; } public string Owner { get; } public string Provider { get; } - - public Identifier(string id, string repo, string owner, string provider) + public string Author { get; } + public Identifier(string id, string repo, string owner, string author, string provider) { Id = id ?? throw new ArgumentNullException(nameof(id)); Repo = repo ?? throw new ArgumentNullException(nameof(repo)); Owner = owner ?? throw new ArgumentNullException(nameof(owner)); Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + Author = author ?? throw new ArgumentNullException(nameof(author)); } public bool IsMatch(PartialIdentifier partial) diff --git a/Peer.Domain/Peer.Domain.csproj b/Peer.Domain/Peer.Domain.csproj index 188ff72..42ae277 100644 --- a/Peer.Domain/Peer.Domain.csproj +++ b/Peer.Domain/Peer.Domain.csproj @@ -19,6 +19,7 @@ + diff --git a/Peer.Domain/PullRequestService.cs b/Peer.Domain/PullRequestService.cs index 4745c2d..2d1ae73 100644 --- a/Peer.Domain/PullRequestService.cs +++ b/Peer.Domain/PullRequestService.cs @@ -17,21 +17,19 @@ public PullRequestService(IEnumerable fetchers) } //TODO(cn): AsyncEnumerable - public async Task> FetchAllPullRequests(CancellationToken token = default) + public Task> FetchAllPullRequests(CancellationToken token = default) { - var tasks = _fetchers.Select(async x => await x.GetPullRequestsAsync(token)); - var prs = await Task.WhenAll(tasks); - var combined = prs.Aggregate((x, y) => x.Concat(y)); - return combined; + var prIterator = _fetchers.ToAsyncEnumerable().SelectManyAwait(async x => await x.GetPullRequestsAsync(token)); + return Task.FromResult(prIterator); } - public async Task> FindByPartial(PartialIdentifier partial, CancellationToken token = default) + public async Task> FindSingleByPartial(PartialIdentifier partial, CancellationToken token = default) { Validators.ArgIsNotNull(partial); var prs = await FetchAllPullRequests(token); - var matches = prs.Where(x => x.Identifier.IsMatch(partial)).ToList(); + var matches = await prs.Where(x => x.Identifier.IsMatch(partial)).ToListAsync(token); return matches.Count switch { diff --git a/Peer.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index 0a03dd9..657a83c 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using GraphQL.Client.Http; using Peer.Domain; +using Peer.Domain.Exceptions; using Peer.Utils; using GQL = Peer.GitHub.GraphQL; using PRSearch = Peer.GitHub.GraphQL.PullRequestSearch; @@ -17,38 +20,50 @@ public class GitHubRequestFetcher : IPullRequestFetcher private readonly GraphQLHttpClient _gqlClient; private readonly GitHubPeerConfig _config; private readonly AsyncLazy _username; - private readonly AsyncLazy _involvesRequest; - private readonly AsyncLazy _reviewRequestedRequest; private readonly CancellationTokenSource _cts = new(); - public GitHubRequestFetcher(GraphQLHttpClient client, GitHubPeerConfig gitHubPeerConfig) + public GitHubRequestFetcher( + GraphQLHttpClient client, + GitHubPeerConfig gitHubPeerConfig) { _gqlClient = client; _config = gitHubPeerConfig; _username = new AsyncLazy(() => GetUsername(_cts.Token)); - _involvesRequest = new AsyncLazy(() => GenerateInvolvesRequest()); - _reviewRequestedRequest = new AsyncLazy(() => GenerateReviewRequestedRequest()); } - public async Task> GetPullRequestsAsync(CancellationToken token = default) + public Task> GetPullRequestsAsync(CancellationToken token = default) { using var registration = token.Register(() => _cts.Cancel()); + return Task.FromResult(GetPullRequestsImpl(token)); + } - var involvesResponse = - await _gqlClient.SendQueryAsync>(await _involvesRequest, token); + private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCancellation] CancellationToken token) + { + if (token.IsCancellationRequested) + yield break; - var reviewRequestedResponse = await _gqlClient.SendQueryAsync>(await _reviewRequestedRequest, token); + var responses = new List> + { + QueryGithubPullRequests(QueryType.Involves, token), + QueryGithubPullRequests(QueryType.TeamRequested, token) + }.Merge(); - var deduplicated = involvesResponse.Data.Search.Nodes - .Concat(reviewRequestedResponse.Data.Search.Nodes) - .DistinctBy(x => x.Id); + var deduplicated = responses.Distinct(x => x.Id); - // todo: Handle errors. + var prs = new Dictionary(); + var prsWithMoreThreads = new List(); - var prs = deduplicated.ToDictionary(pr => pr.Id); + await foreach (var value in deduplicated) + { + if (value.ReviewThreads.PageInfo.HasNextPage) + { + prsWithMoreThreads.Add(value); + prs[value.Id] = value; + continue; + } - var prsWithMoreThreads = - prs.Values.Where(pr => pr.ReviewThreads.PageInfo.HasNextPage).ToList(); + yield return value.Into(); + } while (prsWithMoreThreads.Any()) { @@ -68,29 +83,64 @@ public async Task> GetPullRequestsAsync(CancellationTok prThreadPages.Where(pr => pr.ReviewThreads.PageInfo.HasNextPage).ToList(); } - return prs.Values.Select(pr => pr.Into()); + foreach (var value in prs.Values.Select(x => x.Into())) + { + yield return value; + } } - private async Task GenerateInvolvesRequest() + private async IAsyncEnumerable QueryGithubPullRequests(QueryType type, [EnumeratorCancellation] CancellationToken token) { - var searchParams = new PRSearch.SearchParams( - await _username, - _config.Orgs, - _config.ExcludedOrgs, - _config.Count); + var cursor = null as string; + + while (!token.IsCancellationRequested) + { + var response = await _gqlClient.SendQueryAsync>(await GenerateRequest(type, cursor), token); - return new GraphQLHttpRequest(PRSearch.Search.GenerateInvolves(searchParams)); + if (response.Errors != null) + { + Console.WriteLine($"ERROR: {string.Join('\n', response.Errors.Select(x => x.Message))}"); + } + + var pageInfo = response.Data.Search.PageInfo; + + cursor = pageInfo.EndCursor; + + foreach (var pr in response.Data.Search.Nodes) + { + yield return pr; + } + + if (!pageInfo.HasNextPage) + { + break; + } + } } - private async Task GenerateReviewRequestedRequest() + private enum QueryType + { + Involves, + TeamRequested + } + + private async Task GenerateRequest(QueryType type, string? endCursor = null) { var searchParams = new PRSearch.SearchParams( await _username, _config.Orgs, _config.ExcludedOrgs, - _config.Count); + _config.Count, + endCursor); + + var queryString = type switch + { + QueryType.Involves => PRSearch.Search.GenerateInvolves(searchParams), + QueryType.TeamRequested => PRSearch.Search.GenerateReviewRequested(searchParams), + _ => throw new UnreachableException() + }; - return new GraphQLHttpRequest(PRSearch.Search.GenerateReviewRequested(searchParams)); + return new GraphQLHttpRequest(queryString); } private async Task GetUsername(CancellationToken token) diff --git a/Peer.GitHub/GitHubWebRegistrationHandler.cs b/Peer.GitHub/GitHubWebRegistrationHandler.cs index 752669f..492a3f7 100644 --- a/Peer.GitHub/GitHubWebRegistrationHandler.cs +++ b/Peer.GitHub/GitHubWebRegistrationHandler.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using GraphQL.Client.Http; using GraphQL.Client.Serializer.Newtonsoft; using Microsoft.Extensions.Configuration; diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/CheckRun.cs b/Peer.GitHub/GraphQL/PullRequestSearch/CheckRun.cs index d2debcf..bb4e6c7 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/CheckRun.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/CheckRun.cs @@ -8,7 +8,7 @@ public class CheckRun { #nullable disable public string Name { get; set; } - public CheckConclusionState Conclusion { get; set; } + public CheckConclusionState? Conclusion { get; set; } public Uri Url { get; set; } public CheckRunStatusState Status { get; set; } #nullable enable @@ -45,9 +45,9 @@ private CheckResult MapResult() CheckConclusionState.Stale => CheckResult.Stale, CheckConclusionState.Startup_Failure => CheckResult.Fire, CheckConclusionState.Timed_Out => CheckResult.Timeout, + null => CheckResult.Unknown, _ => throw new UnreachableException() }; } } - } diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/Commits.cs b/Peer.GitHub/GraphQL/PullRequestSearch/Commits.cs deleted file mode 100644 index 7e2e05d..0000000 --- a/Peer.GitHub/GraphQL/PullRequestSearch/Commits.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; - -namespace Peer.GitHub.GraphQL.PullRequestSearch -{ -#nullable disable - public class Commits - { - public List Nodes { get; set; } - } -#nullable enable -} diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs b/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs index 46d0c60..b7a78f5 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs @@ -1,11 +1,16 @@ using System; -using System.Collections.Generic; using System.Linq; using Peer.Domain; namespace Peer.GitHub.GraphQL.PullRequestSearch { + #nullable disable + public class Author + { + public string Login { get; set; } + } + public class PullRequest { public string Id { get; set; } @@ -20,7 +25,8 @@ public class PullRequest public string Mergeable { get; set; } public string ReviewDecision { get; set; } public bool IsDraft { get; set; } - public Commits Commits { get; set; } + public Author Author { get; set; } + public NodeList Commits { get; set; } public bool ActionsPending => Commits.Nodes[0].Commit.StatusCheckRollup?.State.In(new[] { "PENDING", "EXPECTED" }) ?? false; @@ -33,6 +39,7 @@ public class PullRequest Mergeable == "MERGEABLE" && (Commits.Nodes[0].Commit.StatusCheckRollup?.State.Equals("SUCCESS") ?? true) && ReviewDecision.In("APPROVED", null); + public BaseRepository BaseRepository { get; set; } public Domain.PullRequest Into() @@ -41,10 +48,13 @@ public Domain.PullRequest Into() var totalComments = ReviewThreads.Nodes.Count; var activeComments = ReviewThreads.Nodes.Count(t => !t.IsResolved); var suites = Commits.Nodes.SelectMany(x => x.Commit.CheckSuites.Nodes); - var checks = suites.SelectMany(suite => suite.CheckRuns.Nodes.Where(run => run.Url != null).Select(z => z.Into())).ToList(); + var checks = suites.SelectMany(suite => suite.CheckRuns.Nodes.Where(checkRun => checkRun.Url != null) + .Select(checkRun => checkRun.Into())) + .ToList(); + return new Domain.PullRequest( Number.ToString(), - new Identifier(Number.ToString(), BaseRepository.Name, BaseRepository.Owner.Login, ProviderConstants.Github), + new Identifier(Number.ToString(), BaseRepository.Name, BaseRepository.Owner.Login, Author.Login, ProviderConstants.Github), Url, new Descriptor(Title, Body ?? string.Empty), new State(status, totalComments, activeComments), @@ -69,13 +79,5 @@ private PullRequestStatus CalculateStatus() }; } } - - public static class ScalarExtensions - { - public static bool In(this T target, params T[] options) => target.In(options.AsEnumerable()); - - public static bool In(this T target, IEnumerable options) => - options.Any(o => EqualityComparer.Default.Equals(o, target)); - } #nullable enable } diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/ScalarExtensions.cs b/Peer.GitHub/GraphQL/PullRequestSearch/ScalarExtensions.cs new file mode 100644 index 0000000..da80696 --- /dev/null +++ b/Peer.GitHub/GraphQL/PullRequestSearch/ScalarExtensions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Peer.GitHub.GraphQL.PullRequestSearch +{ + public static class ScalarExtensions + { + public static bool In(this T target, params T[] options) => target.In(options.AsEnumerable()); + + public static bool In(this T target, IEnumerable options) => + options.Any(o => EqualityComparer.Default.Equals(o, target)); + } +} diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs b/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs index 87c6bcf..3548b56 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs @@ -11,7 +11,7 @@ public static string GenerateReviewRequested(SearchParams search) var excludedOrgsClauses = string.Join(' ', search.ExcludedOrgs.Select(o => $"-org:{o}")); var searchTerms = string.Join(' ', reviewRequestedClause, orgsClauses, excludedOrgsClauses); - return GenerateQuery(searchTerms, search.PageSize); + return GenerateQuery(searchTerms, search.PageSize, search.After); } public static string GenerateInvolves(SearchParams search) @@ -21,16 +21,26 @@ public static string GenerateInvolves(SearchParams search) var excludedOrgsClauses = string.Join(' ', search.ExcludedOrgs.Select(o => $"-org:{o}")); var searchTerms = $"{involvesClause} {orgsClauses} {excludedOrgsClauses}"; - return GenerateQuery(searchTerms, search.PageSize); + return GenerateQuery(searchTerms, search.PageSize, search.After); } - private static string GenerateQuery(string searchTerms, int pageSize) + private static string GenerateQuery(string searchTerms, int pageSize, string? endCursor = null) { - return $@"{{ - search(query: ""is:pr is:open archived:false {searchTerms}"", type: ISSUE, first: {pageSize}) {{ + var afterValue = endCursor == null ? string.Empty : $@"after: ""{endCursor}"""; + return $@" + {{ + search(query: ""is:pr is:open archived:false {searchTerms}"", type: ISSUE, first: {pageSize} {afterValue}) {{ issueCount + pageInfo {{ endCursor, hasNextPage }} nodes {{ - ... on PullRequest {{ + {_prValueQuery} + }} + }} + }}"; + } + + private const string _prValueQuery = @" + ... on PullRequest { id number url @@ -39,43 +49,41 @@ ... on PullRequest {{ body baseRefName headRefName - reviewThreads(first: 100) {{ - nodes {{ isResolved }} - pageInfo {{ hasNextPage, endCursor }} - }} + reviewThreads(first: 100) { + nodes { isResolved } + pageInfo { hasNextPage, endCursor } + } isDraft state mergeable reviewDecision - baseRepository {{ + author { + login + } + baseRepository { name - owner {{ login }} - }} + owner { login } + } #THIS IS THE STUFF FOR THE CHECKS - commits(last: 1) {{ - nodes {{ - commit {{ - checkSuites(first: 100) {{ - nodes {{ - checkRuns(first: 100) {{ - nodes {{ + commits(last: 1) { + nodes { + commit { + checkSuites(first: 20) { + nodes { + checkRuns(first: 20) { + nodes { name conclusion status url - }} - }} - }} - }} - }} - }} - }} + } + } + } + } + } + } + } #THIS IS THE END OF THE STUFF FOR THE CHECKS - }} - }} - pageInfo {{ endCursor }} - }} - }}"; - } + }"; } } diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/SearchParams.cs b/Peer.GitHub/GraphQL/PullRequestSearch/SearchParams.cs index 3de547a..a1c31df 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/SearchParams.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/SearchParams.cs @@ -13,11 +13,14 @@ public class SearchParams public int PageSize { get; } + public string? After { get; } + public SearchParams( string subject, IEnumerable orgs, IEnumerable excludedOrgs, - int pageSize) + int pageSize, + string? endCursor = null) { // todo: Something should validate that the involves and org tokens don't contain spaces // (or that they CAN and we should quote them). I'm torn about whether that's here or @@ -31,6 +34,7 @@ public SearchParams( throw new ArgumentOutOfRangeException(nameof(pageSize), "Must be between 1 and 100"); PageSize = pageSize; + After = endCursor; } } } diff --git a/Peer.GitHub/Peer.GitHub.csproj b/Peer.GitHub/Peer.GitHub.csproj index d6d54ee..20ae2c9 100644 --- a/Peer.GitHub/Peer.GitHub.csproj +++ b/Peer.GitHub/Peer.GitHub.csproj @@ -12,6 +12,10 @@ True + + + + diff --git a/Peer.UnitTests/Formatters/DetailsFormatterTests.cs b/Peer.UnitTests/Formatters/DetailsFormatterTests.cs new file mode 100644 index 0000000..aea9f5b --- /dev/null +++ b/Peer.UnitTests/Formatters/DetailsFormatterTests.cs @@ -0,0 +1,39 @@ +using System; +using Moq; +using Peer.Domain; +using Peer.Domain.Commands; +using Peer.UnitTests.Util; +using Xunit; + +namespace Peer.UnitTests.Formatters +{ + public class DetailsFormatterTests + { + public class Format + { + private readonly Mock _symbolProvider = new(); + + [Fact] + public void PullRequestNull_Throws() + { + var underTest = Construct(); + Assert.Throws("pullRequest", () => underTest.Format(null)); + } + + [Fact] + public void NoChecksExist_DoesntWriteChecks() + { + var pr = ValueGenerators.CreatePullRequest(); + var underTest = Construct(); + + var results = underTest.Format(pr); + Assert.DoesNotContain("Checks", results); + } + + private DetailsFormatter Construct() + { + return new DetailsFormatter(_symbolProvider.Object); + } + } + } +} diff --git a/Peer.UnitTests/IdentifierTests.cs b/Peer.UnitTests/IdentifierTests.cs index 1e141f9..faf71f2 100644 --- a/Peer.UnitTests/IdentifierTests.cs +++ b/Peer.UnitTests/IdentifierTests.cs @@ -8,7 +8,8 @@ public class IdentifierTests { public class IsMatch { - private readonly Identifier _identifier = new("10", "secundatives", "wareismymind", "github"); + private readonly Identifier _identifier = + new("10", "secundatives", "wareismymind", "insomniak47", "github"); [Theory] [InlineData("github/wareismymind/secundatives/11")] @@ -48,7 +49,6 @@ public void AllSectionsMatch_IsMatch() Assert.True(res); } - } } } diff --git a/Peer.UnitTests/Peer.UnitTests.csproj b/Peer.UnitTests/Peer.UnitTests.csproj index b6bd81d..dbaff7c 100644 --- a/Peer.UnitTests/Peer.UnitTests.csproj +++ b/Peer.UnitTests/Peer.UnitTests.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Peer.UnitTests/PullRequestTests.cs b/Peer.UnitTests/PullRequestTests.cs index 6ed45fa..96c5f73 100644 --- a/Peer.UnitTests/PullRequestTests.cs +++ b/Peer.UnitTests/PullRequestTests.cs @@ -14,7 +14,7 @@ public class Construct private readonly Descriptor _descriptor = new("title", "description"); private readonly State _state = new(PullRequestStatus.ActionsPending, 10, 4); private readonly GitInfo _gitInfo = new("refs/heads/wat", "refs/head/to"); - private readonly Identifier _identifier = new("1", "peer", "wareismymind", "github"); + private readonly Identifier _identifier = new("1", "peer", "wareismymind", "insomniak47", "github"); [Fact] public void IdNull_Throws() { diff --git a/Peer.UnitTests/SortParserTests.cs b/Peer.UnitTests/SortParserTests.cs index e405b47..83fddfe 100644 --- a/Peer.UnitTests/SortParserTests.cs +++ b/Peer.UnitTests/SortParserTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Peer.Domain; using Peer.Parsing; using Peer.UnitTests.Util; @@ -46,7 +47,7 @@ public void SortOptionHasTooManySections_ReturnsTooManySections() } [Fact] - public void SortDirectionUnspecified_ReturnsAscendingSorter() + public async Task SortDirectionUnspecified_ReturnsAscendingSorter() { var res = SortParser.ParseSortOption("id"); @@ -60,7 +61,7 @@ public void SortDirectionUnspecified_ReturnsAscendingSorter() }; var reversed = prs.OrderByDescending(p => int.Parse(p.Id)).ToList(); - var sorted = res.Value.Sort(reversed).ToList(); + var sorted = await res.Value.Sort(reversed.ToAsyncEnumerable()).ToListAsync(); Assert.Equal(reversed.AsEnumerable().Reverse(), sorted); } diff --git a/Peer.UnitTests/Util/PrimitiveGenerators.cs b/Peer.UnitTests/Util/PrimitiveGenerators.cs index 685d131..607b262 100644 --- a/Peer.UnitTests/Util/PrimitiveGenerators.cs +++ b/Peer.UnitTests/Util/PrimitiveGenerators.cs @@ -51,7 +51,7 @@ public static PullRequest CreatePullRequest() var id = PrimitiveGenerators.GetInt(max: 99999); return new PullRequest( id.ToString(), - new Identifier(id.ToString(), "wareismymind", "doot", "github"), + new Identifier(id.ToString(), "wareismymind", "doot", "Insomniak47", "github"), new Uri($"https://github.com/wareismymind/doot/pulls/{id}"), new Descriptor(PrimitiveGenerators.GetString(20), PrimitiveGenerators.GetString(30)), new State(PrimitiveGenerators.RandomEnumValue(), 10, 10), diff --git a/Peer/Parsing/SortParser.cs b/Peer/Parsing/SortParser.cs index 280de33..f5ede98 100644 --- a/Peer/Parsing/SortParser.cs +++ b/Peer/Parsing/SortParser.cs @@ -64,9 +64,12 @@ private static Result, ParseError> GetSelector(st "repo" => MakeSelector(x => x.Identifier.Repo), "id-lex" => MakeSelector(x => x.Id), "id" => MakeSelector(x => int.Parse(x.Id)), + "provider" => MakeSelector(x => x.Identifier.Provider), + "author" => MakeSelector(x => x.Identifier.Author), "owner" => MakeSelector(x => x.Identifier.Owner), "status" => MakeSelector(x => x.State.Status), "active" => MakeSelector(x => x.State.ActiveComments), + "title" => MakeSelector(x => x.Descriptor.Title), _ => ParseError.UnknownSortKey }; } diff --git a/Peer/Program.cs b/Peer/Program.cs index fa80941..087eb94 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -115,13 +115,13 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services var command = provider.GetRequiredService(); var watchOpts = provider.GetRequiredService(); var args = watchOpts.Into(); - await command.WatchAsync(args, new ShowArguments(), token); + await command.WatchAsync(args, new ShowArguments(opts.Count), token); } else { var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - await command.ShowAsync(new ShowArguments(), token); + await command.ShowAsync(new ShowArguments(opts.Count), token); } } @@ -217,7 +217,6 @@ private static Result SetupServices() services.AddSingleton(); services.AddSingleton, ShowHelpTextFormatter>(); services.AddSingleton, DetailsHelpTextFormatter>(); - return services; } } diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index 5f68267..0d2482d 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "details 123" + "commandLineArgs": "sort -s repo" } } } diff --git a/Peer/Verbs/ShowOptions.cs b/Peer/Verbs/ShowOptions.cs index 07bfa06..90eee1a 100644 --- a/Peer/Verbs/ShowOptions.cs +++ b/Peer/Verbs/ShowOptions.cs @@ -10,5 +10,8 @@ public class ShowOptions [Option(shortName: 'w', longName: "watch", Required = false)] public bool Watch { get; set; } + + [Option(shortName: 'c', longName: "count", Required = false, Default = 40)] + public int Count { get; set; } } } From 20ebc69005bdc28dab55f5e8ff448de3a068d699 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 23 Dec 2021 21:26:00 -0400 Subject: [PATCH 04/28] chore: release 1.6.0 (#133) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d87071..37cff8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.6.0](https://www.github.com/wareismymind/peer/compare/v1.5.2...v1.6.0) (2021-12-24) + + +### Features + +* async enumerable and lazy paging ([#128](https://www.github.com/wareismymind/peer/issues/128)) ([44a0620](https://www.github.com/wareismymind/peer/commit/44a062087bf98145fcc6b2221eaa34a89a26c430)) + ### [1.5.2](https://www.github.com/wareismymind/peer/compare/v1.5.1...v1.5.2) (2021-12-23) diff --git a/version.txt b/version.txt index 4cda8f1..dc1e644 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.2 +1.6.0 From b9caa76108cf282d49ae3af7f546acd49fcc1c74 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Sun, 26 Dec 2021 17:28:09 -0400 Subject: [PATCH 05/28] feat: add sort keys to show help (#132) * feat: add sort keys to show help * replace extra : in sort direction description with | * chocky * id -lex typo --- Peer/Parsing/SortParser.cs | 35 ++++++++++++++--------------- Peer/Program.cs | 1 - Peer/Verbs/ShowHelpTextFormatter.cs | 6 +++++ Peer/Verbs/ShowOptions.cs | 3 ++- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/Peer/Parsing/SortParser.cs b/Peer/Parsing/SortParser.cs index f5ede98..154531b 100644 --- a/Peer/Parsing/SortParser.cs +++ b/Peer/Parsing/SortParser.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Peer.Domain; using wimm.Secundatives; @@ -6,6 +7,18 @@ namespace Peer.Parsing { public class SortParser { + private static readonly Dictionary> _selectorMap = new() + { + ["repo"] = pr => pr.Identifier.Repo, + ["id-lex"] = pr => pr.Id, + ["id"] = pr => int.Parse(pr.Id), + ["owner"] = pr => pr.Identifier.Owner, + ["status"] = pr => pr.State.Status, + ["active"] = pr => pr.State.ActiveComments, + }; + + public static IEnumerable SortKeys => _selectorMap.Keys; + public static Result, ParseError> ParseSortOption(string sortOption) { if (sortOption == null) @@ -59,26 +72,12 @@ private static Result GetSortDirection(string[] secti private static Result, ParseError> GetSelector(string name) { - return name switch + if(_selectorMap.TryGetValue(name, out var selector)) { - "repo" => MakeSelector(x => x.Identifier.Repo), - "id-lex" => MakeSelector(x => x.Id), - "id" => MakeSelector(x => int.Parse(x.Id)), - "provider" => MakeSelector(x => x.Identifier.Provider), - "author" => MakeSelector(x => x.Identifier.Author), - "owner" => MakeSelector(x => x.Identifier.Owner), - "status" => MakeSelector(x => x.State.Status), - "active" => MakeSelector(x => x.State.ActiveComments), - "title" => MakeSelector(x => x.Descriptor.Title), - _ => ParseError.UnknownSortKey - }; - } + return selector; + } - //CN: Being lazy here but just want to be able to wrap it and use the switch :| - private static Func MakeSelector(Func selector) - where TProp : IComparable - { - return x => selector(x); + return ParseError.UnknownSortKey; } } } diff --git a/Peer/Program.cs b/Peer/Program.cs index 087eb94..736507e 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -16,7 +16,6 @@ using Peer.Parsing; using Peer.Verbs; using wimm.Secundatives; -using wimm.Secundatives.Extensions; namespace Peer { diff --git a/Peer/Verbs/ShowHelpTextFormatter.cs b/Peer/Verbs/ShowHelpTextFormatter.cs index 1aa34d9..fc41256 100644 --- a/Peer/Verbs/ShowHelpTextFormatter.cs +++ b/Peer/Verbs/ShowHelpTextFormatter.cs @@ -3,6 +3,7 @@ using CommandLine; using CommandLine.Text; using Peer.Domain; +using Peer.Parsing; namespace Peer.Verbs { @@ -27,6 +28,11 @@ public HelpText GetHelpText(ParserResult parserResult) help.AddPostOptionsLines(statuses.Select(x => $" {x.ToString().PadRight(maxWidth)} => {_symbolProvider.GetSymbol(x)}")); help.AddPostOptionsLine(""); + + help.AddPostOptionsLine("The sort option can use a number of different keys and default to ascending order. The avaliable keys are:"); + help.AddPostOptionsLine(""); + help.AddPostOptionsLines(SortParser.SortKeys.Select(x => $" {x}")); + help.AddPostOptionsLine(""); help.AddPostOptionsLine(""); return help; } diff --git a/Peer/Verbs/ShowOptions.cs b/Peer/Verbs/ShowOptions.cs index 90eee1a..f22dc0e 100644 --- a/Peer/Verbs/ShowOptions.cs +++ b/Peer/Verbs/ShowOptions.cs @@ -5,7 +5,8 @@ namespace Peer.Verbs [Verb("show", isDefault: true, HelpText = "Display pull requests assigned to your account")] public class ShowOptions { - [Option(shortName: 's', longName: "sort", Required = false)] + [Option(shortName: 's', longName: "sort", Required = false, HelpText = "The sort key and direction you would like to use. " + + "The default sort direction is ascending. :[(asc|ascending)|(desc|descending)]")] public string? Sort { get; set; } [Option(shortName: 'w', longName: "watch", Required = false)] From 8b782e59a23d989c333cdd332a398d1744f32045 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Fri, 31 Dec 2021 13:51:10 -0400 Subject: [PATCH 06/28] chore: tests for some common model classes (#136) * chore: tests and validation for Check class * chore: add tests for emoji provider and stryker tool config --- Peer.Domain/Check.cs | 4 + Peer.Domain/DefaultEmojiProvider.cs | 4 + Peer.Domain/Util/Validators.cs | 2 +- Peer.UnitTests/DefaultEmojiProviderTests.cs | 87 +++++++++++++++++++++ Peer.UnitTests/Models/CheckTests.cs | 75 ++++++++++++++++++ Peer.UnitTests/stryker-config.json | 5 ++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 Peer.UnitTests/DefaultEmojiProviderTests.cs create mode 100644 Peer.UnitTests/Models/CheckTests.cs create mode 100644 Peer.UnitTests/stryker-config.json diff --git a/Peer.Domain/Check.cs b/Peer.Domain/Check.cs index f2f1df6..b92480d 100644 --- a/Peer.Domain/Check.cs +++ b/Peer.Domain/Check.cs @@ -1,4 +1,5 @@ using System; +using Peer.Domain.Util; namespace Peer.Domain { @@ -12,6 +13,9 @@ public class Check public Check(string name, string? description, Uri url, CheckStatus status, CheckResult result) { + Validators.ArgIsDefined(status); + Validators.ArgIsDefined(result); + Name = name ?? throw new ArgumentNullException(nameof(name)); Description = description; Url = url ?? throw new ArgumentNullException(nameof(url)); diff --git a/Peer.Domain/DefaultEmojiProvider.cs b/Peer.Domain/DefaultEmojiProvider.cs index e42a695..52255d7 100644 --- a/Peer.Domain/DefaultEmojiProvider.cs +++ b/Peer.Domain/DefaultEmojiProvider.cs @@ -1,4 +1,5 @@ using Peer.Domain.Exceptions; +using Peer.Domain.Util; using wimm.Secundatives; namespace Peer.Domain @@ -26,6 +27,9 @@ public string GetSymbol(PullRequestStatus status) public Maybe GetSymbol(CheckStatus status, CheckResult result) { + Validators.ArgIsDefined(status); + Validators.ArgIsDefined(result); + var check = new { Status = status, Result = result }; return check switch { diff --git a/Peer.Domain/Util/Validators.cs b/Peer.Domain/Util/Validators.cs index 801fce3..0264ce7 100644 --- a/Peer.Domain/Util/Validators.cs +++ b/Peer.Domain/Util/Validators.cs @@ -53,7 +53,7 @@ public static void ArgIsNotNullOrEmpty(ICollection collection, string name) ArgIsNotEmpty(collection, name); } - public static void ArgIsDefined(T value, string name) where T : struct, Enum + public static void ArgIsDefined(T value, [CallerArgumentExpression("value")] string? name = null) where T : struct, Enum { if (!Enum.IsDefined(value)) throw new ArgumentException(UndefinedEnum, name); diff --git a/Peer.UnitTests/DefaultEmojiProviderTests.cs b/Peer.UnitTests/DefaultEmojiProviderTests.cs new file mode 100644 index 0000000..bc5ec56 --- /dev/null +++ b/Peer.UnitTests/DefaultEmojiProviderTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Peer.Domain; +using Peer.Domain.Exceptions; +using Xunit; + +namespace Peer.UnitTests +{ + public class DefaultEmojiProviderTests + { + public class GetSymbolPRStatus + { + public static IEnumerable AllStatuses => + Enum.GetValues() + .Select(x => new object[] { x }); + + [Fact] + public void StatusUndefined_Throws() + { + var underTest = Construct(); + Assert.Throws(() => + underTest.GetSymbol((PullRequestStatus)int.MaxValue)); + } + + [Theory] + [MemberData(nameof(AllStatuses))] + public void StatusDefined_ReturnsSymbol(PullRequestStatus status) + { + var underTest = Construct(); + + var symbol = underTest.GetSymbol(status); + + Assert.NotNull(symbol); + Assert.NotEmpty(symbol); + } + + private static DefaultEmojiProvider Construct() + { + return new DefaultEmojiProvider(); + } + } + + public class GetSymbolCheckStatusAndResult + { + [Fact] + public void StatusResultComboHasNoSymbol_ReturnsNone() + { + var underTest = Construct(); + var symbol = underTest.GetSymbol(CheckStatus.Queued, CheckResult.Success); + Assert.False(symbol.Exists); + } + + [Fact] + public void CheckStatusUndefined_Throws() + { + var underTest = Construct(); + Assert.Throws( + () => underTest.GetSymbol((CheckStatus)int.MaxValue, CheckResult.Success)); + + } + + [Fact] + public void CheckResultUndefined_Throws() + { + var underTest = Construct(); + Assert.Throws( + () => underTest.GetSymbol(CheckStatus.Queued, (CheckResult)int.MaxValue)); + } + + [Fact] + public void StatusResultComboHasSymbol_ReturnsValue() + { + var underTest = Construct(); + var symbol = underTest.GetSymbol(CheckStatus.Completed, CheckResult.Success); + Assert.True(symbol.Exists); + } + + private static DefaultEmojiProvider Construct() + { + return new DefaultEmojiProvider(); + } + } + } +} diff --git a/Peer.UnitTests/Models/CheckTests.cs b/Peer.UnitTests/Models/CheckTests.cs new file mode 100644 index 0000000..8714ebe --- /dev/null +++ b/Peer.UnitTests/Models/CheckTests.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Peer.Domain; +using Xunit; + +namespace Peer.UnitTests.Models +{ + public class CheckTests + { + public class Construct + { + [Fact] + public void NameNull_Throws() + { + Assert.Throws(() => + new Check( + null, + "doot", + new Uri("https://github.com"), + CheckStatus.InProgress, + CheckResult.Unknown)); + } + + [Fact] + public void UrlNull_Throws() + { + Assert.Throws(() => + new Check( + "waka", + "doot", + null, + CheckStatus.InProgress, + CheckResult.Unknown)); + } + + [Fact] + public void StatusUndefined_Throws() + { + Assert.Throws(() => + new Check( + "waka", + "doot", + new Uri("https://github.com"), + (CheckStatus)int.MaxValue, + CheckResult.Unknown)); + } + + [Fact] + public void ResultUndefined_Throws() + { + Assert.Throws(() => + new Check( + "waka", + "doot", + new Uri("https://github.com"), + CheckStatus.InProgress, + (CheckResult)int.MaxValue)); + } + + [Fact] + public void ValidArgs_Constructs() + { + _ = new Check( + "waka", + "doot", + new Uri("https://github.com"), + CheckStatus.InProgress, + CheckResult.Unknown); + } + } + } +} diff --git a/Peer.UnitTests/stryker-config.json b/Peer.UnitTests/stryker-config.json new file mode 100644 index 0000000..8cb6d19 --- /dev/null +++ b/Peer.UnitTests/stryker-config.json @@ -0,0 +1,5 @@ +{ + "stryker-config": { + "ignore-mutations": ["string"] + } +} From 9de2b552cde8a412963008577f836c5b6df58ada Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Fri, 31 Dec 2021 14:15:39 -0400 Subject: [PATCH 07/28] feat: add initial filter impl (#137) Admin override into main (Reviewers all busy) * feat: add initial filter impl * move to using regex filter instead of action filter * separating files and formatting * case insensitive regex * adding initial tests for filters * Add filter separator as ' ', remove debug statements, aggregate instead of for loop --- Peer.Domain/Commands/Show.cs | 11 +- Peer.Domain/Filters/ActionFilter.cs | 23 ++++ Peer.Domain/Filters/EnumMatchingFilter.cs | 30 +++++ Peer.Domain/Filters/IFilter.cs | 9 ++ Peer.Domain/Filters/IPropertySelector.cs | 10 ++ Peer.Domain/Filters/PropertySelector.cs | 18 +++ Peer.Domain/Filters/RegexFilter.cs | 29 +++++ Peer.Domain/Util/Validators.cs | 10 +- Peer.UnitTests/Parsing/FilterParserTests.cs | 122 ++++++++++++++++++ .../{ => Parsing}/SortParserTests.cs | 12 +- Peer/Parsing/FilterParseError.cs | 10 ++ Peer/Parsing/FilterParser.cs | 106 +++++++++++++++ Peer/Parsing/SelectorMap.cs | 31 +++++ .../{ParseError.cs => SortParseError.cs} | 2 +- Peer/Parsing/SortParser.cs | 48 ++----- Peer/Program.cs | 17 +++ Peer/Properties/launchSettings.json | 2 +- Peer/Verbs/ShowHelpTextFormatter.cs | 2 +- Peer/Verbs/ShowOptions.cs | 6 +- 19 files changed, 445 insertions(+), 53 deletions(-) create mode 100644 Peer.Domain/Filters/ActionFilter.cs create mode 100644 Peer.Domain/Filters/EnumMatchingFilter.cs create mode 100644 Peer.Domain/Filters/IFilter.cs create mode 100644 Peer.Domain/Filters/IPropertySelector.cs create mode 100644 Peer.Domain/Filters/PropertySelector.cs create mode 100644 Peer.Domain/Filters/RegexFilter.cs create mode 100644 Peer.UnitTests/Parsing/FilterParserTests.cs rename Peer.UnitTests/{ => Parsing}/SortParserTests.cs (85%) create mode 100644 Peer/Parsing/FilterParseError.cs create mode 100644 Peer/Parsing/FilterParser.cs create mode 100644 Peer/Parsing/SelectorMap.cs rename Peer/Parsing/{ParseError.cs => SortParseError.cs} (83%) diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index dcfb900..3d44c4a 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -1,7 +1,9 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Peer.Domain.Filters; using wimm.Secundatives; namespace Peer.Domain.Commands @@ -12,22 +14,27 @@ public class Show private readonly IListFormatter _formatter; private readonly IConsoleWriter _writer; private readonly ISorter? _sorter; + private readonly List _filters; public Show( IPullRequestService prService, IListFormatter formatter, IConsoleWriter writer, - ISorter? sorter = null) + ISorter? sorter = null, + IEnumerable? filters = null) { _pullRequestService = prService; _formatter = formatter; _writer = writer; _sorter = sorter; + _filters = filters?.ToList() ?? new(); } public async Task> ShowAsync(ShowArguments args, CancellationToken token = default) { var prs = await _pullRequestService.FetchAllPullRequests(token); + prs = _filters.Aggregate(prs, (prs, filter) => filter.Filter(prs)); + var sorted = await (_sorter?.Sort(prs) ?? prs).Take(args.Count).ToListAsync(token); var lines = _formatter.FormatLines(sorted).ToList(); _writer.Display(lines, token); diff --git a/Peer.Domain/Filters/ActionFilter.cs b/Peer.Domain/Filters/ActionFilter.cs new file mode 100644 index 0000000..7c5327d --- /dev/null +++ b/Peer.Domain/Filters/ActionFilter.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Peer.Domain.Filters +{ + public class ActionFilter : IFilter + { + private readonly Func _func; + private readonly bool _negated; + + public ActionFilter(Func func, bool negated) + { + _func = func; + _negated = negated; + } + + public IAsyncEnumerable Filter(IAsyncEnumerable pullRequests) + { + return pullRequests.Where(x => _func(x) ^ _negated); + } + } +} diff --git a/Peer.Domain/Filters/EnumMatchingFilter.cs b/Peer.Domain/Filters/EnumMatchingFilter.cs new file mode 100644 index 0000000..2a84fd9 --- /dev/null +++ b/Peer.Domain/Filters/EnumMatchingFilter.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Peer.Domain.Filters +{ + public class EnumMatchingFilter : IFilter + where T : struct, IComparable + { + private readonly T _value; + private readonly bool _negated; + private readonly PropertySelector _selector; + + public EnumMatchingFilter(PropertySelector selector, T value, bool negated) + { + _value = value; + _negated = negated; + _selector = selector; + } + + public IAsyncEnumerable Filter(IAsyncEnumerable pullRequests) + { + return pullRequests.Where(pr => + { + var prop = _selector.Selector(pr); + return prop.Equals(_value) ^ _negated; + }); + } + } +} diff --git a/Peer.Domain/Filters/IFilter.cs b/Peer.Domain/Filters/IFilter.cs new file mode 100644 index 0000000..1af6200 --- /dev/null +++ b/Peer.Domain/Filters/IFilter.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Peer.Domain.Filters +{ + public interface IFilter + { + IAsyncEnumerable Filter(IAsyncEnumerable pullRequests); + } +} diff --git a/Peer.Domain/Filters/IPropertySelector.cs b/Peer.Domain/Filters/IPropertySelector.cs new file mode 100644 index 0000000..73946af --- /dev/null +++ b/Peer.Domain/Filters/IPropertySelector.cs @@ -0,0 +1,10 @@ +using System; + +namespace Peer.Domain.Filters +{ + public interface IPropertySelector + { + Func Selector { get; } + Type ReturnType { get; } + } +} diff --git a/Peer.Domain/Filters/PropertySelector.cs b/Peer.Domain/Filters/PropertySelector.cs new file mode 100644 index 0000000..3be9ab9 --- /dev/null +++ b/Peer.Domain/Filters/PropertySelector.cs @@ -0,0 +1,18 @@ +using System; + +namespace Peer.Domain.Filters +{ + public class PropertySelector : IPropertySelector + where T : IComparable + { + public Func Selector { get; } + public Type ReturnType => typeof(T); + + Func IPropertySelector.Selector => pr => Selector(pr); + + public PropertySelector(Func func) + { + Selector = pr => func(pr); + } + } +} diff --git a/Peer.Domain/Filters/RegexFilter.cs b/Peer.Domain/Filters/RegexFilter.cs new file mode 100644 index 0000000..0b6cfad --- /dev/null +++ b/Peer.Domain/Filters/RegexFilter.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Peer.Domain.Filters +{ + public class RegexFilter : IFilter + { + private readonly PropertySelector _selector; + private readonly Regex _regex; + private readonly bool _negated; + + public RegexFilter(PropertySelector selector, Regex regex, bool negated) + { + _selector = selector; + _regex = regex; + _negated = negated; + } + + public IAsyncEnumerable Filter(IAsyncEnumerable pullRequests) + { + return pullRequests.Where(pr => + { + var prop = _selector.Selector(pr); + return _regex.IsMatch(prop) ^ _negated; + }); + } + } +} diff --git a/Peer.Domain/Util/Validators.cs b/Peer.Domain/Util/Validators.cs index 0264ce7..b2bfad2 100644 --- a/Peer.Domain/Util/Validators.cs +++ b/Peer.Domain/Util/Validators.cs @@ -14,13 +14,13 @@ public class Validators public const string NotEmpty = "Cannot be empty"; public const string UndefinedEnum = "Enum value cannot be undefined"; - public static void ArgNotLessThanOrEqualToZero(int value, string name) + public static void ArgNotLessThanOrEqualToZero(int value, [CallerArgumentExpression("value")] string? name = null) { if (value <= 0) throw new ArgumentException(NotLessThanOrEqualToZero, name); } - public static void ArgIsNotNullEmptyOrWhitespace(string value, string name) + public static void ArgIsNotNullEmptyOrWhitespace(string value, [CallerArgumentExpression("value")] string? name = null) { if (value == null) throw new ArgumentNullException(name); @@ -35,19 +35,19 @@ public static void ArgIsNotNull(object value, [CallerArgumentExpression("value") throw new ArgumentNullException(name); } - public static void ArgIsNotEmpty(Guid value, string name) + public static void ArgIsNotEmpty(Guid value, [CallerArgumentExpression("value")] string? name = null) { if (value == Guid.Empty) throw new ArgumentException(NotGuidEmpty, name); } - public static void ArgIsNotEmpty(ICollection collection, string name) + public static void ArgIsNotEmpty(ICollection collection, [CallerArgumentExpression("collection")] string? name = null) { if (collection?.Count == 0) throw new ArgumentException(NotEmpty, name); } - public static void ArgIsNotNullOrEmpty(ICollection collection, string name) + public static void ArgIsNotNullOrEmpty(ICollection collection, [CallerArgumentExpression("collection")] string? name = null) { ArgIsNotNull(collection, name); ArgIsNotEmpty(collection, name); diff --git a/Peer.UnitTests/Parsing/FilterParserTests.cs b/Peer.UnitTests/Parsing/FilterParserTests.cs new file mode 100644 index 0000000..3d07959 --- /dev/null +++ b/Peer.UnitTests/Parsing/FilterParserTests.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Peer.Domain; +using Peer.Domain.Filters; +using Peer.Parsing; +using Xunit; + +namespace Peer.UnitTests.Parsing +{ + public class FilterParserTests + { + public class ParseFilterOption + { + [Fact] + public void RawStringNull_Throws() + { + Assert.Throws(() => FilterParser.ParseFilterOption(null)); + } + + [Fact] + public void RawStringEmpty_ReturnsNotEnoughSections() + { + var value = FilterParser.ParseFilterOption(string.Empty); + ResultAsserts.IsError(value, FilterParseError.NotEnoughSections); + } + + [Fact] + public void RawStringHasNoDivider_ReturnsNotEnoughSections() + { + var value = FilterParser.ParseFilterOption("authorInsomniak"); + ResultAsserts.IsError(value, FilterParseError.NotEnoughSections); + } + + [Fact] + public void RawStringHasTooManyDividersWithContent_ReturnsTooManySections() + { + var value = FilterParser.ParseFilterOption("author:Insomnia:k"); + ResultAsserts.IsError(value, FilterParseError.TooManySections); + } + + [Fact] + public void KeyNotFound_ReturnsUnknownKey() + { + var value = FilterParser.ParseFilterOption("doot:Insomniak"); + ResultAsserts.IsError(value, FilterParseError.UnknownFilterKey); + } + + [Fact] + public void RegexInvalidForStringKey_ReturnsUnknownMatchValue() + { + var value = FilterParser.ParseFilterOption("author:Insom**"); + ResultAsserts.IsError(value, FilterParseError.UnknownMatchValue); + } + + [Fact] + public void IntInvalidForIntKey_ReturnsUnknownMatchValue() + { + var value = FilterParser.ParseFilterOption("id:waka"); + ResultAsserts.IsError(value, FilterParseError.UnknownMatchValue); + } + + [Fact] + public void EnumValueInvalidForEnumKey_ReturnsUnknownMatchValue() + { + var value = FilterParser.ParseFilterOption("status:theBreadOne"); + ResultAsserts.IsError(value, FilterParseError.UnknownMatchValue); + } + + [Fact] + public void RegexValidForStringKey_ReturnsRegexFilter() + { + var value = FilterParser.ParseFilterOption("author:waka"); + ResultAsserts.IsValue(value); + Assert.IsType(value.Value); + } + + [Fact] + public void RawStringHasTooManyDividersButNoContent_ReturnsRegexFilter() + { + var value = FilterParser.ParseFilterOption("author:Insomniak:::::::::"); + ResultAsserts.IsValue(value); + Assert.IsType(value.Value); + } + + [Fact] + public void RawStringHasLeadingOrTrailingWhitespaceInSections_ReturnsRegexFilter() + { + var value = FilterParser.ParseFilterOption("author :Insomniak\t\t\t"); + ResultAsserts.IsValue(value); + Assert.IsType(value.Value); + } + + [Fact] + public void IntValidForIntKey_ReturnsActionFilter() + { + var value = FilterParser.ParseFilterOption("id:10"); + ResultAsserts.IsValue(value); + Assert.IsType(value.Value); + } + + [Fact] + public void EnumValidForEnumKey_ReturnsEnumFilter() + { + var value = FilterParser.ParseFilterOption("status:Stale"); + ResultAsserts.IsValue(value); + Assert.IsType>(value.Value); + } + + [Fact] + public void EnumValidCaseDoesntMatch_ReturnsEnumFilter() + { + //CN: Ensuring case insensitive parsing + var value = FilterParser.ParseFilterOption("status:stale"); + ResultAsserts.IsValue(value); + Assert.IsType>(value.Value); + } + } + } +} diff --git a/Peer.UnitTests/SortParserTests.cs b/Peer.UnitTests/Parsing/SortParserTests.cs similarity index 85% rename from Peer.UnitTests/SortParserTests.cs rename to Peer.UnitTests/Parsing/SortParserTests.cs index 83fddfe..1aa523d 100644 --- a/Peer.UnitTests/SortParserTests.cs +++ b/Peer.UnitTests/Parsing/SortParserTests.cs @@ -7,7 +7,7 @@ using Peer.UnitTests.Util; using Xunit; -namespace Peer.UnitTests +namespace Peer.UnitTests.Parsing { public class SortParserTests { @@ -29,21 +29,21 @@ public void SortOptionNull_Throws() public void SortOptionEmptyOrWhitespace_ReturnsNotEnoughSections(string value) { var res = SortParser.ParseSortOption(value); - ResultAsserts.IsError(res, ParseError.NotEnoughSections); + ResultAsserts.IsError(res, SortParseError.NotEnoughSections); } [Fact] public void SortSectionsEmpty_ReturnsNotEnoughSections() { var res = SortParser.ParseSortOption(":"); - ResultAsserts.IsError(res, ParseError.NotEnoughSections); + ResultAsserts.IsError(res, SortParseError.NotEnoughSections); } [Fact] public void SortOptionHasTooManySections_ReturnsTooManySections() { var res = SortParser.ParseSortOption(":"); - ResultAsserts.IsError(res, ParseError.NotEnoughSections); + ResultAsserts.IsError(res, SortParseError.NotEnoughSections); } [Fact] @@ -70,14 +70,14 @@ public async Task SortDirectionUnspecified_ReturnsAscendingSorter() public void PropertyNotAvailableForSorting_ReturnsUnknownSortKey() { var res = SortParser.ParseSortOption("floop:asc"); - ResultAsserts.IsError(res, ParseError.UnknownSortKey); + ResultAsserts.IsError(res, SortParseError.UnknownSortKey); } [Fact] public void DirectionInvalid_ReturnsInvalidSortDirection() { var res = SortParser.ParseSortOption("id:floop"); - ResultAsserts.IsError(res, ParseError.InvalidSortDirection); + ResultAsserts.IsError(res, SortParseError.InvalidSortDirection); } [Fact] diff --git a/Peer/Parsing/FilterParseError.cs b/Peer/Parsing/FilterParseError.cs new file mode 100644 index 0000000..a4af78c --- /dev/null +++ b/Peer/Parsing/FilterParseError.cs @@ -0,0 +1,10 @@ +namespace Peer.Parsing +{ + public enum FilterParseError + { + TooManySections, + NotEnoughSections, + UnknownFilterKey, + UnknownMatchValue + } +} diff --git a/Peer/Parsing/FilterParser.cs b/Peer/Parsing/FilterParser.cs new file mode 100644 index 0000000..e92e757 --- /dev/null +++ b/Peer/Parsing/FilterParser.cs @@ -0,0 +1,106 @@ +using System; +using System.Text.RegularExpressions; +using Peer.Domain; +using Peer.Domain.Exceptions; +using Peer.Domain.Filters; +using wimm.Secundatives; +using wimm.Secundatives.Extensions; + +namespace Peer.Parsing +{ + public class FilterParser + { + public static Result ParseFilterOption(string filterRaw) + { + if (filterRaw == null) + { + throw new ArgumentNullException(nameof(filterRaw)); + } + + var split = filterRaw.ToLower() + .Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + return split.Length switch + { + 0 or 1 => FilterParseError.NotEnoughSections, + 2 => ParseSections(split), + _ => FilterParseError.TooManySections + }; + } + + private static Result ParseSections(string[] sections) + { + var negated = sections[0].StartsWith('!'); + //CN: Bit ugly + var key = sections[0][(negated ? 1 : 0)..].ToLowerInvariant(); + var value = sections[1]; + + var maybeSelector = SelectorMap.TryGetSelector(key); + return maybeSelector.OkOr(FilterParseError.UnknownFilterKey) + .Map(selector => + { + return selector switch + { + PropertySelector x => CreateRegexFilter(x, value, negated), + PropertySelector x => CreateIntFilter(x, value, negated), + PropertySelector x => CreateEnumMatchFilter(x, value, negated), + _ => throw new UnreachableException() + }; + }).Flatten(); + } + + private static Result CreateRegexFilter(PropertySelector selector, string value, bool negate) + { + var parsed = ParseRegex(value); + return parsed.OkOr(FilterParseError.UnknownMatchValue) + .Map(v => new RegexFilter(selector, v, negate) as IFilter); + } + + private static Maybe ParseRegex(string raw) + { + try + { + return new Regex(raw, RegexOptions.IgnoreCase); + } + catch (ArgumentException) + { + return Maybe.None; + } + } + + private static Result CreateIntFilter(PropertySelector selector, string value, bool negate) + { + var parsed = ParseInt(value); + return parsed.OkOr(FilterParseError.UnknownMatchValue) + .Map(v => new ActionFilter(pr => selector.Selector(pr) == v, negate) as IFilter); + } + + private static Result CreateEnumMatchFilter(PropertySelector selector, string value, bool negate) + where T : struct, IComparable + { + var parsed = ParseEnum(value); + return parsed.OkOr(FilterParseError.UnknownMatchValue) + .Map(v => new EnumMatchingFilter(selector, v, negate) as IFilter); + } + + private static Maybe ParseInt(string raw) + { + if (int.TryParse(raw, out var result)) + { + return result; + } + + return Maybe.None; + } + + private static Maybe ParseEnum(string raw) where T : struct + { + if (Enum.TryParse(raw, true, out var result)) + { + return result; + } + + return Maybe.None; + } + } +} diff --git a/Peer/Parsing/SelectorMap.cs b/Peer/Parsing/SelectorMap.cs new file mode 100644 index 0000000..776a18a --- /dev/null +++ b/Peer/Parsing/SelectorMap.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using Peer.Domain; +using Peer.Domain.Filters; +using wimm.Secundatives; +using wimm.Secundatives.Extensions; + +namespace Peer.Parsing +{ + public static class SelectorMap + { + private static readonly Dictionary _selectorMap = new() + { + ["repo"] = new PropertySelector(pr => pr.Identifier.Repo), + ["id-lex"] = new PropertySelector(pr => pr.Id), + ["id"] = new PropertySelector(pr => int.Parse(pr.Id)), + ["owner"] = new PropertySelector(pr => pr.Identifier.Owner), + ["author"] = new PropertySelector(pr => pr.Identifier.Author), + ["status"] = new PropertySelector(pr => pr.State.Status), + ["active"] = new PropertySelector(pr => pr.State.ActiveComments), + ["title"] = new PropertySelector(pr => pr.Descriptor.Title) + }; + + public static IEnumerable Keys => _selectorMap.Keys; + + public static Maybe TryGetSelector(string key) + { + _selectorMap.TryGetValue(key.ToLowerInvariant(), out var selector); + return selector!.AsMaybe(); + } + } +} diff --git a/Peer/Parsing/ParseError.cs b/Peer/Parsing/SortParseError.cs similarity index 83% rename from Peer/Parsing/ParseError.cs rename to Peer/Parsing/SortParseError.cs index e49fbe0..b115913 100644 --- a/Peer/Parsing/ParseError.cs +++ b/Peer/Parsing/SortParseError.cs @@ -1,6 +1,6 @@ namespace Peer.Parsing { - public enum ParseError + public enum SortParseError { Fire, TooManySections, diff --git a/Peer/Parsing/SortParser.cs b/Peer/Parsing/SortParser.cs index 154531b..02a97e1 100644 --- a/Peer/Parsing/SortParser.cs +++ b/Peer/Parsing/SortParser.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Peer.Domain; using wimm.Secundatives; @@ -7,19 +6,7 @@ namespace Peer.Parsing { public class SortParser { - private static readonly Dictionary> _selectorMap = new() - { - ["repo"] = pr => pr.Identifier.Repo, - ["id-lex"] = pr => pr.Id, - ["id"] = pr => int.Parse(pr.Id), - ["owner"] = pr => pr.Identifier.Owner, - ["status"] = pr => pr.State.Status, - ["active"] = pr => pr.State.ActiveComments, - }; - - public static IEnumerable SortKeys => _selectorMap.Keys; - - public static Result, ParseError> ParseSortOption(string sortOption) + public static Result, SortParseError> ParseSortOption(string sortOption) { if (sortOption == null) { @@ -28,20 +15,12 @@ public static Result, ParseError> ParseSortOption(string so var split = sortOption.ToLower().Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - if (split.Length > 2) + var sortDirectionResult = split.Length switch { - return ParseError.TooManySections; - } - - if (split.Length == 0) - { - return ParseError.NotEnoughSections; - } - - //CN: Should see if we can do any reflecty witchcraft here to make this really generic. - // till then maybe just have a specific set of things? - - var sortDirectionResult = GetSortDirection(split); + 0 => SortParseError.NotEnoughSections, + 1 or 2 => GetSortDirection(split), + _ => SortParseError.TooManySections, + }; if (sortDirectionResult.IsError) { @@ -55,7 +34,7 @@ public static Result, ParseError> ParseSortOption(string so } - private static Result GetSortDirection(string[] sections) + private static Result GetSortDirection(string[] sections) { if (sections.Length == 1) return SortDirection.Ascending; @@ -66,18 +45,15 @@ private static Result GetSortDirection(string[] secti "ascending" => SortDirection.Ascending, "desc" => SortDirection.Descending, "descending" => SortDirection.Descending, - _ => ParseError.InvalidSortDirection + _ => SortParseError.InvalidSortDirection }; } - private static Result, ParseError> GetSelector(string name) + private static Result, SortParseError> GetSelector(string name) { - if(_selectorMap.TryGetValue(name, out var selector)) - { - return selector; - } - - return ParseError.UnknownSortKey; + return SelectorMap.TryGetSelector(name) + .OkOr(SortParseError.UnknownSortKey) + .Map(x => x.Selector); } } } diff --git a/Peer/Program.cs b/Peer/Program.cs index 736507e..95196a7 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -92,6 +92,7 @@ private static HelpText GetHelpText(ParserResult parserResult, public static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) { + if (opts.Sort != null) { var sort = SortParser.ParseSortOption(opts.Sort); @@ -104,6 +105,22 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services services.AddSingleton(sort.Value); } + if (opts.Filter != null) + { + foreach (var filter in opts.Filter) + { + var parsedFilter = FilterParser.ParseFilterOption(filter); + + if (parsedFilter.IsError) + { + Console.Error.WriteLine($"Failed to parse filter option: {parsedFilter.Error}"); + return; + } + + services.AddSingleton(parsedFilter.Value); + } + } + services.AddSingleton(); services.AddSingleton(new ConsoleConfig(inline: !opts.Watch)); diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index 0d2482d..249406e 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "sort -s repo" + "commandLineArgs": "-f author:Insomniak47" } } } diff --git a/Peer/Verbs/ShowHelpTextFormatter.cs b/Peer/Verbs/ShowHelpTextFormatter.cs index fc41256..7aa6d8a 100644 --- a/Peer/Verbs/ShowHelpTextFormatter.cs +++ b/Peer/Verbs/ShowHelpTextFormatter.cs @@ -31,7 +31,7 @@ public HelpText GetHelpText(ParserResult parserResult) help.AddPostOptionsLine("The sort option can use a number of different keys and default to ascending order. The avaliable keys are:"); help.AddPostOptionsLine(""); - help.AddPostOptionsLines(SortParser.SortKeys.Select(x => $" {x}")); + help.AddPostOptionsLines(SelectorMap.Keys.Select(x => $" {x}")); help.AddPostOptionsLine(""); help.AddPostOptionsLine(""); return help; diff --git a/Peer/Verbs/ShowOptions.cs b/Peer/Verbs/ShowOptions.cs index f22dc0e..f452aad 100644 --- a/Peer/Verbs/ShowOptions.cs +++ b/Peer/Verbs/ShowOptions.cs @@ -1,4 +1,5 @@ -using CommandLine; +using System.Collections.Generic; +using CommandLine; namespace Peer.Verbs { @@ -14,5 +15,8 @@ public class ShowOptions [Option(shortName: 'c', longName: "count", Required = false, Default = 40)] public int Count { get; set; } + + [Option(shortName: 'f', longName: "filter", Required = false, Separator = ' ')] + public IEnumerable? Filter { get; set; } } } From f90de838b05d054210c8ce3f5cf5784bdec4ffc7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Dec 2021 14:17:43 -0400 Subject: [PATCH 08/28] chore: release 1.7.0 (#134) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ version.txt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37cff8b..d61a722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.7.0](https://www.github.com/wareismymind/peer/compare/v1.6.0...v1.7.0) (2021-12-31) + + +### Features + +* add initial filter impl ([#137](https://www.github.com/wareismymind/peer/issues/137)) ([9de2b55](https://www.github.com/wareismymind/peer/commit/9de2b552cde8a412963008577f836c5b6df58ada)) +* add sort keys to show help ([#132](https://www.github.com/wareismymind/peer/issues/132)) ([b9caa76](https://www.github.com/wareismymind/peer/commit/b9caa76108cf282d49ae3af7f546acd49fcc1c74)) + ## [1.6.0](https://www.github.com/wareismymind/peer/compare/v1.5.2...v1.6.0) (2021-12-24) diff --git a/version.txt b/version.txt index dc1e644..bd8bf88 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.6.0 +1.7.0 From fe138ddd22f259d4245e792e73dd3008583be47c Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Fri, 31 Dec 2021 14:56:27 -0400 Subject: [PATCH 09/28] fix: accept filter contents with embedded colons (like 'title:fix:') (#139) * fix: accept filter contents with embedded colons (like 'title:fix:') * fix: raw literal instead of expression * chore: add additional tests for the filter parsing code --- Peer.UnitTests/Parsing/FilterParserTests.cs | 18 ++++++++++++++++-- Peer/Parsing/FilterParseError.cs | 5 +++-- Peer/Parsing/FilterParser.cs | 12 ++++++------ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Peer.UnitTests/Parsing/FilterParserTests.cs b/Peer.UnitTests/Parsing/FilterParserTests.cs index 3d07959..32cbb30 100644 --- a/Peer.UnitTests/Parsing/FilterParserTests.cs +++ b/Peer.UnitTests/Parsing/FilterParserTests.cs @@ -35,10 +35,17 @@ public void RawStringHasNoDivider_ReturnsNotEnoughSections() } [Fact] - public void RawStringHasTooManyDividersWithContent_ReturnsTooManySections() + public void RawStringHasSemiColonsInContent_ReturnsRegexFilter() { var value = FilterParser.ParseFilterOption("author:Insomnia:k"); - ResultAsserts.IsError(value, FilterParseError.TooManySections); + ResultAsserts.IsValue(value); + } + + [Fact] + public void KeyIsEmpty_ReturnsNoFilterKeySpecified() + { + var value = FilterParser.ParseFilterOption(":Insomniak"); + ResultAsserts.IsError(value, FilterParseError.NoFilterKeySpecified); } [Fact] @@ -48,6 +55,13 @@ public void KeyNotFound_ReturnsUnknownKey() ResultAsserts.IsError(value, FilterParseError.UnknownFilterKey); } + [Fact] + public void FilterValueEmpty_ReturnsFilterContentEmpty() + { + var value = FilterParser.ParseFilterOption("author:"); + ResultAsserts.IsError(value, FilterParseError.FilterContentEmpty); + } + [Fact] public void RegexInvalidForStringKey_ReturnsUnknownMatchValue() { diff --git a/Peer/Parsing/FilterParseError.cs b/Peer/Parsing/FilterParseError.cs index a4af78c..cfd641e 100644 --- a/Peer/Parsing/FilterParseError.cs +++ b/Peer/Parsing/FilterParseError.cs @@ -2,9 +2,10 @@ { public enum FilterParseError { - TooManySections, NotEnoughSections, UnknownFilterKey, - UnknownMatchValue + UnknownMatchValue, + FilterContentEmpty, + NoFilterKeySpecified } } diff --git a/Peer/Parsing/FilterParser.cs b/Peer/Parsing/FilterParser.cs index e92e757..6cd24fa 100644 --- a/Peer/Parsing/FilterParser.cs +++ b/Peer/Parsing/FilterParser.cs @@ -17,14 +17,14 @@ public static Result ParseFilterOption(string filterR throw new ArgumentNullException(nameof(filterRaw)); } - var split = filterRaw.ToLower() - .Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + var splitPoint = filterRaw.IndexOf(':'); - return split.Length switch + return splitPoint switch { - 0 or 1 => FilterParseError.NotEnoughSections, - 2 => ParseSections(split), - _ => FilterParseError.TooManySections + -1 => FilterParseError.NotEnoughSections, + 0 => FilterParseError.NoFilterKeySpecified, + var x when x == filterRaw.Length - 1 => FilterParseError.FilterContentEmpty, + _ => ParseSections(new string[] { filterRaw[0..splitPoint].Trim(), filterRaw[(splitPoint + 1)..].Trim() }) }; } From dbaf907cb3c859de205e13e40a33970a85a78bd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Dec 2021 15:00:38 -0400 Subject: [PATCH 10/28] chore: release 1.7.1 (#140) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d61a722..29f1f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.7.1](https://www.github.com/wareismymind/peer/compare/v1.7.0...v1.7.1) (2021-12-31) + + +### Bug Fixes + +* accept filter contents with embedded colons (like 'title:fix:') ([#139](https://www.github.com/wareismymind/peer/issues/139)) ([fe138dd](https://www.github.com/wareismymind/peer/commit/fe138ddd22f259d4245e792e73dd3008583be47c)) + ## [1.7.0](https://www.github.com/wareismymind/peer/compare/v1.6.0...v1.7.0) (2021-12-31) diff --git a/version.txt b/version.txt index bd8bf88..943f9cb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.0 +1.7.1 From 187f3d9cf318aa49563370c65c1883cedc165975 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Tue, 4 Jan 2022 12:51:22 -0400 Subject: [PATCH 11/28] fix: during the changes to status fetching the rollup got lost along the way (#141) OVERRIDE - Hotfix + reviewers are all out sick --- Peer.GitHub/GraphQL/PullRequestSearch/Search.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs b/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs index 3548b56..ada147e 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/Search.cs @@ -68,6 +68,7 @@ ... on PullRequest { commits(last: 1) { nodes { commit { + statusCheckRollup { state } checkSuites(first: 20) { nodes { checkRuns(first: 20) { From 291ba6cd4f3acfaaf7a11a73e16dbc960c36d1a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Jan 2022 12:53:07 -0400 Subject: [PATCH 12/28] chore: release 1.7.2 (#142) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f1f66..609da63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.7.2](https://www.github.com/wareismymind/peer/compare/v1.7.1...v1.7.2) (2022-01-04) + + +### Bug Fixes + +* during the changes to status fetching the rollup got lost along the way ([#141](https://www.github.com/wareismymind/peer/issues/141)) ([187f3d9](https://www.github.com/wareismymind/peer/commit/187f3d9cf318aa49563370c65c1883cedc165975)) + ### [1.7.1](https://www.github.com/wareismymind/peer/compare/v1.7.0...v1.7.1) (2021-12-31) diff --git a/version.txt b/version.txt index 943f9cb..f8a696c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.1 +1.7.2 From eb00ab0e3ea00411e973fc324b1d49a6445facfa Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Thu, 6 Jan 2022 14:34:47 -0400 Subject: [PATCH 13/28] fix: when a user is deleted the Author field isn't returned. replacing author with 'octoghost' in those cases (#143) --- Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs b/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs index b7a78f5..94b77b2 100644 --- a/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs +++ b/Peer.GitHub/GraphQL/PullRequestSearch/PullRequest.cs @@ -54,7 +54,12 @@ public Domain.PullRequest Into() return new Domain.PullRequest( Number.ToString(), - new Identifier(Number.ToString(), BaseRepository.Name, BaseRepository.Owner.Login, Author.Login, ProviderConstants.Github), + new Identifier( + Number.ToString(), + BaseRepository.Name, + BaseRepository.Owner.Login, + Author?.Login ?? "octoghost", + ProviderConstants.Github), Url, new Descriptor(Title, Body ?? string.Empty), new State(status, totalComments, activeComments), From 01f5d1f4bf4c781d34be47b779443a18c2ae2723 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 6 Jan 2022 14:37:20 -0400 Subject: [PATCH 14/28] chore: release 1.7.3 (#144) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 609da63..4910b7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.7.3](https://www.github.com/wareismymind/peer/compare/v1.7.2...v1.7.3) (2022-01-06) + + +### Bug Fixes + +* when a user is deleted the Author field isn't returned. replacing author with 'octoghost' in those cases ([#143](https://www.github.com/wareismymind/peer/issues/143)) ([eb00ab0](https://www.github.com/wareismymind/peer/commit/eb00ab0e3ea00411e973fc324b1d49a6445facfa)) + ### [1.7.2](https://www.github.com/wareismymind/peer/compare/v1.7.1...v1.7.2) (2022-01-04) diff --git a/version.txt b/version.txt index f8a696c..661e7ae 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.2 +1.7.3 From 824786781edf230f7e83032f59b0fe5aafcd567a Mon Sep 17 00:00:00 2001 From: Thomas Showers <68649564+tshowers-bt@users.noreply.github.com> Date: Wed, 26 Jan 2022 12:33:12 -0400 Subject: [PATCH 15/28] fix: handle errors in show/watch (#147) * fix: handle errors in show/watch Add exception handling to the show command to display the exception message instead of crashing (which would previously dump the stack trace) and return an error result. Update the watch command so that it will handle a few failed show commands before exiting gracefully. * use configurable retry in show and watch * dotnet format + remove unused usings * wrap all GQL requests in cancellation-terminated retry * remove unnecessary setters --- Peer.Domain/Check.cs | 2 +- Peer.Domain/Commands/Show.cs | 48 +++++++++++++++-- Peer.Domain/Commands/WatchArguments.cs | 14 ----- Peer.Domain/Commands/WatchShow.cs | 22 ++++++-- .../CommandConfigs/ShowConfig.cs | 27 ++++++++++ Peer.Domain/Exceptions/FetchException.cs | 16 ++++++ Peer.Domain/PullRequestService.cs | 1 - Peer.GitHub/GitHubRequestFetcher.cs | 51 +++++++++++++++---- Peer.UnitTests/DefaultEmojiProviderTests.cs | 2 - Peer.UnitTests/Models/CheckTests.cs | 4 -- Peer.UnitTests/Parsing/FilterParserTests.cs | 4 -- Peer/ConfigSections/ShowConfigSection.cs | 18 +++++++ Peer/Parsing/WatchOptions.cs | 7 +-- Peer/Program.cs | 17 ++++--- Peer/Properties/launchSettings.json | 2 +- 15 files changed, 177 insertions(+), 58 deletions(-) delete mode 100644 Peer.Domain/Commands/WatchArguments.cs create mode 100644 Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs create mode 100644 Peer.Domain/Exceptions/FetchException.cs create mode 100644 Peer/ConfigSections/ShowConfigSection.cs diff --git a/Peer.Domain/Check.cs b/Peer.Domain/Check.cs index b92480d..b032540 100644 --- a/Peer.Domain/Check.cs +++ b/Peer.Domain/Check.cs @@ -15,7 +15,7 @@ public Check(string name, string? description, Uri url, CheckStatus status, Chec { Validators.ArgIsDefined(status); Validators.ArgIsDefined(result); - + Name = name ?? throw new ArgumentNullException(nameof(name)); Description = description; Url = url ?? throw new ArgumentNullException(nameof(url)); diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index 3d44c4a..24c1777 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -1,8 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Peer.Domain.Configuration.CommandConfigs; +using Peer.Domain.Exceptions; using Peer.Domain.Filters; using wimm.Secundatives; @@ -16,34 +20,68 @@ public class Show private readonly ISorter? _sorter; private readonly List _filters; + public ShowConfig Config { get; } + public Show( IPullRequestService prService, IListFormatter formatter, IConsoleWriter writer, + ShowConfig config, ISorter? sorter = null, IEnumerable? filters = null) { _pullRequestService = prService; _formatter = formatter; _writer = writer; + Config = config; _sorter = sorter; _filters = filters?.ToList() ?? new(); } public async Task> ShowAsync(ShowArguments args, CancellationToken token = default) { - var prs = await _pullRequestService.FetchAllPullRequests(token); - prs = _filters.Aggregate(prs, (prs, filter) => filter.Filter(prs)); + using var cts = new CancellationTokenSource(); + token.Register(() => cts.Cancel()); + cts.CancelAfter(Config.TimeoutSeconds * 1000); + + var prs = await GetPullRequests(args, cts.Token); + if (prs.IsError) + { + _writer.Clear(); + _writer.Display(new List + { + $"error: failed to fetch pull request info", + }, token); + return prs.Error; + } - var sorted = await (_sorter?.Sort(prs) ?? prs).Take(args.Count).ToListAsync(token); - var lines = _formatter.FormatLines(sorted).ToList(); + var lines = _formatter.FormatLines(prs.Value).ToList(); _writer.Display(lines, token); return Maybe.None; } + + private async Task, ShowError>> GetPullRequests(ShowArguments args, CancellationToken token) + { + try + { + var prs = await _pullRequestService.FetchAllPullRequests(token); + prs = _filters.Aggregate(prs, (prs, filter) => filter.Filter(prs)); + return await (_sorter?.Sort(prs) ?? prs).Take(args.Count).ToListAsync(token); + } + catch (OperationCanceledException) + { + return ShowError.Timeout; + } + catch (FetchException) + { + return ShowError.Fire; + } + } } public enum ShowError { + Timeout, Fire, } } diff --git a/Peer.Domain/Commands/WatchArguments.cs b/Peer.Domain/Commands/WatchArguments.cs deleted file mode 100644 index a05dcba..0000000 --- a/Peer.Domain/Commands/WatchArguments.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace Peer.Domain.Commands -{ - public class WatchArguments - { - public TimeSpan IntervalSeconds { get; } - - public WatchArguments(TimeSpan intervalSeconds) - { - IntervalSeconds = intervalSeconds; - } - } -} diff --git a/Peer.Domain/Commands/WatchShow.cs b/Peer.Domain/Commands/WatchShow.cs index abe155c..e1b414f 100644 --- a/Peer.Domain/Commands/WatchShow.cs +++ b/Peer.Domain/Commands/WatchShow.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -15,20 +16,31 @@ public WatchShow(Show show, IConsoleWriter consoleWriter) _consoleWriter = consoleWriter; } - public async Task> WatchAsync(WatchArguments watchConfig, ShowArguments args, CancellationToken token) + public async Task> WatchAsync(ShowArguments args, CancellationToken token) { + int consecutiveFailures = 0; _consoleWriter.Clear(); while (!token.IsCancellationRequested) { var res = await _show.ShowAsync(args, token); - if (res.IsError) + consecutiveFailures = res.IsError + ? consecutiveFailures + 1 + : 0; + + if (consecutiveFailures > _show.Config.WatchMaxConsecutiveShowFailures) { - return res; + _consoleWriter.Clear(); + _consoleWriter.Display(new List + { + $"error: too many consecutive errors", + }, token); + + return ShowError.Fire; } - await Task.Delay(watchConfig.IntervalSeconds, token); + await Task.Delay(_show.Config.WatchIntervalSeconds, token); } return Maybe.None; diff --git a/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs b/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs new file mode 100644 index 0000000..2c80753 --- /dev/null +++ b/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs @@ -0,0 +1,27 @@ +namespace Peer.Domain.Configuration.CommandConfigs +{ + public class ShowConfig + { + private const int _defaultTimeoutSeconds = 10; + private const int _defaultWatchIntervalSeconds = 30; + private const int _defaultWatchMaxConsecutiveShowFailures = 5; + + public int TimeoutSeconds { get; set; } + + + public int WatchIntervalSeconds { get; set; } + + public int WatchMaxConsecutiveShowFailures { get; set; } + + public ShowConfig( + int? timeoutSeconds, + int? watchIntervalSeconds, + int? watchMaxConsecutiveShowFailures) + { + TimeoutSeconds = timeoutSeconds ?? _defaultTimeoutSeconds; + WatchIntervalSeconds = watchIntervalSeconds ?? _defaultWatchIntervalSeconds; + WatchMaxConsecutiveShowFailures = + watchMaxConsecutiveShowFailures ?? _defaultWatchMaxConsecutiveShowFailures; + } + } +} diff --git a/Peer.Domain/Exceptions/FetchException.cs b/Peer.Domain/Exceptions/FetchException.cs new file mode 100644 index 0000000..c21cc61 --- /dev/null +++ b/Peer.Domain/Exceptions/FetchException.cs @@ -0,0 +1,16 @@ +using System; +using System.Runtime.Serialization; + +namespace Peer.Domain.Exceptions +{ + public class FetchException : Exception + { + public FetchException() { } + + public FetchException(string? message) : base(message) { } + + public FetchException(string? message, Exception? innerException) : base(message, innerException) { } + + protected FetchException(SerializationInfo info, StreamingContext context) : base(info, context) { } + } +} diff --git a/Peer.Domain/PullRequestService.cs b/Peer.Domain/PullRequestService.cs index 2d1ae73..02ae8d1 100644 --- a/Peer.Domain/PullRequestService.cs +++ b/Peer.Domain/PullRequestService.cs @@ -16,7 +16,6 @@ public PullRequestService(IEnumerable fetchers) _fetchers = fetchers.ToList(); } - //TODO(cn): AsyncEnumerable public Task> FetchAllPullRequests(CancellationToken token = default) { var prIterator = _fetchers.ToAsyncEnumerable().SelectManyAwait(async x => await x.GetPullRequestsAsync(token)); diff --git a/Peer.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index 657a83c..790ebff 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using GraphQL; using GraphQL.Client.Http; using Peer.Domain; using Peer.Domain.Exceptions; @@ -39,8 +41,7 @@ public Task> GetPullRequestsAsync(CancellationToke private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCancellation] CancellationToken token) { - if (token.IsCancellationRequested) - yield break; + token.ThrowIfCancellationRequested(); var responses = new List> { @@ -67,10 +68,11 @@ private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCance while (prsWithMoreThreads.Any()) { - var queryResponse = - await _gqlClient.SendQueryAsync>( - ThreadPageQuery(prsWithMoreThreads), - token); + var query = ThreadPageQuery(prsWithMoreThreads); + + var queryResponse = await RetryUntilCancelled( + async () => await _gqlClient.SendQueryAsync>(query, token), + token); var prThreadPages = queryResponse.Data.Values; @@ -93,9 +95,11 @@ private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCance { var cursor = null as string; - while (!token.IsCancellationRequested) + while (true) { - var response = await _gqlClient.SendQueryAsync>(await GenerateRequest(type, cursor), token); + token.ThrowIfCancellationRequested(); + + var response = await GetPullRequests(type, cursor, token); if (response.Errors != null) { @@ -118,6 +122,17 @@ private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCance } } + private async Task>> GetPullRequests( + QueryType type, + string? cursor, CancellationToken token) + { + var request = await GenerateRequest(type, cursor); + + return await RetryUntilCancelled( + async () => await _gqlClient.SendQueryAsync>(request, token), + token); + } + private enum QueryType { Involves, @@ -153,7 +168,9 @@ private async Task GetUsername(CancellationToken token) private async Task QueryUsername(CancellationToken token) { var query = new GraphQLHttpRequest(ViewerQuery.Query.Generate(), token); - var viewerResponse = await _gqlClient.SendQueryAsync(query, token); + var viewerResponse = await RetryUntilCancelled( + async () => await _gqlClient.SendQueryAsync(query, token), + token); return viewerResponse.Data.Viewer.Login; } @@ -165,5 +182,21 @@ private static GraphQLHttpRequest ThreadPageQuery(IEnumerable RetryUntilCancelled(Func> fn, CancellationToken token) + { + while (true) + { + token.ThrowIfCancellationRequested(); + + try + { + return await fn(); + } + // Catching these exceptions works because we're only retrying queries, but it seems like something we + // should specify at the callsite instead. + catch (Exception ex) when (ex is GraphQLHttpRequestException || ex is HttpRequestException) { } + } + } } } diff --git a/Peer.UnitTests/DefaultEmojiProviderTests.cs b/Peer.UnitTests/DefaultEmojiProviderTests.cs index bc5ec56..fcf71ed 100644 --- a/Peer.UnitTests/DefaultEmojiProviderTests.cs +++ b/Peer.UnitTests/DefaultEmojiProviderTests.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using Peer.Domain; using Peer.Domain.Exceptions; using Xunit; diff --git a/Peer.UnitTests/Models/CheckTests.cs b/Peer.UnitTests/Models/CheckTests.cs index 8714ebe..e0487b8 100644 --- a/Peer.UnitTests/Models/CheckTests.cs +++ b/Peer.UnitTests/Models/CheckTests.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Peer.Domain; using Xunit; diff --git a/Peer.UnitTests/Parsing/FilterParserTests.cs b/Peer.UnitTests/Parsing/FilterParserTests.cs index 32cbb30..d7558ad 100644 --- a/Peer.UnitTests/Parsing/FilterParserTests.cs +++ b/Peer.UnitTests/Parsing/FilterParserTests.cs @@ -1,8 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; using Peer.Domain; using Peer.Domain.Filters; using Peer.Parsing; diff --git a/Peer/ConfigSections/ShowConfigSection.cs b/Peer/ConfigSections/ShowConfigSection.cs new file mode 100644 index 0000000..54dcbb0 --- /dev/null +++ b/Peer/ConfigSections/ShowConfigSection.cs @@ -0,0 +1,18 @@ +using Peer.Domain.Configuration.CommandConfigs; + +namespace Peer.ConfigSections +{ + public class ShowConfigSection + { + public int? TimeoutSeconds { get; set; } + + public int? WatchIntervalSeconds { get; set; } + + public int? WatchMaxConsecutiveShowFailures { get; set; } + + public ShowConfig Into() => new( + TimeoutSeconds, + WatchIntervalSeconds, + WatchMaxConsecutiveShowFailures); + } +} diff --git a/Peer/Parsing/WatchOptions.cs b/Peer/Parsing/WatchOptions.cs index 24f541d..f74a21b 100644 --- a/Peer/Parsing/WatchOptions.cs +++ b/Peer/Parsing/WatchOptions.cs @@ -5,11 +5,6 @@ namespace Peer.Parsing { public class WatchOptions { - public int WatchIntervalSeconds { get; set; } = 30; - - public WatchArguments Into() - { - return new WatchArguments(TimeSpan.FromSeconds(WatchIntervalSeconds)); - } + public int? WatchIntervalSeconds { get; set; } } } diff --git a/Peer/Program.cs b/Peer/Program.cs index 95196a7..79744e6 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -8,6 +8,7 @@ using CommandLine.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Peer.ConfigSections; using Peer.Domain; using Peer.Domain.Commands; using Peer.Domain.Configuration; @@ -129,9 +130,7 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services services.AddSingleton(); var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var watchOpts = provider.GetRequiredService(); - var args = watchOpts.Into(); - await command.WatchAsync(args, new ShowArguments(opts.Count), token); + await command.WatchAsync(new ShowArguments(opts.Count), token); } else { @@ -215,15 +214,21 @@ private static Result SetupServices() return configResults.Error; } + // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to + // expose the new settings until we've settled on some configuration patterns. + // https://github.com/wareismymind/peer/issues/149 + var watchOptions = configuration.GetSection("Peer") .Get() ?? new WatchOptions(); - - if (watchOptions != null) + var showConfig = new ShowConfigSection(); + if (watchOptions.WatchIntervalSeconds != null) { - services.AddSingleton(watchOptions); + showConfig.WatchIntervalSeconds = watchOptions.WatchIntervalSeconds; } + services.AddSingleton(showConfig.Into()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index 249406e..e234c35 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "-f author:Insomniak47" + "commandLineArgs": "-w" } } } From 15f65f231b72b06231aca7f937c0635e6ba95f91 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Wed, 30 Mar 2022 15:02:06 -0300 Subject: [PATCH 16/28] feat: initial addition of loggers with some simple top level log statements (and one in show) until we figure out how we want to approach it (#152) Overriding - reviewers are ghosts --- Peer.Domain/Commands/Show.cs | 7 +++- Peer.Domain/Commands/WatchShow.cs | 7 ++-- Peer.GitHub/GitHubRequestFetcher.cs | 2 +- Peer.GitHub/GitHubWebRegistrationHandler.cs | 1 + Peer/Peer.csproj | 3 ++ Peer/Program.cs | 44 +++++++++++++++++---- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index 24c1777..8d21e4c 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -5,6 +5,7 @@ using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Peer.Domain.Configuration.CommandConfigs; using Peer.Domain.Exceptions; using Peer.Domain.Filters; @@ -17,6 +18,7 @@ public class Show private readonly IPullRequestService _pullRequestService; private readonly IListFormatter _formatter; private readonly IConsoleWriter _writer; + private readonly ILogger _logger; private readonly ISorter? _sorter; private readonly List _filters; @@ -27,6 +29,7 @@ public Show( IListFormatter formatter, IConsoleWriter writer, ShowConfig config, + ILogger logger, ISorter? sorter = null, IEnumerable? filters = null) { @@ -34,15 +37,17 @@ public Show( _formatter = formatter; _writer = writer; Config = config; + _logger = logger; _sorter = sorter; _filters = filters?.ToList() ?? new(); } public async Task> ShowAsync(ShowArguments args, CancellationToken token = default) { + _logger.LogInformation("Running 'Show' with timeout: {TimeOut}s", Config.TimeoutSeconds); using var cts = new CancellationTokenSource(); token.Register(() => cts.Cancel()); - cts.CancelAfter(Config.TimeoutSeconds * 1000); + cts.CancelAfter(TimeSpan.FromSeconds(Config.TimeoutSeconds)); var prs = await GetPullRequests(args, cts.Token); if (prs.IsError) diff --git a/Peer.Domain/Commands/WatchShow.cs b/Peer.Domain/Commands/WatchShow.cs index e1b414f..603874e 100644 --- a/Peer.Domain/Commands/WatchShow.cs +++ b/Peer.Domain/Commands/WatchShow.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -18,7 +19,7 @@ public WatchShow(Show show, IConsoleWriter consoleWriter) public async Task> WatchAsync(ShowArguments args, CancellationToken token) { - int consecutiveFailures = 0; + var consecutiveFailures = 0; _consoleWriter.Clear(); while (!token.IsCancellationRequested) @@ -40,7 +41,7 @@ public async Task> WatchAsync(ShowArguments args, Cancel return ShowError.Fire; } - await Task.Delay(_show.Config.WatchIntervalSeconds, token); + await Task.Delay(TimeSpan.FromSeconds(_show.Config.WatchIntervalSeconds), token); } return Maybe.None; diff --git a/Peer.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index 790ebff..ef753ff 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -183,7 +183,7 @@ private static GraphQLHttpRequest ThreadPageQuery(IEnumerable RetryUntilCancelled(Func> fn, CancellationToken token) + private static async Task RetryUntilCancelled(Func> fn, CancellationToken token) { while (true) { diff --git a/Peer.GitHub/GitHubWebRegistrationHandler.cs b/Peer.GitHub/GitHubWebRegistrationHandler.cs index 492a3f7..48e28d7 100644 --- a/Peer.GitHub/GitHubWebRegistrationHandler.cs +++ b/Peer.GitHub/GitHubWebRegistrationHandler.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using GraphQL.Client.Http; using GraphQL.Client.Serializer.Newtonsoft; diff --git a/Peer/Peer.csproj b/Peer/Peer.csproj index f6b295e..b628c63 100644 --- a/Peer/Peer.csproj +++ b/Peer/Peer.csproj @@ -41,6 +41,9 @@ + + + diff --git a/Peer/Program.cs b/Peer/Program.cs index 79744e6..f71a030 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; using System.Threading; @@ -8,6 +9,7 @@ using CommandLine.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Peer.ConfigSections; using Peer.Domain; using Peer.Domain.Commands; @@ -16,6 +18,8 @@ using Peer.GitHub; using Peer.Parsing; using Peer.Verbs; +using Serilog; +using Serilog.Events; using wimm.Secundatives; namespace Peer @@ -23,6 +27,7 @@ namespace Peer public static class Program { private static readonly string _configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); + private static readonly string _logFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.log"); private static readonly Dictionary _configErrorMap = new() { @@ -44,6 +49,7 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; var setupResult = SetupServices(); + Log.Logger.Information("Setup complete with success: {Result}", setupResult.IsValue); var parser = new Parser(config => { @@ -57,18 +63,26 @@ public static async Task Main(string[] args) if (setupResult.IsError && !parseResult.Is()) { + Log.Logger.Error("Setup failed with error: {Error}", setupResult.Error); Console.Error.WriteLine(_configErrorMap[setupResult.Error]); return; } if (parseResult.Tag == ParserResultType.Parsed) { - await parseResult.MapResult( - (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), - (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), - (ConfigOptions x) => ConfigAsync(x), - (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), - err => Task.CompletedTask); + try + { + await parseResult.MapResult( + (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), + (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), + (ConfigOptions x) => ConfigAsync(x), + (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), + err => Task.CompletedTask); + } + catch (OperationCanceledException) + { + Log.Logger.Information("Caught operation cancelled exception, exiting"); + } return; } @@ -93,9 +107,9 @@ private static HelpText GetHelpText(ParserResult parserResult, public static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) { - if (opts.Sort != null) { + Log.Information("Sort option exists"); var sort = SortParser.ParseSortOption(opts.Sort); if (sort.IsError) { @@ -108,6 +122,7 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services if (opts.Filter != null) { + Log.Information("filter option exists"); foreach (var filter in opts.Filter) { var parsedFilter = FilterParser.ParseFilterOption(filter); @@ -217,10 +232,10 @@ private static Result SetupServices() // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to // expose the new settings until we've settled on some configuration patterns. // https://github.com/wareismymind/peer/issues/149 - var watchOptions = configuration.GetSection("Peer") .Get() ?? new WatchOptions(); + var showConfig = new ShowConfigSection(); if (watchOptions.WatchIntervalSeconds != null) { @@ -229,6 +244,19 @@ private static Result SetupServices() services.AddSingleton(showConfig.Into()); + var logger = new LoggerConfiguration() + .MinimumLevel.Is(LogEventLevel.Information) + .WriteTo.File(_logFile) + .Enrich.FromLogContext() + .CreateLogger(); + + Log.Logger = logger; + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger); + }); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From f68e47b73acab5318877de1822a6551b217bf572 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 21:21:40 -0300 Subject: [PATCH 17/28] chore: release 1.8.0 (#153) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ version.txt | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4910b7b..bc3574a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.8.0](https://www.github.com/wareismymind/peer/compare/v1.7.3...v1.8.0) (2022-03-30) + + +### Features + +* initial addition of loggers with some simple top level log statements (and one in show) until we figure out how we want to approach it ([#152](https://www.github.com/wareismymind/peer/issues/152)) ([15f65f2](https://www.github.com/wareismymind/peer/commit/15f65f231b72b06231aca7f937c0635e6ba95f91)) + + +### Bug Fixes + +* handle errors in show/watch ([#147](https://www.github.com/wareismymind/peer/issues/147)) ([8247867](https://www.github.com/wareismymind/peer/commit/824786781edf230f7e83032f59b0fe5aafcd567a)) + ### [1.7.3](https://www.github.com/wareismymind/peer/compare/v1.7.2...v1.7.3) (2022-01-06) diff --git a/version.txt b/version.txt index 661e7ae..27f9cd3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.3 +1.8.0 From 6a8ee49c58b911a86a218b98e228f841359b316e Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Thu, 7 Apr 2022 22:12:02 -0300 Subject: [PATCH 18/28] fix: Revert "feat: initial addition of loggers " (#154) * Revert "feat: initial addition of loggers with some simple top level log statements (and one in show) until we figure out how we want to approach it (#152)" This reverts commit 15f65f231b72b06231aca7f937c0635e6ba95f91. * use timespan instead of seconds (fix bug) --- Peer.Domain/Commands/Show.cs | 7 +-- Peer.Domain/Commands/WatchShow.cs | 5 +-- .../CommandConfigs/ShowConfig.cs | 14 +++--- Peer.Domain/Formatters/CompactFormatter.cs | 2 +- Peer.GitHub/GitHubRequestFetcher.cs | 2 +- Peer.GitHub/GitHubWebRegistrationHandler.cs | 1 - Peer/Peer.csproj | 3 -- Peer/Program.cs | 44 ++++--------------- 8 files changed, 20 insertions(+), 58 deletions(-) diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index 8d21e4c..b69b0b8 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -5,7 +5,6 @@ using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Peer.Domain.Configuration.CommandConfigs; using Peer.Domain.Exceptions; using Peer.Domain.Filters; @@ -18,7 +17,6 @@ public class Show private readonly IPullRequestService _pullRequestService; private readonly IListFormatter _formatter; private readonly IConsoleWriter _writer; - private readonly ILogger _logger; private readonly ISorter? _sorter; private readonly List _filters; @@ -29,7 +27,6 @@ public Show( IListFormatter formatter, IConsoleWriter writer, ShowConfig config, - ILogger logger, ISorter? sorter = null, IEnumerable? filters = null) { @@ -37,17 +34,15 @@ public Show( _formatter = formatter; _writer = writer; Config = config; - _logger = logger; _sorter = sorter; _filters = filters?.ToList() ?? new(); } public async Task> ShowAsync(ShowArguments args, CancellationToken token = default) { - _logger.LogInformation("Running 'Show' with timeout: {TimeOut}s", Config.TimeoutSeconds); using var cts = new CancellationTokenSource(); token.Register(() => cts.Cancel()); - cts.CancelAfter(TimeSpan.FromSeconds(Config.TimeoutSeconds)); + cts.CancelAfter(Config.TimeoutSeconds); var prs = await GetPullRequests(args, cts.Token); if (prs.IsError) diff --git a/Peer.Domain/Commands/WatchShow.cs b/Peer.Domain/Commands/WatchShow.cs index 603874e..910512a 100644 --- a/Peer.Domain/Commands/WatchShow.cs +++ b/Peer.Domain/Commands/WatchShow.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -41,7 +40,7 @@ public async Task> WatchAsync(ShowArguments args, Cancel return ShowError.Fire; } - await Task.Delay(TimeSpan.FromSeconds(_show.Config.WatchIntervalSeconds), token); + await Task.Delay(_show.Config.WatchIntervalSeconds, token); } return Maybe.None; diff --git a/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs b/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs index 2c80753..74f573a 100644 --- a/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs +++ b/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs @@ -1,4 +1,6 @@ -namespace Peer.Domain.Configuration.CommandConfigs +using System; + +namespace Peer.Domain.Configuration.CommandConfigs { public class ShowConfig { @@ -6,10 +8,8 @@ public class ShowConfig private const int _defaultWatchIntervalSeconds = 30; private const int _defaultWatchMaxConsecutiveShowFailures = 5; - public int TimeoutSeconds { get; set; } - - - public int WatchIntervalSeconds { get; set; } + public TimeSpan TimeoutSeconds { get; set; } + public TimeSpan WatchIntervalSeconds { get; set; } public int WatchMaxConsecutiveShowFailures { get; set; } @@ -18,8 +18,8 @@ public ShowConfig( int? watchIntervalSeconds, int? watchMaxConsecutiveShowFailures) { - TimeoutSeconds = timeoutSeconds ?? _defaultTimeoutSeconds; - WatchIntervalSeconds = watchIntervalSeconds ?? _defaultWatchIntervalSeconds; + TimeoutSeconds = TimeSpan.FromSeconds(timeoutSeconds ?? _defaultTimeoutSeconds); + WatchIntervalSeconds = TimeSpan.FromSeconds(watchIntervalSeconds ?? _defaultWatchIntervalSeconds); WatchMaxConsecutiveShowFailures = watchMaxConsecutiveShowFailures ?? _defaultWatchMaxConsecutiveShowFailures; } diff --git a/Peer.Domain/Formatters/CompactFormatter.cs b/Peer.Domain/Formatters/CompactFormatter.cs index 27bdf44..106f01d 100644 --- a/Peer.Domain/Formatters/CompactFormatter.cs +++ b/Peer.Domain/Formatters/CompactFormatter.cs @@ -48,7 +48,7 @@ private string CreatePullRequestLine(PullRequest pr) return $"{id} {title} {comments} {status} {pr.Url}"; } - private string PadOrTruncate(string value, int length) + private static string PadOrTruncate(string value, int length) { return value.Length > length ? value[0..(length - 1)] + _ellipsis diff --git a/Peer.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index ef753ff..790ebff 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -183,7 +183,7 @@ private static GraphQLHttpRequest ThreadPageQuery(IEnumerable RetryUntilCancelled(Func> fn, CancellationToken token) + private async Task RetryUntilCancelled(Func> fn, CancellationToken token) { while (true) { diff --git a/Peer.GitHub/GitHubWebRegistrationHandler.cs b/Peer.GitHub/GitHubWebRegistrationHandler.cs index 48e28d7..492a3f7 100644 --- a/Peer.GitHub/GitHubWebRegistrationHandler.cs +++ b/Peer.GitHub/GitHubWebRegistrationHandler.cs @@ -1,4 +1,3 @@ -using System.Diagnostics.CodeAnalysis; using System.Linq; using GraphQL.Client.Http; using GraphQL.Client.Serializer.Newtonsoft; diff --git a/Peer/Peer.csproj b/Peer/Peer.csproj index b628c63..f6b295e 100644 --- a/Peer/Peer.csproj +++ b/Peer/Peer.csproj @@ -41,9 +41,6 @@ - - - diff --git a/Peer/Program.cs b/Peer/Program.cs index f71a030..79744e6 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; using System.Threading; @@ -9,7 +8,6 @@ using CommandLine.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Peer.ConfigSections; using Peer.Domain; using Peer.Domain.Commands; @@ -18,8 +16,6 @@ using Peer.GitHub; using Peer.Parsing; using Peer.Verbs; -using Serilog; -using Serilog.Events; using wimm.Secundatives; namespace Peer @@ -27,7 +23,6 @@ namespace Peer public static class Program { private static readonly string _configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); - private static readonly string _logFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.log"); private static readonly Dictionary _configErrorMap = new() { @@ -49,7 +44,6 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; var setupResult = SetupServices(); - Log.Logger.Information("Setup complete with success: {Result}", setupResult.IsValue); var parser = new Parser(config => { @@ -63,26 +57,18 @@ public static async Task Main(string[] args) if (setupResult.IsError && !parseResult.Is()) { - Log.Logger.Error("Setup failed with error: {Error}", setupResult.Error); Console.Error.WriteLine(_configErrorMap[setupResult.Error]); return; } if (parseResult.Tag == ParserResultType.Parsed) { - try - { - await parseResult.MapResult( - (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), - (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), - (ConfigOptions x) => ConfigAsync(x), - (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), - err => Task.CompletedTask); - } - catch (OperationCanceledException) - { - Log.Logger.Information("Caught operation cancelled exception, exiting"); - } + await parseResult.MapResult( + (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), + (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), + (ConfigOptions x) => ConfigAsync(x), + (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), + err => Task.CompletedTask); return; } @@ -107,9 +93,9 @@ private static HelpText GetHelpText(ParserResult parserResult, public static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) { + if (opts.Sort != null) { - Log.Information("Sort option exists"); var sort = SortParser.ParseSortOption(opts.Sort); if (sort.IsError) { @@ -122,7 +108,6 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services if (opts.Filter != null) { - Log.Information("filter option exists"); foreach (var filter in opts.Filter) { var parsedFilter = FilterParser.ParseFilterOption(filter); @@ -232,10 +217,10 @@ private static Result SetupServices() // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to // expose the new settings until we've settled on some configuration patterns. // https://github.com/wareismymind/peer/issues/149 + var watchOptions = configuration.GetSection("Peer") .Get() ?? new WatchOptions(); - var showConfig = new ShowConfigSection(); if (watchOptions.WatchIntervalSeconds != null) { @@ -244,19 +229,6 @@ private static Result SetupServices() services.AddSingleton(showConfig.Into()); - var logger = new LoggerConfiguration() - .MinimumLevel.Is(LogEventLevel.Information) - .WriteTo.File(_logFile) - .Enrich.FromLogContext() - .CreateLogger(); - - Log.Logger = logger; - services.AddLogging(builder => - { - builder.ClearProviders(); - builder.AddSerilog(logger); - }); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); From 650402dd8028b2201de1d0e0c454786d41ba0280 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 7 Apr 2022 22:13:24 -0300 Subject: [PATCH 19/28] chore: release 1.8.1 (#155) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3574a..f49767c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.8.1](https://www.github.com/wareismymind/peer/compare/v1.8.0...v1.8.1) (2022-04-08) + + +### Bug Fixes + +* Revert "feat: initial addition of loggers " ([#154](https://www.github.com/wareismymind/peer/issues/154)) ([6a8ee49](https://www.github.com/wareismymind/peer/commit/6a8ee49c58b911a86a218b98e228f841359b316e)) + ## [1.8.0](https://www.github.com/wareismymind/peer/compare/v1.7.3...v1.8.0) (2022-03-30) diff --git a/version.txt b/version.txt index 27f9cd3..a8fdfda 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.0 +1.8.1 From 60e923d345b656f01381b9a8e288642513f4f145 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Sat, 9 Apr 2022 12:47:33 -0300 Subject: [PATCH 20/28] fix: registration was not living long enough to handle cancellation properly. Also flattened out some of the async enumerables (#156) --- Peer.Domain/Commands/IPullRequestService.cs | 2 +- Peer.Domain/Commands/Show.cs | 3 +-- Peer.Domain/Commands/WatchShow.cs | 12 ++++++++++-- Peer.Domain/IPullRequestFetcher.cs | 3 +-- Peer.Domain/PullRequestService.cs | 8 ++++---- Peer.GitHub/GitHubRequestFetcher.cs | 17 +++++++++++------ Peer/Parsing/WatchOptions.cs | 5 +---- Peer/Program.cs | 2 +- 8 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Peer.Domain/Commands/IPullRequestService.cs b/Peer.Domain/Commands/IPullRequestService.cs index 7b75fd8..3fc7e4c 100644 --- a/Peer.Domain/Commands/IPullRequestService.cs +++ b/Peer.Domain/Commands/IPullRequestService.cs @@ -7,7 +7,7 @@ namespace Peer.Domain.Commands { public interface IPullRequestService { - Task> FetchAllPullRequests(CancellationToken token = default); + IAsyncEnumerable FetchAllPullRequests(CancellationToken token = default); Task> FindSingleByPartial(PartialIdentifier partial, CancellationToken token = default); } } diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index b69b0b8..30f0b75 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reactive.Linq; using System.Threading; @@ -64,7 +63,7 @@ private async Task, ShowError>> GetPullRequests(ShowAr { try { - var prs = await _pullRequestService.FetchAllPullRequests(token); + var prs = _pullRequestService.FetchAllPullRequests(token); prs = _filters.Aggregate(prs, (prs, filter) => filter.Filter(prs)); return await (_sorter?.Sort(prs) ?? prs).Take(args.Count).ToListAsync(token); } diff --git a/Peer.Domain/Commands/WatchShow.cs b/Peer.Domain/Commands/WatchShow.cs index 910512a..8ed59e7 100644 --- a/Peer.Domain/Commands/WatchShow.cs +++ b/Peer.Domain/Commands/WatchShow.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -40,7 +41,14 @@ public async Task> WatchAsync(ShowArguments args, Cancel return ShowError.Fire; } - await Task.Delay(_show.Config.WatchIntervalSeconds, token); + try + { + await Task.Delay(_show.Config.WatchIntervalSeconds, token); + } + catch (OperationCanceledException) + { + break; + } } return Maybe.None; diff --git a/Peer.Domain/IPullRequestFetcher.cs b/Peer.Domain/IPullRequestFetcher.cs index 8465b1d..60d29bf 100644 --- a/Peer.Domain/IPullRequestFetcher.cs +++ b/Peer.Domain/IPullRequestFetcher.cs @@ -1,11 +1,10 @@ using System.Collections.Generic; using System.Threading; -using System.Threading.Tasks; namespace Peer.Domain { public interface IPullRequestFetcher { - Task> GetPullRequestsAsync(CancellationToken token = default); + IAsyncEnumerable GetPullRequestsAsync(CancellationToken token = default); } } diff --git a/Peer.Domain/PullRequestService.cs b/Peer.Domain/PullRequestService.cs index 02ae8d1..abe814d 100644 --- a/Peer.Domain/PullRequestService.cs +++ b/Peer.Domain/PullRequestService.cs @@ -16,17 +16,17 @@ public PullRequestService(IEnumerable fetchers) _fetchers = fetchers.ToList(); } - public Task> FetchAllPullRequests(CancellationToken token = default) + public IAsyncEnumerable FetchAllPullRequests(CancellationToken token = default) { - var prIterator = _fetchers.ToAsyncEnumerable().SelectManyAwait(async x => await x.GetPullRequestsAsync(token)); - return Task.FromResult(prIterator); + var prIterator = _fetchers.ToAsyncEnumerable().SelectMany(x => x.GetPullRequestsAsync(token)); + return prIterator; } public async Task> FindSingleByPartial(PartialIdentifier partial, CancellationToken token = default) { Validators.ArgIsNotNull(partial); - var prs = await FetchAllPullRequests(token); + var prs = FetchAllPullRequests(token); var matches = await prs.Where(x => x.Identifier.IsMatch(partial)).ToListAsync(token); diff --git a/Peer.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index 790ebff..b7a1468 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -17,13 +17,13 @@ namespace Peer.GitHub { - public class GitHubRequestFetcher : IPullRequestFetcher + public sealed class GitHubRequestFetcher : IPullRequestFetcher, IDisposable { private readonly GraphQLHttpClient _gqlClient; private readonly GitHubPeerConfig _config; private readonly AsyncLazy _username; private readonly CancellationTokenSource _cts = new(); - + private CancellationTokenRegistration _registration; public GitHubRequestFetcher( GraphQLHttpClient client, GitHubPeerConfig gitHubPeerConfig) @@ -33,10 +33,10 @@ public GitHubRequestFetcher( _username = new AsyncLazy(() => GetUsername(_cts.Token)); } - public Task> GetPullRequestsAsync(CancellationToken token = default) + public IAsyncEnumerable GetPullRequestsAsync(CancellationToken token = default) { - using var registration = token.Register(() => _cts.Cancel()); - return Task.FromResult(GetPullRequestsImpl(token)); + _registration = token.Register(() => _cts.Cancel()); + return GetPullRequestsImpl(token); } private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCancellation] CancellationToken token) @@ -183,7 +183,7 @@ private static GraphQLHttpRequest ThreadPageQuery(IEnumerable RetryUntilCancelled(Func> fn, CancellationToken token) + private static async Task RetryUntilCancelled(Func> fn, CancellationToken token) { while (true) { @@ -198,5 +198,10 @@ private async Task RetryUntilCancelled(Func> fn, CancellationToken catch (Exception ex) when (ex is GraphQLHttpRequestException || ex is HttpRequestException) { } } } + + public void Dispose() + { + _registration.Dispose(); + } } } diff --git a/Peer/Parsing/WatchOptions.cs b/Peer/Parsing/WatchOptions.cs index f74a21b..ae261c5 100644 --- a/Peer/Parsing/WatchOptions.cs +++ b/Peer/Parsing/WatchOptions.cs @@ -1,7 +1,4 @@ -using System; -using Peer.Domain.Commands; - -namespace Peer.Parsing +namespace Peer.Parsing { public class WatchOptions { diff --git a/Peer/Program.cs b/Peer/Program.cs index 79744e6..f1c5f38 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -217,10 +217,10 @@ private static Result SetupServices() // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to // expose the new settings until we've settled on some configuration patterns. // https://github.com/wareismymind/peer/issues/149 - var watchOptions = configuration.GetSection("Peer") .Get() ?? new WatchOptions(); + var showConfig = new ShowConfigSection(); if (watchOptions.WatchIntervalSeconds != null) { From 748d2f7a7a072e1d1c69aeb2a66cba8d65ec6ffc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 Apr 2022 13:27:55 -0300 Subject: [PATCH 21/28] chore: release 1.8.2 (#157) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f49767c..cebe8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.8.2](https://www.github.com/wareismymind/peer/compare/v1.8.1...v1.8.2) (2022-04-09) + + +### Bug Fixes + +* registration was not living long enough to handle cancellation properly. Also flattened out some of the async enumerables ([#156](https://www.github.com/wareismymind/peer/issues/156)) ([60e923d](https://www.github.com/wareismymind/peer/commit/60e923d345b656f01381b9a8e288642513f4f145)) + ### [1.8.1](https://www.github.com/wareismymind/peer/compare/v1.8.0...v1.8.1) (2022-04-08) diff --git a/version.txt b/version.txt index a8fdfda..53adb84 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.1 +1.8.2 From cdd9db4184e9226e6638059d6cd56862eb46cf14 Mon Sep 17 00:00:00 2001 From: Thomas Showers <68649564+tshowers-bt@users.noreply.github.com> Date: Fri, 6 May 2022 15:46:38 -0300 Subject: [PATCH 22/28] feat: configurable show timeout (#160) * feat: configurable show timeout Adds a configurable show timeout to the existing configuration format. There is already a TODO to reconsider the configuration shape so this is a quick/temporary solution until that thinking is done. Adds a specific error message for timeouts. * docs: update comments in config example * docs: mention timeout setting in error message * Update Peer.Domain/Commands/Config.cs Co-authored-by: Chris Nantau Co-authored-by: Chris Nantau --- Peer.Domain/Commands/Config.cs | 31 +++++++++++-------- Peer.Domain/Commands/Show.cs | 6 +++- .../{WatchOptions.cs => PeerOptions.cs} | 3 +- Peer/Program.cs | 5 +-- 4 files changed, 28 insertions(+), 17 deletions(-) rename Peer/Parsing/{WatchOptions.cs => PeerOptions.cs} (54%) diff --git a/Peer.Domain/Commands/Config.cs b/Peer.Domain/Commands/Config.cs index fa265c0..01bd678 100644 --- a/Peer.Domain/Commands/Config.cs +++ b/Peer.Domain/Commands/Config.cs @@ -11,27 +11,32 @@ public class Config private const string _configHelp = @" { ""Peer"": { - //optional: The amount of time between calls to providers when using the --watch flag - ""WatchIntervalSeconds"": 30 + // The amount of time to wait for the show command to fetch pull request info (default: 10) + ""ShowTimeoutSeconds"": 30 + // The amount of time between calls to providers when using the --watch flag (default: 30) + ""WatchIntervalSeconds"": 15 }, + // Pull request provider configurations organized by type (currently there's only github!) ""Providers"": { - //The type of the provider you're configuring (currently there's only github!) + // A list of GitHub pull request provider configurations ""github"": [{ - //required: a friendly name for this provider - ""Name"": ""required: a friendly name for this provider"", + // A friendly name for this provider (required) + ""Name"": ""GitHub-Work"", ""Configuration"": { - //required: your API token + // An API token with permission to read issues (required) + // You will need to configure SSO on the PAT to see pull requests from organizations that require it ""AccessToken"": """", - //optional: the github username you're interested in investigating, alternatively we'll fetch yours from the api + // The GitHub username you're interested in investigating (optional) + // If not provided we'll fetch the username associated with the AccessToken from the API ""Username"": """", - //optional: Orgs can be either be traditional (github, wareismymind) or a username for user's repos - // if left empty we'll look at all orgs available to your user + // A list of organizations or other GitHub users whose repos to include pull requests from (default: []) + // If the list is empty then we'll include all visible requests regardless of the repo owner ""Orgs"": [""myorg"", ""wareismymind"", ""someuser""], - //optional: Orgs that you'd like to exclude from the output, only really makes sense if no orgs are set + // A list of organizations or other GitHub users whose repos will be excluded when searching for pull requests (default: []) + // Use this option as an alternative to `Orgs` ""ExcludedOrgs"": [], - //optional: indicates the number of pull requests that will be listed, should be number between 1 and 100. - // if not provided will default to 20. - ""Count"": 20 + // The number of pull requests to include in your search results (min: 1, max: 100, default: 20) + ""Count"": 30 } }] } diff --git a/Peer.Domain/Commands/Show.cs b/Peer.Domain/Commands/Show.cs index 30f0b75..85a0fa3 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -49,7 +49,11 @@ public async Task> ShowAsync(ShowArguments args, Cancell _writer.Clear(); _writer.Display(new List { - $"error: failed to fetch pull request info", + prs.Error switch + { + ShowError.Timeout => "error: timeout fetching pull request info; consider increasing the 'ShowTimeoutSeconds' in your peer config", + _ => "error: failed to fetch pull request info", + }, }, token); return prs.Error; } diff --git a/Peer/Parsing/WatchOptions.cs b/Peer/Parsing/PeerOptions.cs similarity index 54% rename from Peer/Parsing/WatchOptions.cs rename to Peer/Parsing/PeerOptions.cs index ae261c5..1ead333 100644 --- a/Peer/Parsing/WatchOptions.cs +++ b/Peer/Parsing/PeerOptions.cs @@ -1,7 +1,8 @@ namespace Peer.Parsing { - public class WatchOptions + public class PeerOptions { + public int? ShowTimeoutSeconds { get; set; } public int? WatchIntervalSeconds { get; set; } } } diff --git a/Peer/Program.cs b/Peer/Program.cs index f1c5f38..b3f09ed 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -218,13 +218,14 @@ private static Result SetupServices() // expose the new settings until we've settled on some configuration patterns. // https://github.com/wareismymind/peer/issues/149 var watchOptions = configuration.GetSection("Peer") - .Get() - ?? new WatchOptions(); + .Get() + ?? new PeerOptions(); var showConfig = new ShowConfigSection(); if (watchOptions.WatchIntervalSeconds != null) { showConfig.WatchIntervalSeconds = watchOptions.WatchIntervalSeconds; + showConfig.TimeoutSeconds = watchOptions.ShowTimeoutSeconds; } services.AddSingleton(showConfig.Into()); From 84d884b9126f8482fcf75b90fbe565275d92af19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 May 2022 10:15:36 -0300 Subject: [PATCH 23/28] chore: release 1.9.0 (#162) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cebe8ec..9ba9aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.9.0](https://www.github.com/wareismymind/peer/compare/v1.8.2...v1.9.0) (2022-05-06) + + +### Features + +* configurable show timeout ([#160](https://www.github.com/wareismymind/peer/issues/160)) ([cdd9db4](https://www.github.com/wareismymind/peer/commit/cdd9db4184e9226e6638059d6cd56862eb46cf14)) + ### [1.8.2](https://www.github.com/wareismymind/peer/compare/v1.8.1...v1.8.2) (2022-04-09) diff --git a/version.txt b/version.txt index 53adb84..f8e233b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.8.2 +1.9.0 From dd71f6f8f7b7957f681131ab9f23cb70d71dc60b Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Sat, 8 Oct 2022 11:20:42 -0300 Subject: [PATCH 24/28] fix: add docs for filter, cleanup sorting and other text (#164) OVERRRIDE - Preapproved diffs --- Peer/Verbs/ShowHelpTextFormatter.cs | 38 ++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/Peer/Verbs/ShowHelpTextFormatter.cs b/Peer/Verbs/ShowHelpTextFormatter.cs index 7aa6d8a..e1c43f8 100644 --- a/Peer/Verbs/ShowHelpTextFormatter.cs +++ b/Peer/Verbs/ShowHelpTextFormatter.cs @@ -20,8 +20,14 @@ public HelpText GetHelpText(ParserResult parserResult) { var help = HelpText.AutoBuild(parserResult); help.AddPreOptionsLine("Shows a list of your pull requests and their statuses with applied transforms"); - help.AddPostOptionsLine("The symbols in the show command and their meanings are shown below:"); - help.AddPostOptionsLine(""); + + help.AddPostOptionsLines(new[] + { + "-- SYMBOLS --", + "", + "The symbols in the show command and their meanings are shown below:", + "" + }); var statuses = Enum.GetValues(); var maxWidth = statuses.Max(x => x.ToString().Length); @@ -29,11 +35,35 @@ public HelpText GetHelpText(ParserResult parserResult) help.AddPostOptionsLines(statuses.Select(x => $" {x.ToString().PadRight(maxWidth)} => {_symbolProvider.GetSymbol(x)}")); help.AddPostOptionsLine(""); - help.AddPostOptionsLine("The sort option can use a number of different keys and default to ascending order. The avaliable keys are:"); - help.AddPostOptionsLine(""); + help.AddPostOptionsLines(new[] + { + "-- SORTING --", + "", + "The sort option can use a number of different keys and default to ascending order. The avaliable keys are:", + "" + }); + help.AddPostOptionsLines(SelectorMap.Keys.Select(x => $" {x}")); help.AddPostOptionsLine(""); + help.AddPostOptionsLines(new[] + { + "-- FILTERING --", + "", + "Filtering can be done on any of the sort keys. The filter syntax is: 'key:value', negation is provided by prepending a '!' before the filter key.", + "", + " - Numbers can be matched only for equality (or inequality)", + " - Strings will be matched by regex", + " - The pull request status filter is an enum based filter and supports all of the statuses from the previous section." + }); + help.AddPostOptionsLine(""); + help.AddPostOptionsLine("The filter key types are listed below:"); + help.AddPostOptionsLine(""); + maxWidth = SelectorMap.Keys.Max(x => x.Length); + help.AddPostOptionsLines(SelectorMap.Keys.Select(x => $" {x.PadRight(maxWidth)} => {SelectorMap.TryGetSelector(x).Value.ReturnType.Name}")); + help.AddPostOptionsLine(""); + help.AddPostOptionsLine(""); + return help; } } From 4cb1d815d37ee81c6f0ea37959a4a51221939654 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 8 Oct 2022 11:23:28 -0300 Subject: [PATCH 25/28] chore: release 1.9.1 (#165) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba9aa9..f85f80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +### [1.9.1](https://www.github.com/wareismymind/peer/compare/v1.9.0...v1.9.1) (2022-10-08) + + +### Bug Fixes + +* add docs for filter, cleanup sorting and other text ([#164](https://www.github.com/wareismymind/peer/issues/164)) ([dd71f6f](https://www.github.com/wareismymind/peer/commit/dd71f6f8f7b7957f681131ab9f23cb70d71dc60b)) + ## [1.9.0](https://www.github.com/wareismymind/peer/compare/v1.8.2...v1.9.0) (2022-05-06) diff --git a/version.txt b/version.txt index f8e233b..9ab8337 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.0 +1.9.1 From 9d5f33ba7e9a8f8b21b4949db0d93ead75dd7774 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Sun, 23 Oct 2022 15:53:16 -0300 Subject: [PATCH 26/28] feat: add config subcommands (and move to an app builder setup) (#166) * feat: wrap all the comand stuff into a builder so we can reduce some of the complexity and do subcomands because they're unsupported * re-org * Swap to return codes for handlers for now * add some poistive tests on exit code for now * feat: rearrange a bit, separate parsing from running * pull out TextResults into their own files * fix configuration * Verbs must have either a handler or subs * formatting pass * move handlers into handler namespace * more local tests and remove public from setup handler * Add the file location to the config info * feat: config edit command (#167) * initial impl of the config edit verb I've included support for EDITOR with a fallback to the default OS provider * formatting pass * classes into their own files * add runtime config per command for registering handlers * pull ProviderLoader from VerbBuilder * cleanup parsing and handle more cases, remove some code from try --- Peer.Domain/Commands/ConfigEdit.cs | 76 ++++++ Peer.Domain/Commands/ConfigEditConfig.cs | 13 ++ Peer.Domain/Commands/ConfigEditError.cs | 8 + Peer.Domain/Commands/Open.cs | 1 + .../Configuration/ConfigurationService.cs | 10 +- .../Configuration/IConfigurationService.cs | 2 +- .../Configuration/IRegistrationHandler.cs | 3 +- Peer.Domain/FileOperations.cs | 16 ++ Peer.Domain/IFileOperations.cs | 9 + .../{Commands => }/IPullRequestService.cs | 3 +- Peer.Domain/PullRequestService.cs | 3 +- Peer.Domain/Util/IOSInfoProvider.cs | 2 +- Peer.Domain/Util/OSInfoProvider.cs | 2 +- Peer.GitHub/GitHubWebRegistrationHandler.cs | 14 +- Peer.UnitTests/Apps/AppBuilderTests.cs | 116 +++++++++ Peer.UnitTests/Apps/AppTests.cs | 42 ++++ Peer.UnitTests/Apps/CommandLineParserTests.cs | 9 + Peer.UnitTests/Apps/VerbBuilderTests.cs | 24 ++ Peer.UnitTests/Apps/VerbTests.cs | 9 + .../GithubPeerConfigDtoTests.cs | 0 Peer.UnitTests/Parsing/FilterParserTests.cs | 1 + Peer.UnitTests/PartialIdentifierTests.cs | 1 + Peer.UnitTests/Util/ResultAsserts.cs | 2 +- Peer/Apps/ActionHandler.cs | 22 ++ Peer/Apps/App.cs | 91 ++++++++ Peer/Apps/AppBuilder/AppBuilder.cs | 64 +++++ Peer/Apps/AppBuilder/Command.cs | 13 ++ Peer/Apps/AppBuilder/CommandLineParser.cs | 93 ++++++++ Peer/Apps/AppBuilder/HandlerWrapper.cs | 26 +++ Peer/Apps/AppBuilder/Help.cs | 8 + Peer/Apps/AppBuilder/IRunTimeConfigHandler.cs | 16 ++ Peer/Apps/AppBuilder/RunTimeConfigMapping.cs | 21 ++ Peer/Apps/AppBuilder/SubVerbBuilder.cs | 10 + Peer/Apps/AppBuilder/TextResult.cs | 11 + Peer/Apps/AppBuilder/UsageError.cs | 8 + Peer/Apps/AppBuilder/VerbBuilder.cs | 56 +++++ Peer/Apps/AppBuilder/Version.cs | 9 + Peer/Apps/FuncServiceSetupHandler.cs | 21 ++ Peer/Apps/IHandler.cs | 17 ++ Peer/Apps/IServiceSetupHandler.cs | 10 + Peer/Apps/IVerb.cs | 17 ++ Peer/Apps/SubVerb.cs | 21 ++ Peer/Apps/Verb.cs | 49 ++++ Peer/Apps/VerbExtensions.cs | 18 ++ Peer/ConfigSections/Constants.cs | 10 + Peer/Handlers/ConfigEditHandler.cs | 30 +++ .../Handlers/ConfigInitHandler.cs | 38 +-- Peer/Handlers/ConfigShowHandler.cs | 31 +++ Peer/Peer.csproj | 1 + Peer/Program.cs | 220 +++++++++--------- Peer/Properties/launchSettings.json | 2 +- Peer/ProviderLoader.cs | 25 ++ Peer/Verbs/ConfigOptions.cs | 16 ++ Peer/Verbs/DetailsHelpTextFormatter.cs | 2 +- Peer/Verbs/IHelpTextFormatter.cs | 6 +- Peer/Verbs/SubVerbHandler.cs | 14 ++ run-locals.ps1 | 85 +++++++ 57 files changed, 1298 insertions(+), 149 deletions(-) create mode 100644 Peer.Domain/Commands/ConfigEdit.cs create mode 100644 Peer.Domain/Commands/ConfigEditConfig.cs create mode 100644 Peer.Domain/Commands/ConfigEditError.cs create mode 100644 Peer.Domain/FileOperations.cs create mode 100644 Peer.Domain/IFileOperations.cs rename Peer.Domain/{Commands => }/IPullRequestService.cs (88%) create mode 100644 Peer.UnitTests/Apps/AppBuilderTests.cs create mode 100644 Peer.UnitTests/Apps/AppTests.cs create mode 100644 Peer.UnitTests/Apps/CommandLineParserTests.cs create mode 100644 Peer.UnitTests/Apps/VerbBuilderTests.cs create mode 100644 Peer.UnitTests/Apps/VerbTests.cs rename Peer.UnitTests/{Github => GitHub}/GithubPeerConfigDtoTests.cs (100%) create mode 100644 Peer/Apps/ActionHandler.cs create mode 100644 Peer/Apps/App.cs create mode 100644 Peer/Apps/AppBuilder/AppBuilder.cs create mode 100644 Peer/Apps/AppBuilder/Command.cs create mode 100644 Peer/Apps/AppBuilder/CommandLineParser.cs create mode 100644 Peer/Apps/AppBuilder/HandlerWrapper.cs create mode 100644 Peer/Apps/AppBuilder/Help.cs create mode 100644 Peer/Apps/AppBuilder/IRunTimeConfigHandler.cs create mode 100644 Peer/Apps/AppBuilder/RunTimeConfigMapping.cs create mode 100644 Peer/Apps/AppBuilder/SubVerbBuilder.cs create mode 100644 Peer/Apps/AppBuilder/TextResult.cs create mode 100644 Peer/Apps/AppBuilder/UsageError.cs create mode 100644 Peer/Apps/AppBuilder/VerbBuilder.cs create mode 100644 Peer/Apps/AppBuilder/Version.cs create mode 100644 Peer/Apps/FuncServiceSetupHandler.cs create mode 100644 Peer/Apps/IHandler.cs create mode 100644 Peer/Apps/IServiceSetupHandler.cs create mode 100644 Peer/Apps/IVerb.cs create mode 100644 Peer/Apps/SubVerb.cs create mode 100644 Peer/Apps/Verb.cs create mode 100644 Peer/Apps/VerbExtensions.cs create mode 100644 Peer/ConfigSections/Constants.cs create mode 100644 Peer/Handlers/ConfigEditHandler.cs rename Peer.Domain/Commands/Config.cs => Peer/Handlers/ConfigInitHandler.cs (65%) create mode 100644 Peer/Handlers/ConfigShowHandler.cs create mode 100644 Peer/ProviderLoader.cs create mode 100644 Peer/Verbs/SubVerbHandler.cs create mode 100644 run-locals.ps1 diff --git a/Peer.Domain/Commands/ConfigEdit.cs b/Peer.Domain/Commands/ConfigEdit.cs new file mode 100644 index 0000000..fa7d95b --- /dev/null +++ b/Peer.Domain/Commands/ConfigEdit.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Peer.Domain.Util; +using wimm.Secundatives; +using wimm.Secundatives.Extensions; + +namespace Peer.Domain.Commands; + +public class ConfigEdit +{ + private readonly IOSInfoProvider _infoProvider; + private readonly ConfigEditConfig _config; + private readonly IFileOperations _fileOps; + + public ConfigEdit(ConfigEditConfig config, IOSInfoProvider infoProvider, IFileOperations fileOps) + { + _config = config; + _infoProvider = infoProvider; + _fileOps = fileOps; + } + + public Task> RunAsync() + { + return OpenFileAsync(_config.ConfigPath); + } + + private async Task> OpenFileAsync(string path) + { + if (!_fileOps.Exists(path)) + { + await using var _ = File.Create(path); + } + + var proc = _config.Editor == null + ? OpenWithOsDefault(path) + : OpenWithEditor(path); + + + if (proc.IsError) + { + return proc.Error; + } + + await proc.Value!.WaitForExitAsync(); + return Maybe.None; + } + + private Result OpenWithEditor(string path) + { + var split = _config.Editor!.Split(' ').ToArray(); + return Process.Start(split[0], split[1..].Append(path)); + } + + + private Result OpenWithOsDefault(string path) + { + //info(cn): See https://github.com/dotnet/runtime/issues/17938 + return _infoProvider.GetPlatform() + .OkOr(ConfigEditError.UnsupportedOs) + .Map( + os => os switch + { + _ when os == OSPlatform.Windows => Process.Start( + new ProcessStartInfo { UseShellExecute = true, FileName = path })!.AsMaybe() + .OkOr(() => ConfigEditError.ProcessFailedToOpen), + _ when os == OSPlatform.Linux => new Result( + Process.Start("xdg-open", path)), + _ when os == OSPlatform.OSX => new Result(Process.Start("open", path)), + _ => new Result(ConfigEditError.UnsupportedOs) + }) + .Flatten(); + } +} diff --git a/Peer.Domain/Commands/ConfigEditConfig.cs b/Peer.Domain/Commands/ConfigEditConfig.cs new file mode 100644 index 0000000..a98a8d1 --- /dev/null +++ b/Peer.Domain/Commands/ConfigEditConfig.cs @@ -0,0 +1,13 @@ +namespace Peer.Domain.Commands; + +public class ConfigEditConfig +{ + public string? Editor { get; } + public string ConfigPath { get; } + + public ConfigEditConfig(string? editor, string configPath) + { + Editor = editor; + ConfigPath = configPath; + } +} diff --git a/Peer.Domain/Commands/ConfigEditError.cs b/Peer.Domain/Commands/ConfigEditError.cs new file mode 100644 index 0000000..456dd48 --- /dev/null +++ b/Peer.Domain/Commands/ConfigEditError.cs @@ -0,0 +1,8 @@ +namespace Peer.Domain.Commands; + +public enum ConfigEditError +{ + Fire, + UnsupportedOs, + ProcessFailedToOpen, +} diff --git a/Peer.Domain/Commands/Open.cs b/Peer.Domain/Commands/Open.cs index c4995b5..b279e7f 100644 --- a/Peer.Domain/Commands/Open.cs +++ b/Peer.Domain/Commands/Open.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Peer.Domain.Exceptions; +using Peer.Domain.Util; using wimm.Secundatives; namespace Peer.Domain.Commands diff --git a/Peer.Domain/Configuration/ConfigurationService.cs b/Peer.Domain/Configuration/ConfigurationService.cs index 5f07f89..57c49ff 100644 --- a/Peer.Domain/Configuration/ConfigurationService.cs +++ b/Peer.Domain/Configuration/ConfigurationService.cs @@ -12,15 +12,17 @@ namespace Peer.Domain.Configuration public class ConfigurationService : IConfigurationService { private readonly Dictionary _handlers; + private readonly IConfiguration _config; - public ConfigurationService(IEnumerable handlers) + public ConfigurationService(IEnumerable handlers, IConfiguration config) { _handlers = handlers.ToDictionary(x => x.ProviderKey, x => x); + _config = config; } - public Result RegisterProvidersForConfiguration(IConfiguration configuration, IServiceCollection services) + public Result RegisterProvidersForConfiguration(IServiceCollection services) { - var providerTypes = configuration.GetSection("Providers").GetChildren(); + var providerTypes = _config.GetSection("Providers").GetChildren(); if (!providerTypes.Any()) { @@ -34,7 +36,7 @@ public Result RegisterProvidersForConfiguration(IConfiguratio return ConfigError.ProviderNotMatched; } - return handler.Register(providerConfig) + return handler.Register(providerConfig, services) .MapError(err => err switch { RegistrationError.BadConfig => ConfigError.InvalidProviderValues, diff --git a/Peer.Domain/Configuration/IConfigurationService.cs b/Peer.Domain/Configuration/IConfigurationService.cs index d9c0a7e..899bd04 100644 --- a/Peer.Domain/Configuration/IConfigurationService.cs +++ b/Peer.Domain/Configuration/IConfigurationService.cs @@ -6,6 +6,6 @@ namespace Peer.Domain.Configuration { public interface IConfigurationService { - Result RegisterProvidersForConfiguration(IConfiguration configuration, IServiceCollection services); + Result RegisterProvidersForConfiguration(IServiceCollection services); } } diff --git a/Peer.Domain/Configuration/IRegistrationHandler.cs b/Peer.Domain/Configuration/IRegistrationHandler.cs index f715a76..f393784 100644 --- a/Peer.Domain/Configuration/IRegistrationHandler.cs +++ b/Peer.Domain/Configuration/IRegistrationHandler.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using wimm.Secundatives; namespace Peer.Domain.Configuration @@ -6,6 +7,6 @@ namespace Peer.Domain.Configuration public interface IRegistrationHandler { string ProviderKey { get; } - Result Register(IConfigurationSection config); + Result Register(IConfigurationSection config, IServiceCollection services); } } diff --git a/Peer.Domain/FileOperations.cs b/Peer.Domain/FileOperations.cs new file mode 100644 index 0000000..6c45218 --- /dev/null +++ b/Peer.Domain/FileOperations.cs @@ -0,0 +1,16 @@ +using System.IO; + +namespace Peer.Domain; + +public class FileOperations : IFileOperations +{ + public bool Exists(string path) + { + return File.Exists(path); + } + + public FileStream Create(string path) + { + return File.Create(path); + } +} diff --git a/Peer.Domain/IFileOperations.cs b/Peer.Domain/IFileOperations.cs new file mode 100644 index 0000000..ed56bdf --- /dev/null +++ b/Peer.Domain/IFileOperations.cs @@ -0,0 +1,9 @@ +using System.IO; + +namespace Peer.Domain; + +public interface IFileOperations +{ + bool Exists(string path); + FileStream Create(string path); +} diff --git a/Peer.Domain/Commands/IPullRequestService.cs b/Peer.Domain/IPullRequestService.cs similarity index 88% rename from Peer.Domain/Commands/IPullRequestService.cs rename to Peer.Domain/IPullRequestService.cs index 3fc7e4c..0f1e778 100644 --- a/Peer.Domain/Commands/IPullRequestService.cs +++ b/Peer.Domain/IPullRequestService.cs @@ -1,9 +1,10 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Peer.Domain.Commands; using wimm.Secundatives; -namespace Peer.Domain.Commands +namespace Peer.Domain { public interface IPullRequestService { diff --git a/Peer.Domain/PullRequestService.cs b/Peer.Domain/PullRequestService.cs index abe814d..fd7eddf 100644 --- a/Peer.Domain/PullRequestService.cs +++ b/Peer.Domain/PullRequestService.cs @@ -2,10 +2,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Peer.Domain.Commands; using Peer.Domain.Util; using wimm.Secundatives; -namespace Peer.Domain.Commands +namespace Peer.Domain { public class PullRequestService : IPullRequestService { diff --git a/Peer.Domain/Util/IOSInfoProvider.cs b/Peer.Domain/Util/IOSInfoProvider.cs index dbc5ce1..bddcdd9 100644 --- a/Peer.Domain/Util/IOSInfoProvider.cs +++ b/Peer.Domain/Util/IOSInfoProvider.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; using wimm.Secundatives; -namespace Peer.Domain +namespace Peer.Domain.Util { public interface IOSInfoProvider { diff --git a/Peer.Domain/Util/OSInfoProvider.cs b/Peer.Domain/Util/OSInfoProvider.cs index 0c3aa88..3f5c8ea 100644 --- a/Peer.Domain/Util/OSInfoProvider.cs +++ b/Peer.Domain/Util/OSInfoProvider.cs @@ -1,7 +1,7 @@ using System.Runtime.InteropServices; using wimm.Secundatives; -namespace Peer.Domain +namespace Peer.Domain.Util { public class OSInfoProvider : IOSInfoProvider { diff --git a/Peer.GitHub/GitHubWebRegistrationHandler.cs b/Peer.GitHub/GitHubWebRegistrationHandler.cs index 492a3f7..aebe0ca 100644 --- a/Peer.GitHub/GitHubWebRegistrationHandler.cs +++ b/Peer.GitHub/GitHubWebRegistrationHandler.cs @@ -14,15 +14,9 @@ namespace Peer.GitHub { public class GitHubWebRegistrationHandler : IRegistrationHandler { - private readonly IServiceCollection _services; public string ProviderKey => ProviderConstants.Github; - public GitHubWebRegistrationHandler(IServiceCollection services) - { - _services = services; - } - - public Result Register(IConfigurationSection config) + public Result Register(IConfigurationSection config, IServiceCollection services) { var childConfigs = config.GetChildren().Select(x => x.Get()) .Select(x => x.Into()) @@ -36,21 +30,21 @@ public Result Register(IConfigurationSection config) foreach (var gitHubConfig in childConfigs.Value) { - _services.AddSingleton(sp => + services.AddSingleton(sp => { var client = new GraphQLHttpClient("https://api.github.com/graphql", new NewtonsoftJsonSerializer()); client.HttpClient.DefaultRequestHeaders.Authorization = new("Bearer", gitHubConfig.AccessToken); return new NamedGraphQLBinding(gitHubConfig.Name, client); }); - _services.AddSingleton(sp => + services.AddSingleton(sp => { var client = sp.GetRequiredService().GetClient(gitHubConfig.Name); return new GitHubRequestFetcher(client, gitHubConfig); }); } - _services.TryAddSingleton(); + services.TryAddSingleton(); return Maybe.None; } diff --git a/Peer.UnitTests/Apps/AppBuilderTests.cs b/Peer.UnitTests/Apps/AppBuilderTests.cs new file mode 100644 index 0000000..ae1bd83 --- /dev/null +++ b/Peer.UnitTests/Apps/AppBuilderTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Peer.Apps; +using Peer.Apps.AppBuilder; +using Peer.Domain.Configuration; +using wimm.Secundatives; +using Xunit; + +namespace Peer.UnitTests.Apps; + +public class AppBuilderTests +{ + private IServiceCollection _services; + + public class WithParseTimeConfig : AppBuilderTests + { + [Fact] + public void ConfigIsCalledWithoutRunningApp() + { + var called = false; + var underTest = Construct() + .WithParseTimeServiceConfig( + sc => + { + called = true; + return new Result(sc.AddSingleton(100)); + }); + + underTest.WithVerb(x => x.WithHandler()); + underTest.Build(); + Assert.True(called); + } + } + + public class WithSharedServiceConfig : AppBuilderTests + { + [Fact] + public void ConfigIsNotCalledWhenAppBuilt() + { + var called = false; + var underTest = Construct(); + underTest.WithSharedServiceConfig( + sp => + { + called = true; + return new Result(sp); + }); + + underTest.WithVerb(x => x.WithHandler()); + var _ = underTest.Build(); + + Assert.False(called); + } + + [Fact] + public async Task ConfigIsCalledWhenAppIsRun() + { + var called = false; + var underTest = Construct(); + underTest.WithSharedServiceConfig( + sp => + { + called = true; + return new Result(sp); + }); + + underTest.WithVerb(x => x.WithHandler()); + var built = underTest.Build(); + + await built.RunAsync(new[] { "doot" }); + + Assert.True(called); + } + } + + public class WithVerb : AppBuilderTests + { + [Fact] + public void TypeIsNotAnnotatedWithVerbAttribute_Throws() + { + var underTest = Construct(); + Assert.Throws(() => underTest.WithVerb(_ => { })); + } + + [Fact] + public void TypeIsAnnotatedWithVerbAttribute_Succeeds() + { + var underTest = Construct(); + underTest.WithVerb(_ => { }); + } + } + + + private AppBuilder Construct() + { + _services = new ServiceCollection(); + return new AppBuilder(_services); + } + + [Verb("doot", isDefault: true, HelpText = "The joyous sound of a skeleton")] + internal class Doot + { + + } + + internal class DoNothing : IHandler + { + public Task HandleAsync(Doot opts, IServiceCollection services, CancellationToken token = default) + { + return Task.FromResult(0); + } + } +} diff --git a/Peer.UnitTests/Apps/AppTests.cs b/Peer.UnitTests/Apps/AppTests.cs new file mode 100644 index 0000000..2fffacf --- /dev/null +++ b/Peer.UnitTests/Apps/AppTests.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Moq; +using Peer.Apps; +using Peer.Apps.AppBuilder; +using Xunit; + +namespace Peer.UnitTests.Apps; + +public class AppTests +{ + public class Construct + { + private readonly Mock _verb = new(); + private readonly Mock _parser = new(); + private readonly Mock _config = new(); + + [Fact] + public void Constructs() + { + var verbs = new List { _verb.Object }; + _ = new App(_parser.Object, verbs, _config.Object); + } + + [Fact] + public void VerbsEmpty_Throws() + { + Assert.Throws( + () => new App(_parser.Object, new List(), _config.Object)); + } + } + + public class RunAsync + { + public static async Task ParseFails_ReturnsNonZero() + { + + } + } +} diff --git a/Peer.UnitTests/Apps/CommandLineParserTests.cs b/Peer.UnitTests/Apps/CommandLineParserTests.cs new file mode 100644 index 0000000..9b05352 --- /dev/null +++ b/Peer.UnitTests/Apps/CommandLineParserTests.cs @@ -0,0 +1,9 @@ +namespace Peer.UnitTests.Apps; + +public class CommandLineParserTests +{ + public class Parse + { + + } +} diff --git a/Peer.UnitTests/Apps/VerbBuilderTests.cs b/Peer.UnitTests/Apps/VerbBuilderTests.cs new file mode 100644 index 0000000..ee3933b --- /dev/null +++ b/Peer.UnitTests/Apps/VerbBuilderTests.cs @@ -0,0 +1,24 @@ +namespace Peer.UnitTests.Apps; + +public class VerbBuilderTests +{ + public class WithHandler + { + + } + + public class WithActionHandler + { + + } + + public class WithSubVerb + { + + } + + public class WithCustomHelp + { + + } +} diff --git a/Peer.UnitTests/Apps/VerbTests.cs b/Peer.UnitTests/Apps/VerbTests.cs new file mode 100644 index 0000000..47ee861 --- /dev/null +++ b/Peer.UnitTests/Apps/VerbTests.cs @@ -0,0 +1,9 @@ +namespace Peer.UnitTests.Apps; + +public class VerbTests +{ + public class Name + { + + } +} diff --git a/Peer.UnitTests/Github/GithubPeerConfigDtoTests.cs b/Peer.UnitTests/GitHub/GithubPeerConfigDtoTests.cs similarity index 100% rename from Peer.UnitTests/Github/GithubPeerConfigDtoTests.cs rename to Peer.UnitTests/GitHub/GithubPeerConfigDtoTests.cs diff --git a/Peer.UnitTests/Parsing/FilterParserTests.cs b/Peer.UnitTests/Parsing/FilterParserTests.cs index d7558ad..bfc9693 100644 --- a/Peer.UnitTests/Parsing/FilterParserTests.cs +++ b/Peer.UnitTests/Parsing/FilterParserTests.cs @@ -2,6 +2,7 @@ using Peer.Domain; using Peer.Domain.Filters; using Peer.Parsing; +using Peer.UnitTests.Util; using Xunit; namespace Peer.UnitTests.Parsing diff --git a/Peer.UnitTests/PartialIdentifierTests.cs b/Peer.UnitTests/PartialIdentifierTests.cs index 430266a..5941aa0 100644 --- a/Peer.UnitTests/PartialIdentifierTests.cs +++ b/Peer.UnitTests/PartialIdentifierTests.cs @@ -1,4 +1,5 @@ using Peer.Domain; +using Peer.UnitTests.Util; using Xunit; namespace Peer.UnitTests diff --git a/Peer.UnitTests/Util/ResultAsserts.cs b/Peer.UnitTests/Util/ResultAsserts.cs index ab77049..4393905 100644 --- a/Peer.UnitTests/Util/ResultAsserts.cs +++ b/Peer.UnitTests/Util/ResultAsserts.cs @@ -1,7 +1,7 @@ using wimm.Secundatives; using Xunit; -namespace Peer.UnitTests +namespace Peer.UnitTests.Util { public static class ResultAsserts { diff --git a/Peer/Apps/ActionHandler.cs b/Peer/Apps/ActionHandler.cs new file mode 100644 index 0000000..89a77a4 --- /dev/null +++ b/Peer/Apps/ActionHandler.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Peer.Apps; + +public class ActionHandler : IHandler +{ + private readonly Func> _handler; + + public ActionHandler(Func> handler) + { + _handler = handler; + } + + + public Task HandleAsync(TVerb opts, IServiceCollection services, CancellationToken token = default) + { + return _handler.Invoke(opts, services, token); + } +} diff --git a/Peer/Apps/App.cs b/Peer/Apps/App.cs new file mode 100644 index 0000000..54f4788 --- /dev/null +++ b/Peer/Apps/App.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommandLine; +using CommandLine.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Peer.Apps.AppBuilder; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps; + +public class App +{ + private readonly ICommandLineParser _parser; + private readonly List _verbs; + private readonly IServiceSetupHandler? _setupHandler; + private readonly IConfiguration _config; + + //CN - This is a temp thing - Really we just should fail out if config fails probably. + // Alternatively since there's a few different cases where it doesn't matter maybe we don't + // care about if it succeeds or fails at this point - or break up the + // different handlers.. Since we don't care about loading config till later in + // the process + // I think really what we want is: + // AppBuilderTime - Only the stuff required to Create handlers/junk + // PreParse - Only the common setup stuff for each handler (Stuff common to everybody - probably don't *need* this) + // VerbConfig - All of the stuff required to run specific verbs - initialized right before running it (probably Verb.CustomConfig or similar) + private static readonly Dictionary _configErrorMap = new() + { + [ConfigError.InvalidProviderValues] = "One or more providers have invalid configuration", + [ConfigError.NoProvidersConfigured] = "No providers are configured! Run 'peer config' to get started", + [ConfigError.ProviderNotMatched] = "Provider was not recognized, make sure you're using one of supported providers!" + }; + + public App(ICommandLineParser parser, IEnumerable verbs, IConfiguration config, IServiceSetupHandler? setupHandler = null) + { + _parser = parser; + _verbs = verbs.ToList(); + _setupHandler = setupHandler; + _config = config; + + if (_verbs.Count == 0) + { + throw new ArgumentException("There must be at least one configured verb"); + } + } + + public async Task RunAsync(string[] args, CancellationToken token = default) + { + var result = _parser.Parse(args); + var services = new ServiceCollection(); + services.AddSingleton(_config); + _setupHandler?.SetupServices(services); + + if (result.IsError) + { + Console.WriteLine(result.Error.Text); + return result.Error is UsageError ? 1 : 0; + } + + var value = result.Value; + + if (value.Verb.RunTimeConfigHandler != null) + { + var configResult = value.Verb.RunTimeConfigHandler.ConfigureServices(services, _config); + + if (configResult.IsError) + { + Console.WriteLine(_configErrorMap[configResult.Error]); + return 1; + } + } + + try + { + await value.Verb.Handler!.HandleAsync(value.Options, services, token); + return 0; + } + catch (Exception ex) + { + Console.WriteLine(ex); + return 1; + } + + } +} diff --git a/Peer/Apps/AppBuilder/AppBuilder.cs b/Peer/Apps/AppBuilder/AppBuilder.cs new file mode 100644 index 0000000..42ba785 --- /dev/null +++ b/Peer/Apps/AppBuilder/AppBuilder.cs @@ -0,0 +1,64 @@ +using System; +using System.Reflection; +using CommandLine; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps.AppBuilder; + +public class AppBuilder +{ + private readonly IServiceCollection _services; + + public AppBuilder(IServiceCollection services) + { + _services = services; + _services.AddSingleton(); + _services.AddSingleton(); + } + + public AppBuilder WithParseTimeServiceConfig( + Func> config) + { + config.Invoke(_services); + return this; + } + + public AppBuilder WithConfiguration(Action builder) + { + var cb = new ConfigurationBuilder(); + builder(cb); + + _services.AddSingleton(cb.Build()); + return this; + } + + public AppBuilder WithSharedServiceConfig(Func> config) + { + _services.AddSingleton(new FuncServiceSetupHandler(config)); + return this; + } + + public AppBuilder WithVerb(Action> config) + { + if (typeof(TVerb).GetCustomAttribute(typeof(VerbAttribute)) == null) + { + throw new ArgumentException($"The type must have the {nameof(VerbAttribute)} Attribute"); + } + + var builder = new VerbBuilder(_services); + config.Invoke(builder); + _services.AddSingleton>(); + return this; + } + + public App Build() + { + _services.TryAddSingleton(new ConfigurationBuilder().Build()); + var sp = _services.BuildServiceProvider(); + return sp.GetRequiredService(); + } +} diff --git a/Peer/Apps/AppBuilder/Command.cs b/Peer/Apps/AppBuilder/Command.cs new file mode 100644 index 0000000..3868060 --- /dev/null +++ b/Peer/Apps/AppBuilder/Command.cs @@ -0,0 +1,13 @@ +namespace Peer.Apps.AppBuilder; + +public class Command +{ + public IVerb Verb { get; } + public object Options { get; } + + public Command(IVerb verb, object options) + { + Verb = verb; + Options = options; + } +} diff --git a/Peer/Apps/AppBuilder/CommandLineParser.cs b/Peer/Apps/AppBuilder/CommandLineParser.cs new file mode 100644 index 0000000..746a8b0 --- /dev/null +++ b/Peer/Apps/AppBuilder/CommandLineParser.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CommandLine; +using CommandLine.Text; +using wimm.Secundatives; +using Error = CommandLine.Error; + +namespace Peer.Apps.AppBuilder; + +public interface ICommandLineParser +{ + Result Parse(string[] args); +} + +public class CommandLineParser : ICommandLineParser +{ + private readonly List _verbs; + + public CommandLineParser(IEnumerable verbs) + { + _verbs = verbs.ToList(); + } + + //CN -- Should just swap this over to a different result type: + // might be good to munge together the result type and the verb? + // ArgInfo { IVerb, ParseResult }? + // Actually it's really just a Result + + public Result Parse(string[] args) + { + //I'm just dealing with one layer of nesting right now. I'm lazy + var verbCount = args.Count(x => !x.StartsWith('-')); + var rootVerb = _verbs.FirstOrDefault(x => x.Name == args.FirstOrDefault()); + var subs = rootVerb?.Subs.ToArray() ?? Array.Empty(); + var subMatch = subs.Any(x => x.Name == args.Skip(1).FirstOrDefault()); + var parser = new Parser(config => + { + config.AutoHelp = true; + config.AutoVersion = true; + config.IgnoreUnknownArguments = false; + }); + + var state = new { rootVerb, subCount = subs.Length, subMatch }; + + var parseResult = state switch + { + // If the first non-option token is not a recognized top-level command or the top-level command has no + // subcommands then parse the complete command line. + { rootVerb: null } or { subCount: 0 } => Parse(parser, args, _verbs), + // If no subcommand name was specified, or the provided subcommand name does not refer to a subcommand of + // the matched root verb, then skip the two verbs. This isn't perfect -- deal with it. + { subMatch: false } => Parse(parser, args.Skip(2), subs), + // Otherwise we've matched a top-level command and a subcommand so skip the top-level command token so + // CommandLine.Parser will match the Options type corresponding to our subcommand. + _ => Parse(parser, args[1..], subs), + }; + + var verb = _verbs.SelectMany(x => x.Subs.Append(x)) + .FirstOrDefault(x => x.Type == parseResult.TypeInfo.Current); + + return parseResult.MapResult( + opts => new Result(new Command(verb!, opts)), + err => GetHelpText(verb, parseResult, err)); + } + + private static ParserResult Parse(Parser parser, IEnumerable args, IEnumerable verbs) + { + return parser.ParseArguments(args, verbs.Select(x => x.Type).ToArray()); + } + + private static TextResult GetHelpText(IVerb? verb, ParserResult result, IEnumerable errors) + { + var help = verb?.CustomHelp?.GetHelpText(result) ?? HelpText.AutoBuild(result); + + foreach (var err in errors) + { + var type = err.Tag switch + { + ErrorType.HelpRequestedError => new Help(help), + ErrorType.VersionRequestedError => new Version(help), + _ => null as TextResult + }; + + if (type != null) + { + return type; + } + } + + return new UsageError(help); + } +} diff --git a/Peer/Apps/AppBuilder/HandlerWrapper.cs b/Peer/Apps/AppBuilder/HandlerWrapper.cs new file mode 100644 index 0000000..1c95d63 --- /dev/null +++ b/Peer/Apps/AppBuilder/HandlerWrapper.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Peer.Apps.AppBuilder; + +public class HandlerWrapper : IHandler +{ + private readonly IHandler _handler; + public Type Type => typeof(T); + + public HandlerWrapper(IHandler handler) + { + _handler = handler; + } + public Task HandleAsync(T opts, IServiceCollection collection, CancellationToken token = default) + { + return _handler.HandleAsync(opts, collection, token); + } + + public Task HandleAsync(object opts, IServiceCollection collection, CancellationToken token = default) + { + return HandleAsync((T)opts, collection, token); + } +} diff --git a/Peer/Apps/AppBuilder/Help.cs b/Peer/Apps/AppBuilder/Help.cs new file mode 100644 index 0000000..c28909c --- /dev/null +++ b/Peer/Apps/AppBuilder/Help.cs @@ -0,0 +1,8 @@ +namespace Peer.Apps.AppBuilder; + +public class Help : TextResult +{ + public Help(string text) : base(text) + { + } +} diff --git a/Peer/Apps/AppBuilder/IRunTimeConfigHandler.cs b/Peer/Apps/AppBuilder/IRunTimeConfigHandler.cs new file mode 100644 index 0000000..b411bf9 --- /dev/null +++ b/Peer/Apps/AppBuilder/IRunTimeConfigHandler.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps.AppBuilder; + +// Shaaapes https://www.youtube.com/watch?v=evthRoKoE1o +public interface IRunTimeConfigHandler +{ + Result ConfigureServices(IServiceCollection services, IConfiguration config); +} + +public interface IRunTimeConfigHandler : IRunTimeConfigHandler +{ +} diff --git a/Peer/Apps/AppBuilder/RunTimeConfigMapping.cs b/Peer/Apps/AppBuilder/RunTimeConfigMapping.cs new file mode 100644 index 0000000..de3f623 --- /dev/null +++ b/Peer/Apps/AppBuilder/RunTimeConfigMapping.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps.AppBuilder; + +public class RunTimeConfigMapping : IRunTimeConfigHandler where THandler : IRunTimeConfigHandler +{ + private readonly IRunTimeConfigHandler _handler; + + public RunTimeConfigMapping(THandler configHandler) + { + _handler = configHandler; + } + + public Result ConfigureServices(IServiceCollection services, IConfiguration config) + { + return _handler.ConfigureServices(services, config); + } +} diff --git a/Peer/Apps/AppBuilder/SubVerbBuilder.cs b/Peer/Apps/AppBuilder/SubVerbBuilder.cs new file mode 100644 index 0000000..98aeb30 --- /dev/null +++ b/Peer/Apps/AppBuilder/SubVerbBuilder.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Peer.Apps.AppBuilder; + +public sealed class SubVerbBuilder : VerbBuilder +{ + public SubVerbBuilder(IServiceCollection services) : base(services) + { + } +} diff --git a/Peer/Apps/AppBuilder/TextResult.cs b/Peer/Apps/AppBuilder/TextResult.cs new file mode 100644 index 0000000..0730eec --- /dev/null +++ b/Peer/Apps/AppBuilder/TextResult.cs @@ -0,0 +1,11 @@ +namespace Peer.Apps.AppBuilder; + +public abstract class TextResult +{ + public string Text { get; } + + protected TextResult(string text) + { + Text = text; + } +} diff --git a/Peer/Apps/AppBuilder/UsageError.cs b/Peer/Apps/AppBuilder/UsageError.cs new file mode 100644 index 0000000..a5a7d18 --- /dev/null +++ b/Peer/Apps/AppBuilder/UsageError.cs @@ -0,0 +1,8 @@ +namespace Peer.Apps.AppBuilder; + +public class UsageError : TextResult +{ + public UsageError(string text) : base(text) + { + } +} diff --git a/Peer/Apps/AppBuilder/VerbBuilder.cs b/Peer/Apps/AppBuilder/VerbBuilder.cs new file mode 100644 index 0000000..5793cc9 --- /dev/null +++ b/Peer/Apps/AppBuilder/VerbBuilder.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Peer.GitHub; +using Peer.GitHub.GraphQL.PullRequestSearch; +using Peer.Verbs; + +namespace Peer.Apps.AppBuilder; + +public class VerbBuilder +{ + private readonly IServiceCollection _services; + private readonly List _subs = new(); + public VerbBuilder(IServiceCollection services) + { + _services = services; + } + + public VerbBuilder WithHandler() where THandler : class, IHandler + { + _services.AddSingleton, THandler>(); + _services.AddSingleton>(); + return this; + } + + public VerbBuilder WithActionHandler(Func> handler) + { + _services.AddSingleton>(new ActionHandler(handler)); + _services.AddSingleton>(); + return this; + } + + public VerbBuilder WithSubVerb(Action>? config = null) + { + _services.AddSingleton, SubVerb>(); + var sub = new SubVerbBuilder(_services); + config?.Invoke(sub); + return this; + } + + public VerbBuilder WithCustomHelp() where THelp : class, IHelpTextFormatter + { + _services.AddSingleton, THelp>(); + return this; + } + + public VerbBuilder WithRunTimeConfig() where TRegHandler : class, IRunTimeConfigHandler + { + _services.TryAddSingleton(); + _services.AddSingleton, RunTimeConfigMapping>(); + return this; + } +} diff --git a/Peer/Apps/AppBuilder/Version.cs b/Peer/Apps/AppBuilder/Version.cs new file mode 100644 index 0000000..8a869ff --- /dev/null +++ b/Peer/Apps/AppBuilder/Version.cs @@ -0,0 +1,9 @@ +namespace Peer.Apps.AppBuilder; + +public class Version : TextResult +{ + public Version(string text) : base(text) + { + + } +} diff --git a/Peer/Apps/FuncServiceSetupHandler.cs b/Peer/Apps/FuncServiceSetupHandler.cs new file mode 100644 index 0000000..8412d9e --- /dev/null +++ b/Peer/Apps/FuncServiceSetupHandler.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps; + +public class FuncServiceSetupHandler : IServiceSetupHandler +{ + private readonly Func> _config; + + public FuncServiceSetupHandler(Func> config) + { + _config = config; + } + + public Result SetupServices(IServiceCollection services) + { + return _config(services); + } +} diff --git a/Peer/Apps/IHandler.cs b/Peer/Apps/IHandler.cs new file mode 100644 index 0000000..c5add3b --- /dev/null +++ b/Peer/Apps/IHandler.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Peer.Apps; + +public interface IHandler +{ + Task HandleAsync(TVerb opts, IServiceCollection services, CancellationToken token = default); +} + +public interface IHandler +{ + Task HandleAsync(object opts, IServiceCollection collection, CancellationToken token = default); + Type Type { get; } +} diff --git a/Peer/Apps/IServiceSetupHandler.cs b/Peer/Apps/IServiceSetupHandler.cs new file mode 100644 index 0000000..ea972c6 --- /dev/null +++ b/Peer/Apps/IServiceSetupHandler.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.DependencyInjection; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer.Apps; + +public interface IServiceSetupHandler +{ + Result SetupServices(IServiceCollection services); +} diff --git a/Peer/Apps/IVerb.cs b/Peer/Apps/IVerb.cs new file mode 100644 index 0000000..d634b62 --- /dev/null +++ b/Peer/Apps/IVerb.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using Peer.Apps.AppBuilder; +using Peer.Verbs; + +namespace Peer.Apps; + +public interface IVerb +{ + string Name { get; } + Type Type { get; } + IEnumerable Subs { get; } + IHandler? Handler { get; } + + IRunTimeConfigHandler? RunTimeConfigHandler { get; } + IHelpTextFormatter? CustomHelp { get; } +} diff --git a/Peer/Apps/SubVerb.cs b/Peer/Apps/SubVerb.cs new file mode 100644 index 0000000..8e2b92c --- /dev/null +++ b/Peer/Apps/SubVerb.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Peer.Apps.AppBuilder; +using Peer.Verbs; + +namespace Peer.Apps; + +public class SubVerb : Verb, ISubVerb +{ + public SubVerb( + IEnumerable> subs, + IHandler handler, + IRunTimeConfigHandler? runTimeHandler = null, + IHelpTextFormatter? helpFormatter = null) + : base( + subs, + runTimeHandler, + handler, + helpFormatter) + { + } +} diff --git a/Peer/Apps/Verb.cs b/Peer/Apps/Verb.cs new file mode 100644 index 0000000..c439742 --- /dev/null +++ b/Peer/Apps/Verb.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using CommandLine; +using Peer.Apps.AppBuilder; +using Peer.Verbs; + +namespace Peer.Apps; + +public class Verb : IVerb +{ + public IEnumerable Subs { get; } + public IHelpTextFormatter? CustomHelp { get; } + + public IHandler? Handler { get; } + public IRunTimeConfigHandler? RunTimeConfigHandler { get; } + + + public Type Type => typeof(TVerb); + public IEnumerable SubVerbs => Subs.Select(x => x.Type); + + + + public Verb( + IEnumerable> subs, + IRunTimeConfigHandler? runtimeHandler = null, + IHandler? handler = null, //CN - should probably have just a different name for top level verbs + IHelpTextFormatter? helpFormatter = null) + { + RunTimeConfigHandler = runtimeHandler; + CustomHelp = helpFormatter; + Handler = handler != null ? new HandlerWrapper(handler) : null; + var subVerbs = subs.ToList(); + Subs = subVerbs; + if (subVerbs.Count == 0 && handler == null) + { + throw new ArgumentException("Verbs must have a handler or sub-verbs"); + } + } + + + public string Name => Type.GetCustomAttributes(typeof(VerbAttribute), false) + .OfType() + .Select(x => x.Name) + .First(); + +} + diff --git a/Peer/Apps/VerbExtensions.cs b/Peer/Apps/VerbExtensions.cs new file mode 100644 index 0000000..c6de19d --- /dev/null +++ b/Peer/Apps/VerbExtensions.cs @@ -0,0 +1,18 @@ +using System.Linq; +using CommandLine; + +namespace Peer.Apps; + +public static class VerbExtensions +{ + public static string GetOptionLongName(this T value, string propName) where T : notnull + { + return value.GetType() + .GetProperties() + .First(x => x.Name == propName) + .GetCustomAttributes(typeof(OptionAttribute), false) + .OfType() + .First() + .LongName; + } +} diff --git a/Peer/ConfigSections/Constants.cs b/Peer/ConfigSections/Constants.cs new file mode 100644 index 0000000..d7e7f87 --- /dev/null +++ b/Peer/ConfigSections/Constants.cs @@ -0,0 +1,10 @@ +using System; +using System.IO; + +namespace Peer.ConfigSections; + +public static class Constants +{ + public static readonly string DefaultConfigPath = + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); +} diff --git a/Peer/Handlers/ConfigEditHandler.cs b/Peer/Handlers/ConfigEditHandler.cs new file mode 100644 index 0000000..e4cd70e --- /dev/null +++ b/Peer/Handlers/ConfigEditHandler.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Peer.Apps; +using Peer.ConfigSections; +using Peer.Domain.Commands; +using Peer.Verbs; +using wimm.Secundatives; + +namespace Peer.Handlers; + +public class ConfigEditHandler : IHandler +{ + public async Task HandleAsync(ConfigEditOptions opts, IServiceCollection services, CancellationToken token = default) + { + services.AddSingleton(); + services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + return new ConfigEditConfig(config["Editor"], Constants.DefaultConfigPath); + }); + + var sp = services.BuildServiceProvider(); + + var command = sp.GetRequiredService(); + return await command.RunAsync() + .MapOr(_ => 0, _ => 1); + } +} diff --git a/Peer.Domain/Commands/Config.cs b/Peer/Handlers/ConfigInitHandler.cs similarity index 65% rename from Peer.Domain/Commands/Config.cs rename to Peer/Handlers/ConfigInitHandler.cs index 01bd678..22cd796 100644 --- a/Peer.Domain/Commands/Config.cs +++ b/Peer/Handlers/ConfigInitHandler.cs @@ -1,18 +1,21 @@ -using System; +using System; using System.IO; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Peer.Apps; +using Peer.ConfigSections; +using Peer.Verbs; -namespace Peer.Domain.Commands -{ - public class Config - { - private static readonly string _configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); +namespace Peer.Handlers; - private const string _configHelp = @" +public class ConfigInitHandler : IHandler +{ + private const string _defaultConfig = @" { ""Peer"": { // The amount of time to wait for the show command to fetch pull request info (default: 10) - ""ShowTimeoutSeconds"": 30 + ""ShowTimeoutSeconds"": 30, // The amount of time between calls to providers when using the --watch flag (default: 30) ""WatchIntervalSeconds"": 15 }, @@ -21,7 +24,7 @@ public class Config // A list of GitHub pull request provider configurations ""github"": [{ // A friendly name for this provider (required) - ""Name"": ""GitHub-Work"", + ""Name"": ""Github"", ""Configuration"": { // An API token with permission to read issues (required) // You will need to configure SSO on the PAT to see pull requests from organizations that require it @@ -42,12 +45,19 @@ public class Config } } "; - public static Task ConfigAsync() + + public async Task HandleAsync(ConfigInitOptions opts, IServiceCollection services, CancellationToken token = default) + { + if (File.Exists(Constants.DefaultConfigPath) && !opts.Force) { - Console.Error.WriteLine("Hey lets get you set up and working with Peer!"); - Console.Error.WriteLine($"Toss the following into this location: {_configFile} and fill in values for your github account"); - Console.WriteLine(_configHelp); - return Task.CompletedTask; + var forceName = opts.GetOptionLongName(nameof(opts.Force)); + Console.WriteLine($"You already have a config file! If you want it overwritten use the --{forceName} option"); + return 1; } + + await using var file = File.Create(Constants.DefaultConfigPath); + await using var writer = new StreamWriter(file); + await writer.WriteAsync(_defaultConfig); + return 0; } } diff --git a/Peer/Handlers/ConfigShowHandler.cs b/Peer/Handlers/ConfigShowHandler.cs new file mode 100644 index 0000000..f20c4ad --- /dev/null +++ b/Peer/Handlers/ConfigShowHandler.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Peer.Apps; +using Peer.ConfigSections; +using Peer.Verbs; + +namespace Peer.Handlers; + +public class ConfigShowHandler : IHandler +{ + public async Task HandleAsync( + ConfigShowOptions opts, + IServiceCollection services, + CancellationToken token = default) + { + if (!File.Exists(Constants.DefaultConfigPath)) + { + + await Console.Error.WriteLineAsync($"You don't have a config! User config --init to create a default config"); + return 1; + } + + var text = await File.ReadAllTextAsync(Constants.DefaultConfigPath, token); + Console.WriteLine($"//Path: {Constants.DefaultConfigPath}"); + Console.WriteLine(text); + return 0; + } +} diff --git a/Peer/Peer.csproj b/Peer/Peer.csproj index f6b295e..e2e615f 100644 --- a/Peer/Peer.csproj +++ b/Peer/Peer.csproj @@ -41,6 +41,7 @@ + diff --git a/Peer/Program.cs b/Peer/Program.cs index b3f09ed..9567734 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,19 +1,20 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Diagnostics.CodeAnalysis; using System.Text; using System.Threading; using System.Threading.Tasks; -using CommandLine; -using CommandLine.Text; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Peer.Apps.AppBuilder; using Peer.ConfigSections; using Peer.Domain; using Peer.Domain.Commands; using Peer.Domain.Configuration; using Peer.Domain.Formatters; +using Peer.Domain.Util; using Peer.GitHub; +using Peer.Handlers; using Peer.Parsing; using Peer.Verbs; using wimm.Secundatives; @@ -22,85 +23,96 @@ namespace Peer { public static class Program { - private static readonly string _configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); - - private static readonly Dictionary _configErrorMap = new() - { - [ConfigError.InvalidProviderValues] = "One or more providers have invalid configuration", - [ConfigError.NoProvidersConfigured] = "No providers are configured! Run 'peer config' to get started", - [ConfigError.ProviderNotMatched] = "Provider was not recognized, make sure you're using one of supported providers!" - }; - private static readonly CancellationTokenSource _tcs = new(); - public static async Task Main(string[] args) + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", + Justification = "")] + public static async Task Main(string[] args) { - Console.CancelKeyPress += (evt, eventArgs) => + Console.CancelKeyPress += (_, _) => { Console.WriteLine("Stopping..."); _tcs.Cancel(); }; Console.OutputEncoding = Encoding.UTF8; + + var builder = new AppBuilder(new ServiceCollection()) + .WithParseTimeServiceConfig(SetupParseTimeServices) + .WithSharedServiceConfig(SetupServices) + .WithConfiguration(configBuilder => + { + configBuilder.AddJsonFile(Constants.DefaultConfigPath, optional: true) + .AddEnvironmentVariables(); + }); - var setupResult = SetupServices(); - - var parser = new Parser(config => - { - config.AutoHelp = true; - config.AutoVersion = true; - config.IgnoreUnknownArguments = false; - }); - - - var parseResult = parser.ParseArguments(args); + builder.WithVerb( + conf => + { + conf.WithSubVerb(x => x.WithHandler()) + .WithSubVerb(x => x.WithHandler()) + .WithSubVerb(x => x.WithHandler()); + }) + .WithVerb( + conf => + { + conf.WithCustomHelp() + .WithActionHandler(ShowAsync) + .WithRunTimeConfig(); - if (setupResult.IsError && !parseResult.Is()) - { - Console.Error.WriteLine(_configErrorMap[setupResult.Error]); - return; - } + }) + .WithVerb( + conf => + { + conf.WithActionHandler(OpenAsync) + .WithRunTimeConfig(); + }) + .WithVerb( + conf => + { + conf.WithCustomHelp() + .WithActionHandler(DetailsAsync) + .WithRunTimeConfig(); + }); - if (parseResult.Tag == ParserResultType.Parsed) - { - await parseResult.MapResult( - (ShowOptions x) => ShowAsync(x, setupResult.Value, _tcs.Token), - (OpenOptions x) => OpenAsync(x, setupResult.Value, _tcs.Token), - (ConfigOptions x) => ConfigAsync(x), - (DetailsOptions x) => DetailsAsync(x, setupResult.Value, _tcs.Token), - err => Task.CompletedTask); - - return; - } - var services = setupResult.Value; - var text = parseResult switch - { - var v when v.Is() => GetHelpText(parseResult, services.BuildServiceProvider()), - var v when v.Is() => GetHelpText(parseResult, services.BuildServiceProvider()), - _ => HelpText.AutoBuild(parseResult) - }; + var p = builder.Build(); - Console.Write(text); + return await p.RunAsync(args); } - //todo:cn -- maybe pun + indirect here? - private static HelpText GetHelpText(ParserResult parserResult, IServiceProvider serviceProvider) + private static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) { - var formatter = serviceProvider.GetRequiredService>(); - return formatter.GetHelpText(parserResult); - } + services.AddSingleton( + sp => + { + var config = sp.GetRequiredService(); + // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to + // expose the new settings until we've settled on some configuration patterns. + // https://github.com/wareismymind/peer/issues/149 + var watchOptions = config.GetSection("Peer") + ?.Get() + ?? new PeerOptions(); + + var showConfig = new ShowConfigSection(); + if (watchOptions.WatchIntervalSeconds != null) + { + showConfig.WatchIntervalSeconds = watchOptions.WatchIntervalSeconds; + showConfig.TimeoutSeconds = watchOptions.ShowTimeoutSeconds; + } - public static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) - { + return showConfig.Into(); + }); if (opts.Sort != null) { var sort = SortParser.ParseSortOption(opts.Sort); if (sort.IsError) { - Console.Error.WriteLine($"Failed to parse sort option: {sort.Error}"); - return; + await Console.Error.WriteLineAsync($"Failed to parse sort option: {sort.Error}"); + return 1; } services.AddSingleton(sort.Value); @@ -114,8 +126,8 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services if (parsedFilter.IsError) { - Console.Error.WriteLine($"Failed to parse filter option: {parsedFilter.Error}"); - return; + await Console.Error.WriteLineAsync($"Failed to parse filter option: {parsedFilter.Error}"); + return 1; } services.AddSingleton(parsedFilter.Value); @@ -136,19 +148,25 @@ public static async Task ShowAsync(ShowOptions opts, IServiceCollection services { var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - await command.ShowAsync(new ShowArguments(opts.Count), token); + var res = await command.ShowAsync(new ShowArguments(opts.Count), token); + if (res.IsError) + { + return 1; + } } + + return 0; } - public static async Task OpenAsync(OpenOptions opts, IServiceCollection services, CancellationToken token) + private static async Task OpenAsync(OpenOptions opts, IServiceCollection services, CancellationToken token) { var parseResult = PartialIdentifier.Parse(opts.Partial!); if (parseResult.IsError) { - Console.Error.WriteLine( + await Console.Error.WriteLineAsync( $"Failed to parse partial identifier '{opts.Partial}' with error: {parseResult.Error}"); - return; + return 1; } services.AddSingleton(); @@ -159,19 +177,26 @@ public static async Task OpenAsync(OpenOptions opts, IServiceCollection services if (result.IsError) { - Console.Error.WriteLine($"Partial identifier '{opts.Partial}' failed with error: {result.Error}"); + await Console.Error.WriteLineAsync( + $"Partial identifier '{opts.Partial}' failed with error: {result.Error}"); + return 1; } + + return 0; } - public static async Task DetailsAsync(DetailsOptions opts, IServiceCollection services, CancellationToken token) + private static async Task DetailsAsync( + DetailsOptions opts, + IServiceCollection services, + CancellationToken token) { var parseResult = PartialIdentifier.Parse(opts.Partial!); if (parseResult.IsError) { - Console.Error.WriteLine( + await Console.Error.WriteLineAsync( $"Failed to parse partial identifier '{opts.Partial}' with error: {parseResult.Error}"); - return; + return 1; } services.AddSingleton
(); @@ -182,54 +207,27 @@ public static async Task DetailsAsync(DetailsOptions opts, IServiceCollection se var res = await command.DetailsAsync(new DetailsArguments(parseResult.Value), token); - if (res.IsError) + if (!res.IsError) { - Console.Error.WriteLine($"Partial identifier '{opts.Partial}' failed with error: {res.Error}"); + return 0; } - } - public static async Task ConfigAsync(ConfigOptions _) - { - await Config.ConfigAsync(); + await Console.Error.WriteLineAsync($"Partial identifier '{opts.Partial}' failed with error: {res.Error}"); + return 1; } - private static Result SetupServices() + private static Result SetupParseTimeServices(IServiceCollection services) { - var services = new ServiceCollection(); - - var configLoader = new ConfigurationService(new List - { - new GitHubWebRegistrationHandler(services) - }); - - var configuration = new ConfigurationBuilder() - .AddJsonFile(_configFile, optional: true) - .AddEnvironmentVariables() - .Build(); - - var configResults = configLoader.RegisterProvidersForConfiguration(configuration, services); - - if (configResults.IsError) - { - return configResults.Error; - } - - // Parse legacy configuration setting and layer it with default values for newer settings. We'll wait to - // expose the new settings until we've settled on some configuration patterns. - // https://github.com/wareismymind/peer/issues/149 - var watchOptions = configuration.GetSection("Peer") - .Get() - ?? new PeerOptions(); - - var showConfig = new ShowConfigSection(); - if (watchOptions.WatchIntervalSeconds != null) - { - showConfig.WatchIntervalSeconds = watchOptions.WatchIntervalSeconds; - showConfig.TimeoutSeconds = watchOptions.ShowTimeoutSeconds; - } + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); - services.AddSingleton(showConfig.Into()); + return new Result(services); + } + [RequiresUnreferencedCode("Calls Microsoft.Extensions.Configuration.IConfiguration.Get()")] + private static Result SetupServices(IServiceCollection services) + { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -237,9 +235,9 @@ private static Result SetupServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, ShowHelpTextFormatter>(); - services.AddSingleton, DetailsHelpTextFormatter>(); - return services; + services.AddSingleton(); + + return new Result(services); } } } diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index e234c35..f8871dd 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "-w" + "commandLineArgs": "show" } } } diff --git a/Peer/ProviderLoader.cs b/Peer/ProviderLoader.cs new file mode 100644 index 0000000..4c864a0 --- /dev/null +++ b/Peer/ProviderLoader.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Peer.Apps.AppBuilder; +using Peer.Domain.Configuration; +using wimm.Secundatives; + +namespace Peer; + +public class ProviderLoader : IRunTimeConfigHandler +{ + private readonly List _registrationHandlers; + + public ProviderLoader(IEnumerable registrationHandlers) + { + _registrationHandlers = registrationHandlers.ToList(); + } + + public Result ConfigureServices(IServiceCollection services, IConfiguration config) + { + var configLoader = new ConfigurationService(_registrationHandlers, config); + return configLoader.RegisterProvidersForConfiguration(services); + } +} diff --git a/Peer/Verbs/ConfigOptions.cs b/Peer/Verbs/ConfigOptions.cs index 007b630..f732caf 100644 --- a/Peer/Verbs/ConfigOptions.cs +++ b/Peer/Verbs/ConfigOptions.cs @@ -5,6 +5,22 @@ namespace Peer.Verbs [Verb("config", isDefault: false, HelpText = "Create or edit the configuration for peer")] public class ConfigOptions { + } + + [Verb("init", isDefault: false, HelpText = "Create a default configuration file")] + public class ConfigInitOptions + { + [Option(shortName: 'f', longName: "force", Required = false, HelpText = "Overwrite any existing configuration")] + public bool Force { get; set; } + } + + [Verb("show", isDefault: false, HelpText = "Print the current config and it's location")] + public class ConfigShowOptions + { } + + [Verb("edit", isDefault: false, HelpText = "Open your config in your default text editor")] + public class ConfigEditOptions + { } } diff --git a/Peer/Verbs/DetailsHelpTextFormatter.cs b/Peer/Verbs/DetailsHelpTextFormatter.cs index e4044d9..bd5ccce 100644 --- a/Peer/Verbs/DetailsHelpTextFormatter.cs +++ b/Peer/Verbs/DetailsHelpTextFormatter.cs @@ -43,7 +43,7 @@ public HelpText GetHelpText(ParserResult parserResult) help.AddPostOptionsLines(existingPairs.Select( triple => $"Status: {triple.status.ToString().PadRight(statusWidth)} " + - $"Result: { triple.result.ToString().PadRight(resultWidth)} => {triple.symbol}")); + $"Result: {triple.result.ToString().PadRight(resultWidth)} => {triple.symbol}")); help.AddPostOptionsLine(""); help.AddPostOptionsLine(""); diff --git a/Peer/Verbs/IHelpTextFormatter.cs b/Peer/Verbs/IHelpTextFormatter.cs index bcca8ce..41cde30 100644 --- a/Peer/Verbs/IHelpTextFormatter.cs +++ b/Peer/Verbs/IHelpTextFormatter.cs @@ -3,7 +3,11 @@ namespace Peer.Verbs { - public interface IHelpTextFormatter + public interface IHelpTextFormatter : IHelpTextFormatter + { + } + + public interface IHelpTextFormatter { HelpText GetHelpText(ParserResult parserResult); } diff --git a/Peer/Verbs/SubVerbHandler.cs b/Peer/Verbs/SubVerbHandler.cs new file mode 100644 index 0000000..ad2fe23 --- /dev/null +++ b/Peer/Verbs/SubVerbHandler.cs @@ -0,0 +1,14 @@ +using System; +using Peer.Apps; + +namespace Peer.Verbs; + +public interface ISubVerbTypeBinding +{ + public Type Super { get; } + public Type Sub { get; } +} + +public interface ISubVerb : IVerb +{ +} diff --git a/run-locals.ps1 b/run-locals.ps1 new file mode 100644 index 0000000..29ebc18 --- /dev/null +++ b/run-locals.ps1 @@ -0,0 +1,85 @@ +function Run-Test($Command, $Name) { + $res = iex "dotnet run -c release --no-build -- $Command"; + + # Write-Output "COMMAND: $COMMAND" + # Write-Output "NAME: $NAME" + # Write-Output "LEC: $LASTEXITCODE" + $emoji = $null + if ($LASTEXITCODE -eq 0) { + $emoji = "✔️" + $status = "Succeeded" + } + else{ + $emoji = "❌" + $status = "Failed" + } + if($env:Verbose -eq $true) + { + Write-Output $res + } + Write-Output "[ $emoji ][$Name] Completed - Result : $status" +} + +function Run-TestBlock($BlockName, $Tests, [scriptblock]$BeforeAllAction, [scriptblock]$FinallyAction) +{ + try { + + + Invoke-Command $($BeforeAllAction ?? {}) + Write-Output "[$BlockName]" + foreach($test in $Tests){ + $res = Run-Test @test + Write-Output " $res" + } + } + finally + { + Invoke-Command $($FinallyAction ?? {}) + } +} + + +cd ./Peer +dotnet build -c release + +$detailsCommands = @( + @{ Command = "details 159"; Name = "Details 1 segment"}, + @{ Command = "details peer/159"; Name = "Details 2 segment"}, + @{ Command = "details wareismymind/peer/159"; Name = "Details 3 segment"}, + @{ Command = "details github/wareismymind/peer/159"; Name = "Details 4 segment"} + @{ Command = "details --help"; Name = "Details help flag"} + @{ Command = "details --version"; Name = "Details version flag"} +) + +$showCommands = @( + @{ Command = "show"; Name = "Show no args"}, + @{ Command = "show -f id:159"; Name = "Show with filter"}, + @{ Command = "show -s id:asc"; Name = "Show id asc"}, + @{ Command = "show -s id:desc"; Name = "Show id desc"}, + @{ Command = "show --help"; Name = "Show help flag"}, + @{ Command = "show --version"; Name = "Show version flag"} +) + +$configShowCommands = @( + @{ Command = "config show"; Name = "Config show no args"} + @{ Command = "config show --help"; Name = "Config show help flag"} + @{ Command = "config show --version"; Name = "Config show version flag"} + +) + +$configInitCommands = @( + @{ Command = "config init"; Name = "Config init no args"} + @{ Command = "config init -f"; Name = "Config init force"} + @{ Command = "config init --help"; Name = "Config init help flag"} + @{ Command = "config init --version"; Name = "Config init version flag"} +) + +Run-TestBlock -BlockName "Details" -Tests $detailsCommands +Run-TestBlock -BlockName "Show" -Tests $showCommands +Run-TestBlock -BlockName "Config Show" -Tests $configShowCommands +Run-TestBlock -BlockName "Config init" ` + -Tests $configInitCommands ` + -BeforeAllAction { cp ~/.config/peer.json ~/.config/peer.json.bak }` + -FinallyAction { mv ~/.config/peer.json.bak ~/.config/peer.json} + +cd .. \ No newline at end of file From c8e174a8328d9ee46637485424b83e844e5186ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 23 Oct 2022 15:54:43 -0300 Subject: [PATCH 27/28] chore: release 1.10.0 (#169) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85f80c..cdbc0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.10.0](https://www.github.com/wareismymind/peer/compare/v1.9.1...v1.10.0) (2022-10-23) + + +### Features + +* add config subcommands (and move to an app builder setup) ([#166](https://www.github.com/wareismymind/peer/issues/166)) ([9d5f33b](https://www.github.com/wareismymind/peer/commit/9d5f33ba7e9a8f8b21b4949db0d93ead75dd7774)) + ### [1.9.1](https://www.github.com/wareismymind/peer/compare/v1.9.0...v1.9.1) (2022-10-08) diff --git a/version.txt b/version.txt index 9ab8337..81c871d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.1 +1.10.0 From 8fea83b4d5ce4778e57a42ea5087ad5876b3e007 Mon Sep 17 00:00:00 2001 From: Chris Nantau Date: Tue, 1 Nov 2022 20:14:33 -0300 Subject: [PATCH 28/28] feat: configurable configfile path (#171) --- Peer.Domain/Commands/ConfigEdit.cs | 22 +++++++++++------ Peer.Domain/Util/ResultExtensions.cs | 16 ++++++++++++ Peer.UnitTests/Apps/AppBuilderTests.cs | 4 +-- Peer/Apps/AppBuilder/AppBuilder.cs | 2 +- Peer/Handlers/ConfigEditHandler.cs | 10 +++++++- Peer/Handlers/ConfigInitHandler.cs | 12 +++++++-- Peer/Handlers/ConfigShowHandler.cs | 14 ++++++++--- Peer/Parsing/PeerOptions.cs | 10 +++++++- Peer/Program.cs | 34 +++++++++++++++++++++++--- Peer/Properties/launchSettings.json | 5 +++- README.md | 21 ++++++++++++++++ 11 files changed, 127 insertions(+), 23 deletions(-) create mode 100644 Peer.Domain/Util/ResultExtensions.cs diff --git a/Peer.Domain/Commands/ConfigEdit.cs b/Peer.Domain/Commands/ConfigEdit.cs index fa7d95b..40944c5 100644 --- a/Peer.Domain/Commands/ConfigEdit.cs +++ b/Peer.Domain/Commands/ConfigEdit.cs @@ -51,9 +51,19 @@ private async Task> OpenFileAsync(string path) private Result OpenWithEditor(string path) { var split = _config.Editor!.Split(' ').ToArray(); - return Process.Start(split[0], split[1..].Append(path)); - } + var os = _infoProvider.GetPlatform().OkOr(ConfigEditError.UnsupportedOs); + return os.Map(os => + Process.Start( + new ProcessStartInfo + { + FileName = split[0], + Arguments = string.Join(' ', split.Skip(1).Append(path)), + UseShellExecute = os == OSPlatform.Windows + }) + .ValueOr(ConfigEditError.ProcessFailedToOpen)) + .Flatten(); + } private Result OpenWithOsDefault(string path) { @@ -63,12 +73,8 @@ private Result OpenWithOsDefault(string path) .Map( os => os switch { - _ when os == OSPlatform.Windows => Process.Start( - new ProcessStartInfo { UseShellExecute = true, FileName = path })!.AsMaybe() - .OkOr(() => ConfigEditError.ProcessFailedToOpen), - _ when os == OSPlatform.Linux => new Result( - Process.Start("xdg-open", path)), - _ when os == OSPlatform.OSX => new Result(Process.Start("open", path)), + _ when os == OSPlatform.Windows => Process.Start(new ProcessStartInfo { UseShellExecute = true, FileName = path }).ValueOr(ConfigEditError.ProcessFailedToOpen), + _ when os == OSPlatform.OSX => Process.Start("open", path).ValueOr(ConfigEditError.ProcessFailedToOpen), _ => new Result(ConfigEditError.UnsupportedOs) }) .Flatten(); diff --git a/Peer.Domain/Util/ResultExtensions.cs b/Peer.Domain/Util/ResultExtensions.cs new file mode 100644 index 0000000..844d7a0 --- /dev/null +++ b/Peer.Domain/Util/ResultExtensions.cs @@ -0,0 +1,16 @@ +using wimm.Secundatives; + +namespace Peer.Domain.Util; + +public static class ResultExtensions +{ + public static Result ValueOr(this T? value, TErr err) + { + if (value == null) + { + return err; + } + + return value; + } +} diff --git a/Peer.UnitTests/Apps/AppBuilderTests.cs b/Peer.UnitTests/Apps/AppBuilderTests.cs index ae1bd83..cb062f4 100644 --- a/Peer.UnitTests/Apps/AppBuilderTests.cs +++ b/Peer.UnitTests/Apps/AppBuilderTests.cs @@ -42,7 +42,7 @@ public void ConfigIsNotCalledWhenAppBuilt() { var called = false; var underTest = Construct(); - underTest.WithSharedServiceConfig( + underTest.WithSharedRuntimeConfig( sp => { called = true; @@ -60,7 +60,7 @@ public async Task ConfigIsCalledWhenAppIsRun() { var called = false; var underTest = Construct(); - underTest.WithSharedServiceConfig( + underTest.WithSharedRuntimeConfig( sp => { called = true; diff --git a/Peer/Apps/AppBuilder/AppBuilder.cs b/Peer/Apps/AppBuilder/AppBuilder.cs index 42ba785..a6bf77c 100644 --- a/Peer/Apps/AppBuilder/AppBuilder.cs +++ b/Peer/Apps/AppBuilder/AppBuilder.cs @@ -36,7 +36,7 @@ public AppBuilder WithConfiguration(Action builder) return this; } - public AppBuilder WithSharedServiceConfig(Func> config) + public AppBuilder WithSharedRuntimeConfig(Func> config) { _services.AddSingleton(new FuncServiceSetupHandler(config)); return this; diff --git a/Peer/Handlers/ConfigEditHandler.cs b/Peer/Handlers/ConfigEditHandler.cs index e4cd70e..d36cea5 100644 --- a/Peer/Handlers/ConfigEditHandler.cs +++ b/Peer/Handlers/ConfigEditHandler.cs @@ -5,6 +5,7 @@ using Peer.Apps; using Peer.ConfigSections; using Peer.Domain.Commands; +using Peer.Parsing; using Peer.Verbs; using wimm.Secundatives; @@ -12,13 +13,20 @@ namespace Peer.Handlers; public class ConfigEditHandler : IHandler { + private readonly PeerEnvironmentOptions _opts; + + public ConfigEditHandler(PeerEnvironmentOptions opts) + { + _opts = opts; + } + public async Task HandleAsync(ConfigEditOptions opts, IServiceCollection services, CancellationToken token = default) { services.AddSingleton(); services.AddSingleton(sp => { var config = sp.GetRequiredService(); - return new ConfigEditConfig(config["Editor"], Constants.DefaultConfigPath); + return new ConfigEditConfig(_opts.Editor, _opts.ConfigPath); }); var sp = services.BuildServiceProvider(); diff --git a/Peer/Handlers/ConfigInitHandler.cs b/Peer/Handlers/ConfigInitHandler.cs index 22cd796..98e9d31 100644 --- a/Peer/Handlers/ConfigInitHandler.cs +++ b/Peer/Handlers/ConfigInitHandler.cs @@ -5,12 +5,15 @@ using Microsoft.Extensions.DependencyInjection; using Peer.Apps; using Peer.ConfigSections; +using Peer.Parsing; using Peer.Verbs; namespace Peer.Handlers; public class ConfigInitHandler : IHandler { + private readonly PeerEnvironmentOptions _opts; + private const string _defaultConfig = @" { ""Peer"": { @@ -46,16 +49,21 @@ public class ConfigInitHandler : IHandler } "; + public ConfigInitHandler(PeerEnvironmentOptions opts) + { + _opts = opts; + } + public async Task HandleAsync(ConfigInitOptions opts, IServiceCollection services, CancellationToken token = default) { - if (File.Exists(Constants.DefaultConfigPath) && !opts.Force) + if (File.Exists(_opts.ConfigPath) && !opts.Force) { var forceName = opts.GetOptionLongName(nameof(opts.Force)); Console.WriteLine($"You already have a config file! If you want it overwritten use the --{forceName} option"); return 1; } - await using var file = File.Create(Constants.DefaultConfigPath); + await using var file = File.Create(_opts.ConfigPath); await using var writer = new StreamWriter(file); await writer.WriteAsync(_defaultConfig); return 0; diff --git a/Peer/Handlers/ConfigShowHandler.cs b/Peer/Handlers/ConfigShowHandler.cs index f20c4ad..6fc2994 100644 --- a/Peer/Handlers/ConfigShowHandler.cs +++ b/Peer/Handlers/ConfigShowHandler.cs @@ -5,26 +5,34 @@ using Microsoft.Extensions.DependencyInjection; using Peer.Apps; using Peer.ConfigSections; +using Peer.Parsing; using Peer.Verbs; namespace Peer.Handlers; public class ConfigShowHandler : IHandler { + private readonly PeerEnvironmentOptions _opts; + + public ConfigShowHandler(PeerEnvironmentOptions opts) + { + _opts = opts; + } + public async Task HandleAsync( ConfigShowOptions opts, IServiceCollection services, CancellationToken token = default) { - if (!File.Exists(Constants.DefaultConfigPath)) + if (!File.Exists(_opts.ConfigPath)) { await Console.Error.WriteLineAsync($"You don't have a config! User config --init to create a default config"); return 1; } - var text = await File.ReadAllTextAsync(Constants.DefaultConfigPath, token); - Console.WriteLine($"//Path: {Constants.DefaultConfigPath}"); + var text = await File.ReadAllTextAsync(_opts.ConfigPath, token); + Console.WriteLine($"// Path: {_opts.ConfigPath}"); Console.WriteLine(text); return 0; } diff --git a/Peer/Parsing/PeerOptions.cs b/Peer/Parsing/PeerOptions.cs index 1ead333..3327a50 100644 --- a/Peer/Parsing/PeerOptions.cs +++ b/Peer/Parsing/PeerOptions.cs @@ -1,8 +1,16 @@ -namespace Peer.Parsing +using Peer.ConfigSections; + +namespace Peer.Parsing { public class PeerOptions { public int? ShowTimeoutSeconds { get; set; } public int? WatchIntervalSeconds { get; set; } } + + public class PeerEnvironmentOptions + { + public string ConfigPath { get; set; } = Constants.DefaultConfigPath; + public string? Editor { get; set; } + } } diff --git a/Peer/Program.cs b/Peer/Program.cs index 9567734..5a99b06 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -38,13 +40,34 @@ public static async Task Main(string[] args) }; Console.OutputEncoding = Encoding.UTF8; - + var builder = new AppBuilder(new ServiceCollection()) .WithParseTimeServiceConfig(SetupParseTimeServices) - .WithSharedServiceConfig(SetupServices) + .WithSharedRuntimeConfig(SetupServices) .WithConfiguration(configBuilder => { - configBuilder.AddJsonFile(Constants.DefaultConfigPath, optional: true) + var configPath = Environment.GetEnvironmentVariable("PEER_CONFIGPATH") + ?? Constants.DefaultConfigPath; + + if (configPath.StartsWith("~/")) + { + + configPath = Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + configPath[2..]); + } + + var editor = Environment.GetEnvironmentVariable("EDITOR"); + var inMemory = new Dictionary + { + ["Peer:Environment:ConfigPath"] = configPath, + ["Peer:Environment:Editor"] = editor + }; + + configBuilder + .AddInMemoryCollection(inMemory) + .AddJsonFile(configPath, optional: true) + .SetFileLoadExceptionHandler(x => x.Ignore = true) .AddEnvironmentVariables(); }); @@ -221,7 +244,10 @@ private static Result SetupParseTimeServices(IS services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - + services.AddSingleton( + sp => sp.GetRequiredService() + .GetSection("Peer:Environment") + .Get()); return new Result(services); } diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index f8871dd..81682e4 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,10 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "show" + "commandLineArgs": "config edit", + "environmentVariables": { + "EDITOR": "code -r" + } } } } diff --git a/README.md b/README.md index 0f458ed..de7ec9a 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,24 @@ Peer requires a few things to run properly on different platforms. This list may - Tier 3 (We don't have the hardware to test but we'll do our best!) - win-arm64 - osx-arm64 + +## Environment variables + +Peer configuration can be specified in the config file or in environment variables. Environment variables take precedence over the config file. When using environment variables, the levels of nesting are represented using double underscores. For example, the environment variable `PEER__WATCHINTERVALSECONDS` would override the `Peer.WatchInvervalSeconds` value in the config file. + +Setting a config variable in sh-like shells +``` +export PEER__WATCHINTERVALSECONDS=30 +``` + + +Setting a config variable in pwsh/powershell +``` +$env:PEER__WATCHINTERVALSECONDS = 20 +``` + + +Some specially named variables are respected during the config load and editor opening commands: + +- `PEER_CONFIGPATH` if this is set then peer will look for its config there instead of the default locations +- `EDITOR` peer will invoke your editor for commands that require it such as `config edit`. If this variable isn't set peer will open it using the default handler on your operating system