From 918a0bfda1492989ccd0efc54b0027358bdaf3d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 09:39:11 +0100 Subject: [PATCH 01/28] =?UTF-8?q?=F0=9F=94=A7=20local=20build=20builds=20p?= =?UTF-8?q?roject=20only,=20not=20the=20whole=20solution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _local_build.cmd | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 _local_build.cmd diff --git a/_local_build.cmd b/_local_build.cmd new file mode 100644 index 0000000..cad8fb4 --- /dev/null +++ b/_local_build.cmd @@ -0,0 +1,3 @@ +pushd jwl.console +call global_build.cmd jira-worklogger +@exit /b %ERRORLEVEL% From ef28b904cb1c9a1eaa4555254ab47555dcba42a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 09:45:09 +0100 Subject: [PATCH 02/28] =?UTF-8?q?=F0=9F=94=A7=20fixed=20example=20config?= =?UTF-8?q?=20file=20&=20readme=20for=20changed=20server=20flavours?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- jwl.core/_assets/jwl.config | 2 +- jwl.core/jwl.core.csproj | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c5d6eb1..a33d709 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ As for the CLI worklogger binary, there are command-line options available as we ### "ServerClass" setting Available values are: - - VanillaJira - - TempoTimeSheetsPlugin - - ICTimePlugin (not implemented yet) + - Vanilla + - TempoTimeSheets + - ICTime (not implemented yet) ## The input CSV structure diff --git a/jwl.core/_assets/jwl.config b/jwl.core/_assets/jwl.config index ce7889f..7ab44d9 100644 --- a/jwl.core/_assets/jwl.config +++ b/jwl.core/_assets/jwl.config @@ -1,7 +1,7 @@ { "UseVerboseFeedback": false, "JiraServer": { - "ServerClass": "VanillaJira", + "ServerClass": "Vanilla", "MaxConnectionsPerServer": 4, "UseProxy": false, "SkipSslCertificateCheck": false diff --git a/jwl.core/jwl.core.csproj b/jwl.core/jwl.core.csproj index b0a6a15..00bae86 100644 --- a/jwl.core/jwl.core.csproj +++ b/jwl.core/jwl.core.csproj @@ -53,6 +53,14 @@ <CommonAssetFiles Include="../*.md" CopyToOutputDirectory="PreserveNewest" /> </ItemGroup> + <ItemGroup> + <CommonAssetFiles Remove="..\README.md" /> + </ItemGroup> + + <ItemGroup> + <None Include="..\README.md" Link="_assets\README.md" /> + </ItemGroup> + <Target Name="CopyCustomContent" AfterTargets="AfterBuild"> <Copy SourceFiles="@(CommonAssetFiles)" DestinationFolder="$(OutDir)/%(RecursiveDir)" SkipUnchangedFiles="true" /> </Target> From 8d1701f82f2af7683131f3ad5af8fb172371a405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:38:20 +0100 Subject: [PATCH 03/28] =?UTF-8?q?=F0=9F=94=A7=20core=20process=20now=20rel?= =?UTF-8?q?ies=20on=20JJiraServerApi=20interface=20instead=20of=20vanilla?= =?UTF-8?q?=20jira=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/JwlCoreProcess.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/jwl.core/JwlCoreProcess.cs b/jwl.core/JwlCoreProcess.cs index f76420b..e7389a9 100644 --- a/jwl.core/JwlCoreProcess.cs +++ b/jwl.core/JwlCoreProcess.cs @@ -21,7 +21,7 @@ public class JwlCoreProcess : IDisposable private AppConfig _config; private HttpClientHandler _httpClientHandler; private HttpClient _httpClient; - private VanillaJiraServerApi _jiraClient; + private IJiraServerApi _jiraClient; private jwl.jira.api.rest.common.JiraUserInfo? _userInfo; private Dictionary<string, WorkLogType> availableWorklogTypes = new (); @@ -44,8 +44,10 @@ public JwlCoreProcess(AppConfig config, ICoreProcessInteraction interaction) _httpClient = new HttpClient(_httpClientHandler) { BaseAddress = new Uri(_config.JiraServer?.BaseUrl ?? string.Empty) - }; - _jiraClient = new VanillaJiraServerApi(_httpClient, _config.User?.Name ?? throw new ArgumentNullException($"{nameof(_config)}.{nameof(_config.User)}.{nameof(_config.User.Name)})")); + }; + + string userName = _config.User?.Name ?? throw new ArgumentNullException($"{nameof(_config)}.{nameof(_config.User)}.{nameof(_config.User.Name)})"); + _jiraClient = ServerApiFactory.CreateApi(_httpClient, userName, _config.JiraServer?.ServerFlavourId ?? JiraServerFlavour.Vanilla); /* 2do!... _jiraClient.WsClient.HttpRequestPostprocess = req => @@ -154,7 +156,7 @@ private async Task FillJiraWithWorklogs(InputWorkLog[] inputWorklogs, WorkLog[] Task[] fillJiraWithWorklogsTasks = worklogsForDeletion .Select(worklog => _jiraClient.DeleteWorklog(worklog.IssueId, worklog.Id)) .Concat(inputWorklogs - .Select(worklog => _jiraClient.AddWorklog(worklog.IssueKey.ToString(), DateOnly.FromDateTime(worklog.Date), (int)worklog.TimeSpent.TotalSeconds, worklog.TempWorklogType, string.Empty)) + .Select(worklog => _jiraClient.AddWorklog(worklog.IssueKey.ToString(), DateOnly.FromDateTime(worklog.Date), (int)worklog.TimeSpent.TotalSeconds, worklog.WorkLogActivity, string.Empty)) ) .ToArray(); @@ -205,8 +207,8 @@ private async Task<InputWorkLog[]> ReadInputFile(string fileName) return worklogReader .Read(row => { - if (!availableWorklogTypes.ContainsKey(row.TempWorklogType)) - throw new InvalidDataException($"Worklog type {row.TempWorklogType} not found on server"); + if (!availableWorklogTypes.ContainsKey(row.WorkLogActivity)) + throw new InvalidDataException($"Worklog type {row.WorkLogActivity} not found on server"); }) .ToArray(); }); From 4b5055e486ec7af0cb5da3439843203b8145b79e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:40:19 +0100 Subject: [PATCH 04/28] =?UTF-8?q?=F0=9F=94=A7=20input=20fields=20naming=20?= =?UTF-8?q?refactored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.inputs/InputWorkLog.cs | 4 ++-- jwl.inputs/JiraWorklogRawCsv.cs | 4 ++-- jwl.inputs/WorklogCsvReader.cs | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/jwl.inputs/InputWorkLog.cs b/jwl.inputs/InputWorkLog.cs index b95da9b..5d259f5 100644 --- a/jwl.inputs/InputWorkLog.cs +++ b/jwl.inputs/InputWorkLog.cs @@ -6,6 +6,6 @@ public struct InputWorkLog public JiraIssueKey IssueKey; public DateTime Date; public TimeSpan TimeSpent; - public string TempWorklogType; - public string Comment; + public string WorkLogActivity; + public string WorkLogComment; } diff --git a/jwl.inputs/JiraWorklogRawCsv.cs b/jwl.inputs/JiraWorklogRawCsv.cs index 912a547..98a34fe 100644 --- a/jwl.inputs/JiraWorklogRawCsv.cs +++ b/jwl.inputs/JiraWorklogRawCsv.cs @@ -6,7 +6,7 @@ public record JiraWorklogRawCsv string IssueKey, string Date, string TimeSpent, - string TempoWorklogType, - string Comment + string WorkLogActivity, + string WorkLogComment ) { } diff --git a/jwl.inputs/WorklogCsvReader.cs b/jwl.inputs/WorklogCsvReader.cs index e0961d6..443377a 100644 --- a/jwl.inputs/WorklogCsvReader.cs +++ b/jwl.inputs/WorklogCsvReader.cs @@ -37,6 +37,7 @@ public IEnumerable<InputWorkLog> Read(Action<InputWorkLog>? postProcessResult = string[] timespanTimeFormats = { + @"hh\:mm\:ss", @"hh\:mm", @"mm", @"hh'h'mm", @@ -69,8 +70,8 @@ public IEnumerable<InputWorkLog> Read(Action<InputWorkLog>? postProcessResult = IssueKey = worklogIssueKey, Date = worklogDate, TimeSpent = worklogTimeSpent, - TempWorklogType = row.TempoWorklogType, - Comment = row.Comment + WorkLogActivity = row.WorkLogActivity, + WorkLogComment = row.WorkLogComment }; } catch (Exception e) From 91824e20214229bb29a51112a2b19d1215c8dd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:41:11 +0100 Subject: [PATCH 05/28] =?UTF-8?q?=F0=9F=94=A7=20fix:=20retrieval=20of=20wo?= =?UTF-8?q?rklogs=20per=20user,=20issue=20and=20date=20range=20was=20missi?= =?UTF-8?q?ng=20the=20user=20predicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/VanillaJiraServerApi.cs | 26 ++++++++++--------- .../rest/request/JiraAddWorklogByIssueKey.cs | 6 ++--- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/jwl.jira/VanillaJiraServerApi.cs b/jwl.jira/VanillaJiraServerApi.cs index 646e7d9..45036cf 100644 --- a/jwl.jira/VanillaJiraServerApi.cs +++ b/jwl.jira/VanillaJiraServerApi.cs @@ -3,6 +3,7 @@ namespace jwl.jira; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; +using System.Xml.Linq; using jwl.infra; public class VanillaJiraServerApi @@ -58,6 +59,7 @@ public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnume var result = responseTasks .SelectMany(task => task.Result.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, @@ -83,15 +85,15 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds .Add(issueKey) .Add(@"worklog") }; - var request = new api.rest.request.JiraAddWorklogByIssueKey() - { - Started = day + 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 - }; + TimeSpentSeconds: timeSpentSeconds, + Comment: comment + ); await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); } @@ -137,15 +139,15 @@ public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, i .Add(@"worklog") .Add(worklogId.ToString()) }; - var request = new api.rest.request.JiraAddWorklogByIssueKey() - { - Started = day + 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 - }; + TimeSpentSeconds: timeSpentSeconds, + Comment: comment + ); await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); } } diff --git a/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs b/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs index ee04cb4..035be2c 100644 --- a/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs +++ b/jwl.jira/api/rest/request/JiraAddWorklogByIssueKey.cs @@ -1,8 +1,6 @@ +#pragma warning disable SA1313 namespace jwl.jira.api.rest.request; -public class JiraAddWorklogByIssueKey +public record JiraAddWorklogByIssueKey(string Started, int TimeSpentSeconds, string? Comment) { - public string? Started { get; init; } - public int? TimeSpentSeconds { get; init; } - public string? Comment { get; init; } } From e8ef1fd0aa37591f1f3a5942b67d2633d10833f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:45:45 +0100 Subject: [PATCH 06/28] =?UTF-8?q?=E2=9E=95=20worklog=20comments=20made=20t?= =?UTF-8?q?heir=20way=20to=20Jira=20worklogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/JwlCoreProcess.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jwl.core/JwlCoreProcess.cs b/jwl.core/JwlCoreProcess.cs index e7389a9..abe4050 100644 --- a/jwl.core/JwlCoreProcess.cs +++ b/jwl.core/JwlCoreProcess.cs @@ -156,7 +156,7 @@ private async Task FillJiraWithWorklogs(InputWorkLog[] inputWorklogs, WorkLog[] Task[] fillJiraWithWorklogsTasks = worklogsForDeletion .Select(worklog => _jiraClient.DeleteWorklog(worklog.IssueId, worklog.Id)) .Concat(inputWorklogs - .Select(worklog => _jiraClient.AddWorklog(worklog.IssueKey.ToString(), DateOnly.FromDateTime(worklog.Date), (int)worklog.TimeSpent.TotalSeconds, worklog.WorkLogActivity, string.Empty)) + .Select(worklog => _jiraClient.AddWorklog(worklog.IssueKey.ToString(), DateOnly.FromDateTime(worklog.Date), (int)worklog.TimeSpent.TotalSeconds, worklog.WorkLogActivity, worklog.WorkLogComment)) ) .ToArray(); From c2a895ead696d020e49803b5934533944a8d24df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:53:22 +0100 Subject: [PATCH 07/28] =?UTF-8?q?=F0=9F=94=A7=20launch=20settings=20git-ig?= =?UTF-8?q?nored?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9877504..e12cefa 100644 --- a/.gitignore +++ b/.gitignore @@ -349,3 +349,4 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ +/jwl.console/Properties/launchSettings.json From 7b5285d1b2918fe974f2f5eb2cfd8bebb1c1212d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:53:49 +0100 Subject: [PATCH 08/28] =?UTF-8?q?=F0=9F=94=A7=20varible/parameter=20naming?= =?UTF-8?q?=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/IJiraServerApi.cs | 6 +++--- jwl.jira/JiraWithICTimePluginApi.cs | 6 +++--- jwl.jira/JiraWithTempoPluginApi.cs | 12 ++++++------ jwl.jira/VanillaJiraServerApi.cs | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/jwl.jira/IJiraServerApi.cs b/jwl.jira/IJiraServerApi.cs index 36981be..07c9203 100644 --- a/jwl.jira/IJiraServerApi.cs +++ b/jwl.jira/IJiraServerApi.cs @@ -13,11 +13,11 @@ public interface IJiraServerApi Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys); - Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment); + Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment); - Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? tempoWorklogType, string? comment, bool includeNonWorkingDays = false); + Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false); Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = false); - Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment); + Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment); } diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index 2bacd8c..171c8b2 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -32,12 +32,12 @@ public Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable< throw new NotImplementedException(); } - public Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { throw new NotImplementedException(); } - public Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? tempoWorklogType, string? comment, bool includeNonWorkingDays = false) + public Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) { throw new NotImplementedException(); } @@ -47,7 +47,7 @@ public Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = false throw new NotImplementedException(); } - public Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { throw new NotImplementedException(); } diff --git a/jwl.jira/JiraWithTempoPluginApi.cs b/jwl.jira/JiraWithTempoPluginApi.cs index 5a529c3..ddd4975 100644 --- a/jwl.jira/JiraWithTempoPluginApi.cs +++ b/jwl.jira/JiraWithTempoPluginApi.cs @@ -83,9 +83,9 @@ public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnume return result; } - public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { - await AddWorklogPeriod(issueKey, day, day, timeSpentSeconds, worklogType, 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) @@ -129,12 +129,12 @@ public async Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); } - public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { - await UpdateWorklogPeriod(issueKey, worklogId, day, day, timeSpentSeconds, comment, worklogType); + 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? tempoWorklogType, bool includeNonWorkingDays = false) + 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() { @@ -157,7 +157,7 @@ private async Task UpdateWorklogPeriod(string issueKey, long worklogId, DateOnly Key = WorklogTypeAttributeKey, Name = @"Worklog Type", Type = api.rest.common.TempoWorklogAttributeTypeIdentifier.StaticList, - Value = tempoWorklogType + Value = activity } } }; diff --git a/jwl.jira/VanillaJiraServerApi.cs b/jwl.jira/VanillaJiraServerApi.cs index 45036cf..ff53d60 100644 --- a/jwl.jira/VanillaJiraServerApi.cs +++ b/jwl.jira/VanillaJiraServerApi.cs @@ -77,7 +77,7 @@ public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnume return result; } - public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { UriBuilder uriBuilder = new UriBuilder() { @@ -97,7 +97,7 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); } - public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? worklogType, string? comment, bool includeNonWorkingDays = false) + 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)) @@ -110,7 +110,7 @@ public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly d int timeSpentSecondsPerSingleDay = timeSpentSeconds / daysInPeriod.Length; Task[] addWorklogTasks = daysInPeriod - .Select(day => AddWorklog(issueKey, day, timeSpentSecondsPerSingleDay, worklogType, comment)) + .Select(day => AddWorklog(issueKey, day, timeSpentSecondsPerSingleDay, activity, comment)) .ToArray(); await Task.WhenAll(addWorklogTasks); @@ -130,7 +130,7 @@ public async Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); } - public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? worklogType, string? comment) + public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) { UriBuilder uriBuilder = new UriBuilder() { From e114ae195d23aa743c51cb33f0ac9b4521c4c771 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 10:58:32 +0100 Subject: [PATCH 09/28] =?UTF-8?q?=F0=9F=A7=B9=20http=20client=20made=20rea?= =?UTF-8?q?donly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraWithICTimePluginApi.cs | 5 +++-- jwl.jira/VanillaJiraServerApi.cs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index 171c8b2..c637aa7 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -5,12 +5,13 @@ public class JiraWithICTimePluginApi : IJiraServerApi { - public HttpClient HttpClient { get; } public string UserName { get; } + private readonly HttpClient _httpClient { get; } + public JiraWithICTimePluginApi(HttpClient httpClient, string userName) { - HttpClient = httpClient; + _httpClient = httpClient; UserName = userName; _vanillaJiraApi = new VanillaJiraServerApi(httpClient, userName); } diff --git a/jwl.jira/VanillaJiraServerApi.cs b/jwl.jira/VanillaJiraServerApi.cs index ff53d60..d4d1531 100644 --- a/jwl.jira/VanillaJiraServerApi.cs +++ b/jwl.jira/VanillaJiraServerApi.cs @@ -9,10 +9,10 @@ namespace jwl.jira; public class VanillaJiraServerApi : IJiraServerApi { - private readonly HttpClient _httpClient; - public string UserName { get; } + private readonly HttpClient _httpClient; + public VanillaJiraServerApi(HttpClient httpClient, string userName) { _httpClient = httpClient; From 33e194a5a257462ae721a125b7f50b6479d23fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 11:00:16 +0100 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraWithICTimePluginApi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index c637aa7..0b6995f 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -7,7 +7,7 @@ public class JiraWithICTimePluginApi { public string UserName { get; } - private readonly HttpClient _httpClient { get; } + private readonly HttpClient _httpClient; public JiraWithICTimePluginApi(HttpClient httpClient, string userName) { From b8ffa5175602add1a7fe5ce578ae7283fdfff71f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 11:02:28 +0100 Subject: [PATCH 11/28] =?UTF-8?q?=F0=9F=A7=B9=20IJiraServerApi=20interface?= =?UTF-8?q?=20renamed,=20since=20it's=20a=20client=20interface,=20not=20a?= =?UTF-8?q?=20server=20interface=20=F0=9F=99=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/JwlCoreProcess.cs | 2 +- jwl.jira/{IJiraServerApi.cs => IJiraClient.cs} | 2 +- jwl.jira/JiraWithICTimePluginApi.cs | 6 +++--- jwl.jira/JiraWithTempoPluginApi.cs | 6 +++--- jwl.jira/ServerApiFactory.cs | 4 ++-- jwl.jira/{VanillaJiraServerApi.cs => VanillaJiraClient.cs} | 6 +++--- 6 files changed, 13 insertions(+), 13 deletions(-) rename jwl.jira/{IJiraServerApi.cs => IJiraClient.cs} (93%) rename jwl.jira/{VanillaJiraServerApi.cs => VanillaJiraClient.cs} (95%) diff --git a/jwl.core/JwlCoreProcess.cs b/jwl.core/JwlCoreProcess.cs index abe4050..28937f4 100644 --- a/jwl.core/JwlCoreProcess.cs +++ b/jwl.core/JwlCoreProcess.cs @@ -21,7 +21,7 @@ public class JwlCoreProcess : IDisposable private AppConfig _config; private HttpClientHandler _httpClientHandler; private HttpClient _httpClient; - private IJiraServerApi _jiraClient; + private IJiraClient _jiraClient; private jwl.jira.api.rest.common.JiraUserInfo? _userInfo; private Dictionary<string, WorkLogType> availableWorklogTypes = new (); diff --git a/jwl.jira/IJiraServerApi.cs b/jwl.jira/IJiraClient.cs similarity index 93% rename from jwl.jira/IJiraServerApi.cs rename to jwl.jira/IJiraClient.cs index 07c9203..bbef248 100644 --- a/jwl.jira/IJiraServerApi.cs +++ b/jwl.jira/IJiraClient.cs @@ -5,7 +5,7 @@ using System.Text; using System.Threading.Tasks; -public interface IJiraServerApi +public interface IJiraClient { Task<api.rest.common.JiraUserInfo> GetUserInfo(); diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index 0b6995f..b858ad3 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -3,7 +3,7 @@ // https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API public class JiraWithICTimePluginApi - : IJiraServerApi + : IJiraClient { public string UserName { get; } @@ -13,10 +13,10 @@ public JiraWithICTimePluginApi(HttpClient httpClient, string userName) { _httpClient = httpClient; UserName = userName; - _vanillaJiraApi = new VanillaJiraServerApi(httpClient, userName); + _vanillaJiraApi = new VanillaJiraClient(httpClient, userName); } - private readonly VanillaJiraServerApi _vanillaJiraApi; + private readonly VanillaJiraClient _vanillaJiraApi; public async Task<JiraUserInfo> GetUserInfo() { diff --git a/jwl.jira/JiraWithTempoPluginApi.cs b/jwl.jira/JiraWithTempoPluginApi.cs index ddd4975..39b0128 100644 --- a/jwl.jira/JiraWithTempoPluginApi.cs +++ b/jwl.jira/JiraWithTempoPluginApi.cs @@ -5,19 +5,19 @@ namespace jwl.jira; // https://www.tempo.io/server-api-documentation/timesheets public class JiraWithTempoPluginApi - : IJiraServerApi + : IJiraClient { private const string WorklogTypeAttributeKey = @"_WorklogType_"; private readonly HttpClient _httpClient; - private readonly VanillaJiraServerApi _vanillaJiraServerApi; + private readonly VanillaJiraClient _vanillaJiraServerApi; public string UserName { get; } public JiraWithTempoPluginApi(HttpClient httpClient, string userName) { _httpClient = httpClient; - _vanillaJiraServerApi = new VanillaJiraServerApi(httpClient, userName); + _vanillaJiraServerApi = new VanillaJiraClient(httpClient, userName); UserName = userName; } diff --git a/jwl.jira/ServerApiFactory.cs b/jwl.jira/ServerApiFactory.cs index 913bb81..508be99 100644 --- a/jwl.jira/ServerApiFactory.cs +++ b/jwl.jira/ServerApiFactory.cs @@ -2,11 +2,11 @@ public static class ServerApiFactory { - public static IJiraServerApi CreateApi(HttpClient httpClient, string userName, JiraServerFlavour serverClass) + public static IJiraClient CreateApi(HttpClient httpClient, string userName, JiraServerFlavour serverClass) { return serverClass switch { - JiraServerFlavour.Vanilla => new VanillaJiraServerApi(httpClient, userName), + JiraServerFlavour.Vanilla => new VanillaJiraClient(httpClient, userName), JiraServerFlavour.TempoTimeSheets => new JiraWithTempoPluginApi(httpClient, userName), _ => throw new NotImplementedException($"Jira server class {nameof(serverClass)} not yet implemented") }; diff --git a/jwl.jira/VanillaJiraServerApi.cs b/jwl.jira/VanillaJiraClient.cs similarity index 95% rename from jwl.jira/VanillaJiraServerApi.cs rename to jwl.jira/VanillaJiraClient.cs index d4d1531..9b7706d 100644 --- a/jwl.jira/VanillaJiraServerApi.cs +++ b/jwl.jira/VanillaJiraClient.cs @@ -6,14 +6,14 @@ namespace jwl.jira; using System.Xml.Linq; using jwl.infra; -public class VanillaJiraServerApi - : IJiraServerApi +public class VanillaJiraClient + : IJiraClient { public string UserName { get; } private readonly HttpClient _httpClient; - public VanillaJiraServerApi(HttpClient httpClient, string userName) + public VanillaJiraClient(HttpClient httpClient, string userName) { _httpClient = httpClient; UserName = userName; From 84708fe4029f142d8d5fbf78ca1ad9feffa516ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 17:07:38 +0100 Subject: [PATCH 12/28] =?UTF-8?q?=F0=9F=9B=A0=20ICTime=20plugin=20API=20mo?= =?UTF-8?q?ck'd=20via=20vanilla=20Jira=20API=20for=20the=20time=20being?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.console/CLI.cs | 2 +- jwl.jira/JiraServerFlavour.cs | 2 +- jwl.jira/JiraWithICTimePluginApi.cs | 55 ----------------------- jwl.jira/JiraWithICTimePluginMock.cs | 67 ++++++++++++++++++++++++++++ jwl.jira/ServerApiFactory.cs | 1 + 5 files changed, 70 insertions(+), 57 deletions(-) delete mode 100644 jwl.jira/JiraWithICTimePluginApi.cs create mode 100644 jwl.jira/JiraWithICTimePluginMock.cs diff --git a/jwl.console/CLI.cs b/jwl.console/CLI.cs index 52db571..e29da74 100644 --- a/jwl.console/CLI.cs +++ b/jwl.console/CLI.cs @@ -22,7 +22,7 @@ public class FillCLI [Option("server-flavour", HelpText = "Jira server flavour (whether vanilla or with some timesheet plugins)" + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.ServerFlavour)}" - + $"\nAvailable values: {nameof(jira.JiraServerFlavour.Vanilla)}, {nameof(jira.JiraServerFlavour.TempoTimeSheets)}, {nameof(jira.JiraServerFlavour.ICTime)}")] + + $"\nAvailable values: {nameof(jira.JiraServerFlavour.Vanilla)}, {nameof(jira.JiraServerFlavour.TempoTimeSheets)}, {nameof(jira.JiraServerFlavour.ICTimeMockViaJira)}")] public string? ServerClass { get; set; } [Option("no-proxy", HelpText = "Turn off proxying the HTTP(S) connections to Jira server" diff --git a/jwl.jira/JiraServerFlavour.cs b/jwl.jira/JiraServerFlavour.cs index 10efe2b..544d4a2 100644 --- a/jwl.jira/JiraServerFlavour.cs +++ b/jwl.jira/JiraServerFlavour.cs @@ -4,5 +4,5 @@ public enum JiraServerFlavour { Vanilla = 0, TempoTimeSheets = 1, - ICTime = 2 + ICTimeMockViaJira = 2 } diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs deleted file mode 100644 index b858ad3..0000000 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace jwl.jira; -using jwl.jira.api.rest.common; - -// https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API -public class JiraWithICTimePluginApi - : IJiraClient -{ - public string UserName { get; } - - private readonly HttpClient _httpClient; - - public JiraWithICTimePluginApi(HttpClient httpClient, string userName) - { - _httpClient = httpClient; - UserName = userName; - _vanillaJiraApi = new VanillaJiraClient(httpClient, userName); - } - - private readonly VanillaJiraClient _vanillaJiraApi; - - public async Task<JiraUserInfo> GetUserInfo() - { - return await _vanillaJiraApi.GetUserInfo(); - } - - public Task<WorkLogType[]> GetWorklogTypes() - { - throw new NotImplementedException(); - } - - public Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys) - { - throw new NotImplementedException(); - } - - public Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - throw new NotImplementedException(); - } - - public Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) - { - throw new NotImplementedException(); - } - - public Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = false) - { - throw new NotImplementedException(); - } - - public Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - throw new NotImplementedException(); - } -} diff --git a/jwl.jira/JiraWithICTimePluginMock.cs b/jwl.jira/JiraWithICTimePluginMock.cs new file mode 100644 index 0000000..e797103 --- /dev/null +++ b/jwl.jira/JiraWithICTimePluginMock.cs @@ -0,0 +1,67 @@ +namespace jwl.jira; +using jwl.jira.api.rest.common; +using System.Text; + +// https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API +// https://interconcept.atlassian.net/wiki/spaces/ICBIZ/pages/34701333/REST+Services +public class JiraWithICTimePluginMock + : IJiraClient +{ + public string UserName { get; } + + private readonly HttpClient _httpClient; + + public JiraWithICTimePluginMock(HttpClient httpClient, string userName) + { + _httpClient = httpClient; + UserName = userName; + _vanillaJiraApi = new VanillaJiraClient(httpClient, userName); + } + + private readonly VanillaJiraClient _vanillaJiraApi; + + public async Task<JiraUserInfo> GetUserInfo() + { + return await _vanillaJiraApi.GetUserInfo(); + } + + public async Task<WorkLogType[]> GetWorklogTypes() + { + return await _vanillaJiraApi.GetWorklogTypes(); + } + + public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys) + { + return await _vanillaJiraApi.GetIssueWorklogs(from, to, issueKeys); + } + + public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + StringBuilder commentBuilder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(activity)) + commentBuilder.Append($"({activity}){Environment.NewLine}"); + commentBuilder.Append(comment); + + await _vanillaJiraApi.AddWorklog(issueKey, day, timeSpentSeconds, null, commentBuilder.ToString()); + } + + public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) + { + StringBuilder commentBuilder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(activity)) + commentBuilder.Append($"({activity}){Environment.NewLine}"); + commentBuilder.Append(comment); + + await _vanillaJiraApi.AddWorklogPeriod(issueKey, dayFrom, dayTo, timeSpentSeconds, null, commentBuilder.ToString(), includeNonWorkingDays); + } + + 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); + } +} diff --git a/jwl.jira/ServerApiFactory.cs b/jwl.jira/ServerApiFactory.cs index 508be99..2b62719 100644 --- a/jwl.jira/ServerApiFactory.cs +++ b/jwl.jira/ServerApiFactory.cs @@ -8,6 +8,7 @@ public static IJiraClient CreateApi(HttpClient httpClient, string userName, Jira { JiraServerFlavour.Vanilla => new VanillaJiraClient(httpClient, userName), JiraServerFlavour.TempoTimeSheets => new JiraWithTempoPluginApi(httpClient, userName), + JiraServerFlavour.ICTimeMockViaJira => new JiraWithICTimePluginMock(httpClient, userName), _ => throw new NotImplementedException($"Jira server class {nameof(serverClass)} not yet implemented") }; } From d90ad42a878a10abf1a4b32480e72c152d9b056e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 21:57:32 +0100 Subject: [PATCH 13/28] =?UTF-8?q?=F0=9F=9B=A0=20AppConfig.OverrideWith()?= =?UTF-8?q?=20now=20works=20more-or-less-OK=20with=20ActivityMap=20diction?= =?UTF-8?q?ary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/AppConfig.cs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/jwl.core/AppConfig.cs b/jwl.core/AppConfig.cs index 171a8ad..52760f1 100644 --- a/jwl.core/AppConfig.cs +++ b/jwl.core/AppConfig.cs @@ -8,7 +8,7 @@ public class AppConfig public jwl.core.UserConfig? User { get; init; } public jwl.inputs.CsvFormatConfig? CsvOptions { get; init; } - private static MapperConfiguration overridingMapperConfiguration = new MapperConfiguration(cfg => + private static Lazy<MapperConfiguration> overridingMapperConfiguration = new (() => new MapperConfiguration(cfg => { cfg.CreateMap<AppConfig, AppConfig>() .ForAllMembers(m => m.Condition((src, dest, member) => member != null)); @@ -17,15 +17,19 @@ public class AppConfig cfg.CreateMap<inputs.CsvFormatConfig, inputs.CsvFormatConfig>() .ForAllMembers(m => m.Condition((src, dest, member) => member != null)); cfg.CreateMap<core.UserConfig, core.UserConfig>() - .ForAllMembers(m => m.Condition((src, dest, member) => member != null)); - }); - private static IMapper overridingMapper = overridingMapperConfiguration.CreateMapper(); + .ForAllMembers(m => m.Condition((src, dest, member) => member != null)); + + cfg.AddGlobalIgnore(nameof(AppConfig.JiraServer.ActivityMap)); + })); + + private static Lazy<IMapper> overridingMapper = new (() => overridingMapperConfiguration.Value.CreateMapper()); public AppConfig OverrideWith(AppConfig? other) { if (other == null) - return this; - - return overridingMapper.Map<AppConfig, AppConfig>(other, this); + return this; + + AppConfig result = overridingMapper.Value.Map<AppConfig, AppConfig>(other, this); + return result; } } From 643bbbb4046d9bfe185f1b8ff5b11165d3237744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 21:58:10 +0100 Subject: [PATCH 14/28] =?UTF-8?q?=F0=9F=94=A7=20fix:=20default=20server=20?= =?UTF-8?q?flavour=20is=20now=20Vanilla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/AppConfigFactory.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/jwl.core/AppConfigFactory.cs b/jwl.core/AppConfigFactory.cs index 4059176..99b0947 100644 --- a/jwl.core/AppConfigFactory.cs +++ b/jwl.core/AppConfigFactory.cs @@ -16,6 +16,8 @@ public static AppConfig CreateWithDefaults() { JiraServer = new ServerConfig() { + ServerFlavour = nameof(JiraServerFlavour.Vanilla), + ActivityMap = null, BaseUrl = @"http://jira.my-domain.xyz", MaxConnectionsPerServer = DefaultMaxConnectionsPerServer, SkipSslCertificateCheck = false, From 4fb00cf02f65163bbe787da4aa615cccc0901787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:00:35 +0100 Subject: [PATCH 15/28] =?UTF-8?q?=E2=9E=95=20ICTime=20plugin=20client=20AP?= =?UTF-8?q?I=20(...=20still=20not=20working,=20though)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraServerFlavour.cs | 2 +- ...uginMock.cs => JiraWithICTimePluginApi.cs} | 151 ++++++++++-------- jwl.jira/ServerApiFactory.cs | 2 +- .../request/ICTimeAddWorklogByIssueKey.cs | 6 + 4 files changed, 96 insertions(+), 65 deletions(-) rename jwl.jira/{JiraWithICTimePluginMock.cs => JiraWithICTimePluginApi.cs} (51%) create mode 100644 jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs diff --git a/jwl.jira/JiraServerFlavour.cs b/jwl.jira/JiraServerFlavour.cs index 544d4a2..10efe2b 100644 --- a/jwl.jira/JiraServerFlavour.cs +++ b/jwl.jira/JiraServerFlavour.cs @@ -4,5 +4,5 @@ public enum JiraServerFlavour { Vanilla = 0, TempoTimeSheets = 1, - ICTimeMockViaJira = 2 + ICTime = 2 } diff --git a/jwl.jira/JiraWithICTimePluginMock.cs b/jwl.jira/JiraWithICTimePluginApi.cs similarity index 51% rename from jwl.jira/JiraWithICTimePluginMock.cs rename to jwl.jira/JiraWithICTimePluginApi.cs index e797103..b5a833a 100644 --- a/jwl.jira/JiraWithICTimePluginMock.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -1,67 +1,92 @@ -namespace jwl.jira; -using jwl.jira.api.rest.common; +namespace jwl.jira; + +using jwl.infra; +using jwl.jira.api.rest.common; +using System.Diagnostics; +using System.Net.Http.Json; +using System.Numerics; using System.Text; // https://interconcept.atlassian.net/wiki/spaces/ICTIME/pages/31686672/API // https://interconcept.atlassian.net/wiki/spaces/ICBIZ/pages/34701333/REST+Services -public class JiraWithICTimePluginMock - : IJiraClient -{ - public string UserName { get; } - - private readonly HttpClient _httpClient; - - public JiraWithICTimePluginMock(HttpClient httpClient, string userName) - { - _httpClient = httpClient; - UserName = userName; - _vanillaJiraApi = new VanillaJiraClient(httpClient, userName); - } - - private readonly VanillaJiraClient _vanillaJiraApi; - - public async Task<JiraUserInfo> GetUserInfo() - { - return await _vanillaJiraApi.GetUserInfo(); - } - - public async Task<WorkLogType[]> GetWorklogTypes() - { - return await _vanillaJiraApi.GetWorklogTypes(); - } - - public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys) - { - return await _vanillaJiraApi.GetIssueWorklogs(from, to, issueKeys); - } - - public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) - { - StringBuilder commentBuilder = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(activity)) - commentBuilder.Append($"({activity}){Environment.NewLine}"); - commentBuilder.Append(comment); - - await _vanillaJiraApi.AddWorklog(issueKey, day, timeSpentSeconds, null, commentBuilder.ToString()); - } - - public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) - { - StringBuilder commentBuilder = new StringBuilder(); - if (!string.IsNullOrWhiteSpace(activity)) - commentBuilder.Append($"({activity}){Environment.NewLine}"); - commentBuilder.Append(comment); - - await _vanillaJiraApi.AddWorklogPeriod(issueKey, dayFrom, dayTo, timeSpentSeconds, null, commentBuilder.ToString(), includeNonWorkingDays); - } - - 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); - } -} +public class JiraWithICTimePluginApi + : IJiraClient +{ + public string UserName { get; } + + private readonly HttpClient _httpClient; + + public JiraWithICTimePluginApi(HttpClient httpClient, string userName) + { + _httpClient = httpClient; + UserName = userName; + _vanillaJiraApi = new VanillaJiraClient(httpClient, userName); + } + + private readonly VanillaJiraClient _vanillaJiraApi; + + public async Task<JiraUserInfo> GetUserInfo() + { + return await _vanillaJiraApi.GetUserInfo(); + } + + public async Task<WorkLogType[]> GetWorklogTypes() + { + return await _vanillaJiraApi.GetWorklogTypes(); + } + + public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys) + { + return await _vanillaJiraApi.GetIssueWorklogs(from, to, issueKeys); + } + + public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds, string? activity, string? comment) + { + UriBuilder uriBuilder = new UriBuilder() + { + Path = new UriPathBuilder(@"rest/api/2/issue") + .Add(issueKey) + .Add(@"worklog") + }; + var request = new api.rest.request.ICTimeAddWorklogByIssueKey( + Started: day + .ToDateTime(TimeOnly.MinValue) + .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") + .Replace(":", string.Empty) + .Replace(';', ':'), + TimeSpentSeconds: timeSpentSeconds, + Activity: string.IsNullOrEmpty(activity) ? null : int.Parse(activity), + Comment: comment + ); + await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + } + + 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); + } +} diff --git a/jwl.jira/ServerApiFactory.cs b/jwl.jira/ServerApiFactory.cs index 2b62719..9cae3a4 100644 --- a/jwl.jira/ServerApiFactory.cs +++ b/jwl.jira/ServerApiFactory.cs @@ -8,7 +8,7 @@ public static IJiraClient CreateApi(HttpClient httpClient, string userName, Jira { JiraServerFlavour.Vanilla => new VanillaJiraClient(httpClient, userName), JiraServerFlavour.TempoTimeSheets => new JiraWithTempoPluginApi(httpClient, userName), - JiraServerFlavour.ICTimeMockViaJira => new JiraWithICTimePluginMock(httpClient, userName), + JiraServerFlavour.ICTime => new JiraWithICTimePluginApi(httpClient, userName), _ => throw new NotImplementedException($"Jira server class {nameof(serverClass)} not yet implemented") }; } diff --git a/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs new file mode 100644 index 0000000..f11261d --- /dev/null +++ b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs @@ -0,0 +1,6 @@ +#pragma warning disable SA1313 +namespace jwl.jira.api.rest.request; + +public record ICTimeAddWorklogByIssueKey(string Started, int TimeSpentSeconds, int? Activity, string? Comment) +{ +} From 90bc834dfd732013026cfdcce6be826ad8aabdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:01:20 +0100 Subject: [PATCH 16/28] =?UTF-8?q?=E2=9E=95=20configurable=20activity=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.console/CLI.cs | 7 ++++--- jwl.jira/ServerConfig.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/jwl.console/CLI.cs b/jwl.console/CLI.cs index e29da74..2df6d0e 100644 --- a/jwl.console/CLI.cs +++ b/jwl.console/CLI.cs @@ -22,8 +22,8 @@ public class FillCLI [Option("server-flavour", HelpText = "Jira server flavour (whether vanilla or with some timesheet plugins)" + $"\nJSON config: $.{nameof(core.AppConfig.JiraServer)}.{nameof(jira.ServerConfig.ServerFlavour)}" - + $"\nAvailable values: {nameof(jira.JiraServerFlavour.Vanilla)}, {nameof(jira.JiraServerFlavour.TempoTimeSheets)}, {nameof(jira.JiraServerFlavour.ICTimeMockViaJira)}")] - public string? ServerClass { get; set; } + + $"\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!)")] @@ -55,7 +55,8 @@ public core.AppConfig ToAppConfig() UseVerboseFeedback = UseVerboseFeedback, JiraServer = new jira.ServerConfig() { - ServerFlavour = ServerClass, + ServerFlavour = ServerFlavour, + ActivityMap = null, BaseUrl = jiraServerSpecification, UseProxy = !NoProxy, MaxConnectionsPerServer = MaxConnectionsPerServer, diff --git a/jwl.jira/ServerConfig.cs b/jwl.jira/ServerConfig.cs index d7d6440..3c5ae3e 100644 --- a/jwl.jira/ServerConfig.cs +++ b/jwl.jira/ServerConfig.cs @@ -1,10 +1,10 @@ namespace jwl.jira; -using System.ComponentModel; public class ServerConfig { public string? ServerFlavour { get; init; } public JiraServerFlavour ServerFlavourId => ServerApiFactory.DecodeServerClass(ServerFlavour) ?? JiraServerFlavour.Vanilla; + public Dictionary<string, string>? ActivityMap { get; init; } public string? BaseUrl { get; init; } public bool? UseProxy { get; init; } public int? MaxConnectionsPerServer { get; init; } From 63f06bfeec0868090adb7c423a024cd015603503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:01:57 +0100 Subject: [PATCH 17/28] =?UTF-8?q?=E2=9E=95=20activity=20now=20gets=20refle?= =?UTF-8?q?cted=20in=20vanilla=20Jira=20worklog=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/VanillaJiraClient.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/jwl.jira/VanillaJiraClient.cs b/jwl.jira/VanillaJiraClient.cs index 9b7706d..b20a127 100644 --- a/jwl.jira/VanillaJiraClient.cs +++ b/jwl.jira/VanillaJiraClient.cs @@ -3,6 +3,7 @@ namespace jwl.jira; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; +using System.Text; using System.Xml.Linq; using jwl.infra; @@ -69,7 +70,7 @@ public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnume Created: wl.Created.Value, Started: wl.Started.Value, TimeSpentSeconds: wl.TimeSpentSeconds, - WorkLogType: null, + Activity: null, Comment: wl.Comment )) .ToArray(); @@ -85,6 +86,12 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds .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) @@ -92,7 +99,7 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds .Replace(":", string.Empty) .Replace(';', ':'), TimeSpentSeconds: timeSpentSeconds, - Comment: comment + Comment: commentBuilder.ToString() ); await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); } From 90039772aaaa1ad674b8fed741f73233d283e101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:02:40 +0100 Subject: [PATCH 18/28] =?UTF-8?q?=F0=9F=94=A7=20worklogtype=20renamed=20to?= =?UTF-8?q?=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraWithTempoPluginApi.cs | 2 +- jwl.jira/WorkLog.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jwl.jira/JiraWithTempoPluginApi.cs b/jwl.jira/JiraWithTempoPluginApi.cs index 39b0128..44f5049 100644 --- a/jwl.jira/JiraWithTempoPluginApi.cs +++ b/jwl.jira/JiraWithTempoPluginApi.cs @@ -75,7 +75,7 @@ public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnume Created: wl.Created?.Value ?? DateTime.MinValue, Started: wl.Started?.Value ?? DateTime.MinValue, TimeSpentSeconds: wl.TimeSpentSeconds ?? -1, - WorkLogType: wl.Attributes?[WorklogTypeAttributeKey].Value, + Activity: wl.Attributes?[WorklogTypeAttributeKey].Value, Comment: wl.Comment ?? string.Empty )) .ToArray(); diff --git a/jwl.jira/WorkLog.cs b/jwl.jira/WorkLog.cs index a5232bc..e2a8c3b 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? WorkLogType, string Comment) +public record WorkLog(long Id, long IssueId, string? AuthorName, string? AuthorKey, DateTime Created, DateTime Started, int TimeSpentSeconds, string? Activity, string Comment) { } From c8e9b90611df3b9ae0bd461ff2c240839c45e56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:03:30 +0100 Subject: [PATCH 19/28] =?UTF-8?q?=E2=9E=95=20worklog=20activity=20now=20ge?= =?UTF-8?q?ts=20retrieved=20from=20activity=20map,=20if=20configured,=20ot?= =?UTF-8?q?herwise=20the=20original=20activity=20string=20gets=20used?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.core/JwlCoreProcess.cs | 510 +++++++++++++++++++------------------ jwl.core/jwl.core.csproj | 4 +- 2 files changed, 262 insertions(+), 252 deletions(-) diff --git a/jwl.core/JwlCoreProcess.cs b/jwl.core/JwlCoreProcess.cs index 28937f4..8f4b3f4 100644 --- a/jwl.core/JwlCoreProcess.cs +++ b/jwl.core/JwlCoreProcess.cs @@ -1,253 +1,261 @@ -namespace jwl.core; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using jwl.infra; -using jwl.inputs; -using jwl.jira; -using jwl.jira.api.rest.response; -using Microsoft.Extensions.Configuration; -using NoP77svk.Linq; - -public class JwlCoreProcess : IDisposable -{ - public const int TotalProcessSteps = 7; - - public ICoreProcessFeedback? Feedback { get; init; } - public ICoreProcessInteraction _interaction { get; } - - private bool _isDisposed; - - private AppConfig _config; - private HttpClientHandler _httpClientHandler; - private HttpClient _httpClient; - private IJiraClient _jiraClient; - - private jwl.jira.api.rest.common.JiraUserInfo? _userInfo; - private Dictionary<string, WorkLogType> availableWorklogTypes = new (); - - public JwlCoreProcess(AppConfig config, ICoreProcessInteraction interaction) - { - _config = config; - _interaction = interaction; - - _httpClientHandler = new HttpClientHandler() - { - UseProxy = _config.JiraServer?.UseProxy ?? false, - UseDefaultCredentials = false, - MaxConnectionsPerServer = _config.JiraServer?.MaxConnectionsPerServer ?? AppConfigFactory.DefaultMaxConnectionsPerServer - }; - - if (_config.JiraServer?.SkipSslCertificateCheck ?? false) - _httpClientHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; - - _httpClient = new HttpClient(_httpClientHandler) - { - BaseAddress = new Uri(_config.JiraServer?.BaseUrl ?? string.Empty) +namespace jwl.core; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using jwl.infra; +using jwl.inputs; +using jwl.jira; +using NoP77svk.Linq; + +public class JwlCoreProcess : IDisposable +{ + public const int TotalProcessSteps = 7; + + public ICoreProcessFeedback? Feedback { get; init; } + public ICoreProcessInteraction _interaction { get; } + + private bool _isDisposed; + + private AppConfig _config; + private HttpClientHandler _httpClientHandler; + private HttpClient _httpClient; + private IJiraClient _jiraClient; + + private jwl.jira.api.rest.common.JiraUserInfo? _userInfo; + + public JwlCoreProcess(AppConfig config, ICoreProcessInteraction interaction) + { + _config = config; + _interaction = interaction; + + _httpClientHandler = new HttpClientHandler() + { + UseProxy = _config.JiraServer?.UseProxy ?? false, + UseDefaultCredentials = false, + MaxConnectionsPerServer = _config.JiraServer?.MaxConnectionsPerServer ?? AppConfigFactory.DefaultMaxConnectionsPerServer + }; + + if (_config.JiraServer?.SkipSslCertificateCheck ?? false) + _httpClientHandler.ServerCertificateCustomValidationCallback = (_, _, _, _) => true; + + _httpClient = new HttpClient(_httpClientHandler) + { + BaseAddress = new Uri(_config.JiraServer?.BaseUrl ?? string.Empty) + }; + + string userName = _config.User?.Name ?? throw new ArgumentNullException($"{nameof(_config)}.{nameof(_config.User)}.{nameof(_config.User.Name)})"); + _jiraClient = ServerApiFactory.CreateApi(_httpClient, userName, _config.JiraServer?.ServerFlavourId ?? JiraServerFlavour.Vanilla); + + /* 2do!... + _jiraClient.WsClient.HttpRequestPostprocess = req => + { + // 2do! optional logging of request bodies + }; + + _jiraClient.WsClient.HttpResponsePostprocess = resp => + { + // 2do! optional logging of response bodies + }; + */ + } + + public async Task PreProcess() + { + Feedback?.OverallProcessStart(); + + string? jiraUserName = _config.User?.Name; + string? jiraUserPassword = _config.User?.Password; + + if (string.IsNullOrEmpty(jiraUserName) || string.IsNullOrEmpty(jiraUserPassword)) + { + if (_interaction != null) + (jiraUserName, jiraUserPassword) = _interaction.AskForCredentials(jiraUserName); + } + + if (string.IsNullOrEmpty(jiraUserName) || string.IsNullOrEmpty(jiraUserPassword)) + throw new ArgumentNullException($"Jira credentials not supplied"); + + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(@"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(jiraUserName + ":" + jiraUserPassword))); + + Feedback?.PreloadUserInfoStart(jiraUserName); + _userInfo = await _jiraClient.GetUserInfo(); + Feedback?.PreloadUserInfoEnd(); + } + + public async Task Process(IEnumerable<string> inputFiles) + { + string[] inputFilesFetched = inputFiles.ToArray(); + + if (inputFilesFetched.Length > 0) + { + Feedback?.ReadCsvInputStart(); + InputWorkLog[] inputWorklogs = await ReadInputFiles(inputFiles); + Feedback?.ReadCsvInputEnd(); + + if (inputWorklogs.Length > 0) + { + Feedback?.RetrieveWorklogsForDeletionStart(); + WorkLog[] worklogsForDeletion = await RetrieveWorklogsForDeletion(inputWorklogs); + Feedback?.RetrieveWorklogsForDeletionEnd(); + + Feedback?.FillJiraWithWorklogsStart(); + await FillJiraWithWorklogs(inputWorklogs, worklogsForDeletion); + Feedback?.FillJiraWithWorklogsEnd(); + } + else + { + Feedback?.NoWorklogsToFill(); + } + + Feedback?.OverallProcessEnd(); + } + else + { + Feedback?.NoFilesOnInput(); + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + } + + // note: free unmanaged resources (unmanaged objects) and override finalizer + // note: set large fields to null + _httpClient?.Dispose(); + _httpClientHandler?.Dispose(); + + _isDisposed = true; + } + } + + private async Task FillJiraWithWorklogs(InputWorkLog[] inputWorklogs, WorkLog[] worklogsForDeletion) + { + Feedback?.FillJiraWithWorklogsSetTarget(inputWorklogs.Length, worklogsForDeletion.Length); + + if (_userInfo == null || _userInfo.Key == null) + throw new ArgumentNullException(@"Unresolved Jira key for the logged-on user"); + + Task[] fillJiraWithWorklogsTasks = worklogsForDeletion + .Select(worklog => _jiraClient.DeleteWorklog(worklog.IssueId, worklog.Id)) + .Concat(inputWorklogs + .LeftOuterJoin( + innerTable: _config.JiraServer?.ActivityMap ?? new Dictionary<string, string>(), + outerKeySelector: wl => wl.WorkLogActivity.ToLower(), + innerKeySelector: am => am.Key.ToLower(), + resultSelector: (wl, am) => new ValueTuple<InputWorkLog, string?>(wl, am.Value) + ) + .Select(x => _jiraClient.AddWorklog( + issueKey: x.Item1.IssueKey.ToString(), + day: DateOnly.FromDateTime(x.Item1.Date), + timeSpentSeconds: (int)x.Item1.TimeSpent.TotalSeconds, + activity: !(_config.JiraServer?.ActivityMap?.Any() ?? false) ? x.Item1.WorkLogActivity : x.Item2, + comment: x.Item1.WorkLogComment + )) + ) + .ToArray(); + + MultiTaskProgress progress = new MultiTaskProgress(fillJiraWithWorklogsTasks.Length); + + await MultiTask.WhenAll( + tasks: fillJiraWithWorklogsTasks, + progressFeedback: (_, t) => Feedback?.FillJiraWithWorklogsProcess(progress.AddTaskStatus(t?.Status)) + ); + } + + private async Task<InputWorkLog[]> ReadInputFiles(IEnumerable<string> fileNames) + { + Task<InputWorkLog[]>[] readerTasks = fileNames + .Select(fileName => ReadInputFile(fileName)) + .ToArray(); + + Feedback?.ReadCsvInputSetTarget(readerTasks.Length); + + MultiTaskProgress progress = new MultiTaskProgress(readerTasks.Length); + + if (readerTasks.Any()) + { + await MultiTask.WhenAll( + tasks: readerTasks, + progressFeedback: (_, t) => Feedback?.ReadCsvInputProcess(progress.AddTaskStatus(t?.Status)) + ); + } + + InputWorkLog[] result = readerTasks + .SelectMany(response => response.Result) + .ToArray(); + + return result; + } + + private async Task<InputWorkLog[]> ReadInputFile(string fileName) + { + WorklogReaderAggregatedConfig readerConfig = new WorklogReaderAggregatedConfig() + { + CsvFormatConfig = _config.CsvOptions }; - string userName = _config.User?.Name ?? throw new ArgumentNullException($"{nameof(_config)}.{nameof(_config.User)}.{nameof(_config.User.Name)})"); - _jiraClient = ServerApiFactory.CreateApi(_httpClient, userName, _config.JiraServer?.ServerFlavourId ?? JiraServerFlavour.Vanilla); - - /* 2do!... - _jiraClient.WsClient.HttpRequestPostprocess = req => - { - // 2do! optional logging of request bodies - }; - - _jiraClient.WsClient.HttpResponsePostprocess = resp => - { - // 2do! optional logging of response bodies - }; - */ - } - - public async Task PreProcess() - { - Feedback?.OverallProcessStart(); - - string? jiraUserName = _config.User?.Name; - string? jiraUserPassword = _config.User?.Password; - - if (string.IsNullOrEmpty(jiraUserName) || string.IsNullOrEmpty(jiraUserPassword)) - { - if (_interaction != null) - (jiraUserName, jiraUserPassword) = _interaction.AskForCredentials(jiraUserName); - } - - if (string.IsNullOrEmpty(jiraUserName) || string.IsNullOrEmpty(jiraUserPassword)) - throw new ArgumentNullException($"Jira credentials not supplied"); - - _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(@"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(jiraUserName + ":" + jiraUserPassword))); - - Feedback?.PreloadAvailableWorklogTypesStart(); - availableWorklogTypes = (await _jiraClient.GetWorklogTypes()).ToDictionary(wlt => wlt.Value); - Feedback?.PreloadAvailableWorklogTypesEnd(); - - Feedback?.PreloadUserInfoStart(jiraUserName); - _userInfo = await _jiraClient.GetUserInfo(); - Feedback?.PreloadUserInfoEnd(); - } - - public async Task Process(IEnumerable<string> inputFiles) - { - string[] inputFilesFetched = inputFiles.ToArray(); - - if (inputFilesFetched.Length > 0) - { - Feedback?.ReadCsvInputStart(); - InputWorkLog[] inputWorklogs = await ReadInputFiles(inputFiles); - Feedback?.ReadCsvInputEnd(); - - if (inputWorklogs.Length > 0) - { - Feedback?.RetrieveWorklogsForDeletionStart(); - WorkLog[] worklogsForDeletion = await RetrieveWorklogsForDeletion(inputWorklogs); - Feedback?.RetrieveWorklogsForDeletionEnd(); - - Feedback?.FillJiraWithWorklogsStart(); - await FillJiraWithWorklogs(inputWorklogs, worklogsForDeletion); - Feedback?.FillJiraWithWorklogsEnd(); - } - else - { - Feedback?.NoWorklogsToFill(); - } - - Feedback?.OverallProcessEnd(); - } - else - { - Feedback?.NoFilesOnInput(); - } - } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_isDisposed) - { - if (disposing) - { - } - - // note: free unmanaged resources (unmanaged objects) and override finalizer - // note: set large fields to null - _httpClient?.Dispose(); - _httpClientHandler?.Dispose(); - - _isDisposed = true; - } - } - - private async Task FillJiraWithWorklogs(InputWorkLog[] inputWorklogs, WorkLog[] worklogsForDeletion) - { - Feedback?.FillJiraWithWorklogsSetTarget(inputWorklogs.Length, worklogsForDeletion.Length); - - if (_userInfo == null || _userInfo.Key == null) - throw new ArgumentNullException(@"Unresolved Jira key for the logged-on user"); - - Task[] fillJiraWithWorklogsTasks = worklogsForDeletion - .Select(worklog => _jiraClient.DeleteWorklog(worklog.IssueId, worklog.Id)) - .Concat(inputWorklogs - .Select(worklog => _jiraClient.AddWorklog(worklog.IssueKey.ToString(), DateOnly.FromDateTime(worklog.Date), (int)worklog.TimeSpent.TotalSeconds, worklog.WorkLogActivity, worklog.WorkLogComment)) - ) - .ToArray(); - - MultiTaskProgress progress = new MultiTaskProgress(fillJiraWithWorklogsTasks.Length); - - await MultiTask.WhenAll( - tasks: fillJiraWithWorklogsTasks, - progressFeedback: (_, t) => Feedback?.FillJiraWithWorklogsProcess(progress.AddTaskStatus(t?.Status)) - ); - } - - private async Task<InputWorkLog[]> ReadInputFiles(IEnumerable<string> fileNames) - { - Task<InputWorkLog[]>[] readerTasks = fileNames - .Select(fileName => ReadInputFile(fileName)) - .ToArray(); - - Feedback?.ReadCsvInputSetTarget(readerTasks.Length); - - MultiTaskProgress progress = new MultiTaskProgress(readerTasks.Length); - - if (readerTasks.Any()) - { - await MultiTask.WhenAll( - tasks: readerTasks, - progressFeedback: (_, t) => Feedback?.ReadCsvInputProcess(progress.AddTaskStatus(t?.Status)) - ); - } - - InputWorkLog[] result = readerTasks - .SelectMany(response => response.Result) - .ToArray(); - - return result; - } - - private async Task<InputWorkLog[]> ReadInputFile(string fileName) - { - WorklogReaderAggregatedConfig readerConfig = new WorklogReaderAggregatedConfig() - { - CsvFormatConfig = _config.CsvOptions - }; - - using IWorklogReader worklogReader = WorklogReaderFactory.GetReaderFromFilePath(fileName, readerConfig); - - Task<InputWorkLog[]> response = Task.Factory.StartNew(() => - { - return worklogReader - .Read(row => - { - if (!availableWorklogTypes.ContainsKey(row.WorkLogActivity)) - throw new InvalidDataException($"Worklog type {row.WorkLogActivity} not found on server"); - }) - .ToArray(); - }); - - return await response; - } - - private async Task<WorkLog[]> RetrieveWorklogsForDeletion(InputWorkLog[] inputWorklogs) - { - WorkLog[] result; - - if (_userInfo == null) - throw new ArgumentNullException(@"User info not preloaded from Jira server"); - if (string.IsNullOrEmpty(_userInfo.Key)) - throw new ArgumentNullException(@"Empty user key preloaded from Jira server"); - - DateTime[] inputWorklogDays = inputWorklogs - .Select(worklog => worklog.Date) - .OrderBy(worklogDate => worklogDate) - .ToArray(); - - if (!inputWorklogDays.Any()) - { - result = Array.Empty<WorkLog>(); - } - else - { - DateOnly minInputWorklogDay = DateOnly.FromDateTime(inputWorklogDays.First().Date); - DateOnly maxInputWorklogDay = DateOnly.FromDateTime(inputWorklogDays.Last().Date); - - string[] inputIssueKeys = inputWorklogs - .Select(worklog => worklog.IssueKey.ToString()) - .Distinct() - .OrderByDescending(x => x) - .ToArray(); - - result = await _jiraClient.GetIssueWorklogs(minInputWorklogDay, maxInputWorklogDay, inputIssueKeys); - } - - return result; - } -} + using IWorklogReader worklogReader = WorklogReaderFactory.GetReaderFromFilePath(fileName, readerConfig); + + Task<InputWorkLog[]> response = Task.Factory.StartNew(() => + { + return worklogReader + .Read(row => + { + if (row.WorkLogActivity is not null) + { + if (!_config.JiraServer?.ActivityMap?.ContainsKey(row.WorkLogActivity) ?? false) + throw new InvalidDataException($"Worklog type {row.WorkLogActivity} not found in activity map"); + } + }) + .ToArray(); + }); + + return await response; + } + + private async Task<WorkLog[]> RetrieveWorklogsForDeletion(InputWorkLog[] inputWorklogs) + { + WorkLog[] result; + + if (_userInfo == null) + throw new ArgumentNullException(@"User info not preloaded from Jira server"); + if (string.IsNullOrEmpty(_userInfo.Key)) + throw new ArgumentNullException(@"Empty user key preloaded from Jira server"); + + DateTime[] inputWorklogDays = inputWorklogs + .Select(worklog => worklog.Date) + .OrderBy(worklogDate => worklogDate) + .ToArray(); + + if (!inputWorklogDays.Any()) + { + result = Array.Empty<WorkLog>(); + } + else + { + DateOnly minInputWorklogDay = DateOnly.FromDateTime(inputWorklogDays.First().Date); + DateOnly maxInputWorklogDay = DateOnly.FromDateTime(inputWorklogDays.Last().Date); + + string[] inputIssueKeys = inputWorklogs + .Select(worklog => worklog.IssueKey.ToString()) + .Distinct() + .OrderByDescending(x => x) + .ToArray(); + + result = await _jiraClient.GetIssueWorklogs(minInputWorklogDay, maxInputWorklogDay, inputIssueKeys); + } + + return result; + } +} diff --git a/jwl.core/jwl.core.csproj b/jwl.core/jwl.core.csproj index 00bae86..009a83d 100644 --- a/jwl.core/jwl.core.csproj +++ b/jwl.core/jwl.core.csproj @@ -28,7 +28,9 @@ <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> - <PackageReference Include="NoP77svk.Linq" Version="2023.3.1" /> + <PackageReference Include="NoP77svk.Commons" Version="2023.10.1" /> + <PackageReference Include="NoP77svk.Linq" Version="2023.10.1" /> + <PackageReference Include="NoP77svk.Linq.OuterJoins" Version="2023.10.1" /> <PackageReference Include="StyleCop.Analyzers" Version="*"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> From 04adb9434675680164c95f060d120eae761a2ee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 22:58:38 +0100 Subject: [PATCH 20/28] =?UTF-8?q?=E2=9E=95=20Jira=20REST=20error=20message?= =?UTF-8?q?s=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/IJiraClient.cs | 2 +- jwl.jira/JiraWithICTimePluginApi.cs | 9 +++++--- jwl.jira/JiraWithTempoPluginApi.cs | 13 +++++++---- jwl.jira/VanillaJiraClient.cs | 23 +++++++++++++++---- .../request/ICTimeAddWorklogByIssueKey.cs | 3 +++ .../api/rest/response/JiraRestResponse.cs | 6 +++++ 6 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 jwl.jira/api/rest/response/JiraRestResponse.cs diff --git a/jwl.jira/IJiraClient.cs b/jwl.jira/IJiraClient.cs index bbef248..9fa6adb 100644 --- a/jwl.jira/IJiraClient.cs +++ b/jwl.jira/IJiraClient.cs @@ -9,7 +9,7 @@ public interface IJiraClient { Task<api.rest.common.JiraUserInfo> GetUserInfo(); - Task<WorkLogType[]> GetWorklogTypes(); + Task<WorkLogType[]> GetAvailableActivities(); Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys); diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index b5a833a..3f23f65 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -2,6 +2,7 @@ using jwl.infra; using jwl.jira.api.rest.common; +using jwl.jira.api.rest.response; using System.Diagnostics; using System.Net.Http.Json; using System.Numerics; @@ -30,9 +31,9 @@ public async Task<JiraUserInfo> GetUserInfo() return await _vanillaJiraApi.GetUserInfo(); } - public async Task<WorkLogType[]> GetWorklogTypes() + public async Task<WorkLogType[]> GetAvailableActivities() { - return await _vanillaJiraApi.GetWorklogTypes(); + return await _vanillaJiraApi.GetAvailableActivities(); } public async Task<WorkLog[]> GetIssueWorklogs(DateOnly from, DateOnly to, IEnumerable<string>? issueKeys) @@ -58,7 +59,9 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds Activity: string.IsNullOrEmpty(activity) ? null : int.Parse(activity), Comment: comment ); - await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + + HttpResponseMessage response = await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); } public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly dayTo, int timeSpentSeconds, string? activity, string? comment, bool includeNonWorkingDays = false) diff --git a/jwl.jira/JiraWithTempoPluginApi.cs b/jwl.jira/JiraWithTempoPluginApi.cs index 44f5049..b85fb0a 100644 --- a/jwl.jira/JiraWithTempoPluginApi.cs +++ b/jwl.jira/JiraWithTempoPluginApi.cs @@ -32,7 +32,7 @@ public async Task<JiraUserInfo> GetUserInfo() return await _httpClient.GetAsJsonAsync<api.rest.response.TempoWorklogAttributeDefinition[]>(@"rest/tempo-core/1/work-attribute"); } - public async Task<WorkLogType[]> GetWorklogTypes() + public async Task<WorkLogType[]> GetAvailableActivities() { api.rest.response.TempoWorklogAttributeDefinition[] attrEnumDefs = await GetWorklogAttributeDefinitions(); @@ -116,7 +116,8 @@ public async Task AddWorklogPeriod(string issueKey, DateOnly dayFrom, DateOnly d } }; - await _httpClient.PostAsJsonAsync(@"rest/tempo-timesheets/4/worklogs", request); + HttpResponseMessage response = await _httpClient.PostAsJsonAsync(@"rest/tempo-timesheets/4/worklogs", request); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); } public async Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = false) @@ -126,7 +127,9 @@ public async Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = Path = new UriPathBuilder(@"rest/tempo-timesheets/4/worklogs") .Add(worklogId.ToString()) }; - await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); + + 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) @@ -161,6 +164,8 @@ private async Task UpdateWorklogPeriod(string issueKey, long worklogId, DateOnly } } }; - await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + + HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); } } diff --git a/jwl.jira/VanillaJiraClient.cs b/jwl.jira/VanillaJiraClient.cs index b20a127..93a3507 100644 --- a/jwl.jira/VanillaJiraClient.cs +++ b/jwl.jira/VanillaJiraClient.cs @@ -6,6 +6,7 @@ namespace jwl.jira; using System.Text; using System.Xml.Linq; using jwl.infra; +using jwl.jira.api.rest.response; public class VanillaJiraClient : IJiraClient @@ -20,6 +21,14 @@ public VanillaJiraClient(HttpClient httpClient, string userName) UserName = userName; } + public static async Task CheckHttpResponseForErrorMessages(HttpResponseMessage responseMessage) + { + using Stream responseContentStream = await responseMessage.Content.ReadAsStreamAsync(); + JiraRestResponse responseContent = await HttpClientJsonExt.DeserializeJsonStreamAsync<JiraRestResponse>(responseContentStream); + if (responseContent?.ErrorMessages is not null && responseContent.ErrorMessages.Any()) + throw new InvalidOperationException(string.Join(Environment.NewLine, responseContent.ErrorMessages)); + } + public async Task<api.rest.common.JiraUserInfo> GetUserInfo() { UriBuilder uriBuilder = new UriBuilder() @@ -32,7 +41,7 @@ public VanillaJiraClient(HttpClient httpClient, string userName) } #pragma warning disable CS1998 - public async Task<WorkLogType[]> GetWorklogTypes() + public async Task<WorkLogType[]> GetAvailableActivities() { return Array.Empty<WorkLogType>(); } @@ -101,7 +110,9 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds TimeSpentSeconds: timeSpentSeconds, Comment: commentBuilder.ToString() ); - await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + + 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) @@ -134,7 +145,9 @@ public async Task DeleteWorklog(long issueId, long worklogId, bool notifyUsers = Query = new UriQueryBuilder() .Add(@"notifyUsers", notifyUsers.ToString().ToLower()) }; - await _httpClient.DeleteAsync(uriBuilder.Uri.PathAndQuery); + + 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) @@ -155,6 +168,8 @@ public async Task UpdateWorklog(string issueKey, long worklogId, DateOnly day, i TimeSpentSeconds: timeSpentSeconds, Comment: comment ); - await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + + HttpResponseMessage response = await _httpClient.PutAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); + await CheckHttpResponseForErrorMessages(response); } } diff --git a/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs index f11261d..9295613 100644 --- a/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs +++ b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs @@ -3,4 +3,7 @@ namespace jwl.jira.api.rest.request; public record ICTimeAddWorklogByIssueKey(string Started, int TimeSpentSeconds, int? Activity, string? Comment) { + public string LogWorkOption { get; init; } = "summary"; + public string AdjustEstimate { get; init; } = "auto"; + public bool ActivateLogwork { get; init; } = true; } diff --git a/jwl.jira/api/rest/response/JiraRestResponse.cs b/jwl.jira/api/rest/response/JiraRestResponse.cs new file mode 100644 index 0000000..6a2bbe2 --- /dev/null +++ b/jwl.jira/api/rest/response/JiraRestResponse.cs @@ -0,0 +1,6 @@ +namespace jwl.jira.api.rest.response; + +internal class JiraRestResponse +{ + public string[]? ErrorMessages { get; init; } +} \ No newline at end of file From 6265b1d18b929c8c89b07179f56953211854ed1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Mon, 29 Jan 2024 23:21:15 +0100 Subject: [PATCH 21/28] =?UTF-8?q?=E2=9E=96=20NameValueCollection.AsEnumera?= =?UTF-8?q?ble()=20removed=20as=20reundant=20to=20.Cast()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.infra/NameValueCollectionExt.cs | 13 ------------- jwl.infra/UriQueryBuilder.cs | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 jwl.infra/NameValueCollectionExt.cs diff --git a/jwl.infra/NameValueCollectionExt.cs b/jwl.infra/NameValueCollectionExt.cs deleted file mode 100644 index b16e37a..0000000 --- a/jwl.infra/NameValueCollectionExt.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace jwl.infra; -using System.Collections.Specialized; - -public static class NameValueCollectionExt -{ - public static IEnumerable<KeyValuePair<string?, string?>> AsEnumerable(this NameValueCollection self) - { - foreach (string? key in self.AllKeys) - { - yield return new KeyValuePair<string?, string?>(key, self[key]); - } - } -} \ No newline at end of file diff --git a/jwl.infra/UriQueryBuilder.cs b/jwl.infra/UriQueryBuilder.cs index 4824967..8e5389e 100644 --- a/jwl.infra/UriQueryBuilder.cs +++ b/jwl.infra/UriQueryBuilder.cs @@ -11,7 +11,7 @@ public UriQueryBuilder() public UriQueryBuilder(string uriQuery) : base(HttpUtility.ParseQueryString(uriQuery) - .AsEnumerable() + .Cast<KeyValuePair<string?, string?>>() .ToList()) { } From bb04cf811cdfa312e404d876523b95ab1458d7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Tue, 30 Jan 2024 10:07:47 +0100 Subject: [PATCH 22/28] =?UTF-8?q?=E2=9E=95=20ICTime=20"add=20worklog"=20re?= =?UTF-8?q?quest=20enhanced=20with=20"custom=20field"=20(...=20yet=20there?= =?UTF-8?q?'s=20more=20work=20to=20do=20here)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraWithICTimePluginApi.cs | 2 ++ .../request/ICTimeAddWorklogByIssueKey.cs | 30 ++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index 3f23f65..39394db 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -50,6 +50,7 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds .Add(@"worklog") }; var request = new api.rest.request.ICTimeAddWorklogByIssueKey( + IssueKey: issueKey, Started: day .ToDateTime(TimeOnly.MinValue) .ToString(@"yyyy-MM-dd""T""hh"";""mm"";""ss.fffzzzz") @@ -59,6 +60,7 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds Activity: string.IsNullOrEmpty(activity) ? null : int.Parse(activity), Comment: comment ); + // 2do! annotate the request.CustomFieldNNN with JSON field name based on ICTime server metadata (retrieved previously) HttpResponseMessage response = await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); diff --git a/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs index 9295613..40824c3 100644 --- a/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs +++ b/jwl.jira/api/rest/request/ICTimeAddWorklogByIssueKey.cs @@ -1,9 +1,31 @@ #pragma warning disable SA1313 namespace jwl.jira.api.rest.request; +using System.Text; +using System.Text.Json.Serialization; -public record ICTimeAddWorklogByIssueKey(string Started, int TimeSpentSeconds, int? Activity, string? Comment) +public record ICTimeAddWorklogByIssueKey(string IssueKey, string Started, int TimeSpentSeconds, int? Activity, string? Comment) { - public string LogWorkOption { get; init; } = "summary"; - public string AdjustEstimate { get; init; } = "auto"; - public bool ActivateLogwork { get; init; } = true; + [JsonIgnore] + public string NoCharge { get; } = "off"; + [JsonIgnore] + public string? NoChargeInfo { get; } = null; + [JsonIgnore] + public string LogWorkOption { get; } = "summary"; + [JsonIgnore] + public string AdjustEstimate { get; } = "auto"; + [JsonIgnore] + public bool ActivateLogwork { get; } = true; + + public string CustomFieldNNN => new StringBuilder() + // noChargeInfo==why this should not be charged||timeSpentCorrected==5h||newEstimate==5w||startDate==Sep 29, 2014 9:34 AM||startTime==5:05 PM||endTime==5:07 PM|| + .Append($"adjustEstimate=={AdjustEstimate}||") + .Append($"nocharge=={NoCharge}||") + .Append(!string.IsNullOrEmpty(NoChargeInfo) ? $"noChargeInfo=={NoCharge}||" : string.Empty) + .Append(Activity is not null ? $"activity=={Activity}||" : string.Empty) + .Append(Comment is not null ? $"comment=={Comment}||" : string.Empty) + .Append($"issueKey=={IssueKey}||") + .Append($"logWorkOption=={LogWorkOption}||") + .Append($"timeLogged=={TimeSpan.FromSeconds(TimeSpentSeconds).ToString()}||") // 2do! + .Append($"activateLogwork=={ActivateLogwork.ToString().ToLower()}||") + .ToString(); } From 0dbf674e6dfa830df910b17e8f2567a14faacacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <phr@whitestein.com> Date: Tue, 30 Jan 2024 10:08:38 +0100 Subject: [PATCH 23/28] =?UTF-8?q?=F0=9F=A7=B9=20style=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jwl.jira/JiraWithICTimePluginApi.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jwl.jira/JiraWithICTimePluginApi.cs b/jwl.jira/JiraWithICTimePluginApi.cs index 39394db..e55ccd1 100644 --- a/jwl.jira/JiraWithICTimePluginApi.cs +++ b/jwl.jira/JiraWithICTimePluginApi.cs @@ -49,6 +49,8 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds .Add(issueKey) .Add(@"worklog") }; + + // 2do! annotate the request.CustomFieldNNN with JSON field name based on ICTime server metadata (retrieved previously) var request = new api.rest.request.ICTimeAddWorklogByIssueKey( IssueKey: issueKey, Started: day @@ -60,7 +62,6 @@ public async Task AddWorklog(string issueKey, DateOnly day, int timeSpentSeconds Activity: string.IsNullOrEmpty(activity) ? null : int.Parse(activity), Comment: comment ); - // 2do! annotate the request.CustomFieldNNN with JSON field name based on ICTime server metadata (retrieved previously) HttpResponseMessage response = await _httpClient.PostAsJsonAsync(uriBuilder.Uri.PathAndQuery, request); await VanillaJiraClient.CheckHttpResponseForErrorMessages(response); From 6ded1a41d220d12f6c6f1168fc36e841866d7ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <16204822+nop77svk@users.noreply.github.com> Date: Wed, 31 Jan 2024 12:58:05 +0000 Subject: [PATCH 24/28] =?UTF-8?q?=F0=9F=9B=A0=20fix=20multitask=20handling?= =?UTF-8?q?=20of=20progress=20feedback=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🛠 MultiTask now handles exceptions and generates AggregateException * 🔧 fix: WorklogCsvReader was not invoking row postprocess * ➕ fix: handling of error response containing JSON error messages * 🛠 fix: MultiTask now handles slave task exceptions correctly * 🧹 formatting * 🔧 multitask progress state enum moved inside the MultiTask class * 🛠 multitask progress fixed * 🛠 multitask progress stats decoupled from the multitask * 🔧 multitask now handles internal state changes more generically --- .../ScrollingConsoleProcessFeedback.cs | 8 +- jwl.core/ICoreProcessFeedback.cs | 6 +- jwl.core/JwlCoreProcess.cs | 24 ++-- .../MultiTaskStats.cs | 11 +- jwl.infra/MultiTask.cs | 128 ++++++++++++------ jwl.infra/MultiTaskProgressState.cs | 11 -- jwl.inputs/WorklogCsvReader.cs | 2 + jwl.jira/VanillaJiraClient.cs | 11 +- 8 files changed, 124 insertions(+), 77 deletions(-) rename jwl.infra/MultiTaskProgress.cs => jwl.core/MultiTaskStats.cs (87%) delete mode 100644 jwl.infra/MultiTaskProgressState.cs diff --git a/jwl.console/ScrollingConsoleProcessFeedback.cs b/jwl.console/ScrollingConsoleProcessFeedback.cs index a2d5c5b..82c0c69 100644 --- a/jwl.console/ScrollingConsoleProcessFeedback.cs +++ b/jwl.console/ScrollingConsoleProcessFeedback.cs @@ -36,7 +36,7 @@ public void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numb Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})..."); } - public void FillJiraWithWorklogsProcess(MultiTaskProgress progress) + public void FillJiraWithWorklogsProcess(MultiTaskStats progress) { Console.Error.Write($"\rFilling Jira with worklogs (+{_numberOfWorklogsToInsert}/-{_numberOfWorklogsToDelete})... {ProgressPercentageAsString(progress)}"); } @@ -104,7 +104,7 @@ public void ReadCsvInputSetTarget(int numberOfInputFiles) Console.Error.Write($"\rReading {numberOfInputFiles} input files..."); } - public void ReadCsvInputProcess(MultiTaskProgress progress) + public void ReadCsvInputProcess(MultiTaskStats progress) { Console.Error.Write($"\rReading {progress.Total} input files... {ProgressPercentageAsString(progress)}"); } @@ -124,7 +124,7 @@ public void RetrieveWorklogsForDeletionSetTarget(int count) Console.Error.Write($"\rRetrieving list of worklogs ({count} Jira issues) to be deleted..."); } - public void RetrieveWorklogsForDeletionProcess(MultiTaskProgress progress) + public void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress) { throw new NotImplementedException(@"--- checkpoint ---"); } @@ -134,7 +134,7 @@ public void RetrieveWorklogsForDeletionEnd() Console.Error.WriteLine(" OK"); } - protected static string ProgressPercentageAsString(MultiTaskProgress progress) + protected static string ProgressPercentageAsString(MultiTaskStats progress) { string result; diff --git a/jwl.core/ICoreProcessFeedback.cs b/jwl.core/ICoreProcessFeedback.cs index 34ae25c..6cd208d 100644 --- a/jwl.core/ICoreProcessFeedback.cs +++ b/jwl.core/ICoreProcessFeedback.cs @@ -6,7 +6,7 @@ public interface ICoreProcessFeedback { void FillJiraWithWorklogsStart(); void FillJiraWithWorklogsSetTarget(int numberOfWorklogsToInsert, int numbeOfWorklogsToDelete); - void FillJiraWithWorklogsProcess(MultiTaskProgress progress); + void FillJiraWithWorklogsProcess(MultiTaskStats progress); void FillJiraWithWorklogsEnd(); void NoExistingWorklogsToDelete(); void NoFilesOnInput(); @@ -19,10 +19,10 @@ public interface ICoreProcessFeedback void PreloadUserInfoEnd(); void ReadCsvInputStart(); void ReadCsvInputSetTarget(int numberOfInputFiles); - void ReadCsvInputProcess(MultiTaskProgress progress); + void ReadCsvInputProcess(MultiTaskStats progress); void ReadCsvInputEnd(); void RetrieveWorklogsForDeletionStart(); void RetrieveWorklogsForDeletionSetTarget(int count); - void RetrieveWorklogsForDeletionProcess(MultiTaskProgress progress); + void RetrieveWorklogsForDeletionProcess(MultiTaskStats progress); void RetrieveWorklogsForDeletionEnd(); } diff --git a/jwl.core/JwlCoreProcess.cs b/jwl.core/JwlCoreProcess.cs index 8f4b3f4..744f874 100644 --- a/jwl.core/JwlCoreProcess.cs +++ b/jwl.core/JwlCoreProcess.cs @@ -165,12 +165,13 @@ private async Task FillJiraWithWorklogs(InputWorkLog[] inputWorklogs, WorkLog[] ) .ToArray(); - MultiTaskProgress progress = new MultiTaskProgress(fillJiraWithWorklogsTasks.Length); + MultiTaskStats progress = new MultiTaskStats(fillJiraWithWorklogsTasks.Length); + MultiTask multiTask = new MultiTask() + { + TaskFeedback = t => Feedback?.FillJiraWithWorklogsProcess(progress.ApplyTaskStatus(t.Status)) + }; - await MultiTask.WhenAll( - tasks: fillJiraWithWorklogsTasks, - progressFeedback: (_, t) => Feedback?.FillJiraWithWorklogsProcess(progress.AddTaskStatus(t?.Status)) - ); + await multiTask.WhenAll(fillJiraWithWorklogsTasks); } private async Task<InputWorkLog[]> ReadInputFiles(IEnumerable<string> fileNames) @@ -181,15 +182,14 @@ private async Task<InputWorkLog[]> ReadInputFiles(IEnumerable<string> fileNames) Feedback?.ReadCsvInputSetTarget(readerTasks.Length); - MultiTaskProgress progress = new MultiTaskProgress(readerTasks.Length); + MultiTaskStats progressStats = new MultiTaskStats(readerTasks.Length); + MultiTask multiTask = new MultiTask() + { + TaskFeedback = t => Feedback?.ReadCsvInputProcess(progressStats.ApplyTaskStatus(t.Status)) + }; if (readerTasks.Any()) - { - await MultiTask.WhenAll( - tasks: readerTasks, - progressFeedback: (_, t) => Feedback?.ReadCsvInputProcess(progress.AddTaskStatus(t?.Status)) - ); - } + await multiTask.WhenAll(readerTasks); InputWorkLog[] result = readerTasks .SelectMany(response => response.Result) diff --git a/jwl.infra/MultiTaskProgress.cs b/jwl.core/MultiTaskStats.cs similarity index 87% rename from jwl.infra/MultiTaskProgress.cs rename to jwl.core/MultiTaskStats.cs index e6b9907..fda1898 100644 --- a/jwl.infra/MultiTaskProgress.cs +++ b/jwl.core/MultiTaskStats.cs @@ -1,8 +1,8 @@ -namespace jwl.infra; +namespace jwl.core; +using jwl.infra; -public class MultiTaskProgress +public class MultiTaskStats { - public MultiTaskProgressState State { get; private set; } public int Total { get; private set; } public int Succeeded { get; private set; } public float SucceededPct => Total > 0 ? (float)Succeeded / Total : float.NaN; @@ -18,10 +18,9 @@ public class MultiTaskProgress public int DoneSoFar => Succeeded + ErredSoFar; public float DoneSoFarPct => Total > 0 ? (float)DoneSoFar / Total : float.NaN; - public MultiTaskProgress(int total) + public MultiTaskStats(int total) { Total = total; - State = MultiTaskProgressState.Unknown; Succeeded = 0; Faulted = 0; Cancelled = 0; @@ -30,7 +29,7 @@ public MultiTaskProgress(int total) private object _locker = new object(); - public MultiTaskProgress AddTaskStatus(TaskStatus? taskStatus) + public MultiTaskStats ApplyTaskStatus(TaskStatus? taskStatus) { if (taskStatus == null) return this; diff --git a/jwl.infra/MultiTask.cs b/jwl.infra/MultiTask.cs index 8ac6601..2d7bb70 100644 --- a/jwl.infra/MultiTask.cs +++ b/jwl.infra/MultiTask.cs @@ -1,38 +1,90 @@ -namespace jwl.infra; - -public static class MultiTask -{ - public static async Task WhenAll(IEnumerable<Task> tasks, Action<MultiTaskProgressState, Task?> progressFeedback, CancellationToken? cancellationToken = null) - { - progressFeedback(MultiTaskProgressState.Starting, null); - HashSet<Task> tasksToExecute = tasks.ToHashSet(); - - try - { - while (tasksToExecute.Any()) - { - cancellationToken?.ThrowIfCancellationRequested(); - - Task taskFinished = await Task.WhenAny(tasksToExecute); - progressFeedback(MultiTaskProgressState.InProgress, taskFinished); - - if (tasksToExecute.Contains(taskFinished)) - tasksToExecute.Remove(taskFinished); - else - throw new Exception("Task reported as finished... again!"); - } - } - catch (TaskCanceledException) - { - progressFeedback(MultiTaskProgressState.Cancelled, null); - throw; - } - catch (Exception) - { - progressFeedback(MultiTaskProgressState.Error, null); - throw; - } - - progressFeedback(MultiTaskProgressState.Finished, null); - } -} +namespace jwl.infra; + +public class MultiTask +{ + public enum ProgressState + { + Unknown, + Starting, + BeforeTaskWait, + AfterTaskWait, + Finished, + Error, + Cancelled + } + + public ProgressState State { get; private set; } = ProgressState.Unknown; + + public Action<MultiTask>? ProcessFeedback { get; init; } + public Action<Task>? TaskFeedback { get; init; } + + public MultiTask() + { + } + + public async Task WhenAll(IEnumerable<Task> tasks, CancellationToken? cancellationToken = null) + { + State = ProgressState.Starting; + ProcessFeedback?.Invoke(this); + + HashSet<Task> tasksToExecute = tasks.ToHashSet(); + List<Exception> errors = new List<Exception>(); + + while (tasksToExecute.Any()) + { + State = ProgressState.BeforeTaskWait; + ProcessFeedback?.Invoke(this); + + Task? taskFinished = null; + try + { + cancellationToken?.ThrowIfCancellationRequested(); + + taskFinished = await Task.WhenAny(tasksToExecute); + + State = ProgressState.AfterTaskWait; + ProcessFeedback?.Invoke(this); + TaskFeedback?.Invoke(taskFinished); + + if (taskFinished.Status is TaskStatus.Faulted or TaskStatus.Canceled) + { + tasksToExecute.Remove(taskFinished); + throw taskFinished.Exception ?? new Exception($"Task ended in {taskFinished.Status} status without exception details"); + } + else if (taskFinished.Status == TaskStatus.RanToCompletion) + { + if (!tasksToExecute.Remove(taskFinished)) + throw new InvalidOperationException("Task reported as finished... again!"); + } + } + catch (AggregateException ex) + { + errors.AddRange(ex.InnerExceptions); + } + catch (Exception ex) + { + errors.Add(ex); + } + } + + if (errors.All(ex => ex is TaskCanceledException)) + { + State = ProgressState.Cancelled; + ProcessFeedback?.Invoke(this); + + throw new TaskCanceledException($"All {errors.Count} tasks have been cancelled", new AggregateException(errors)); + } + else if (errors.Any()) + { + State = ProgressState.Error; + ProcessFeedback?.Invoke(this); + + throw new AggregateException(errors); + } + else + { + State = ProgressState.Finished; + ProcessFeedback?.Invoke(this); + } + } +} diff --git a/jwl.infra/MultiTaskProgressState.cs b/jwl.infra/MultiTaskProgressState.cs deleted file mode 100644 index 59c1495..0000000 --- a/jwl.infra/MultiTaskProgressState.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace jwl.infra; - -public enum MultiTaskProgressState -{ - Unknown, - Starting, - InProgress, - Finished, - Error, - Cancelled -} diff --git a/jwl.inputs/WorklogCsvReader.cs b/jwl.inputs/WorklogCsvReader.cs index 443377a..297cbcc 100644 --- a/jwl.inputs/WorklogCsvReader.cs +++ b/jwl.inputs/WorklogCsvReader.cs @@ -73,6 +73,8 @@ public IEnumerable<InputWorkLog> Read(Action<InputWorkLog>? postProcessResult = WorkLogActivity = row.WorkLogActivity, WorkLogComment = row.WorkLogComment }; + + postProcessResult?.Invoke(result); } catch (Exception e) { diff --git a/jwl.jira/VanillaJiraClient.cs b/jwl.jira/VanillaJiraClient.cs index 93a3507..1204540 100644 --- a/jwl.jira/VanillaJiraClient.cs +++ b/jwl.jira/VanillaJiraClient.cs @@ -4,6 +4,7 @@ namespace jwl.jira; using System.Net.Http; using System.Net.Http.Json; using System.Text; +using System.Text.Json; using System.Xml.Linq; using jwl.infra; using jwl.jira.api.rest.response; @@ -24,9 +25,13 @@ public VanillaJiraClient(HttpClient httpClient, string userName) public static async Task CheckHttpResponseForErrorMessages(HttpResponseMessage responseMessage) { using Stream responseContentStream = await responseMessage.Content.ReadAsStreamAsync(); - JiraRestResponse responseContent = await HttpClientJsonExt.DeserializeJsonStreamAsync<JiraRestResponse>(responseContentStream); - if (responseContent?.ErrorMessages is not null && responseContent.ErrorMessages.Any()) - throw new InvalidOperationException(string.Join(Environment.NewLine, responseContent.ErrorMessages)); + + if (responseContentStream.Length > 0) + { + JiraRestResponse responseContent = await HttpClientJsonExt.DeserializeJsonStreamAsync<JiraRestResponse>(responseContentStream); + if (responseContent?.ErrorMessages is not null && responseContent.ErrorMessages.Any()) + throw new InvalidOperationException(string.Join(Environment.NewLine, responseContent.ErrorMessages)); + } } public async Task<api.rest.common.JiraUserInfo> GetUserInfo() From 8ba0c4beec13701c2efe2a2abdf91a0010eae894 Mon Sep 17 00:00:00 2001 From: nop77svk <nop77svk@gmail.com> Date: Wed, 31 Jan 2024 14:22:48 +0100 Subject: [PATCH 25/28] =?UTF-8?q?=F0=9F=94=A7=20release=20pipeline=20updat?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23c0058..f12de20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,7 @@ jobs: uses: softprops/action-gh-release@v1 with: token: "${{ secrets.GITHUB_TOKEN }}" + body: "<changelog should come here>" prerelease: false files: | ./_publish.all/jira-worklogger.* From 0cb460bbbf3bed9aa741d027e594ff6698b01640 Mon Sep 17 00:00:00 2001 From: nop77svk <nop77svk@gmail.com> Date: Wed, 31 Jan 2024 14:29:51 +0100 Subject: [PATCH 26/28] update release pipeline --- .github/workflows/release.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f12de20..dfa44d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,6 @@ jobs: dotnet-version: 6.0.x - name: Checkout uses: actions/checkout@v3 - - name: Verify commit exists in origin/main - run: | - git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* - git branch --remote --contains | grep origin/main - name: Set VERSION variable from tag run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV - name: Update private NuGet source From 1548254b689e98e187c2bfdf2a3f114f2f73ea67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <16204822+nop77svk@users.noreply.github.com> Date: Wed, 31 Jan 2024 15:55:07 +0000 Subject: [PATCH 27/28] Update README.md (#8) --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a33d709..7059ade 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ Meet the scripted Jira worklogging! Give it your worklogs in a CSV file (and you ## 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) and the "Tempo Timesheets" plugin +- Jira server (with version 2 REST API) +-- "vanilla" Jira server support: ✔️ +-- "Tempo Timesheets" plugin support: ✔️ +-- "ICTime" plugin support: ❎ (planned) ## Configuration From 5c226cfb8caf32808db94862e532fcbbdd9e6a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Hra=C5=A1ko?= <16204822+nop77svk@users.noreply.github.com> Date: Wed, 31 Jan 2024 16:01:49 +0000 Subject: [PATCH 28/28] =?UTF-8?q?=F0=9F=9B=A0=20update=20README=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7059ade..6f17442 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Meet the scripted Jira worklogging! Give it your worklogs in a CSV file (and you - .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) + - "vanilla" Jira server support: ✔️ + - "Tempo Timesheets" plugin support: ✔️ + - "ICTime" plugin support: ❎ (planned) ## Configuration