diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd0da7..cdbc0ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,95 @@ # 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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/Peer.Domain/Check.cs b/Peer.Domain/Check.cs index f2f1df6..b032540 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/Commands/Config.cs b/Peer.Domain/Commands/Config.cs deleted file mode 100644 index fa265c0..0000000 --- a/Peer.Domain/Commands/Config.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; - -namespace Peer.Domain.Commands -{ - public class Config - { - private static readonly string _configFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "peer.json"); - - private const string _configHelp = @" -{ - ""Peer"": { - //optional: The amount of time between calls to providers when using the --watch flag - ""WatchIntervalSeconds"": 30 - }, - ""Providers"": { - //The type of the provider you're configuring (currently there's only github!) - ""github"": [{ - //required: a friendly name for this provider - ""Name"": ""required: a friendly name for this provider"", - ""Configuration"": { - //required: your API token - ""AccessToken"": """", - //optional: the github username you're interested in investigating, alternatively we'll fetch yours 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 - ""Orgs"": [""myorg"", ""wareismymind"", ""someuser""], - //optional: Orgs that you'd like to exclude from the output, only really makes sense if no orgs are set - ""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 - } - }] - } -} -"; - public static Task ConfigAsync() - { - 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; - } - } -} diff --git a/Peer.Domain/Commands/ConfigEdit.cs b/Peer.Domain/Commands/ConfigEdit.cs new file mode 100644 index 0000000..40944c5 --- /dev/null +++ b/Peer.Domain/Commands/ConfigEdit.cs @@ -0,0 +1,82 @@ +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(); + 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) + { + //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 }).ValueOr(ConfigEditError.ProcessFailedToOpen), + _ when os == OSPlatform.OSX => Process.Start("open", path).ValueOr(ConfigEditError.ProcessFailedToOpen), + _ => 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/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 deleted file mode 100644 index 7f6fb3a..0000000 --- a/Peer.Domain/Commands/IPullRequestService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using wimm.Secundatives; - -namespace Peer.Domain.Commands -{ - public interface IPullRequestService - { - Task> FetchAllPullRequests(CancellationToken token = default); - Task> FindByPartial(PartialIdentifier partial, CancellationToken token = default); - } -} diff --git a/Peer.Domain/Commands/Open.cs b/Peer.Domain/Commands/Open.cs index 4ed561b..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 @@ -21,7 +22,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..85a0fa3 100644 --- a/Peer.Domain/Commands/Show.cs +++ b/Peer.Domain/Commands/Show.cs @@ -1,49 +1,90 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; 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; 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; + private readonly List _filters; + + public ShowConfig Config { get; } public Show( - IEnumerable fetchers, + IPullRequestService prService, IListFormatter formatter, IConsoleWriter writer, - ISorter? sorter = null) + ShowConfig config, + ISorter? sorter = null, + IEnumerable? filters = null) { - _fetchers = fetchers.ToList(); + _pullRequestService = prService; _formatter = formatter; _writer = writer; + Config = config; _sorter = sorter; + _filters = filters?.ToList() ?? new(); } - 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); + using var cts = new CancellationTokenSource(); + token.Register(() => cts.Cancel()); + cts.CancelAfter(Config.TimeoutSeconds); + + var prs = await GetPullRequests(args, cts.Token); + if (prs.IsError) + { + _writer.Clear(); + _writer.Display(new List + { + 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; + } + + var lines = _formatter.FormatLines(prs.Value).ToList(); + _writer.Display(lines, token); return Maybe.None; } - private async Task> FetchAllSources(CancellationToken token) + private async Task, ShowError>> GetPullRequests(ShowArguments args, 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; + try + { + 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); + } + catch (OperationCanceledException) + { + return ShowError.Timeout; + } + catch (FetchException) + { + return ShowError.Fire; + } } } public enum ShowError { + Timeout, Fire, } } 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/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..8ed59e7 100644 --- a/Peer.Domain/Commands/WatchShow.cs +++ b/Peer.Domain/Commands/WatchShow.cs @@ -1,4 +1,6 @@ -using System.Threading; +using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using wimm.Secundatives; @@ -15,20 +17,38 @@ 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) { + var 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); + try + { + await Task.Delay(_show.Config.WatchIntervalSeconds, token); + } + catch (OperationCanceledException) + { + break; + } } 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..74f573a --- /dev/null +++ b/Peer.Domain/Configuration/CommandConfigs/ShowConfig.cs @@ -0,0 +1,27 @@ +using System; + +namespace Peer.Domain.Configuration.CommandConfigs +{ + public class ShowConfig + { + private const int _defaultTimeoutSeconds = 10; + private const int _defaultWatchIntervalSeconds = 30; + private const int _defaultWatchMaxConsecutiveShowFailures = 5; + + public TimeSpan TimeoutSeconds { get; set; } + public TimeSpan WatchIntervalSeconds { get; set; } + + public int WatchMaxConsecutiveShowFailures { get; set; } + + public ShowConfig( + int? timeoutSeconds, + int? watchIntervalSeconds, + int? watchMaxConsecutiveShowFailures) + { + TimeoutSeconds = TimeSpan.FromSeconds(timeoutSeconds ?? _defaultTimeoutSeconds); + WatchIntervalSeconds = TimeSpan.FromSeconds(watchIntervalSeconds ?? _defaultWatchIntervalSeconds); + WatchMaxConsecutiveShowFailures = + watchMaxConsecutiveShowFailures ?? _defaultWatchMaxConsecutiveShowFailures; + } + } +} 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/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/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/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/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/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.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/IPullRequestFetcher.cs b/Peer.Domain/IPullRequestFetcher.cs index 7a4083d..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/IPullRequestService.cs b/Peer.Domain/IPullRequestService.cs new file mode 100644 index 0000000..0f1e778 --- /dev/null +++ b/Peer.Domain/IPullRequestService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Peer.Domain.Commands; +using wimm.Secundatives; + +namespace Peer.Domain +{ + public interface IPullRequestService + { + IAsyncEnumerable FetchAllPullRequests(CancellationToken token = default); + Task> FindSingleByPartial(PartialIdentifier partial, 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..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 { @@ -16,22 +17,19 @@ public PullRequestService(IEnumerable fetchers) _fetchers = fetchers.ToList(); } - //TODO(cn): AsyncEnumerable - public async Task> FetchAllPullRequests(CancellationToken token = default) + public IAsyncEnumerable 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().SelectMany(x => x.GetPullRequestsAsync(token)); + return 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 prs = 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.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.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.Domain/Util/Validators.cs b/Peer.Domain/Util/Validators.cs index 801fce3..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,25 +35,25 @@ 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); } - 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.GitHub/GitHubRequestFetcher.cs b/Peer.GitHub/GitHubRequestFetcher.cs index 0a03dd9..b7a1468 100644 --- a/Peer.GitHub/GitHubRequestFetcher.cs +++ b/Peer.GitHub/GitHubRequestFetcher.cs @@ -1,9 +1,14 @@ -using System.Collections.Generic; +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; using Peer.Utils; using GQL = Peer.GitHub.GraphQL; using PRSearch = Peer.GitHub.GraphQL.PullRequestSearch; @@ -12,50 +17,62 @@ 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 AsyncLazy _involvesRequest; - private readonly AsyncLazy _reviewRequestedRequest; private readonly CancellationTokenSource _cts = new(); - - public GitHubRequestFetcher(GraphQLHttpClient client, GitHubPeerConfig gitHubPeerConfig) + private CancellationTokenRegistration _registration; + 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 IAsyncEnumerable GetPullRequestsAsync(CancellationToken token = default) { - using var registration = token.Register(() => _cts.Cancel()); + _registration = token.Register(() => _cts.Cancel()); + return GetPullRequestsImpl(token); + } - var involvesResponse = - await _gqlClient.SendQueryAsync>(await _involvesRequest, token); + private async IAsyncEnumerable GetPullRequestsImpl([EnumeratorCancellation] CancellationToken token) + { + token.ThrowIfCancellationRequested(); - 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()) { - 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; @@ -68,29 +85,77 @@ 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 (true) + { + token.ThrowIfCancellationRequested(); + + var response = await GetPullRequests(type, cursor, token); + + if (response.Errors != null) + { + Console.WriteLine($"ERROR: {string.Join('\n', response.Errors.Select(x => x.Message))}"); + } + + var pageInfo = response.Data.Search.PageInfo; - return new GraphQLHttpRequest(PRSearch.Search.GenerateInvolves(searchParams)); + cursor = pageInfo.EndCursor; + + foreach (var pr in response.Data.Search.Nodes) + { + yield return pr; + } + + if (!pageInfo.HasNextPage) + { + break; + } + } + } + + 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, + TeamRequested } - private async Task GenerateReviewRequestedRequest() + 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) @@ -103,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; } @@ -115,5 +182,26 @@ 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) { } + } + } + + public void Dispose() + { + _registration.Dispose(); + } } } diff --git a/Peer.GitHub/GitHubWebRegistrationHandler.cs b/Peer.GitHub/GitHubWebRegistrationHandler.cs index 752669f..aebe0ca 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; @@ -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.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..94b77b2 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,18 @@ 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 ?? "octoghost", + ProviderConstants.Github), Url, new Descriptor(Title, Body ?? string.Empty), new State(status, totalComments, activeComments), @@ -69,13 +84,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..ada147e 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,42 @@ ... 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 { + statusCheckRollup { state } + 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/Apps/AppBuilderTests.cs b/Peer.UnitTests/Apps/AppBuilderTests.cs new file mode 100644 index 0000000..cb062f4 --- /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.WithSharedRuntimeConfig( + 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.WithSharedRuntimeConfig( + 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/DefaultEmojiProviderTests.cs b/Peer.UnitTests/DefaultEmojiProviderTests.cs new file mode 100644 index 0000000..fcf71ed --- /dev/null +++ b/Peer.UnitTests/DefaultEmojiProviderTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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/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/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/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/Models/CheckTests.cs b/Peer.UnitTests/Models/CheckTests.cs new file mode 100644 index 0000000..e0487b8 --- /dev/null +++ b/Peer.UnitTests/Models/CheckTests.cs @@ -0,0 +1,71 @@ +using System; +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/Parsing/FilterParserTests.cs b/Peer.UnitTests/Parsing/FilterParserTests.cs new file mode 100644 index 0000000..bfc9693 --- /dev/null +++ b/Peer.UnitTests/Parsing/FilterParserTests.cs @@ -0,0 +1,133 @@ +using System; +using Peer.Domain; +using Peer.Domain.Filters; +using Peer.Parsing; +using Peer.UnitTests.Util; +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 RawStringHasSemiColonsInContent_ReturnsRegexFilter() + { + var value = FilterParser.ParseFilterOption("author:Insomnia:k"); + ResultAsserts.IsValue(value); + } + + [Fact] + public void KeyIsEmpty_ReturnsNoFilterKeySpecified() + { + var value = FilterParser.ParseFilterOption(":Insomniak"); + ResultAsserts.IsError(value, FilterParseError.NoFilterKeySpecified); + } + + [Fact] + public void KeyNotFound_ReturnsUnknownKey() + { + var value = FilterParser.ParseFilterOption("doot:Insomniak"); + 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() + { + 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 78% rename from Peer.UnitTests/SortParserTests.cs rename to Peer.UnitTests/Parsing/SortParserTests.cs index e405b47..1aa523d 100644 --- a/Peer.UnitTests/SortParserTests.cs +++ b/Peer.UnitTests/Parsing/SortParserTests.cs @@ -1,12 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Peer.Domain; using Peer.Parsing; using Peer.UnitTests.Util; using Xunit; -namespace Peer.UnitTests +namespace Peer.UnitTests.Parsing { public class SortParserTests { @@ -28,25 +29,25 @@ 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] - 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); } @@ -69,14 +70,14 @@ public void 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.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/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/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.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.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"] + } +} 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..a6bf77c --- /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 WithSharedRuntimeConfig(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/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/Handlers/ConfigEditHandler.cs b/Peer/Handlers/ConfigEditHandler.cs new file mode 100644 index 0000000..d36cea5 --- /dev/null +++ b/Peer/Handlers/ConfigEditHandler.cs @@ -0,0 +1,38 @@ +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.Parsing; +using Peer.Verbs; +using wimm.Secundatives; + +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(_opts.Editor, _opts.ConfigPath); + }); + + var sp = services.BuildServiceProvider(); + + var command = sp.GetRequiredService(); + return await command.RunAsync() + .MapOr(_ => 0, _ => 1); + } +} diff --git a/Peer/Handlers/ConfigInitHandler.cs b/Peer/Handlers/ConfigInitHandler.cs new file mode 100644 index 0000000..98e9d31 --- /dev/null +++ b/Peer/Handlers/ConfigInitHandler.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +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"": { + // 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"": { + // A list of GitHub pull request provider configurations + ""github"": [{ + // A friendly name for this provider (required) + ""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 + ""AccessToken"": """", + // 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"": """", + // 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""], + // 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"": [], + // The number of pull requests to include in your search results (min: 1, max: 100, default: 20) + ""Count"": 30 + } + }] + } +} +"; + + public ConfigInitHandler(PeerEnvironmentOptions opts) + { + _opts = opts; + } + + public async Task HandleAsync(ConfigInitOptions opts, IServiceCollection services, CancellationToken token = default) + { + 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(_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 new file mode 100644 index 0000000..6fc2994 --- /dev/null +++ b/Peer/Handlers/ConfigShowHandler.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +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(_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(_opts.ConfigPath, token); + Console.WriteLine($"// Path: {_opts.ConfigPath}"); + Console.WriteLine(text); + return 0; + } +} diff --git a/Peer/Parsing/FilterParseError.cs b/Peer/Parsing/FilterParseError.cs new file mode 100644 index 0000000..cfd641e --- /dev/null +++ b/Peer/Parsing/FilterParseError.cs @@ -0,0 +1,11 @@ +namespace Peer.Parsing +{ + public enum FilterParseError + { + NotEnoughSections, + UnknownFilterKey, + UnknownMatchValue, + FilterContentEmpty, + NoFilterKeySpecified + } +} diff --git a/Peer/Parsing/FilterParser.cs b/Peer/Parsing/FilterParser.cs new file mode 100644 index 0000000..6cd24fa --- /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 splitPoint = filterRaw.IndexOf(':'); + + return splitPoint switch + { + -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() }) + }; + } + + 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/PeerOptions.cs b/Peer/Parsing/PeerOptions.cs new file mode 100644 index 0000000..3327a50 --- /dev/null +++ b/Peer/Parsing/PeerOptions.cs @@ -0,0 +1,16 @@ +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/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 280de33..02a97e1 100644 --- a/Peer/Parsing/SortParser.cs +++ b/Peer/Parsing/SortParser.cs @@ -6,7 +6,7 @@ namespace Peer.Parsing { public class SortParser { - public static Result, ParseError> ParseSortOption(string sortOption) + public static Result, SortParseError> ParseSortOption(string sortOption) { if (sortOption == null) { @@ -15,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) { @@ -42,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; @@ -53,29 +45,15 @@ private static Result GetSortDirection(string[] secti "ascending" => SortDirection.Ascending, "desc" => SortDirection.Descending, "descending" => SortDirection.Descending, - _ => ParseError.InvalidSortDirection - }; - } - - private static Result, ParseError> GetSelector(string name) - { - return name switch - { - "repo" => MakeSelector(x => x.Identifier.Repo), - "id-lex" => MakeSelector(x => x.Id), - "id" => MakeSelector(x => int.Parse(x.Id)), - "owner" => MakeSelector(x => x.Identifier.Owner), - "status" => MakeSelector(x => x.State.Status), - "active" => MakeSelector(x => x.State.ActiveComments), - _ => ParseError.UnknownSortKey + _ => SortParseError.InvalidSortDirection }; } - //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 + private static Result, SortParseError> GetSelector(string name) { - return x => selector(x); + return SelectorMap.TryGetSelector(name) + .OkOr(SortParseError.UnknownSortKey) + .Map(x => x.Selector); } } } diff --git a/Peer/Parsing/WatchOptions.cs b/Peer/Parsing/WatchOptions.cs deleted file mode 100644 index 24f541d..0000000 --- a/Peer/Parsing/WatchOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using Peer.Domain.Commands; - -namespace Peer.Parsing -{ - public class WatchOptions - { - public int WatchIntervalSeconds { get; set; } = 30; - - public WatchArguments Into() - { - return new WatchArguments(TimeSpan.FromSeconds(WatchIntervalSeconds)); - } - } -} 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 bcd73bb..5a99b06 100644 --- a/Peer/Program.cs +++ b/Peer/Program.cs @@ -1,18 +1,22 @@ -using System; +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; -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; @@ -21,20 +25,15 @@ 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(); @@ -42,67 +41,122 @@ public static async Task Main(string[] args) Console.OutputEncoding = Encoding.UTF8; - 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; - config.AutoVersion = true; - config.IgnoreUnknownArguments = false; - }); - - - var parseResult = parser.ParseArguments(args); - - if (parseResult.Tag == ParserResultType.Parsed) - { - await parseResult.MapResult( - (ShowOptions x) => ShowAsync(x, services, _tcs.Token), - (OpenOptions x) => OpenAsync(x, services, _tcs.Token), - (ConfigOptions x) => ConfigAsync(x), - (DetailsOptions x) => DetailsAsync(x, services, _tcs.Token), - err => Task.CompletedTask); - - return; - } - - 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) - }; - - Console.Write(text); + var builder = new AppBuilder(new ServiceCollection()) + .WithParseTimeServiceConfig(SetupParseTimeServices) + .WithSharedRuntimeConfig(SetupServices) + .WithConfiguration(configBuilder => + { + 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(); + }); + + builder.WithVerb( + conf => + { + conf.WithSubVerb(x => x.WithHandler()) + .WithSubVerb(x => x.WithHandler()) + .WithSubVerb(x => x.WithHandler()); + }) + .WithVerb( + conf => + { + conf.WithCustomHelp() + .WithActionHandler(ShowAsync) + .WithRunTimeConfig(); + + }) + .WithVerb( + conf => + { + conf.WithActionHandler(OpenAsync) + .WithRunTimeConfig(); + }) + .WithVerb( + conf => + { + conf.WithCustomHelp() + .WithActionHandler(DetailsAsync) + .WithRunTimeConfig(); + }); + + + var p = builder.Build(); + + 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; + } + + return showConfig.Into(); + }); - public static async Task ShowAsync(ShowOptions opts, IServiceCollection services, CancellationToken token) - { 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); } + if (opts.Filter != null) + { + foreach (var filter in opts.Filter) + { + var parsedFilter = FilterParser.ParseFilterOption(filter); + + if (parsedFilter.IsError) + { + await Console.Error.WriteLineAsync($"Failed to parse filter option: {parsedFilter.Error}"); + return 1; + } + + services.AddSingleton(parsedFilter.Value); + } + } + services.AddSingleton(); services.AddSingleton(new ConsoleConfig(inline: !opts.Watch)); @@ -111,27 +165,31 @@ 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(), token); + await command.WatchAsync(new ShowArguments(opts.Count), token); } else { var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - await command.ShowAsync(new ShowArguments(), 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(); @@ -142,19 +200,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
(); @@ -165,47 +230,30 @@ 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; } + + await Console.Error.WriteLineAsync($"Partial identifier '{opts.Partial}' failed with error: {res.Error}"); + return 1; } - public static async Task ConfigAsync(ConfigOptions _) + private static Result SetupParseTimeServices(IServiceCollection services) { - await Config.ConfigAsync(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton( + sp => sp.GetRequiredService() + .GetSection("Peer:Environment") + .Get()); + return new Result(services); } - private static Result SetupServices() + [RequiresUnreferencedCode("Calls Microsoft.Extensions.Configuration.IConfiguration.Get()")] + private static Result SetupServices(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; - } - - var watchOptions = configuration.GetSection("Peer") - .Get() - ?? new WatchOptions(); - - if (watchOptions != null) - { - services.AddSingleton(watchOptions); - } - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -213,10 +261,9 @@ private static Result SetupServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, ShowHelpTextFormatter>(); - services.AddSingleton, DetailsHelpTextFormatter>(); + services.AddSingleton(); - return services; + return new Result(services); } } } diff --git a/Peer/Properties/launchSettings.json b/Peer/Properties/launchSettings.json index 5f68267..81682e4 100644 --- a/Peer/Properties/launchSettings.json +++ b/Peer/Properties/launchSettings.json @@ -2,7 +2,10 @@ "profiles": { "Peer": { "commandName": "Project", - "commandLineArgs": "details 123" + "commandLineArgs": "config edit", + "environmentVariables": { + "EDITOR": "code -r" + } } } } 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/ShowHelpTextFormatter.cs b/Peer/Verbs/ShowHelpTextFormatter.cs index 1aa34d9..e1c43f8 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 { @@ -19,15 +20,50 @@ 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); help.AddPostOptionsLines(statuses.Select(x => $" {x.ToString().PadRight(maxWidth)} => {_symbolProvider.GetSymbol(x)}")); 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; } } diff --git a/Peer/Verbs/ShowOptions.cs b/Peer/Verbs/ShowOptions.cs index 07bfa06..f452aad 100644 --- a/Peer/Verbs/ShowOptions.cs +++ b/Peer/Verbs/ShowOptions.cs @@ -1,14 +1,22 @@ -using CommandLine; +using System.Collections.Generic; +using CommandLine; 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)] public bool Watch { get; set; } + + [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; } } } 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/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 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 diff --git a/version.txt b/version.txt index 26ca594..81c871d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.5.1 +1.10.0