diff --git a/README.md b/README.md index ba4299c..267aae1 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,47 @@ -# Jira Worklogger - -Creative Commons License
Licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. - -## The problem - -Do you have to track your work by means of Jira issue worklogs? Is the Jira GUI _clickfest_ approach making your neurotic inner beast emerge on the surface? - -## The solution - -Meet the scripted Jira worklogging! Give it your worklogs in a CSV file (and your server URI and your user credentials in a config file) and let the automaton do the rest! - -## Prerequisites - -- .NET 6 run-time installed (for simple, cross-platform build) or no .NET runtime necessary (for self-contained, single-exe, Windows-only build); You choose! -- Jira server (with version 2 REST API) - - "vanilla" Jira server support: ✔️ - - "Tempo Timesheets" plugin support: ✔️ - - "ICTime" plugin support: ❎ (planned) - -## Configuration - -The jwl.config file is a simple JSON structure. It can be placed in (and will be read by jwl in the priority order of) -- "current folder" (as in "where your shell's %CD% or ${PWD} is at the moment") -- local application data (%USERPROFILE%\AppData\Local) -- roaming application data (%USERPROFILE%\AppData\Roaming) -- jwl's "installation" folder - -As for the CLI worklogger binary, there are command-line options available as well. Any partial options supplied via CLI will override their respective jwl.config counterparts with the highest priority. - -### "ServerClass" setting - -Available values are: -- Vanilla -- TempoTimeSheets -- ICTime (not implemented yet) - -## The input CSV structure - -Five columns, data delimited (by default) by a colon: - - Date (string) - worklog day date (valid formats: YYYY-MM-DD, YYYY/MM/DD, DD.MM.YYYY, all with optional HH:MI:SS part) - - IssueKey (string) - Jira issue key (SOMEPROJECT-1234 and the likes) - - Activity (string) - Tempo Timesheets worklog type or ICTime activity; values are remapped - - TimeSpent (string) - time to be logged for the Jira issue and the date (valid formats: HH:MI, MI, HH h MI, HH h MI m) - - Comment (string) - optional worklog comment - -The Activity values are remapped via config $.JiraServer.ActivityMap. The mapping configuration depends on your Jira server+plugins configuration and is a subject of manual setup by yourself. +# Jira Worklogger + +Creative Commons License
Licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. + +## The problem + +Do you have to track your work by means of Jira issue worklogs? Is the Jira GUI _clickfest_ approach making your neurotic inner beast emerge on the surface? + +## The solution + +Meet the scripted Jira worklogging! Give it your worklogs in a CSV file (and your server URI and your user credentials in a config file) and let the automaton do the rest! + +## Prerequisites + +- .NET 6 run-time installed (for simple, cross-platform build) or no .NET runtime necessary (for self-contained, single-exe, Windows-only build); You choose! +- Jira server (with version 2 REST API) + - "vanilla" Jira server support: ✔️ + - "Tempo Timesheets" plugin support: ✔️ + - "ICTime" plugin support: ❎ (planned) + +## Configuration + +The jwl.config file is a simple JSON structure. It can be placed in (and will be read by jwl in the priority order of) +- "current folder" (as in "where your shell's %CD% or ${PWD} is at the moment") +- local application data (%USERPROFILE%\AppData\Local) +- roaming application data (%USERPROFILE%\AppData\Roaming) +- jwl's "installation" folder + +As for the CLI worklogger binary, there are command-line options available as well. Any partial options supplied via CLI will override their respective jwl.config counterparts with the highest priority. + +### "ServerClass" setting + +Available values are: +- Vanilla +- TempoTimeSheets +- ICTime (not implemented yet) + +## The input CSV structure + +Five columns, data delimited (by default) by a colon: + - Date (string) - worklog day date (valid formats: YYYY-MM-DD, YYYY/MM/DD, DD.MM.YYYY, all with optional HH:MI:SS part) + - IssueKey (string) - Jira issue key (SOMEPROJECT-1234 and the likes) + - Activity (string) - Tempo Timesheets worklog type or ICTime activity; values are remapped + - TimeSpent (string) - time to be logged for the Jira issue and the date (valid formats: HH:MI, MI, HH h MI, HH h MI m) + - Comment (string) - optional worklog comment + +The Activity values are remapped via config $.JiraServer.ActivityMap. The mapping configuration depends on your Jira server+plugins configuration and is a subject of manual setup by yourself. diff --git a/_local_build.cmd b/_local_build.cmd index cad8fb4..61b3ce6 100644 --- a/_local_build.cmd +++ b/_local_build.cmd @@ -1,3 +1,3 @@ -pushd jwl.console -call global_build.cmd jira-worklogger -@exit /b %ERRORLEVEL% +pushd jwl.console +call global_build.cmd jira-worklogger +@exit /b %ERRORLEVEL% diff --git a/jwl.console/CLI.cs b/jwl.console/CLI.cs index 0bd11a2..6ab5a59 100644 --- a/jwl.console/CLI.cs +++ b/jwl.console/CLI.cs @@ -1,76 +1,76 @@ -namespace jwl.console; -using CommandLine; - -[Verb("fill", isDefault: true, HelpText = "Fill Jira with worklogs")] -public class FillCLI -{ - [Option('v', "verbose", HelpText = "\nGive more verbose feedback\nNote: Not implemented yet! 2do! :-)", Default = false, Hidden = true)] - public bool UseVerboseFeedback { get; set; } - - [Option('i', "input", HelpText = "\nInput CSVs with the worklogs", Separator = ',', Required = true)] - public IEnumerable InputFiles { get; set; } = new string[0]; - - [Option("ifs", HelpText = "Input CSV fields delimiter" - + $"\nJSON config: $.{nameof(core.AppConfig.CsvOptions)}.{nameof(inputs.CsvFormatConfig.FieldDelimiter)}")] - public string? FieldDelimiter { get; set; } - - [Option('t', "target", HelpText = "Connection string to Jira server in the form of @[:]" - + "\nNote: The \"https://\" scheme is automatically asserted with this option!" - + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.BaseUrl)} for [:]" - + $"\nJSON config: $.{nameof(core.AppConfig.User)}.{nameof(core.UserConfig.Name)} for ")] - public string? UserCredentials { get; set; } - - [Option("server-flavour", HelpText = "Jira server flavour (whether vanilla or with some timesheet plugins)" - + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.Flavour)}" - + $"\nAvailable values: {nameof(jira.JiraServerFlavour.Vanilla)}, {nameof(jira.JiraServerFlavour.TempoTimeSheets)}, {nameof(jira.JiraServerFlavour.ICTime)}")] - public string? ServerFlavour { get; set; } - - [Option("no-proxy", HelpText = "Turn off proxying the HTTP(S) connections to Jira server" - + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.UseProxy)} (negated!)")] - public bool? NoProxy { get; set; } - - [Option("max-connections-per-server", HelpText = "Limit the number of concurrent connections to Jira server" - + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.MaxConnectionsPerServer)}")] - public int? MaxConnectionsPerServer { get; set; } - - [Option("no-ssl-cert-check", HelpText = "Skip SSL/TLS certificate checks (for self-signed certificates)" - + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.SkipSslCertificateCheck)}")] - public bool? SkipSslCertificateCheck { get; set; } - - public core.AppConfig ToAppConfig() - { - string[] connectionSpecifierSplit = UserCredentials?.Split('@', 2, StringSplitOptions.TrimEntries) ?? Array.Empty(); - - string? jiraServerSpecification = connectionSpecifierSplit.Length > 1 ? connectionSpecifierSplit[1] : null; - if (!jiraServerSpecification?.Contains(@"://") ?? false) - jiraServerSpecification = @"https://" + jiraServerSpecification; - - string? jiraUserCredentials = connectionSpecifierSplit.Any() ? connectionSpecifierSplit[0] : null; - string[] jiraUserCredentialsSplit = jiraUserCredentials?.Split('/', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); - string? jiraUserName = jiraUserCredentialsSplit.Any() ? jiraUserCredentialsSplit[0] : null; - string? jiraUserPassword = jiraUserCredentialsSplit.Length > 1 ? jiraUserCredentialsSplit[1] : null; - - return new core.AppConfig() - { - UseVerboseFeedback = UseVerboseFeedback, - JiraServer = new jira.ServerConfig() - { - Flavour = ServerFlavour, - FlavourOptions = null, - BaseUrl = jiraServerSpecification, - UseProxy = !NoProxy, - MaxConnectionsPerServer = MaxConnectionsPerServer, - SkipSslCertificateCheck = SkipSslCertificateCheck - }, - User = new core.UserConfig() - { - Name = jiraUserName, - Password = jiraUserPassword - }, - CsvOptions = new inputs.CsvFormatConfig() - { - FieldDelimiter = FieldDelimiter - } - }; - } +namespace jwl.console; +using CommandLine; + +[Verb("fill", isDefault: true, HelpText = "Fill Jira with worklogs")] +public class FillCLI +{ + [Option('v', "verbose", HelpText = "\nGive more verbose feedback\nNote: Not implemented yet! 2do! :-)", Default = false, Hidden = true)] + public bool UseVerboseFeedback { get; set; } + + [Option('i', "input", HelpText = "\nInput CSVs with the worklogs", Separator = ',', Required = true)] + public IEnumerable InputFiles { get; set; } = new string[0]; + + [Option("ifs", HelpText = "Input CSV fields delimiter" + + $"\nJSON config: $.{nameof(core.AppConfig.CsvOptions)}.{nameof(inputs.CsvFormatConfig.FieldDelimiter)}")] + public string? FieldDelimiter { get; set; } + + [Option('t', "target", HelpText = "Connection string to Jira server in the form of @[:]" + + "\nNote: The \"https://\" scheme is automatically asserted with this option!" + + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.BaseUrl)} for [:]" + + $"\nJSON config: $.{nameof(core.AppConfig.User)}.{nameof(core.UserConfig.Name)} for ")] + public string? UserCredentials { get; set; } + + [Option("server-flavour", HelpText = "Jira server flavour (whether vanilla or with some timesheet plugins)" + + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.Flavour)}" + + $"\nAvailable values: {nameof(jira.JiraServerFlavour.Vanilla)}, {nameof(jira.JiraServerFlavour.TempoTimeSheets)}, {nameof(jira.JiraServerFlavour.ICTime)}")] + public string? ServerFlavour { get; set; } + + [Option("no-proxy", HelpText = "Turn off proxying the HTTP(S) connections to Jira server" + + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.UseProxy)} (negated!)")] + public bool? NoProxy { get; set; } + + [Option("max-connections-per-server", HelpText = "Limit the number of concurrent connections to Jira server" + + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.MaxConnectionsPerServer)}")] + public int? MaxConnectionsPerServer { get; set; } + + [Option("no-ssl-cert-check", HelpText = "Skip SSL/TLS certificate checks (for self-signed certificates)" + + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.SkipSslCertificateCheck)}")] + public bool? SkipSslCertificateCheck { get; set; } + + public core.AppConfig ToAppConfig() + { + string[] connectionSpecifierSplit = UserCredentials?.Split('@', 2, StringSplitOptions.TrimEntries) ?? Array.Empty(); + + string? jiraServerSpecification = connectionSpecifierSplit.Length > 1 ? connectionSpecifierSplit[1] : null; + if (!jiraServerSpecification?.Contains(@"://") ?? false) + jiraServerSpecification = @"https://" + jiraServerSpecification; + + string? jiraUserCredentials = connectionSpecifierSplit.Any() ? connectionSpecifierSplit[0] : null; + string[] jiraUserCredentialsSplit = jiraUserCredentials?.Split('/', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty(); + string? jiraUserName = jiraUserCredentialsSplit.Any() ? jiraUserCredentialsSplit[0] : null; + string? jiraUserPassword = jiraUserCredentialsSplit.Length > 1 ? jiraUserCredentialsSplit[1] : null; + + return new core.AppConfig() + { + UseVerboseFeedback = UseVerboseFeedback, + JiraServer = new jira.ServerConfig() + { + Flavour = ServerFlavour, + FlavourOptions = null, + BaseUrl = jiraServerSpecification, + UseProxy = !NoProxy, + MaxConnectionsPerServer = MaxConnectionsPerServer, + SkipSslCertificateCheck = SkipSslCertificateCheck + }, + User = new core.UserConfig() + { + Name = jiraUserName, + Password = jiraUserPassword + }, + CsvOptions = new inputs.CsvFormatConfig() + { + FieldDelimiter = FieldDelimiter + } + }; + } } \ No newline at end of file diff --git a/jwl.console/ScrollingConsoleProcessFeedback.cs b/jwl.console/ScrollingConsoleProcessFeedback.cs index 82c0c69..4fd7e0f 100644 --- a/jwl.console/ScrollingConsoleProcessFeedback.cs +++ b/jwl.console/ScrollingConsoleProcessFeedback.cs @@ -1,171 +1,171 @@ -namespace jwl.console; -using jwl.core; -using jwl.infra; - -public class ScrollingConsoleProcessFeedback - : ICoreProcessFeedback, IDisposable -{ - public Action? FeedbackDelay { get; init; } = null; - - private bool _isDisposed; - private int _numberOfWorklogsToInsert = 0; - private int _numberOfWorklogsToDelete = 0; - - public ScrollingConsoleProcessFeedback(int totalSteps) - { - } - - public void Dispose() - { - // note: Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - public void FillJiraWithWorklogsStart() - { - _numberOfWorklogsToInsert = 0; - _numberOfWorklogsToDelete = 0; - Console.Error.Write(@"Filling Jira with worklogs..."); - } - - public void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numberOfWorklogsToDelete) - { - _numberOfWorklogsToInsert = numberOfWorklogsToInsert; - _numberOfWorklogsToDelete = numberOfWorklogsToDelete; - Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})..."); - } - - public void FillJiraWithWorklogsProcess(MultiTaskStats progress) - { - Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})... {ProgressPercentageAsString(progress)}"); - } - - public void FillJiraWithWorklogsEnd() - { - _numberOfWorklogsToInsert = 0; - _numberOfWorklogsToDelete = 0; - Console.Error.WriteLine(); - } - - public void NoExistingWorklogsToDelete() - { - } - - public void NoFilesOnInput() - { - Console.Error.WriteLine(@"Note: No files on input - no work to be done"); - } - - public void NoWorklogsToFill() - { - Console.Error.WriteLine(@"Note: Empty files on input - no work to be done"); - } - - public void OverallProcessStart() - { - Console.Error.WriteLine(@"Jira Worklogger CLI 1.1.0"); // 2do! read from assembly - Console.Error.WriteLine(@"by Peter H., practically copyleft"); // 2do! read from assembly - Console.Error.WriteLine(new string('-', Console.WindowWidth - 1)); - } - - public void OverallProcessEnd() - { - Console.Error.WriteLine(@"DONE"); - } - - public void PreloadAvailableWorklogTypesStart() - { - Console.Error.Write(@"Preloading available worklog types from server..."); - } - - public void PreloadAvailableWorklogTypesEnd() - { - Console.Error.WriteLine(@" OK"); - } - - public void PreloadUserInfoStart(string userName) - { - Console.Error.Write($"Preloading user \"{userName}\" info from server..."); - } - - public void PreloadUserInfoEnd() - { - Console.Error.WriteLine(@" OK"); - } - - public void ReadCsvInputStart() - { - Console.Error.Write(@"Reading input files..."); - } - - public void ReadCsvInputSetTarget(int numberOfInputFiles) - { - Console.Error.Write($"\rReading {numberOfInputFiles} input files..."); - } - - public void ReadCsvInputProcess(MultiTaskStats progress) - { - Console.Error.Write($"\rReading {progress.Total} input files... {ProgressPercentageAsString(progress)}"); - } - - public void ReadCsvInputEnd() - { - Console.Error.WriteLine(); - } - - public void RetrieveWorklogsForDeletionStart() - { - Console.Error.Write(@"Retrieving list of worklogs to be deleted..."); - } - - public void RetrieveWorklogsForDeletionSetTarget(int count) - { - Console.Error.Write($"\rRetrieving list of worklogs ({count} Jira issues) to be deleted..."); - } - - public void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress) - { - throw new NotImplementedException(@"--- checkpoint ---"); - } - - public void RetrieveWorklogsForDeletionEnd() - { - Console.Error.WriteLine(" OK"); - } - - protected static string ProgressPercentageAsString(MultiTaskStats progress) - { - string result; - - if (progress.Total <= 0) - { - result = @"done"; - } - else - { - result = progress.SucceededPct.ToString("P"); - - if (progress.ErredSoFar > 0) - { - result += " OK, " + progress.ErredSoFarPct.ToString("P") + " failed"; - } - } - - return result; - } - - protected virtual void Dispose(bool disposing) - { - if (!_isDisposed) - { - if (disposing) - { - } - - // TODO: free unmanaged resources (unmanaged objects) and override finalizer - // TODO: set large fields to null - _isDisposed = true; - } - } -} +namespace jwl.console; +using jwl.core; +using jwl.infra; + +public class ScrollingConsoleProcessFeedback + : ICoreProcessFeedback, IDisposable +{ + public Action? FeedbackDelay { get; init; } = null; + + private bool _isDisposed; + private int _numberOfWorklogsToInsert = 0; + private int _numberOfWorklogsToDelete = 0; + + public ScrollingConsoleProcessFeedback(int totalSteps) + { + } + + public void Dispose() + { + // note: Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + public void FillJiraWithWorklogsStart() + { + _numberOfWorklogsToInsert = 0; + _numberOfWorklogsToDelete = 0; + Console.Error.Write(@"Filling Jira with worklogs..."); + } + + public void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numberOfWorklogsToDelete) + { + _numberOfWorklogsToInsert = numberOfWorklogsToInsert; + _numberOfWorklogsToDelete = numberOfWorklogsToDelete; + Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})..."); + } + + public void FillJiraWithWorklogsProcess(MultiTaskStats progress) + { + Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})... {ProgressPercentageAsString(progress)}"); + } + + public void FillJiraWithWorklogsEnd() + { + _numberOfWorklogsToInsert = 0; + _numberOfWorklogsToDelete = 0; + Console.Error.WriteLine(); + } + + public void NoExistingWorklogsToDelete() + { + } + + public void NoFilesOnInput() + { + Console.Error.WriteLine(@"Note: No files on input - no work to be done"); + } + + public void NoWorklogsToFill() + { + Console.Error.WriteLine(@"Note: Empty files on input - no work to be done"); + } + + public void OverallProcessStart() + { + Console.Error.WriteLine(@"Jira Worklogger CLI 1.1.0"); // 2do! read from assembly + Console.Error.WriteLine(@"by Peter H., practically copyleft"); // 2do! read from assembly + Console.Error.WriteLine(new string('-', Console.WindowWidth - 1)); + } + + public void OverallProcessEnd() + { + Console.Error.WriteLine(@"DONE"); + } + + public void PreloadAvailableWorklogTypesStart() + { + Console.Error.Write(@"Preloading available worklog types from server..."); + } + + public void PreloadAvailableWorklogTypesEnd() + { + Console.Error.WriteLine(@" OK"); + } + + public void PreloadUserInfoStart(string userName) + { + Console.Error.Write($"Preloading user \"{userName}\" info from server..."); + } + + public void PreloadUserInfoEnd() + { + Console.Error.WriteLine(@" OK"); + } + + public void ReadCsvInputStart() + { + Console.Error.Write(@"Reading input files..."); + } + + public void ReadCsvInputSetTarget(int numberOfInputFiles) + { + Console.Error.Write($"\rReading {numberOfInputFiles} input files..."); + } + + public void ReadCsvInputProcess(MultiTaskStats progress) + { + Console.Error.Write($"\rReading {progress.Total} input files... {ProgressPercentageAsString(progress)}"); + } + + public void ReadCsvInputEnd() + { + Console.Error.WriteLine(); + } + + public void RetrieveWorklogsForDeletionStart() + { + Console.Error.Write(@"Retrieving list of worklogs to be deleted..."); + } + + public void RetrieveWorklogsForDeletionSetTarget(int count) + { + Console.Error.Write($"\rRetrieving list of worklogs ({count} Jira issues) to be deleted..."); + } + + public void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress) + { + throw new NotImplementedException(@"--- checkpoint ---"); + } + + public void RetrieveWorklogsForDeletionEnd() + { + Console.Error.WriteLine(" OK"); + } + + protected static string ProgressPercentageAsString(MultiTaskStats progress) + { + string result; + + if (progress.Total <= 0) + { + result = @"done"; + } + else + { + result = progress.SucceededPct.ToString("P"); + + if (progress.ErredSoFar > 0) + { + result += " OK, " + progress.ErredSoFarPct.ToString("P") + " failed"; + } + } + + return result; + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + } + + // TODO: free unmanaged resources (unmanaged objects) and override finalizer + // TODO: set large fields to null + _isDisposed = true; + } + } +} diff --git a/jwl.core/AppConfigFactory.cs b/jwl.core/AppConfigFactory.cs index b1794ba..6437dcb 100644 --- a/jwl.core/AppConfigFactory.cs +++ b/jwl.core/AppConfigFactory.cs @@ -1,91 +1,91 @@ -namespace jwl.core; -using jwl.inputs; -using jwl.jira; -using jwl.jira.Flavours; -using Microsoft.Extensions.Configuration; -using System.Xml.XPath; - -public static class AppConfigFactory -{ - public const int DefaultMaxConnectionsPerServer = 4; - - private const string ConfigFileName = @"jwl.config"; - private const string FlavourConfigFileNameTemplate = @"jwl.{0}.config"; - - public static AppConfig CreateWithDefaults() - { - return new AppConfig() - { - JiraServer = new ServerConfig() - { - Flavour = nameof(JiraServerFlavour.Vanilla), - FlavourOptions = null, - BaseUrl = @"http://jira.my-domain.xyz", - MaxConnectionsPerServer = DefaultMaxConnectionsPerServer, - SkipSslCertificateCheck = false, - UseProxy = false - }, - CsvOptions = new CsvFormatConfig() - { - FieldDelimiter = "," - }, - User = new UserConfig() - { - Name = string.Empty, - Password = null - } - }; - } - - public static AppConfig ReadConfig() - { - AppConfig? result; - - IConfiguration config = new ConfigurationBuilder() - .AddJsonFile(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Path.GetFullPath("."), ConfigFileName), optional: true) - .Build(); - - result = config.Get(opt => - { - opt.BindNonPublicProperties = false; - opt.ErrorOnUnknownConfiguration = true; - }); - - if (result?.JiraServer != null) - { - JiraServerFlavour flavour = result.JiraServer.FlavourId; - string flavourConfigFileName = string.Format(FlavourConfigFileNameTemplate, flavour.ToString().ToLower()); - IConfiguration flavourConfig = new ConfigurationBuilder() - .AddJsonFile(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, flavourConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), flavourConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), flavourConfigFileName), optional: true) - .AddJsonFile(Path.Combine(Path.GetFullPath("."), flavourConfigFileName), optional: true) - .Build(); - - result!.JiraServer.FlavourOptions = flavour switch - { - JiraServerFlavour.Vanilla => flavourConfig.Get(opt => - { - opt.BindNonPublicProperties = false; - opt.ErrorOnUnknownConfiguration = true; - }), - JiraServerFlavour.TempoTimeSheets => flavourConfig.Get(opt => - { - opt.BindNonPublicProperties = false; - opt.ErrorOnUnknownConfiguration = true; - }), - JiraServerFlavour.ICTime => flavourConfig.Get(opt => - { - opt.BindNonPublicProperties = false; - opt.ErrorOnUnknownConfiguration = true; - }), - _ => throw new ArgumentOutOfRangeException(nameof(result.JiraServer.Flavour), $"Don't know how to read flavour {flavour} specific options config") - }; - } - - return result ?? CreateWithDefaults(); - } -} +namespace jwl.core; +using jwl.inputs; +using jwl.jira; +using jwl.jira.Flavours; +using Microsoft.Extensions.Configuration; +using System.Xml.XPath; + +public static class AppConfigFactory +{ + public const int DefaultMaxConnectionsPerServer = 4; + + private const string ConfigFileName = @"jwl.config"; + private const string FlavourConfigFileNameTemplate = @"jwl.{0}.config"; + + public static AppConfig CreateWithDefaults() + { + return new AppConfig() + { + JiraServer = new ServerConfig() + { + Flavour = nameof(JiraServerFlavour.Vanilla), + FlavourOptions = null, + BaseUrl = @"http://jira.my-domain.xyz", + MaxConnectionsPerServer = DefaultMaxConnectionsPerServer, + SkipSslCertificateCheck = false, + UseProxy = false + }, + CsvOptions = new CsvFormatConfig() + { + FieldDelimiter = "," + }, + User = new UserConfig() + { + Name = string.Empty, + Password = null + } + }; + } + + public static AppConfig ReadConfig() + { + AppConfig? result; + + IConfiguration config = new ConfigurationBuilder() + .AddJsonFile(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), ConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Path.GetFullPath("."), ConfigFileName), optional: true) + .Build(); + + result = config.Get(opt => + { + opt.BindNonPublicProperties = false; + opt.ErrorOnUnknownConfiguration = true; + }); + + if (result?.JiraServer != null) + { + JiraServerFlavour flavour = result.JiraServer.FlavourId; + string flavourConfigFileName = string.Format(FlavourConfigFileNameTemplate, flavour.ToString().ToLower()); + IConfiguration flavourConfig = new ConfigurationBuilder() + .AddJsonFile(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, flavourConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), flavourConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), flavourConfigFileName), optional: true) + .AddJsonFile(Path.Combine(Path.GetFullPath("."), flavourConfigFileName), optional: true) + .Build(); + + result!.JiraServer.FlavourOptions = flavour switch + { + JiraServerFlavour.Vanilla => flavourConfig.Get(opt => + { + opt.BindNonPublicProperties = false; + opt.ErrorOnUnknownConfiguration = true; + }), + JiraServerFlavour.TempoTimeSheets => flavourConfig.Get(opt => + { + opt.BindNonPublicProperties = false; + opt.ErrorOnUnknownConfiguration = true; + }), + JiraServerFlavour.ICTime => flavourConfig.Get(opt => + { + opt.BindNonPublicProperties = false; + opt.ErrorOnUnknownConfiguration = true; + }), + _ => throw new ArgumentOutOfRangeException(nameof(result.JiraServer.Flavour), $"Don't know how to read flavour {flavour} specific options config") + }; + } + + return result ?? CreateWithDefaults(); + } +} diff --git a/jwl.core/ICoreProcessFeedback.cs b/jwl.core/ICoreProcessFeedback.cs index 6cd208d..cb0511a 100644 --- a/jwl.core/ICoreProcessFeedback.cs +++ b/jwl.core/ICoreProcessFeedback.cs @@ -1,28 +1,28 @@ -namespace jwl.core; -using jwl.infra; - -public interface ICoreProcessFeedback - : IDisposable -{ - void FillJiraWithWorklogsStart(); - void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numbeOfWorklogsToDelete); - void FillJiraWithWorklogsProcess(MultiTaskStats progress); - void FillJiraWithWorklogsEnd(); - void NoExistingWorklogsToDelete(); - void NoFilesOnInput(); - void NoWorklogsToFill(); - void OverallProcessStart(); - void OverallProcessEnd(); - void PreloadAvailableWorklogTypesStart(); - void PreloadAvailableWorklogTypesEnd(); - void PreloadUserInfoStart(string userName); - void PreloadUserInfoEnd(); - void ReadCsvInputStart(); - void ReadCsvInputSetTarget(int numberOfInputFiles); - void ReadCsvInputProcess(MultiTaskStats progress); - void ReadCsvInputEnd(); - void RetrieveWorklogsForDeletionStart(); - void RetrieveWorklogsForDeletionSetTarget(int count); - void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress); - void RetrieveWorklogsForDeletionEnd(); -} +namespace jwl.core; +using jwl.infra; + +public interface ICoreProcessFeedback + : IDisposable +{ + void FillJiraWithWorklogsStart(); + void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numbeOfWorklogsToDelete); + void FillJiraWithWorklogsProcess(MultiTaskStats progress); + void FillJiraWithWorklogsEnd(); + void NoExistingWorklogsToDelete(); + void NoFilesOnInput(); + void NoWorklogsToFill(); + void OverallProcessStart(); + void OverallProcessEnd(); + void PreloadAvailableWorklogTypesStart(); + void PreloadAvailableWorklogTypesEnd(); + void PreloadUserInfoStart(string userName); + void PreloadUserInfoEnd(); + void ReadCsvInputStart(); + void ReadCsvInputSetTarget(int numberOfInputFiles); + void ReadCsvInputProcess(MultiTaskStats progress); + void ReadCsvInputEnd(); + void RetrieveWorklogsForDeletionStart(); + void RetrieveWorklogsForDeletionSetTarget(int count); + void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress); + void RetrieveWorklogsForDeletionEnd(); +} diff --git a/jwl.core/MultiTaskStats.cs b/jwl.core/MultiTaskStats.cs index fda1898..faacf2e 100644 --- a/jwl.core/MultiTaskStats.cs +++ b/jwl.core/MultiTaskStats.cs @@ -1,63 +1,63 @@ -namespace jwl.core; -using jwl.infra; - -public class MultiTaskStats -{ - public int Total { get; private set; } - public int Succeeded { get; private set; } - public float SucceededPct => Total > 0 ? (float)Succeeded / Total : float.NaN; - public int Faulted { get; private set; } - public float FaultedPct => Total > 0 ? (float)Faulted / Total : float.NaN; - public int Cancelled { get; private set; } - public float CancelledPct => Total > 0 ? (float)Cancelled / Total : float.NaN; - public int Unknown { get; private set; } - public float UnknownPct => Total > 0 ? (float)Unknown / Total : float.NaN; - - public int ErredSoFar => Faulted + Cancelled + Unknown; - public float ErredSoFarPct => Total > 0 ? (float)ErredSoFar / Total : float.NaN; - public int DoneSoFar => Succeeded + ErredSoFar; - public float DoneSoFarPct => Total > 0 ? (float)DoneSoFar / Total : float.NaN; - - public MultiTaskStats(int total) - { - Total = total; - Succeeded = 0; - Faulted = 0; - Cancelled = 0; - Unknown = 0; - } - - private object _locker = new object(); - - public MultiTaskStats ApplyTaskStatus(TaskStatus? taskStatus) - { - if (taskStatus == null) - return this; - - lock (_locker) - { - switch (taskStatus) - { - case TaskStatus.RanToCompletion: - Succeeded++; - break; - case TaskStatus.Canceled: - Cancelled++; - break; - case TaskStatus.Faulted: - Faulted++; - break; - case TaskStatus.Created: - case TaskStatus.WaitingForActivation: - case TaskStatus.WaitingToRun: - case TaskStatus.WaitingForChildrenToComplete: - break; - default: - Unknown++; - break; - } - } - - return this; - } -} +namespace jwl.core; +using jwl.infra; + +public class MultiTaskStats +{ + public int Total { get; private set; } + public int Succeeded { get; private set; } + public float SucceededPct => Total > 0 ? (float)Succeeded / Total : float.NaN; + public int Faulted { get; private set; } + public float FaultedPct => Total > 0 ? (float)Faulted / Total : float.NaN; + public int Cancelled { get; private set; } + public float CancelledPct => Total > 0 ? (float)Cancelled / Total : float.NaN; + public int Unknown { get; private set; } + public float UnknownPct => Total > 0 ? (float)Unknown / Total : float.NaN; + + public int ErredSoFar => Faulted + Cancelled + Unknown; + public float ErredSoFarPct => Total > 0 ? (float)ErredSoFar / Total : float.NaN; + public int DoneSoFar => Succeeded + ErredSoFar; + public float DoneSoFarPct => Total > 0 ? (float)DoneSoFar / Total : float.NaN; + + public MultiTaskStats(int total) + { + Total = total; + Succeeded = 0; + Faulted = 0; + Cancelled = 0; + Unknown = 0; + } + + private object _locker = new object(); + + public MultiTaskStats ApplyTaskStatus(TaskStatus? taskStatus) + { + if (taskStatus == null) + return this; + + lock (_locker) + { + switch (taskStatus) + { + case TaskStatus.RanToCompletion: + Succeeded++; + break; + case TaskStatus.Canceled: + Cancelled++; + break; + case TaskStatus.Faulted: + Faulted++; + break; + case TaskStatus.Created: + case TaskStatus.WaitingForActivation: + case TaskStatus.WaitingToRun: + case TaskStatus.WaitingForChildrenToComplete: + break; + default: + Unknown++; + break; + } + } + + return this; + } +} diff --git a/jwl.core/_assets/jwl.config b/jwl.core/_assets/jwl.config index c2200e7..704078c 100644 --- a/jwl.core/_assets/jwl.config +++ b/jwl.core/_assets/jwl.config @@ -1,17 +1,17 @@ -{ - "UseVerboseFeedback": false, - "JiraServer": { - "BaseUrl": "https://example.company.org/jira/", - "Flavour": "Vanilla", - "MaxConnectionsPerServer": 4, - "UseProxy": false, - "SkipSslCertificateCheck": false - }, - "User": { - "Name": "my Jira user name", - "Password": "my Jira password" - }, - "CsvOptions": { - "FieldDelimiter": "," - } -} +{ + "UseVerboseFeedback": false, + "JiraServer": { + "BaseUrl": "https://example.company.org/jira/", + "Flavour": "Vanilla", + "MaxConnectionsPerServer": 4, + "UseProxy": false, + "SkipSslCertificateCheck": false + }, + "User": { + "Name": "my Jira user name", + "Password": "my Jira password" + }, + "CsvOptions": { + "FieldDelimiter": "," + } +} diff --git a/jwl.core/_assets/jwl.ictime.config b/jwl.core/_assets/jwl.ictime.config index 545ec91..9067358 100644 --- a/jwl.core/_assets/jwl.ictime.config +++ b/jwl.core/_assets/jwl.ictime.config @@ -1,10 +1,10 @@ -{ - "PluginBaseUri": "rest/ictime/1.0", - "DateFormat": "dd/MMM/yyyy", - "TimeSpanFormat": "ddd\" d \"hh\" h \"mm\" m\"", - "ActivityMap": { - "none": "0", - "dev": "1", - "business analysis": "2" - } -} +{ + "PluginBaseUri": "rest/ictime/1.0", + "DateFormat": "dd/MMM/yyyy", + "TimeSpanFormat": "ddd\" d \"hh\" h \"mm\" m\"", + "ActivityMap": { + "none": "0", + "dev": "1", + "business analysis": "2" + } +} diff --git a/jwl.infra/DateOnlyUtils.cs b/jwl.infra/DateOnlyUtils.cs index a2e8082..1b25ca0 100644 --- a/jwl.infra/DateOnlyUtils.cs +++ b/jwl.infra/DateOnlyUtils.cs @@ -1,31 +1,31 @@ -namespace jwl.infra; - -public static class DateOnlyUtils -{ - public static (DateTime, DateTime) DateOnlyRangeToDateTimeRange(DateOnly from, DateOnly to) - { - if (from > to) - throw new ArgumentException($"{nameof(to)} cannot be less than {nameof(from)}", $"{nameof(from)}, {nameof(to)}"); - - DateTime minDt = from.ToDateTime(TimeOnly.MinValue); - DateTime supDt = to.ToDateTime(TimeOnly.MinValue).AddDays(1); - - return (minDt, supDt); - } - - public static int NumberOfDaysInRange(DateOnly from, DateOnly to) - { - (DateTime minDt, DateTime supDt) = DateOnlyRangeToDateTimeRange(from, to); - return (int)(supDt - minDt).TotalDays; - } - - public static int NumberOfDaysTo(this DateOnly from, DateOnly to) - { - return NumberOfDaysInRange(from, to) - 1; - } - - public static int NumberOfDaysSince(this DateOnly to, DateOnly from) - { - return NumberOfDaysInRange(from, to) - 1; - } -} +namespace jwl.infra; + +public static class DateOnlyUtils +{ + public static (DateTime, DateTime) DateOnlyRangeToDateTimeRange(DateOnly from, DateOnly to) + { + if (from > to) + throw new ArgumentException($"{nameof(to)} cannot be less than {nameof(from)}", $"{nameof(from)}, {nameof(to)}"); + + DateTime minDt = from.ToDateTime(TimeOnly.MinValue); + DateTime supDt = to.ToDateTime(TimeOnly.MinValue).AddDays(1); + + return (minDt, supDt); + } + + public static int NumberOfDaysInRange(DateOnly from, DateOnly to) + { + (DateTime minDt, DateTime supDt) = DateOnlyRangeToDateTimeRange(from, to); + return (int)(supDt - minDt).TotalDays; + } + + public static int NumberOfDaysTo(this DateOnly from, DateOnly to) + { + return NumberOfDaysInRange(from, to) - 1; + } + + public static int NumberOfDaysSince(this DateOnly to, DateOnly from) + { + return NumberOfDaysInRange(from, to) - 1; + } +} diff --git a/jwl.infra/HttpClientJsonExt.cs b/jwl.infra/HttpClientJsonExt.cs index 09a9ccf..b9d92b9 100644 --- a/jwl.infra/HttpClientJsonExt.cs +++ b/jwl.infra/HttpClientJsonExt.cs @@ -1,31 +1,31 @@ -namespace jwl.infra; -using System.Text.Json; - -public static class HttpClientJsonExt -{ - public static async Task GetAsJsonAsync(this HttpClient self, string uri) - { - using Stream response = await self.GetStreamAsync(uri); - TResponse result = await DeserializeJsonStreamAsync(response); - return result; - } - - public static async Task GetAsJsonAsync(this HttpClient self, Uri uri) - { - using Stream response = await self.GetStreamAsync(uri); - TResponse result = await DeserializeJsonStreamAsync(response); - return result; - } - - public static async Task DeserializeJsonStreamAsync(Stream stream) - { - JsonSerializerOptions jsonSerializerOptions = new JsonSerializerOptions() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - IncludeFields = true - }; - TResponse result = await JsonSerializer.DeserializeAsync(stream, jsonSerializerOptions) - ?? throw new NullReferenceException("JSON deserialization NULL result"); - return result; - } +namespace jwl.infra; +using System.Text.Json; + +public static class HttpClientJsonExt +{ + public static async Task GetAsJsonAsync(this HttpClient self, string uri) + { + using Stream response = await self.GetStreamAsync(uri); + TResponse result = await DeserializeJsonStreamAsync(response); + return result; + } + + public static async Task GetAsJsonAsync(this HttpClient self, Uri uri) + { + using Stream response = await self.GetStreamAsync(uri); + TResponse result = await DeserializeJsonStreamAsync(response); + return result; + } + + public static async Task DeserializeJsonStreamAsync(Stream stream) + { + JsonSerializerOptions jsonSerializerOptions = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + IncludeFields = true + }; + TResponse result = await JsonSerializer.DeserializeAsync(stream, jsonSerializerOptions) + ?? throw new NullReferenceException("JSON deserialization NULL result"); + return result; + } } \ No newline at end of file diff --git a/jwl.infra/JiraIssueKey.cs b/jwl.infra/JiraIssueKey.cs index dc96d17..9ce6442 100644 --- a/jwl.infra/JiraIssueKey.cs +++ b/jwl.infra/JiraIssueKey.cs @@ -1,60 +1,60 @@ -namespace jwl.infra; -using System.Collections; -using System.Diagnostics.CodeAnalysis; - -public struct JiraIssueKey -{ - public string ProjectKey { get; private set; } - public int IssueNumber { get; private set; } - - public class ProjectOnlyEqualityComparer : IEqualityComparer - { - public ProjectOnlyEqualityComparer() - { - } - - public bool Equals(JiraIssueKey x, JiraIssueKey y) - { - return x.ProjectKey.Equals(y.ProjectKey); - } - - public int GetHashCode([DisallowNull] JiraIssueKey obj) - { - return obj.ProjectKey.GetHashCode(); - } - } - - public JiraIssueKey(string project, int issueNumber) - { - ProjectKey = project; - IssueNumber = issueNumber; - } - - public JiraIssueKey(string issueKey) - { - (ProjectKey, IssueNumber) = SplitIssueKey(issueKey); - } - - public override string ToString() - { - return ProjectKey + "-" + IssueNumber.ToString(); - } - - private static (string, int) SplitIssueKey(string issueKey) - { - (string, int) result; - - string[] issueKeySplit = issueKey.Split('-', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (issueKeySplit.Length != 2) - throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, $"Invalid format of the issue key; must be -"); - - result.Item1 = issueKeySplit[0]; - - if (!int.TryParse(issueKeySplit[1], out result.Item2)) - throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, @"Issue number not an actual number"); - if (result.Item2 <= 0) - throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, @"Issue number must be positive"); - - return result; - } -} +namespace jwl.infra; +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +public struct JiraIssueKey +{ + public string ProjectKey { get; private set; } + public int IssueNumber { get; private set; } + + public class ProjectOnlyEqualityComparer : IEqualityComparer + { + public ProjectOnlyEqualityComparer() + { + } + + public bool Equals(JiraIssueKey x, JiraIssueKey y) + { + return x.ProjectKey.Equals(y.ProjectKey); + } + + public int GetHashCode([DisallowNull] JiraIssueKey obj) + { + return obj.ProjectKey.GetHashCode(); + } + } + + public JiraIssueKey(string project, int issueNumber) + { + ProjectKey = project; + IssueNumber = issueNumber; + } + + public JiraIssueKey(string issueKey) + { + (ProjectKey, IssueNumber) = SplitIssueKey(issueKey); + } + + public override string ToString() + { + return ProjectKey + "-" + IssueNumber.ToString(); + } + + private static (string, int) SplitIssueKey(string issueKey) + { + (string, int) result; + + string[] issueKeySplit = issueKey.Split('-', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (issueKeySplit.Length != 2) + throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, $"Invalid format of the issue key; must be -"); + + result.Item1 = issueKeySplit[0]; + + if (!int.TryParse(issueKeySplit[1], out result.Item2)) + throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, @"Issue number not an actual number"); + if (result.Item2 <= 0) + throw new ArgumentOutOfRangeException(nameof(issueKey), issueKey, @"Issue number must be positive"); + + return result; + } +} diff --git a/jwl.infra/StringExt.cs b/jwl.infra/StringExt.cs index 0104818..510d68d 100644 --- a/jwl.infra/StringExt.cs +++ b/jwl.infra/StringExt.cs @@ -1,18 +1,18 @@ -namespace jwl.infra; -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -public static class StringExt -{ - public static string ToLowerFirstChar(this string str) - => string.Create(str.Length, str, (output, input) - => - { - input.CopyTo(output); - output[0] = char.ToLowerInvariant(input[0]); - }); -} +namespace jwl.infra; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +public static class StringExt +{ + public static string ToLowerFirstChar(this string str) + => string.Create(str.Length, str, (output, input) + => + { + input.CopyTo(output); + output[0] = char.ToLowerInvariant(input[0]); + }); +} diff --git a/jwl.inputs/IWorklogReader.cs b/jwl.inputs/IWorklogReader.cs index 8c674a0..fdff042 100644 --- a/jwl.inputs/IWorklogReader.cs +++ b/jwl.inputs/IWorklogReader.cs @@ -1,6 +1,6 @@ -namespace jwl.inputs; - -public interface IWorklogReader : IDisposable -{ - IEnumerable Read(Action? validateResult = null); -} +namespace jwl.inputs; + +public interface IWorklogReader : IDisposable +{ + IEnumerable Read(Action? validateResult = null); +} diff --git a/jwl.inputs/InputWorkLog.cs b/jwl.inputs/InputWorkLog.cs index 7685368..24d773e 100644 --- a/jwl.inputs/InputWorkLog.cs +++ b/jwl.inputs/InputWorkLog.cs @@ -1,12 +1,12 @@ -#pragma warning disable SA1313 -namespace jwl.inputs; -using jwl.infra; - -public struct InputWorkLog -{ - public JiraIssueKey IssueKey; - public DateTime Date; - public TimeSpan TimeSpent; - public string WorkLogActivity; - public string WorkLogComment; -} +#pragma warning disable SA1313 +namespace jwl.inputs; +using jwl.infra; + +public struct InputWorkLog +{ + public JiraIssueKey IssueKey; + public DateTime Date; + public TimeSpan TimeSpent; + public string WorkLogActivity; + public string WorkLogComment; +} diff --git a/jwl.inputs/JiraWorklogRawCsv.cs b/jwl.inputs/JiraWorklogRawCsv.cs index 98a34fe..bde6cfb 100644 --- a/jwl.inputs/JiraWorklogRawCsv.cs +++ b/jwl.inputs/JiraWorklogRawCsv.cs @@ -1,12 +1,12 @@ -#pragma warning disable SA1313 -namespace jwl.inputs; - -public record JiraWorklogRawCsv -( - string IssueKey, - string Date, - string TimeSpent, - string WorkLogActivity, - string WorkLogComment -) -{ } +#pragma warning disable SA1313 +namespace jwl.inputs; + +public record JiraWorklogRawCsv +( + string IssueKey, + string Date, + string TimeSpent, + string WorkLogActivity, + string WorkLogComment +) +{ } diff --git a/jwl.inputs/WorklogCsvReader.cs b/jwl.inputs/WorklogCsvReader.cs index 297cbcc..4f2dfd3 100644 --- a/jwl.inputs/WorklogCsvReader.cs +++ b/jwl.inputs/WorklogCsvReader.cs @@ -1,97 +1,97 @@ -namespace jwl.inputs; -using System.Globalization; -using CsvHelper; -using CsvHelper.Configuration; -using jwl.infra; -using jwl.jira; - -public class WorklogCsvReader : IWorklogReader -{ - public bool ErrorOnEmptyRow { get; init; } = true; - - private CsvReader _csvReader; - private WorklogReaderAggregatedConfig _readerConfig; - - public WorklogCsvReader(TextReader inputFile, WorklogReaderAggregatedConfig readerConfig) - { - _readerConfig = readerConfig; - CsvConfiguration config = new (CultureInfo.InvariantCulture) - { - Delimiter = readerConfig.CsvFormatConfig?.FieldDelimiter ?? "," - }; - - _csvReader = new CsvReader(inputFile, config); - } - - public IEnumerable Read(Action? postProcessResult = null) - { - string[] dateFormats = - { - @"yyyy-MM-dd", - @"yyyy-MM-dd hh:mm:ss", - @"yyyy/MM/dd", - @"yyyy/MM/dd hh:mm:ss", - @"dd.MM.YYYY", - @"dd.MM.YYYY hh:mm:ss" - }; - - string[] timespanTimeFormats = - { - @"hh\:mm\:ss", - @"hh\:mm", - @"mm", - @"hh'h'mm", - @"hh'h'mm'm'" - }; - - foreach (JiraWorklogRawCsv row in _csvReader.GetRecords()) - { - if (row == null) - { - if (ErrorOnEmptyRow) - throw new InvalidDataException("Empty row on input"); - else - continue; - } - - InputWorkLog result; - - try - { - JiraIssueKey worklogIssueKey = new JiraIssueKey(row.IssueKey); - - if (!DateTime.TryParseExact(row.Date, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime worklogDate)) - throw new FormatException($"Invalid date/datetime value \"{row.Date}\""); - - TimeSpan worklogTimeSpent = HumanReadableTimeSpan.Parse(row.TimeSpent, timespanTimeFormats); - - result = new InputWorkLog() - { - IssueKey = worklogIssueKey, - Date = worklogDate, - TimeSpent = worklogTimeSpent, - WorkLogActivity = row.WorkLogActivity, - WorkLogComment = row.WorkLogComment - }; - - postProcessResult?.Invoke(result); - } - catch (Exception e) - { - throw new InputRowException(_csvReader.Parser.RawRow, e); - } - - yield return result; - } - } - - public void Dispose() - { - _csvReader.Dispose(); - } - - private void ParseHeader() - { - // 2do! - } -} +namespace jwl.inputs; +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using jwl.infra; +using jwl.jira; + +public class WorklogCsvReader : IWorklogReader +{ + public bool ErrorOnEmptyRow { get; init; } = true; + + private CsvReader _csvReader; + private WorklogReaderAggregatedConfig _readerConfig; + + public WorklogCsvReader(TextReader inputFile, WorklogReaderAggregatedConfig readerConfig) + { + _readerConfig = readerConfig; + CsvConfiguration config = new (CultureInfo.InvariantCulture) + { + Delimiter = readerConfig.CsvFormatConfig?.FieldDelimiter ?? "," + }; + + _csvReader = new CsvReader(inputFile, config); + } + + public IEnumerable Read(Action? postProcessResult = null) + { + string[] dateFormats = + { + @"yyyy-MM-dd", + @"yyyy-MM-dd hh:mm:ss", + @"yyyy/MM/dd", + @"yyyy/MM/dd hh:mm:ss", + @"dd.MM.YYYY", + @"dd.MM.YYYY hh:mm:ss" + }; + + string[] timespanTimeFormats = + { + @"hh\:mm\:ss", + @"hh\:mm", + @"mm", + @"hh'h'mm", + @"hh'h'mm'm'" + }; + + foreach (JiraWorklogRawCsv row in _csvReader.GetRecords()) + { + if (row == null) + { + if (ErrorOnEmptyRow) + throw new InvalidDataException("Empty row on input"); + else + continue; + } + + InputWorkLog result; + + try + { + JiraIssueKey worklogIssueKey = new JiraIssueKey(row.IssueKey); + + if (!DateTime.TryParseExact(row.Date, dateFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime worklogDate)) + throw new FormatException($"Invalid date/datetime value \"{row.Date}\""); + + TimeSpan worklogTimeSpent = HumanReadableTimeSpan.Parse(row.TimeSpent, timespanTimeFormats); + + result = new InputWorkLog() + { + IssueKey = worklogIssueKey, + Date = worklogDate, + TimeSpent = worklogTimeSpent, + WorkLogActivity = row.WorkLogActivity, + WorkLogComment = row.WorkLogComment + }; + + postProcessResult?.Invoke(result); + } + catch (Exception e) + { + throw new InputRowException(_csvReader.Parser.RawRow, e); + } + + yield return result; + } + } + + public void Dispose() + { + _csvReader.Dispose(); + } + + private void ParseHeader() + { + // 2do! + } +} diff --git a/jwl.jira/Flavours/FlavourICTimeOptions.cs b/jwl.jira/Flavours/FlavourICTimeOptions.cs index 9d0dc1e..39961f1 100644 --- a/jwl.jira/Flavours/FlavourICTimeOptions.cs +++ b/jwl.jira/Flavours/FlavourICTimeOptions.cs @@ -1,9 +1,9 @@ -namespace jwl.jira.Flavours; - -public class FlavourICTimeOptions : IFlavourOptions -{ - public string PluginBaseUri { get; init; } = "rest/ictime/1.0"; - public string DateFormat { get; init; } = "dd/MMM/yyyy"; - public string TimeSpanFormat { get; init; } = "ddd\" d \"hh\" h \"mm\" m\""; - public Dictionary? ActivityMap { get; init; } +namespace jwl.jira.Flavours; + +public class FlavourICTimeOptions : IFlavourOptions +{ + public string PluginBaseUri { get; init; } = "rest/ictime/1.0"; + public string DateFormat { get; init; } = "dd/MMM/yyyy"; + public string TimeSpanFormat { get; init; } = "ddd\" d \"hh\" h \"mm\" m\""; + public Dictionary? ActivityMap { get; init; } } \ No newline at end of file diff --git a/jwl.jira/Flavours/FlavourTempoTimesheetsOptions.cs b/jwl.jira/Flavours/FlavourTempoTimesheetsOptions.cs index 8c81ebd..46b1a27 100644 --- a/jwl.jira/Flavours/FlavourTempoTimesheetsOptions.cs +++ b/jwl.jira/Flavours/FlavourTempoTimesheetsOptions.cs @@ -1,8 +1,8 @@ -namespace jwl.jira.Flavours; - -public class FlavourTempoTimesheetsOptions - : IFlavourOptions -{ - public string PluginBaseUri { get; init; } = @"rest/tempo-timesheets/4"; - public string PluginCoreUri { get; init; } = @"rest/tempo-core/1"; -} +namespace jwl.jira.Flavours; + +public class FlavourTempoTimesheetsOptions + : IFlavourOptions +{ + public string PluginBaseUri { get; init; } = @"rest/tempo-timesheets/4"; + public string PluginCoreUri { get; init; } = @"rest/tempo-core/1"; +} diff --git a/jwl.jira/Flavours/FlavourVanillaJiraOptions.cs b/jwl.jira/Flavours/FlavourVanillaJiraOptions.cs index 798afa5..9ce2491 100644 --- a/jwl.jira/Flavours/FlavourVanillaJiraOptions.cs +++ b/jwl.jira/Flavours/FlavourVanillaJiraOptions.cs @@ -1,7 +1,7 @@ -namespace jwl.jira.Flavours; - -public class FlavourVanillaJiraOptions - : IFlavourOptions -{ - public string PluginBaseUri { get; init; } = @"rest/api/2"; -} +namespace jwl.jira.Flavours; + +public class FlavourVanillaJiraOptions + : IFlavourOptions +{ + public string PluginBaseUri { get; init; } = @"rest/api/2"; +} diff --git a/jwl.jira/Flavours/JiraServerFlavour.cs b/jwl.jira/Flavours/JiraServerFlavour.cs index 10efe2b..5e55975 100644 --- a/jwl.jira/Flavours/JiraServerFlavour.cs +++ b/jwl.jira/Flavours/JiraServerFlavour.cs @@ -1,8 +1,8 @@ -namespace jwl.jira; - -public enum JiraServerFlavour -{ - Vanilla = 0, - TempoTimeSheets = 1, - ICTime = 2 -} +namespace jwl.jira; + +public enum JiraServerFlavour +{ + Vanilla = 0, + TempoTimeSheets = 1, + ICTime = 2 +} diff --git a/jwl.jira/Flavours/JiraWithICTimePluginApi.cs b/jwl.jira/Flavours/JiraWithICTimePluginApi.cs index cd9c42a..ad5e71e 100644 --- a/jwl.jira/Flavours/JiraWithICTimePluginApi.cs +++ b/jwl.jira/Flavours/JiraWithICTimePluginApi.cs @@ -1,222 +1,222 @@ -namespace jwl.jira; - -using System.Globalization; -using System.Xml.Serialization; -using jwl.infra; -using jwl.jira.api.rest.request; -using jwl.jira.Flavours; -using jwl.wadl; - -// https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API -public class JiraWithICTimePluginApi - : IJiraClient -{ - public string UserName { get; } - public api.rest.common.JiraUserInfo UserInfo => _vanillaJiraApi.UserInfo; - - public Lazy> Endpoints => - new Lazy>(() => this.GetWADL().Result - .AsEnumerable() - .Where(res => !string.IsNullOrEmpty(res.Id)) - .ToDictionary(res => res.Id ?? string.Empty) - ); - - public const string CreateWorkLogMethodName = "createWorklog"; - public wadl.ComposedWadlMethodDefinition CreateWorkLogMethodDefinition => Endpoints.Value[CreateWorkLogMethodName]; - - public const string GetActivityTypesForProjectMethodName = "getActivityTypesForProject"; - public wadl.ComposedWadlMethodDefinition GetActivityTypesForProjectMethodDefinition => Endpoints.Value[GetActivityTypesForProjectMethodName]; - - private readonly HttpClient _httpClient; - private readonly FlavourICTimeOptions _flavourOptions; - private readonly FlavourICTimeOptions _defaultFlavourOptions = new FlavourICTimeOptions(); - private readonly VanillaJiraClient _vanillaJiraApi; - - public JiraWithICTimePluginApi(HttpClient httpClient, string userName, FlavourICTimeOptions? flavourOptions) - { - _httpClient = httpClient; - UserName = userName; - _flavourOptions = flavourOptions ?? _defaultFlavourOptions; - _vanillaJiraApi = new VanillaJiraClient(httpClient, userName, null); - } - - public async Task GetAvailableActivities(string issueKey) - { - var result = await GetAvailableActivities(new string[] { issueKey }); - return result[issueKey]; - } - - public async Task> GetAvailableActivities(IEnumerable issueKeys) - { - IEnumerable issueKeysParsed = issueKeys - .Select(issueKey => new JiraIssueKey(issueKey)) - .ToArray(); - - IEqualityComparer projectComparer = new JiraIssueKey.ProjectOnlyEqualityComparer(); - IEnumerable distinctProjects = issueKeysParsed - .Distinct(projectComparer); - - ComposedWadlMethodDefinition endPoint = GetActivityTypesForProjectMethodDefinition; - - HashSet missingParameters = endPoint.Parameters - .Where(par => !string.IsNullOrEmpty(par.Name)) - .Select(par => par.Name ?? string.Empty) - .ToHashSet(); - - // define - IEnumerable> uris = distinctProjects - .Select(issueKey => new KeyValuePair(issueKey, endPoint.ResourcePath.Replace("{issueKey}", issueKey.ToString()))); - - missingParameters.Remove("issueKey"); - - // check - HttpMethod expectedMethod = HttpMethod.Get; - bool isCorrectHttpMethod = endPoint.HttpMethod == expectedMethod; - if (!isCorrectHttpMethod) - throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} executes via ${endPoint.HttpMethod} method (${expectedMethod.ToString().ToUpperInvariant()} expected)"); - - if (missingParameters.Any()) - throw new ArgumentNullException($"Missing assignment of {string.Join(',', missingParameters)} in the call of {endPoint.Id} at resource path {endPoint.ResourcePath}"); - - bool providesJsonResponses = endPoint.Response?.Representations? - .Any(repr => repr.MediaType == WadlRepresentation.MediaTypes.Json) ?? false; - - if (providesJsonResponses) - throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} does not respond in JSON"); - - // execute - KeyValuePair>[] responseTaks = uris - .Select(uri => new KeyValuePair>(uri.Key, _httpClient.GetAsJsonAsync(uri.Value))) - .ToArray(); - - await Task.WhenAll(responseTaks.Select(x => x.Value)); - - Dictionary result = responseTaks - .Join( - inner: issueKeysParsed, - outerKeySelector: outer => outer.Key, - innerKeySelector: inner => inner, - resultSelector: (outer, inner) => new KeyValuePair( - outer.Key.ToString(), - outer.Value.Result - .Select((def, ix) => new WorkLogType( - Key: def.Id.ToString(), - Value: def.Name, - Sequence: ix - )) - .ToArray() - ), - comparer: projectComparer - ) - .ToDictionary( - keySelector: x => x.Key, - elementSelector: x => x.Value - ); - - return result; - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) - { - return await _vanillaJiraApi.GetIssueWorkLogs(from, to, issueKey); - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) - { - return await _vanillaJiraApi.GetIssueWorkLogs(from, to, issueKeys); - } - - public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - ComposedWadlMethodDefinition endPoint = CreateWorkLogMethodDefinition; - - HashSet missingParameters = endPoint.Parameters - .Where(par => !string.IsNullOrEmpty(par.Name)) - .Select(par => par.Name ?? string.Empty) - .ToHashSet(); - - // define - string uri = endPoint.ResourcePath - .Replace("issueKey", issueKey); - missingParameters.Remove("issueKey"); - - Dictionary args = new () - { - ["date"] = day.ToString(_flavourOptions.DateFormat, CultureInfo.InvariantCulture), - ["logWorkOption"] = ICTimeAddWorklogByIssueKey.LogWorkOption.Summary.ToString().ToLowerFirstChar(), - ["comment"] = comment ?? string.Empty, - ["timeLogged"] = TimeSpan.FromSeconds(timeSpentSeconds).ToString(_flavourOptions.TimeSpanFormat), - ["activity"] = _flavourOptions.ActivityMap == null || !_flavourOptions.ActivityMap.Any() || string.IsNullOrEmpty(activity) - ? activity ?? string.Empty - : _flavourOptions.ActivityMap[activity], - ["user"] = this.UserName, - ["charged"] = true.ToString().ToLowerInvariant() - }; - missingParameters.RemoveWhere(elm => args.ContainsKey(elm)); - - // check - HttpMethod expectedMethod = HttpMethod.Post; - bool isCorrectHttpMethod = endPoint.HttpMethod == expectedMethod; - if (!isCorrectHttpMethod) - throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} executes via ${endPoint.HttpMethod} method (${expectedMethod.ToString().ToUpperInvariant()} expected)"); - - if (missingParameters.Any()) - throw new ArgumentNullException($"Missing assignment of {string.Join(',', missingParameters)} in the call of {endPoint.Id} at resource path {endPoint.ResourcePath}"); - - bool providesJsonResponses = endPoint.Response?.Representations? - .Any(repr => repr.MediaType == WadlRepresentation.MediaTypes.Json) ?? false; - - if (providesJsonResponses) - throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} does not respond in JSON"); - - // execute - HttpContent content = new FormUrlEncodedContent(args); - HttpResponseMessage response = await _httpClient.PostAsync(uri, content); - - if (providesJsonResponses) - await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); - } - - public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) - { - DateOnly[] daysInPeriod = Enumerable.Range(0, dayFrom.NumberOfDaysTo(dayTo)) - .Select(i => dayFrom.AddDays(i)) - .Where(day => includeNonWorkingDays || day.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) - .ToArray(); - - if (!daysInPeriod.Any()) - return; - - int timeSpentSecondsPerSingleDay = timeSpentSeconds / daysInPeriod.Length; - - Task[] addWorklogTasks = daysInPeriod - .Select(day => AddWorkLog(issueKey, day, timeSpentSecondsPerSingleDay, activity, comment)) - .ToArray(); - - await Task.WhenAll(addWorklogTasks); - } - - public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) - { - await _vanillaJiraApi.DeleteWorkLog(issueId, worklogId, notifyUsers); - } - - public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - await _vanillaJiraApi.UpdateWorkLog(issueKey, worklogId, day, timeSpentSeconds, activity, comment); - } - - private async Task GetWADL() - { - Uri uri = new Uri($"{_flavourOptions.PluginBaseUri}/application.wadl", UriKind.Relative); - using Stream response = await _httpClient.GetStreamAsync(uri); - if (response == null || response.Length <= 0) - throw new HttpRequestException($"Empty content received from ${uri}"); - - XmlSerializer serializer = new XmlSerializer(typeof(WadlApplication)); - object resultObj = serializer.Deserialize(response) ?? throw new InvalidDataException($"Empty/null content deserialization result"); - - WadlApplication result = (WadlApplication)resultObj; - return result; - } -} +namespace jwl.jira; + +using System.Globalization; +using System.Xml.Serialization; +using jwl.infra; +using jwl.jira.api.rest.request; +using jwl.jira.Flavours; +using jwl.wadl; + +// https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API +public class JiraWithICTimePluginApi + : IJiraClient +{ + public string UserName { get; } + public api.rest.common.JiraUserInfo UserInfo => _vanillaJiraApi.UserInfo; + + public Lazy> Endpoints => + new Lazy>(() => this.GetWADL().Result + .AsEnumerable() + .Where(res => !string.IsNullOrEmpty(res.Id)) + .ToDictionary(res => res.Id ?? string.Empty) + ); + + public const string CreateWorkLogMethodName = "createWorklog"; + public wadl.ComposedWadlMethodDefinition CreateWorkLogMethodDefinition => Endpoints.Value[CreateWorkLogMethodName]; + + public const string GetActivityTypesForProjectMethodName = "getActivityTypesForProject"; + public wadl.ComposedWadlMethodDefinition GetActivityTypesForProjectMethodDefinition => Endpoints.Value[GetActivityTypesForProjectMethodName]; + + private readonly HttpClient _httpClient; + private readonly FlavourICTimeOptions _flavourOptions; + private readonly FlavourICTimeOptions _defaultFlavourOptions = new FlavourICTimeOptions(); + private readonly VanillaJiraClient _vanillaJiraApi; + + public JiraWithICTimePluginApi(HttpClient httpClient, string userName, FlavourICTimeOptions? flavourOptions) + { + _httpClient = httpClient; + UserName = userName; + _flavourOptions = flavourOptions ?? _defaultFlavourOptions; + _vanillaJiraApi = new VanillaJiraClient(httpClient, userName, null); + } + + public async Task GetAvailableActivities(string issueKey) + { + var result = await GetAvailableActivities(new string[] { issueKey }); + return result[issueKey]; + } + + public async Task> GetAvailableActivities(IEnumerable issueKeys) + { + IEnumerable issueKeysParsed = issueKeys + .Select(issueKey => new JiraIssueKey(issueKey)) + .ToArray(); + + IEqualityComparer projectComparer = new JiraIssueKey.ProjectOnlyEqualityComparer(); + IEnumerable distinctProjects = issueKeysParsed + .Distinct(projectComparer); + + ComposedWadlMethodDefinition endPoint = GetActivityTypesForProjectMethodDefinition; + + HashSet missingParameters = endPoint.Parameters + .Where(par => !string.IsNullOrEmpty(par.Name)) + .Select(par => par.Name ?? string.Empty) + .ToHashSet(); + + // define + IEnumerable> uris = distinctProjects + .Select(issueKey => new KeyValuePair(issueKey, endPoint.ResourcePath.Replace("{issueKey}", issueKey.ToString()))); + + missingParameters.Remove("issueKey"); + + // check + HttpMethod expectedMethod = HttpMethod.Get; + bool isCorrectHttpMethod = endPoint.HttpMethod == expectedMethod; + if (!isCorrectHttpMethod) + throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} executes via ${endPoint.HttpMethod} method (${expectedMethod.ToString().ToUpperInvariant()} expected)"); + + if (missingParameters.Any()) + throw new ArgumentNullException($"Missing assignment of {string.Join(',', missingParameters)} in the call of {endPoint.Id} at resource path {endPoint.ResourcePath}"); + + bool providesJsonResponses = endPoint.Response?.Representations? + .Any(repr => repr.MediaType == WadlRepresentation.MediaTypes.Json) ?? false; + + if (providesJsonResponses) + throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} does not respond in JSON"); + + // execute + KeyValuePair>[] responseTaks = uris + .Select(uri => new KeyValuePair>(uri.Key, _httpClient.GetAsJsonAsync(uri.Value))) + .ToArray(); + + await Task.WhenAll(responseTaks.Select(x => x.Value)); + + Dictionary result = responseTaks + .Join( + inner: issueKeysParsed, + outerKeySelector: outer => outer.Key, + innerKeySelector: inner => inner, + resultSelector: (outer, inner) => new KeyValuePair( + outer.Key.ToString(), + outer.Value.Result + .Select((def, ix) => new WorkLogType( + Key: def.Id.ToString(), + Value: def.Name, + Sequence: ix + )) + .ToArray() + ), + comparer: projectComparer + ) + .ToDictionary( + keySelector: x => x.Key, + elementSelector: x => x.Value + ); + + return result; + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) + { + return await _vanillaJiraApi.GetIssueWorkLogs(from, to, issueKey); + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) + { + return await _vanillaJiraApi.GetIssueWorkLogs(from, to, issueKeys); + } + + public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + ComposedWadlMethodDefinition endPoint = CreateWorkLogMethodDefinition; + + HashSet missingParameters = endPoint.Parameters + .Where(par => !string.IsNullOrEmpty(par.Name)) + .Select(par => par.Name ?? string.Empty) + .ToHashSet(); + + // define + string uri = endPoint.ResourcePath + .Replace("issueKey", issueKey); + missingParameters.Remove("issueKey"); + + Dictionary args = new () + { + ["date"] = day.ToString(_flavourOptions.DateFormat, CultureInfo.InvariantCulture), + ["logWorkOption"] = ICTimeAddWorklogByIssueKey.LogWorkOption.Summary.ToString().ToLowerFirstChar(), + ["comment"] = comment ?? string.Empty, + ["timeLogged"] = TimeSpan.FromSeconds(timeSpentSeconds).ToString(_flavourOptions.TimeSpanFormat), + ["activity"] = _flavourOptions.ActivityMap == null || !_flavourOptions.ActivityMap.Any() || string.IsNullOrEmpty(activity) + ? activity ?? string.Empty + : _flavourOptions.ActivityMap[activity], + ["user"] = this.UserName, + ["charged"] = true.ToString().ToLowerInvariant() + }; + missingParameters.RemoveWhere(elm => args.ContainsKey(elm)); + + // check + HttpMethod expectedMethod = HttpMethod.Post; + bool isCorrectHttpMethod = endPoint.HttpMethod == expectedMethod; + if (!isCorrectHttpMethod) + throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} executes via ${endPoint.HttpMethod} method (${expectedMethod.ToString().ToUpperInvariant()} expected)"); + + if (missingParameters.Any()) + throw new ArgumentNullException($"Missing assignment of {string.Join(',', missingParameters)} in the call of {endPoint.Id} at resource path {endPoint.ResourcePath}"); + + bool providesJsonResponses = endPoint.Response?.Representations? + .Any(repr => repr.MediaType == WadlRepresentation.MediaTypes.Json) ?? false; + + if (providesJsonResponses) + throw new InvalidOperationException($"Method {endPoint.Id} at resource path {endPoint.ResourcePath} does not respond in JSON"); + + // execute + HttpContent content = new FormUrlEncodedContent(args); + HttpResponseMessage response = await _httpClient.PostAsync(uri, content); + + if (providesJsonResponses) + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); + } + + public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) + { + DateOnly[] daysInPeriod = Enumerable.Range(0, dayFrom.NumberOfDaysTo(dayTo)) + .Select(i => dayFrom.AddDays(i)) + .Where(day => includeNonWorkingDays || day.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) + .ToArray(); + + if (!daysInPeriod.Any()) + return; + + int timeSpentSecondsPerSingleDay = timeSpentSeconds / daysInPeriod.Length; + + Task[] addWorklogTasks = daysInPeriod + .Select(day => AddWorkLog(issueKey, day, timeSpentSecondsPerSingleDay, activity, comment)) + .ToArray(); + + await Task.WhenAll(addWorklogTasks); + } + + public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) + { + await _vanillaJiraApi.DeleteWorkLog(issueId, worklogId, notifyUsers); + } + + public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + await _vanillaJiraApi.UpdateWorkLog(issueKey, worklogId, day, timeSpentSeconds, activity, comment); + } + + private async Task GetWADL() + { + Uri uri = new Uri($"{_flavourOptions.PluginBaseUri}/application.wadl", UriKind.Relative); + using Stream response = await _httpClient.GetStreamAsync(uri); + if (response == null || response.Length <= 0) + throw new HttpRequestException($"Empty content received from ${uri}"); + + XmlSerializer serializer = new XmlSerializer(typeof(WadlApplication)); + object resultObj = serializer.Deserialize(response) ?? throw new InvalidDataException($"Empty/null content deserialization result"); + + WadlApplication result = (WadlApplication)resultObj; + return result; + } +} diff --git a/jwl.jira/Flavours/JiraWithTempoPluginApi.cs b/jwl.jira/Flavours/JiraWithTempoPluginApi.cs index be479bb..598513e 100644 --- a/jwl.jira/Flavours/JiraWithTempoPluginApi.cs +++ b/jwl.jira/Flavours/JiraWithTempoPluginApi.cs @@ -1,187 +1,187 @@ -namespace jwl.jira; -using System.Net.Http.Json; -using jwl.infra; -using jwl.jira.api.rest.common; -using jwl.jira.Flavours; -using NoP77svk.Linq; - -// https://www.tempo.io/server-api-documentation/timesheets -public class JiraWithTempoPluginApi - : IJiraClient -{ - private const string WorklogTypeAttributeKey = @"_WorklogType_"; - - private readonly HttpClient _httpClient; - private readonly FlavourTempoTimesheetsOptions _flavourOptions; - private readonly VanillaJiraClient _vanillaJiraApi; - - public string UserName { get; } - public api.rest.common.JiraUserInfo UserInfo => _vanillaJiraApi.UserInfo; - - public JiraWithTempoPluginApi(HttpClient httpClient, string userName, FlavourTempoTimesheetsOptions? flavourOptions) - { - _httpClient = httpClient; - UserName = userName; - _flavourOptions = flavourOptions ?? new FlavourTempoTimesheetsOptions(); - _vanillaJiraApi = new VanillaJiraClient(httpClient, userName, null); - } - - public async Task GetWorklogAttributeDefinitions() - { - return await _httpClient.GetAsJsonAsync($"{_flavourOptions.PluginCoreUri}/work-attribute"); - } - - public async Task GetAvailableActivities(string issueKey) - { - api.rest.response.TempoWorklogAttributeDefinition[] attrEnumDefs = await GetWorklogAttributeDefinitions(); - - WorkLogType[] result = attrEnumDefs - .Where(attrDef => attrDef.Key?.Equals(WorklogTypeAttributeKey) ?? false) - .Where(attrDef => attrDef.Type != null - && attrDef.Type?.Value == TempoWorklogAttributeTypeIdentifier.StaticList - ) - .SelectMany(attrDef => attrDef.StaticListValues) - .Where(staticListItem => !string.IsNullOrEmpty(staticListItem.Name) && !string.IsNullOrEmpty(staticListItem.Value)) - .Select(staticListItem => new WorkLogType( - Key: staticListItem.Name ?? string.Empty, - Value: staticListItem.Value ?? string.Empty, - Sequence: staticListItem.Sequence ?? -1 - )) - .ToArray(); - - return result; - } - - public async Task> GetAvailableActivities(IEnumerable issueKeys) - { - WorkLogType[] activities = await GetAvailableActivities(string.Empty); - - Dictionary result = issueKeys - .Select(issueKey => new ValueTuple(issueKey, activities)) - .ToDictionary( - keySelector: x => x.Item1, - elementSelector: x => x.Item2 - ); - - return result; - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) - { - return await GetIssueWorkLogs(from, to, new string[] { issueKey }); - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) - { - string userKey = UserInfo.Key ?? throw new ArgumentNullException($"{nameof(UserInfo)}.{nameof(UserInfo.Key)}"); - - var request = new api.rest.request.TempoFindWorklogs(from, to) - { - IssueKey = issueKeys?.ToArray(), - UserKey = new string[] { userKey } - }; - var response = await _httpClient.PostAsJsonAsync($"{_flavourOptions.PluginBaseUri}/worklogs/search", request); - var tempoWorkLogs = await HttpClientJsonExt.DeserializeJsonStreamAsync(await response.Content.ReadAsStreamAsync()); - - var result = tempoWorkLogs - .Select(wl => new WorkLog( - Id: wl.Id ?? -1, - IssueId: wl.IssueId ?? -1, - AuthorName: wl.WorkerKey == userKey ? UserName : null, - AuthorKey: wl.WorkerKey, - Created: wl.Created?.Value ?? DateTime.MinValue, - Started: wl.Started?.Value ?? DateTime.MinValue, - TimeSpentSeconds: wl.TimeSpentSeconds ?? -1, - Activity: wl.Attributes?[WorklogTypeAttributeKey].Value, - Comment: wl.Comment ?? string.Empty - )) - .ToArray(); - - return result; - } - - public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - await AddWorkLogPeriod(issueKey, day, day, timeSpentSeconds, activity, comment); - } - - public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? tempoWorklogType, string? comment, bool includeNonWorkingDays = false) - { - string userKey = UserInfo.Key ?? throw new ArgumentNullException($"{nameof(UserInfo)}.{nameof(UserInfo.Key)}"); - - var request = new api.rest.request.TempoAddWorklogByIssueKey() - { - IssueKey = issueKey, - Worker = userKey, - Started = new api.rest.common.TempoDate(dayFrom), - EndDate = new api.rest.common.TempoDate(dayTo), - TimeSpentSeconds = timeSpentSeconds, - BillableSeconds = timeSpentSeconds, - IncludeNonWorkingDays = includeNonWorkingDays, - Comment = comment, - Attributes = new Dictionary() - { - [WorklogTypeAttributeKey] = new api.rest.common.TempoWorklogAttribute() - { - WorkAttributeId = 1, - Key = WorklogTypeAttributeKey, - Name = @"Worklog Type", - Type = api.rest.common.TempoWorklogAttributeTypeIdentifier.StaticList, - Value = tempoWorklogType - } - } - }; - - HttpResponseMessage response = await _httpClient.PostAsJsonAsync($"{_flavourOptions.PluginBaseUri}/worklogs", request); - await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); - } - - public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/worklogs") - .Add(worklogId.ToString()) - }; - - HttpResponseMessage response = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); - await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); - } - - public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - await UpdateWorklogPeriod(issueKey, worklogId, day, day, timeSpentSeconds, comment, activity); - } - - private async Task UpdateWorklogPeriod(string issueKey, long worklogId, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? comment, string? activity, bool includeNonWorkingDays = false) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/worklogs") - .Add(worklogId.ToString()) - }; - var request = new api.rest.request.TempoUpdateWorklog() - { - Started = new api.rest.common.TempoDate(dayFrom), - EndDate = new api.rest.common.TempoDate(dayTo), - TimeSpentSeconds = timeSpentSeconds, - BillableSeconds = timeSpentSeconds, - IncludeNonWorkingDays = includeNonWorkingDays, - Comment = comment, - Attributes = new Dictionary() - { - [WorklogTypeAttributeKey] = new api.rest.common.TempoWorklogAttribute() - { - WorkAttributeId = 1, - Key = WorklogTypeAttributeKey, - Name = @"Worklog Type", - Type = api.rest.common.TempoWorklogAttributeTypeIdentifier.StaticList, - Value = activity - } - } - }; - - HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); - await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); - } -} +namespace jwl.jira; +using System.Net.Http.Json; +using jwl.infra; +using jwl.jira.api.rest.common; +using jwl.jira.Flavours; +using NoP77svk.Linq; + +// https://www.tempo.io/server-api-documentation/timesheets +public class JiraWithTempoPluginApi + : IJiraClient +{ + private const string WorklogTypeAttributeKey = @"_WorklogType_"; + + private readonly HttpClient _httpClient; + private readonly FlavourTempoTimesheetsOptions _flavourOptions; + private readonly VanillaJiraClient _vanillaJiraApi; + + public string UserName { get; } + public api.rest.common.JiraUserInfo UserInfo => _vanillaJiraApi.UserInfo; + + public JiraWithTempoPluginApi(HttpClient httpClient, string userName, FlavourTempoTimesheetsOptions? flavourOptions) + { + _httpClient = httpClient; + UserName = userName; + _flavourOptions = flavourOptions ?? new FlavourTempoTimesheetsOptions(); + _vanillaJiraApi = new VanillaJiraClient(httpClient, userName, null); + } + + public async Task GetWorklogAttributeDefinitions() + { + return await _httpClient.GetAsJsonAsync($"{_flavourOptions.PluginCoreUri}/work-attribute"); + } + + public async Task GetAvailableActivities(string issueKey) + { + api.rest.response.TempoWorklogAttributeDefinition[] attrEnumDefs = await GetWorklogAttributeDefinitions(); + + WorkLogType[] result = attrEnumDefs + .Where(attrDef => attrDef.Key?.Equals(WorklogTypeAttributeKey) ?? false) + .Where(attrDef => attrDef.Type != null + && attrDef.Type?.Value == TempoWorklogAttributeTypeIdentifier.StaticList + ) + .SelectMany(attrDef => attrDef.StaticListValues) + .Where(staticListItem => !string.IsNullOrEmpty(staticListItem.Name) && !string.IsNullOrEmpty(staticListItem.Value)) + .Select(staticListItem => new WorkLogType( + Key: staticListItem.Name ?? string.Empty, + Value: staticListItem.Value ?? string.Empty, + Sequence: staticListItem.Sequence ?? -1 + )) + .ToArray(); + + return result; + } + + public async Task> GetAvailableActivities(IEnumerable issueKeys) + { + WorkLogType[] activities = await GetAvailableActivities(string.Empty); + + Dictionary result = issueKeys + .Select(issueKey => new ValueTuple(issueKey, activities)) + .ToDictionary( + keySelector: x => x.Item1, + elementSelector: x => x.Item2 + ); + + return result; + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) + { + return await GetIssueWorkLogs(from, to, new string[] { issueKey }); + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) + { + string userKey = UserInfo.Key ?? throw new ArgumentNullException($"{nameof(UserInfo)}.{nameof(UserInfo.Key)}"); + + var request = new api.rest.request.TempoFindWorklogs(from, to) + { + IssueKey = issueKeys?.ToArray(), + UserKey = new string[] { userKey } + }; + var response = await _httpClient.PostAsJsonAsync($"{_flavourOptions.PluginBaseUri}/worklogs/search", request); + var tempoWorkLogs = await HttpClientJsonExt.DeserializeJsonStreamAsync(await response.Content.ReadAsStreamAsync()); + + var result = tempoWorkLogs + .Select(wl => new WorkLog( + Id: wl.Id ?? -1, + IssueId: wl.IssueId ?? -1, + AuthorName: wl.WorkerKey == userKey ? UserName : null, + AuthorKey: wl.WorkerKey, + Created: wl.Created?.Value ?? DateTime.MinValue, + Started: wl.Started?.Value ?? DateTime.MinValue, + TimeSpentSeconds: wl.TimeSpentSeconds ?? -1, + Activity: wl.Attributes?[WorklogTypeAttributeKey].Value, + Comment: wl.Comment ?? string.Empty + )) + .ToArray(); + + return result; + } + + public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + await AddWorkLogPeriod(issueKey, day, day, timeSpentSeconds, activity, comment); + } + + public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? tempoWorklogType, string? comment, bool includeNonWorkingDays = false) + { + string userKey = UserInfo.Key ?? throw new ArgumentNullException($"{nameof(UserInfo)}.{nameof(UserInfo.Key)}"); + + var request = new api.rest.request.TempoAddWorklogByIssueKey() + { + IssueKey = issueKey, + Worker = userKey, + Started = new api.rest.common.TempoDate(dayFrom), + EndDate = new api.rest.common.TempoDate(dayTo), + TimeSpentSeconds = timeSpentSeconds, + BillableSeconds = timeSpentSeconds, + IncludeNonWorkingDays = includeNonWorkingDays, + Comment = comment, + Attributes = new Dictionary() + { + [WorklogTypeAttributeKey] = new api.rest.common.TempoWorklogAttribute() + { + WorkAttributeId = 1, + Key = WorklogTypeAttributeKey, + Name = @"Worklog Type", + Type = api.rest.common.TempoWorklogAttributeTypeIdentifier.StaticList, + Value = tempoWorklogType + } + } + }; + + HttpResponseMessage response = await _httpClient.PostAsJsonAsync($"{_flavourOptions.PluginBaseUri}/worklogs", request); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); + } + + public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/worklogs") + .Add(worklogId.ToString()) + }; + + HttpResponseMessage response = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); + } + + public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + await UpdateWorklogPeriod(issueKey, worklogId, day, day, timeSpentSeconds, comment, activity); + } + + private async Task UpdateWorklogPeriod(string issueKey, long worklogId, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? comment, string? activity, bool includeNonWorkingDays = false) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/worklogs") + .Add(worklogId.ToString()) + }; + var request = new api.rest.request.TempoUpdateWorklog() + { + Started = new api.rest.common.TempoDate(dayFrom), + EndDate = new api.rest.common.TempoDate(dayTo), + TimeSpentSeconds = timeSpentSeconds, + BillableSeconds = timeSpentSeconds, + IncludeNonWorkingDays = includeNonWorkingDays, + Comment = comment, + Attributes = new Dictionary() + { + [WorklogTypeAttributeKey] = new api.rest.common.TempoWorklogAttribute() + { + WorkAttributeId = 1, + Key = WorklogTypeAttributeKey, + Name = @"Worklog Type", + Type = api.rest.common.TempoWorklogAttributeTypeIdentifier.StaticList, + Value = activity + } + } + }; + + HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); + } +} diff --git a/jwl.jira/Flavours/ServerApiFactory.cs b/jwl.jira/Flavours/ServerApiFactory.cs index 8b009f0..5baeb46 100644 --- a/jwl.jira/Flavours/ServerApiFactory.cs +++ b/jwl.jira/Flavours/ServerApiFactory.cs @@ -1,38 +1,38 @@ -namespace jwl.jira.Flavours; - -public static class ServerApiFactory -{ - public static IJiraClient CreateApi(HttpClient httpClient, string userName, JiraServerFlavour serverFlavour, IFlavourOptions? flavourOptions) - { - try - { - return serverFlavour switch - { - JiraServerFlavour.Vanilla => new VanillaJiraClient(httpClient, userName, (FlavourVanillaJiraOptions?)flavourOptions), - JiraServerFlavour.TempoTimeSheets => new JiraWithTempoPluginApi(httpClient, userName, (FlavourTempoTimesheetsOptions?)flavourOptions), - JiraServerFlavour.ICTime => new JiraWithICTimePluginApi(httpClient, userName, (FlavourICTimeOptions?)flavourOptions), - _ => throw new NotImplementedException($"Jira server flavour {nameof(serverFlavour)} not yet implemented") - }; - } - catch (InvalidCastException ex) - { - throw new ArgumentOutOfRangeException($"Don't know how to instantiate client API for flavour {serverFlavour}", ex); - } - } - - public static JiraServerFlavour? DecodeServerClass(string? serverFlavour) - { - JiraServerFlavour? result; - - if (string.IsNullOrEmpty(serverFlavour)) - result = null; - else if (int.TryParse(serverFlavour, out int serverFlavourIntId)) - result = (JiraServerFlavour)serverFlavourIntId; - else if (Enum.TryParse(serverFlavour, true, out JiraServerFlavour serverFlavourEnumId)) - result = serverFlavourEnumId; - else - throw new ArgumentOutOfRangeException(nameof(serverFlavour), serverFlavour, "Invalid server flavour configured"); - - return result; - } -} +namespace jwl.jira.Flavours; + +public static class ServerApiFactory +{ + public static IJiraClient CreateApi(HttpClient httpClient, string userName, JiraServerFlavour serverFlavour, IFlavourOptions? flavourOptions) + { + try + { + return serverFlavour switch + { + JiraServerFlavour.Vanilla => new VanillaJiraClient(httpClient, userName, (FlavourVanillaJiraOptions?)flavourOptions), + JiraServerFlavour.TempoTimeSheets => new JiraWithTempoPluginApi(httpClient, userName, (FlavourTempoTimesheetsOptions?)flavourOptions), + JiraServerFlavour.ICTime => new JiraWithICTimePluginApi(httpClient, userName, (FlavourICTimeOptions?)flavourOptions), + _ => throw new NotImplementedException($"Jira server flavour {nameof(serverFlavour)} not yet implemented") + }; + } + catch (InvalidCastException ex) + { + throw new ArgumentOutOfRangeException($"Don't know how to instantiate client API for flavour {serverFlavour}", ex); + } + } + + public static JiraServerFlavour? DecodeServerClass(string? serverFlavour) + { + JiraServerFlavour? result; + + if (string.IsNullOrEmpty(serverFlavour)) + result = null; + else if (int.TryParse(serverFlavour, out int serverFlavourIntId)) + result = (JiraServerFlavour)serverFlavourIntId; + else if (Enum.TryParse(serverFlavour, true, out JiraServerFlavour serverFlavourEnumId)) + result = serverFlavourEnumId; + else + throw new ArgumentOutOfRangeException(nameof(serverFlavour), serverFlavour, "Invalid server flavour configured"); + + return result; + } +} diff --git a/jwl.jira/Flavours/VanillaJiraClient.cs b/jwl.jira/Flavours/VanillaJiraClient.cs index 344fe56..124c20e 100644 --- a/jwl.jira/Flavours/VanillaJiraClient.cs +++ b/jwl.jira/Flavours/VanillaJiraClient.cs @@ -1,209 +1,209 @@ -namespace jwl.jira; - -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text; -using jwl.infra; -using jwl.jira.api.rest.response; -using jwl.jira.Flavours; - -public class VanillaJiraClient - : IJiraClient -{ - public string UserName { get; } - public api.rest.common.JiraUserInfo UserInfo => _lazyUserInfo.Value; - - private readonly HttpClient _httpClient; - private readonly Lazy _lazyUserInfo; - private readonly FlavourVanillaJiraOptions _flavourOptions; - - public VanillaJiraClient(HttpClient httpClient, string userName, FlavourVanillaJiraOptions? flavourOptions) - { - _httpClient = httpClient; - UserName = userName; - _lazyUserInfo = new Lazy(() => GetUserInfo().Result); - _flavourOptions = flavourOptions ?? new FlavourVanillaJiraOptions(); - } - - public static async Task CheckHttpResponseForErrorMessages(HttpResponseMessage responseMessage) - { - using Stream responseContentStream = await responseMessage.Content.ReadAsStreamAsync(); - - if (responseContentStream.Length > 0) - { - JiraRestResponse responseContent = await HttpClientJsonExt.DeserializeJsonStreamAsync(responseContentStream); - if (responseContent?.ErrorMessages is not null && responseContent.ErrorMessages.Any()) - throw new InvalidOperationException(string.Join(Environment.NewLine, responseContent.ErrorMessages)); - } - } - - #pragma warning disable CS1998 - public async Task GetAvailableActivities(string issueKey) - { - return Array.Empty(); - } - #pragma warning restore - - public async Task> GetAvailableActivities(IEnumerable issueKeys) - { - WorkLogType[] activities = await GetAvailableActivities(string.Empty); - - Dictionary result = issueKeys - .Select(issueKey => new ValueTuple(issueKey, activities)) - .ToDictionary( - keySelector: x => x.Item1, - elementSelector: x => x.Item2 - ); - - return result; - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") - .Add(issueKey) - .Add(@"worklog") - }; - - var response = await _httpClient.GetAsJsonAsync(uriBuilder.Uri.PathAndQuery); - - (DateTime minDt, DateTime supDt) = DateOnlyUtils.DateOnlyRangeToDateTimeRange(from, to); - - var result = response.Worklogs - .Where(worklog => worklog.Author.Name == UserName) - .Where(worklog => worklog.Started.Value >= minDt && worklog.Started.Value < supDt) - .Select(wl => new WorkLog( - Id: wl.Id.Value, - IssueId: wl.IssueId.Value, - AuthorName: wl.Author.Name, - AuthorKey: wl.Author.Key, - Created: wl.Created.Value, - Started: wl.Started.Value, - TimeSpentSeconds: wl.TimeSpentSeconds, - Activity: null, - Comment: wl.Comment - )) - .ToArray(); - - return result; - } - - public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) - { - if (issueKeys is null) - return Array.Empty(); - - Task[] responseTasks = issueKeys - .Distinct() - .Select(issueKey => GetIssueWorkLogs(from, to, issueKey)) - .ToArray(); - - await Task.WhenAll(responseTasks); - - var result = responseTasks - .SelectMany(task => task.Result) - .ToArray(); - - return result; - } - - public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") - .Add(issueKey) - .Add(@"worklog") - }; - - StringBuilder commentBuilder = new StringBuilder(); - if (activity != null) - commentBuilder.Append($"({activity}){Environment.NewLine}"); - commentBuilder.Append(comment); - - var request = new api.rest.request.JiraAddWorklogByIssueKey( - Started: day - .ToDateTime(TimeOnly.MinValue) - .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") - .Replace(":", string.Empty) - .Replace(';', ':'), - TimeSpentSeconds: timeSpentSeconds, - Comment: commentBuilder.ToString() - ); - - HttpResponseMessage response = await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); - await CheckHttpResponseForErrorMessages(response); - } - - public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) - { - DateOnly[] daysInPeriod = Enumerable.Range(0, dayFrom.NumberOfDaysTo(dayTo)) - .Select(i => dayFrom.AddDays(i)) - .Where(day => includeNonWorkingDays || day.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) - .ToArray(); - - if (!daysInPeriod.Any()) - return; - - int timeSpentSecondsPerSingleDay = timeSpentSeconds / daysInPeriod.Length; - - Task[] addWorklogTasks = daysInPeriod - .Select(day => AddWorkLog(issueKey, day, timeSpentSecondsPerSingleDay, activity, comment)) - .ToArray(); - - await Task.WhenAll(addWorklogTasks); - } - - public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") - .Add(issueId.ToString()) - .Add(@"worklog") - .Add(worklogId.ToString()), - Query = new UriQueryBuilder() - .Add(@"notifyUsers", notifyUsers.ToString().ToLower()) - }; - - HttpResponseMessage response = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); - await CheckHttpResponseForErrorMessages(response); - } - - public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") - .Add(issueKey) - .Add(@"worklog") - .Add(worklogId.ToString()) - }; - var request = new api.rest.request.JiraAddWorklogByIssueKey( - Started: day - .ToDateTime(TimeOnly.MinValue) - .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") - .Replace(":", string.Empty) - .Replace(';', ':'), - TimeSpentSeconds: timeSpentSeconds, - Comment: comment - ); - - HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); - await CheckHttpResponseForErrorMessages(response); - } - - private async Task GetUserInfo() - { - UriBuilder uriBuilder = new UriBuilder() - { - Path = $"{_flavourOptions.PluginBaseUri}/user", - Query = new UriQueryBuilder() - .Add(@"username", UserName) - }; - return await _httpClient.GetAsJsonAsync(uriBuilder.Uri.PathAndQuery); - } -} +namespace jwl.jira; + +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using jwl.infra; +using jwl.jira.api.rest.response; +using jwl.jira.Flavours; + +public class VanillaJiraClient + : IJiraClient +{ + public string UserName { get; } + public api.rest.common.JiraUserInfo UserInfo => _lazyUserInfo.Value; + + private readonly HttpClient _httpClient; + private readonly Lazy _lazyUserInfo; + private readonly FlavourVanillaJiraOptions _flavourOptions; + + public VanillaJiraClient(HttpClient httpClient, string userName, FlavourVanillaJiraOptions? flavourOptions) + { + _httpClient = httpClient; + UserName = userName; + _lazyUserInfo = new Lazy(() => GetUserInfo().Result); + _flavourOptions = flavourOptions ?? new FlavourVanillaJiraOptions(); + } + + public static async Task CheckHttpResponseForErrorMessages(HttpResponseMessage responseMessage) + { + using Stream responseContentStream = await responseMessage.Content.ReadAsStreamAsync(); + + if (responseContentStream.Length > 0) + { + JiraRestResponse responseContent = await HttpClientJsonExt.DeserializeJsonStreamAsync(responseContentStream); + if (responseContent?.ErrorMessages is not null && responseContent.ErrorMessages.Any()) + throw new InvalidOperationException(string.Join(Environment.NewLine, responseContent.ErrorMessages)); + } + } + + #pragma warning disable CS1998 + public async Task GetAvailableActivities(string issueKey) + { + return Array.Empty(); + } + #pragma warning restore + + public async Task> GetAvailableActivities(IEnumerable issueKeys) + { + WorkLogType[] activities = await GetAvailableActivities(string.Empty); + + Dictionary result = issueKeys + .Select(issueKey => new ValueTuple(issueKey, activities)) + .ToDictionary( + keySelector: x => x.Item1, + elementSelector: x => x.Item2 + ); + + return result; + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, string issueKey) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") + .Add(issueKey) + .Add(@"worklog") + }; + + var response = await _httpClient.GetAsJsonAsync(uriBuilder.Uri.PathAndQuery); + + (DateTime minDt, DateTime supDt) = DateOnlyUtils.DateOnlyRangeToDateTimeRange(from, to); + + var result = response.Worklogs + .Where(worklog => worklog.Author.Name == UserName) + .Where(worklog => worklog.Started.Value >= minDt && worklog.Started.Value < supDt) + .Select(wl => new WorkLog( + Id: wl.Id.Value, + IssueId: wl.IssueId.Value, + AuthorName: wl.Author.Name, + AuthorKey: wl.Author.Key, + Created: wl.Created.Value, + Started: wl.Started.Value, + TimeSpentSeconds: wl.TimeSpentSeconds, + Activity: null, + Comment: wl.Comment + )) + .ToArray(); + + return result; + } + + public async Task GetIssueWorkLogs(DateOnly from, DateOnly to, IEnumerable? issueKeys) + { + if (issueKeys is null) + return Array.Empty(); + + Task[] responseTasks = issueKeys + .Distinct() + .Select(issueKey => GetIssueWorkLogs(from, to, issueKey)) + .ToArray(); + + await Task.WhenAll(responseTasks); + + var result = responseTasks + .SelectMany(task => task.Result) + .ToArray(); + + return result; + } + + public async Task AddWorkLog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") + .Add(issueKey) + .Add(@"worklog") + }; + + StringBuilder commentBuilder = new StringBuilder(); + if (activity != null) + commentBuilder.Append($"({activity}){Environment.NewLine}"); + commentBuilder.Append(comment); + + var request = new api.rest.request.JiraAddWorklogByIssueKey( + Started: day + .ToDateTime(TimeOnly.MinValue) + .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") + .Replace(":", string.Empty) + .Replace(';', ':'), + TimeSpentSeconds: timeSpentSeconds, + Comment: commentBuilder.ToString() + ); + + HttpResponseMessage response = await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await CheckHttpResponseForErrorMessages(response); + } + + public async Task AddWorkLogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) + { + DateOnly[] daysInPeriod = Enumerable.Range(0, dayFrom.NumberOfDaysTo(dayTo)) + .Select(i => dayFrom.AddDays(i)) + .Where(day => includeNonWorkingDays || day.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday) + .ToArray(); + + if (!daysInPeriod.Any()) + return; + + int timeSpentSecondsPerSingleDay = timeSpentSeconds / daysInPeriod.Length; + + Task[] addWorklogTasks = daysInPeriod + .Select(day => AddWorkLog(issueKey, day, timeSpentSecondsPerSingleDay, activity, comment)) + .ToArray(); + + await Task.WhenAll(addWorklogTasks); + } + + public async Task DeleteWorkLog(long issueId, long worklogId, bool notifyUsers = false) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") + .Add(issueId.ToString()) + .Add(@"worklog") + .Add(worklogId.ToString()), + Query = new UriQueryBuilder() + .Add(@"notifyUsers", notifyUsers.ToString().ToLower()) + }; + + HttpResponseMessage response = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); + await CheckHttpResponseForErrorMessages(response); + } + + public async Task UpdateWorkLog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder($"{_flavourOptions.PluginBaseUri}/issue") + .Add(issueKey) + .Add(@"worklog") + .Add(worklogId.ToString()) + }; + var request = new api.rest.request.JiraAddWorklogByIssueKey( + Started: day + .ToDateTime(TimeOnly.MinValue) + .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") + .Replace(":", string.Empty) + .Replace(';', ':'), + TimeSpentSeconds: timeSpentSeconds, + Comment: comment + ); + + HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await CheckHttpResponseForErrorMessages(response); + } + + private async Task GetUserInfo() + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = $"{_flavourOptions.PluginBaseUri}/user", + Query = new UriQueryBuilder() + .Add(@"username", UserName) + }; + return await _httpClient.GetAsJsonAsync(uriBuilder.Uri.PathAndQuery); + } +} diff --git a/jwl.jira/IFlavourOptions.cs b/jwl.jira/IFlavourOptions.cs index de51b2d..4eeea94 100644 --- a/jwl.jira/IFlavourOptions.cs +++ b/jwl.jira/IFlavourOptions.cs @@ -1,6 +1,6 @@ -namespace jwl.jira; - -public interface IFlavourOptions -{ - string PluginBaseUri { get; init; } -} +namespace jwl.jira; + +public interface IFlavourOptions +{ + string PluginBaseUri { get; init; } +} diff --git a/jwl.jira/ServerConfig.cs b/jwl.jira/ServerConfig.cs index 6a2e945..b65d419 100644 --- a/jwl.jira/ServerConfig.cs +++ b/jwl.jira/ServerConfig.cs @@ -1,16 +1,16 @@ -namespace jwl.jira; -using System.Text.Json.Serialization; -using jwl.jira.Flavours; - -public class ServerConfig -{ - public string? BaseUrl { get; init; } - public string? Flavour { get; init; } - public JiraServerFlavour FlavourId => ServerApiFactory.DecodeServerClass(Flavour) ?? JiraServerFlavour.Vanilla; - - [JsonIgnore] - public IFlavourOptions? FlavourOptions { get; set; } - public bool? UseProxy { get; init; } - public int? MaxConnectionsPerServer { get; init; } - public bool? SkipSslCertificateCheck { get; init; } -} +namespace jwl.jira; +using System.Text.Json.Serialization; +using jwl.jira.Flavours; + +public class ServerConfig +{ + public string? BaseUrl { get; init; } + public string? Flavour { get; init; } + public JiraServerFlavour FlavourId => ServerApiFactory.DecodeServerClass(Flavour) ?? JiraServerFlavour.Vanilla; + + [JsonIgnore] + public IFlavourOptions? FlavourOptions { get; set; } + public bool? UseProxy { get; init; } + public int? MaxConnectionsPerServer { get; init; } + public bool? SkipSslCertificateCheck { get; init; } +} diff --git a/jwl.jira/WorkLog.cs b/jwl.jira/WorkLog.cs index e2a8c3b..33e07dc 100644 --- a/jwl.jira/WorkLog.cs +++ b/jwl.jira/WorkLog.cs @@ -1,6 +1,6 @@ -#pragma warning disable SA1313 -namespace jwl.jira; - -public record WorkLog(long Id, long IssueId, string? AuthorName, string? AuthorKey, DateTime Created, DateTime Started, int TimeSpentSeconds, string? Activity, string Comment) -{ -} +#pragma warning disable SA1313 +namespace jwl.jira; + +public record WorkLog(long Id, long IssueId, string? AuthorName, string? AuthorKey, DateTime Created, DateTime Started, int TimeSpentSeconds, string? Activity, string Comment) +{ +} diff --git a/jwl.jira/WorkLogType.cs b/jwl.jira/WorkLogType.cs index c101987..a37e990 100644 --- a/jwl.jira/WorkLogType.cs +++ b/jwl.jira/WorkLogType.cs @@ -1,6 +1,6 @@ -#pragma warning disable SA1313 -namespace jwl.jira; - -public record WorkLogType(string Key, string Value, int Sequence) -{ -} +#pragma warning disable SA1313 +namespace jwl.jira; + +public record WorkLogType(string Key, string Value, int Sequence) +{ +} diff --git a/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs b/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs index 035be2c..795e10e 100644 --- a/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs +++ b/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs @@ -1,6 +1,6 @@ -#pragma warning disable SA1313 -namespace jwl.jira.api.rest.request; - -public record JiraAddWorklogByIssueKey(string Started, int TimeSpentSeconds, string? Comment) -{ -} +#pragma warning disable SA1313 +namespace jwl.jira.api.rest.request; + +public record JiraAddWorklogByIssueKey(string Started, int TimeSpentSeconds, string? Comment) +{ +} diff --git a/jwl.jira/api/rest/response/ICTimeActivityDefinition.cs b/jwl.jira/api/rest/response/ICTimeActivityDefinition.cs index d72979e..dd037f5 100644 --- a/jwl.jira/api/rest/response/ICTimeActivityDefinition.cs +++ b/jwl.jira/api/rest/response/ICTimeActivityDefinition.cs @@ -1,14 +1,14 @@ -namespace jwl.jira.api.rest.response; - -// 2do! -public class ICTimeActivityDefinition -{ - public ICTimeActivityDefinition(int id, string name) - { - Id = id; - Name = name; - } - - public int Id { get; set; } - public string Name { get; set; } -} +namespace jwl.jira.api.rest.response; + +// 2do! +public class ICTimeActivityDefinition +{ + public ICTimeActivityDefinition(int id, string name) + { + Id = id; + Name = name; + } + + public int Id { get; set; } + public string Name { get; set; } +} diff --git a/jwl.wadl/WadlParameter.cs b/jwl.wadl/WadlParameter.cs index addef5a..eb276e4 100644 --- a/jwl.wadl/WadlParameter.cs +++ b/jwl.wadl/WadlParameter.cs @@ -1,30 +1,30 @@ -namespace jwl.wadl -{ - using System.Linq; - using System.Xml.Serialization; - - [XmlRoot("param", Namespace = WadlApplication.XmlNamespace)] - public class WadlParameter - { - public const string QueryStyle = "query"; - public const string TemplateStyle = "template"; - - public const string BooleanType = "xs:boolean"; - public const string DoubleType = "xs:double"; - public const string IntType = "xs:int"; - public const string LongType = "xs:long"; - public const string StringType = "xs:string"; - - [XmlAttribute("name")] - public string? Name { get; set; } - - [XmlAttribute("style")] - public string? Style { get; set; } - - [XmlAttribute("type")] - public string? Type { get; set; } - - [XmlAttribute("default")] - public string? Default { get; set; } - } -} +namespace jwl.wadl +{ + using System.Linq; + using System.Xml.Serialization; + + [XmlRoot("param", Namespace = WadlApplication.XmlNamespace)] + public class WadlParameter + { + public const string QueryStyle = "query"; + public const string TemplateStyle = "template"; + + public const string BooleanType = "xs:boolean"; + public const string DoubleType = "xs:double"; + public const string IntType = "xs:int"; + public const string LongType = "xs:long"; + public const string StringType = "xs:string"; + + [XmlAttribute("name")] + public string? Name { get; set; } + + [XmlAttribute("style")] + public string? Style { get; set; } + + [XmlAttribute("type")] + public string? Type { get; set; } + + [XmlAttribute("default")] + public string? Default { get; set; } + } +}