From ff21d2f9dca1ab5c818d40869fca6ac942327608 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Mon, 15 Jul 2024 13:03:15 +0100 Subject: [PATCH 01/11] Added query --- .../Commands/WorkItemCloneCommand.cs | 16 ++++ AzureDevOps.WorkItemClone/AzureDevOpsApi.cs | 31 +++++-- .../DataContracts/Queries.cs | 90 +++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 AzureDevOps.WorkItemClone/DataContracts/Queries.cs diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 448967d..680f834 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -8,6 +8,7 @@ using Microsoft.Azure.Pipelines.WebApi; using Microsoft.VisualStudio.Services.CircuitBreaker; using Newtonsoft.Json.Linq; +using System.Threading.Tasks; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -76,6 +77,7 @@ await AnsiConsole.Progress() var task5 = ctx.AddTask("[bold]Stage 5[/]: Create Output Plan Relations ", false); //var task51 = ctx.AddTask("[bold]Stage 5.1[/]: Validate Data ", false); var task6 = ctx.AddTask("[bold]Stage 6[/]: Create Work Items", false); + var task7 = ctx.AddTask("[bold]Stage 7[/]: Create Query", false); string cacheTemplateWorkItemsFile = $"{config.CachePath}\\templateCache-{config.templateOrganization}-{config.templateProject}-{config.templateParentId}.json"; @@ -269,8 +271,22 @@ await AnsiConsole.Progress() } task6.StopTask(); //AnsiConsole.WriteLine($"Stage 6: All Work Items Created."); + + + // -------------------------------------------------------------- + // Task 7: Create Query + task7.MaxValue = 1; + task7.StartTask(); + var query = await templateApi.CreateProjectQuery($"Project [{projectItem.id}]: {projectItem.fields.SystemTitle}", "Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id", new Dictionary() { { "@id", config.templateParentId.ToString() } }); + task7.Increment(1); + task7.StopTask(); + + + }); + + AnsiConsole.WriteLine($"Complete..."); diff --git a/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs b/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs index 1b6cd22..40ee28c 100644 --- a/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs +++ b/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs @@ -47,6 +47,17 @@ public async IAsyncEnumerable GetWorkItemsFullAsync(Workitem[] ite } public async Task GetWiqlQueryResults(string wiqlQuery, Dictionary parameters) + { + wiqlQuery = GetQueryString(wiqlQuery, parameters); + string post = JsonConvert.SerializeObject(new + { + query = wiqlQuery + }); + string apiCallUrl = $"https://dev.azure.com/{_account}/_apis/wit/wiql?api-version=7.2-preview.2"; + return await GetObjectResult(apiCallUrl, post); + } + + private string GetQueryString( string wiqlQuery, Dictionary parameters) { if (parameters == null) { @@ -64,12 +75,7 @@ public async IAsyncEnumerable GetWorkItemsFullAsync(Workitem[] ite { wiqlQuery = wiqlQuery.Replace(param.Key, param.Value); } - string post = JsonConvert.SerializeObject(new - { - query = wiqlQuery - }); - string apiCallUrl = $"https://dev.azure.com/{_account}/_apis/wit/wiql?api-version=7.2-preview.2"; - return await GetObjectResult(apiCallUrl, post); + return wiqlQuery; } public async Task GetWiqlQueryResults() @@ -171,5 +177,18 @@ public ValueTask DisposeAsync() { return new(Task.Delay(TimeSpan.FromSeconds(1))); } + + public async Task CreateProjectQuery(string queryName, string wiqlQuery, Dictionary parameters) + { + ///POST https://dev.azure.com/{organization}/{project}/_apis/wit/queries/{query}?api-version=7.1-preview.2 + wiqlQuery = GetQueryString(wiqlQuery, parameters); + string post = JsonConvert.SerializeObject(new + { + name = queryName, + query = wiqlQuery + }); + string apiCallUrl = $"https://dev.azure.com/{_account}/{_project}/_apis/wit/queries/Shared Queries/?api-version=7.2-preview.2"; + return await GetObjectResult(apiCallUrl, post); + } } } diff --git a/AzureDevOps.WorkItemClone/DataContracts/Queries.cs b/AzureDevOps.WorkItemClone/DataContracts/Queries.cs new file mode 100644 index 0000000..7caa8dc --- /dev/null +++ b/AzureDevOps.WorkItemClone/DataContracts/Queries.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AzureDevOps.WorkItemClone.DataContracts +{ + + public class Query + { + public string id { get; set; } + public string name { get; set; } + public string path { get; set; } + public QueryCreatedby createdBy { get; set; } + public DateTime createdDate { get; set; } + public QueryLastmodifiedby lastModifiedBy { get; set; } + public DateTime lastModifiedDate { get; set; } + public bool isFolder { get; set; } + public bool hasChildren { get; set; } + public bool isPublic { get; set; } + public QueryLinks2 _links { get; set; } + public string url { get; set; } + } + + public class QueryCreatedby + { + public string displayName { get; set; } + public string url { get; set; } + public QueryLinks _links { get; set; } + public string id { get; set; } + public string uniqueName { get; set; } + public string imageUrl { get; set; } + public string descriptor { get; set; } + } + + public class QueryLinks + { + public Avatar avatar { get; set; } + } + + public class QueryAvatar + { + public string href { get; set; } + } + + public class QueryLastmodifiedby + { + public string displayName { get; set; } + public string url { get; set; } + public QueryLinks1 _links { get; set; } + public string id { get; set; } + public string uniqueName { get; set; } + public string imageUrl { get; set; } + public string descriptor { get; set; } + } + + public class QueryLinks1 + { + public QueryAvatar1 avatar { get; set; } + } + + public class QueryAvatar1 + { + public string href { get; set; } + } + + public class QueryLinks2 + { + public QuerySelf self { get; set; } + public QueryHtml html { get; set; } + public QueryParent parent { get; set; } + } + + public class QuerySelf + { + public string href { get; set; } + } + + public class QueryHtml + { + public string href { get; set; } + } + + public class QueryParent + { + public string href { get; set; } + } + +} From df13a1c5f58371830ba08d2cc814074a7a7ffab8 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Mon, 15 Jul 2024 13:54:09 +0100 Subject: [PATCH 02/11] Update --- .../Commands/WorkItemCloneCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 680f834..44bb067 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -277,7 +277,7 @@ await AnsiConsole.Progress() // Task 7: Create Query task7.MaxValue = 1; task7.StartTask(); - var query = await templateApi.CreateProjectQuery($"Project [{projectItem.id}]: {projectItem.fields.SystemTitle}", "Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id", new Dictionary() { { "@id", config.templateParentId.ToString() } }); + var query = await targetApi.CreateProjectQuery($"Project [{projectItem.id}]: {projectItem.fields.SystemTitle}", "Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id", new Dictionary() { { "@id", config.templateParentId.ToString() } }); task7.Increment(1); task7.StopTask(); From 68613cd372f8b91f27f34de419d219dd99c2465c Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 11:32:51 +0100 Subject: [PATCH 03/11] feat(WorkItemCloneCommand.cs): replace List with CashedWorkItems to include queryDatetime refactor(WorkItemCloneCommand.cs): optimize cache loading and testing logic to improve performance feat(WorkItem.cs): add CashedWorkItems class to store workitems and queryDatetime for better cache management --- .../Commands/WorkItemCloneCommand.cs | 41 ++++++++++--------- .../DataContracts/WorkItem.cs | 5 +++ 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 119a2b7..82a72ab 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -10,6 +10,7 @@ using Newtonsoft.Json.Linq; using System.Threading.Tasks; using System.Diagnostics.Eventing.Reader; +using AzureDevOps.WorkItemClone.Repositories; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -88,7 +89,7 @@ await AnsiConsole.Progress() } task1.MaxValue = 1; - List templateWorkItems = null; + CashedWorkItems templateWorkItems = null; @@ -97,12 +98,11 @@ await AnsiConsole.Progress() if (System.IO.File.Exists(cacheTemplateWorkItemsFile)) { - - var changedDate = System.IO.File.GetLastWriteTime(cacheTemplateWorkItemsFile).AddDays(1).Date; - //Test Cache - QueryResults fakeItemsFromTemplateQuery; - fakeItemsFromTemplateQuery = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() }, { "@changeddate", changedDate.ToString("yyyy-MM-dd") } }); - if (fakeItemsFromTemplateQuery.workItems.Length == 0) + // load Cache + templateWorkItems = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(cacheTemplateWorkItemsFile)); + //Test Cache date + QueryResults changedWorkItems = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() }, { "@changeddate", templateWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); + if (changedWorkItems.workItems.Length == 0) { AnsiConsole.WriteLine($"Stage 1: Checked template for changes. None Detected. Loading Cache"); @@ -113,10 +113,12 @@ await AnsiConsole.Progress() await Task.Delay(250); task1.StopTask(); ////////////////////// - templateWorkItems = JsonConvert.DeserializeObject>(System.IO.File.ReadAllText(cacheTemplateWorkItemsFile)); - task2.Increment(templateWorkItems.Count); + task2.Increment(templateWorkItems.workitems.Count()); task2.Description = task2.Description + " (cache)"; - AnsiConsole.WriteLine($"Stage 2: Loaded {templateWorkItems.Count()} work items from cache."); + AnsiConsole.WriteLine($"Stage 2: Loaded {templateWorkItems.workitems.Count()} work items from cache."); + } else + { + templateWorkItems = null; } } @@ -128,23 +130,24 @@ await AnsiConsole.Progress() task1.StartTask(); //AnsiConsole.WriteLine("Stage 1: Executing items from Query"); - QueryResults fakeItemsFromTemplateQuery; - fakeItemsFromTemplateQuery = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() } }); - AnsiConsole.WriteLine($"Stage 1: Query returned {fakeItemsFromTemplateQuery.workItems.Count()} items id's from the template."); + QueryResults templateWorkItemLight; + DateTime queryDatetime = DateTime.Now; + templateWorkItemLight = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() } }); + AnsiConsole.WriteLine($"Stage 1: Query returned {templateWorkItemLight.workItems.Count()} items id's from the template."); task1.Increment(1); task1.StopTask(); // -------------------------------------------------------------- // Task 2: getting work items and their full data - task2.MaxValue = fakeItemsFromTemplateQuery.workItems.Count(); + task2.MaxValue = templateWorkItemLight.workItems.Count(); task2.StartTask(); await Task.Delay(250); //AnsiConsole.WriteLine($"Stage 2: Starting process of {task2.MaxValue} work items to get their full data "); - templateWorkItems = new List(); + templateWorkItems.workitems = new List(); //AnsiConsole.WriteLine($"Stage 2: Loading {fakeItemsFromTemplateQuery.workItems.Count()} work items from template."); - await foreach (var workItem in templateApi.GetWorkItemsFullAsync(fakeItemsFromTemplateQuery.workItems)) + await foreach (var workItem in templateApi.GetWorkItemsFullAsync(templateWorkItemLight.workItems)) { //AnsiConsole.WriteLine($"Stage 2: Processing {workItem.id}:`{workItem.fields.SystemTitle}`"); - templateWorkItems.Add(workItem); + templateWorkItems.workitems.Add(workItem); task2.Increment(1); } System.IO.File.WriteAllText(cacheTemplateWorkItemsFile, JsonConvert.SerializeObject(templateWorkItems, Formatting.Indented)); @@ -208,7 +211,7 @@ await AnsiConsole.Progress() await Task.Delay(250); //AnsiConsole.WriteLine($"Stage 4: First Pass generation of Work Items to build will merge the provided json work items with the data from the template."); buildItems = new List(); - await foreach (WorkItemToBuild witb in generateWorkItemsToBuildList(inputWorkItems, templateWorkItems, projectItem, config.targetProject)) + await foreach (WorkItemToBuild witb in generateWorkItemsToBuildList(inputWorkItems, templateWorkItems.workitems, projectItem, config.targetProject)) { // AnsiConsole.WriteLine($"Stage 4: processing {witb.guid}"); buildItems.Add(witb); @@ -223,7 +226,7 @@ await AnsiConsole.Progress() //AnsiConsole.WriteLine($"Stage 5: Second Pass generate relations."); task5.StartTask(); await Task.Delay(250); - await foreach (WorkItemToBuild witb in generateWorkItemsToBuildRelations(buildItems, templateWorkItems)) + await foreach (WorkItemToBuild witb in generateWorkItemsToBuildRelations(buildItems, templateWorkItems.workitems)) { //AnsiConsole.WriteLine($"Stage 5: processing {witb.guid} for output of {witb.relations.Count-1} relations"); task5.Increment(1); diff --git a/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs b/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs index 27a56a0..1049567 100644 --- a/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs +++ b/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs @@ -7,7 +7,12 @@ namespace AzureDevOps.WorkItemClone.DataContracts { + public class CashedWorkItems + { + public List workitems { get; set; } + public DateTime queryDatetime { get; set; } + } public class WorkItemFull { From 7f8e9db5273966c542a44117bf2975fbcf48ed19 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 11:33:50 +0100 Subject: [PATCH 04/11] feat(AzureDevOps.WorkItemClone.csproj): add Repositories folder to the project structure for better organization of repository related classes --- AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj b/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj index 0d9c711..8ea0d4f 100644 --- a/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj +++ b/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj @@ -14,4 +14,8 @@ + + + + From 9b479ac780514abacc02abaf4b3f7673c2775696 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 11:59:25 +0100 Subject: [PATCH 05/11] refactor(WorkItemCloneCommand.cs): add exception handling for cache loading to prevent crashes feat(WorkItemCloneCommand.cs): add cache stale check to ensure data freshness fix(WorkItemCloneCommand.cs): correct task count display off-by-one error in Stage 6 description --- .../Commands/WorkItemCloneCommand.cs | 59 +++++++++++-------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 82a72ab..9dd97d5 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -10,7 +10,6 @@ using Newtonsoft.Json.Linq; using System.Threading.Tasks; using System.Diagnostics.Eventing.Reader; -using AzureDevOps.WorkItemClone.Repositories; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -99,27 +98,40 @@ await AnsiConsole.Progress() if (System.IO.File.Exists(cacheTemplateWorkItemsFile)) { // load Cache - templateWorkItems = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(cacheTemplateWorkItemsFile)); - //Test Cache date - QueryResults changedWorkItems = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() }, { "@changeddate", templateWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); - if (changedWorkItems.workItems.Length == 0) + try { - AnsiConsole.WriteLine($"Stage 1: Checked template for changes. None Detected. Loading Cache"); - - // Load from Cache - - task1.Increment(1); - task1.Description = task1.Description + " (cache)"; - await Task.Delay(250); - task1.StopTask(); - ////////////////////// - task2.Increment(templateWorkItems.workitems.Count()); - task2.Description = task2.Description + " (cache)"; - AnsiConsole.WriteLine($"Stage 2: Loaded {templateWorkItems.workitems.Count()} work items from cache."); - } else + templateWorkItems = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(cacheTemplateWorkItemsFile)); + } + catch (Exception ex) { - templateWorkItems = null; + // failed to load + AnsiConsole.WriteLine($"Cache is moldy, reloading.."); } + if (templateWorkItems != null) + { + //Test Cache date + QueryResults changedWorkItems = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() }, { "@changeddate", templateWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); + if (changedWorkItems.workItems.Length == 0) + { + AnsiConsole.WriteLine($"Stage 1: Checked template for changes. None Detected. Loading Cache"); + + // Load from Cache + + task1.Increment(1); + task1.Description = task1.Description + " (cache)"; + await Task.Delay(250); + task1.StopTask(); + ////////////////////// + task2.Increment(templateWorkItems.workitems.Count()); + task2.Description = task2.Description + " (cache)"; + AnsiConsole.WriteLine($"Stage 2: Loaded {templateWorkItems.workitems.Count()} work items from cache."); + } + else + { + AnsiConsole.WriteLine($"Cache is stale, reloading.."); + templateWorkItems = null; + } + } } if (templateWorkItems == null) @@ -128,10 +140,9 @@ await AnsiConsole.Progress() // -------------------------------------------------------------- // Task 1: query for template work items task1.StartTask(); - + templateWorkItems = new CashedWorkItems() { workitems = new List(), queryDatetime = DateTime.Now }; //AnsiConsole.WriteLine("Stage 1: Executing items from Query"); QueryResults templateWorkItemLight; - DateTime queryDatetime = DateTime.Now; templateWorkItemLight = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() } }); AnsiConsole.WriteLine($"Stage 1: Query returned {templateWorkItemLight.workItems.Count()} items id's from the template."); task1.Increment(1); @@ -140,9 +151,7 @@ await AnsiConsole.Progress() // Task 2: getting work items and their full data task2.MaxValue = templateWorkItemLight.workItems.Count(); task2.StartTask(); - await Task.Delay(250); - //AnsiConsole.WriteLine($"Stage 2: Starting process of {task2.MaxValue} work items to get their full data "); - templateWorkItems.workitems = new List(); + await Task.Delay(250); //AnsiConsole.WriteLine($"Stage 2: Loading {fakeItemsFromTemplateQuery.workItems.Count()} work items from template."); await foreach (var workItem in templateApi.GetWorkItemsFullAsync(templateWorkItemLight.workItems)) { @@ -257,7 +266,7 @@ await AnsiConsole.Progress() //AnsiConsole.WriteLine($"Stage 6: Processing {witb.guid} for output of {witb.relations.Count - 1} relations"); task6.Increment(1); taskCount++; - task6.Description = $"[bold]Stage 6[/]: Create Work Items ({taskCount}/{buildItems.Count()} c:{result.created}, s:{result.skipped}, f:{result.failed})"; + task6.Description = $"[bold]Stage 6[/]: Create Work Items ({taskCount-1}/{buildItems.Count()} c:{result.created}, s:{result.skipped}, f:{result.failed})"; switch (result.status) { case "created": From 352fbf680b58648b61cfbad511147f624c639134 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 12:37:46 +0100 Subject: [PATCH 06/11] feat(WorkItemCloneCommand.cs): remove unnecessary whitespace for cleaner code refactor(AzureDevOps.WorkItemClone.csproj): remove unused Repositories folder reference style(WorkItem.cs): remove unnecessary whitespace for cleaner code feat(WorkItemRepo.cs): add new file to handle work item repository operations, improving code organization and separation of concerns --- .../Commands/WorkItemCloneCommand.cs | 1 + .../AzureDevOps.WorkItemClone.csproj | 4 - .../DataContracts/WorkItem.cs | 1 - .../Repositories/WorkItemRepo.cs | 116 ++++++++++++++++++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 9dd97d5..2f869f8 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -95,6 +95,7 @@ await AnsiConsole.Progress() task1.StartTask(); task2.StartTask(); + if (System.IO.File.Exists(cacheTemplateWorkItemsFile)) { // load Cache diff --git a/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj b/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj index 8ea0d4f..0d9c711 100644 --- a/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj +++ b/AzureDevOps.WorkItemClone/AzureDevOps.WorkItemClone.csproj @@ -14,8 +14,4 @@ - - - - diff --git a/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs b/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs index 1049567..84509c3 100644 --- a/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs +++ b/AzureDevOps.WorkItemClone/DataContracts/WorkItem.cs @@ -9,7 +9,6 @@ namespace AzureDevOps.WorkItemClone.DataContracts { public class CashedWorkItems { - public List workitems { get; set; } public DateTime queryDatetime { get; set; } } diff --git a/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs b/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs new file mode 100644 index 0000000..ef2598c --- /dev/null +++ b/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs @@ -0,0 +1,116 @@ +using AzureDevOps.WorkItemClone.DataContracts; +using Newtonsoft.Json; +using Spectre.Console; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AzureDevOps.WorkItemClone.Repositories +{ + public interface IWorkItemRepository + { + IAsyncEnumerable<(int total, int processed)> GetWorkItemsFullAsync(); + //Task GetWorkItemByIdAsync(int id); + //Task> GetAllWorkItemAsync(); + //Task AddWorkItemAsync(WorkItemFull wif); + //Task UpdateWorkItemAsync(WorkItemFull wif); + //Task DeleteWorkItemAsync(int id); + } + public interface IPersistantCache + { + Task SaveToCache(); + Task LoadFromCache(); + } + + public class WorkItemRepository : IWorkItemRepository + { + public string OrganisationName { get; private set; } + public string ProjectName { get; private set; } + private string AccesToken { get; set; } + public string ParentId { get; private set; } + + private AzureDevOpsApi _context; + private string cacheWorkItemsFile; + public CashedWorkItems WorkItems { get { return cachedWorkItems; } } + + CashedWorkItems cachedWorkItems = null; + + public WorkItemRepository(string cachePath, string organisationName, string projectName, string accessToken, string parentId) + { + if (string.IsNullOrEmpty(organisationName)) + { + throw new ArgumentNullException(nameof(organisationName)); + } + this.OrganisationName = organisationName; + if (string.IsNullOrEmpty(projectName)) + { + throw new ArgumentNullException(nameof(projectName)); + } + this.ProjectName = projectName; + if (string.IsNullOrEmpty(accessToken)) + { + throw new ArgumentNullException(nameof(accessToken)); + } + this.AccesToken = accessToken; + if (string.IsNullOrEmpty(ParentId)) + { + throw new ArgumentNullException(nameof(parentId)); + } + this.ParentId = parentId; + _context = new AzureDevOpsApi(organisationName, projectName, accessToken); + cacheWorkItemsFile = $"{cachePath}\\cache-{organisationName}-{projectName}-{ParentId}.json"; + } + + + public async IAsyncEnumerable<(int total, int processed)> GetWorkItemsFullAsync() + { + if (System.IO.File.Exists(cacheWorkItemsFile)) + { + // load Cache + try + { + cachedWorkItems = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(cacheWorkItemsFile)); + } + catch (Exception ex) + { + // failed to load:: do nothing we will refresh the cache. + } + if (cachedWorkItems != null) + { + //Test Cache date + QueryResults? changedWorkItems = await _context.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", ParentId }, { "@changeddate", cachedWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); + if (changedWorkItems?.workItems.Length == 0) + { + yield return (cachedWorkItems.workitems.Count(), cachedWorkItems.workitems.Count()); + } + else + { + cachedWorkItems = null; + } + } + } + if (cachedWorkItems == null) + { + cachedWorkItems = new CashedWorkItems() { queryDatetime = DateTime.Now, workitems = new List() }; + QueryResults? templateWorkItemLight; + templateWorkItemLight = await _context.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", ParentId.ToString() } }); + int count = 1; + foreach (var item in templateWorkItemLight?.workItems) + { + WorkItemFull result = await _context.GetWorkItem((int)item.id); + if (result != null) + { + cachedWorkItems.workitems.Add(result); + } + yield return (cachedWorkItems.workitems.Count(), count); + count++; + } + System.IO.File.WriteAllText(cacheWorkItemsFile, JsonConvert.SerializeObject(cachedWorkItems, Formatting.Indented)); + } + + } + + } +} From 6ead80608634e7ffda3b5b39857889d4f5f46003 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 12:59:44 +0100 Subject: [PATCH 07/11] refactor(WorkItemCloneCommand.cs): merge two tasks into one for loading template items feat(WorkItemCloneCommand.cs): replace direct API calls with repository pattern for better code organization and separation of concerns refactor(WorkItemRepo.cs): update WorkItemRepository to include loading source in GetWorkItemsFullAsync method fix(WorkItemRepo.cs): change parentId type from string to int for correct type usage refactor(WorkItemRepo.cs): update GetWorkItemsFullAsync method to return loading source along with total and processed count --- .../Commands/WorkItemCloneCommand.cs | 90 +++---------------- .../Repositories/WorkItemRepo.cs | 29 +++--- 2 files changed, 26 insertions(+), 93 deletions(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 2f869f8..c66f7c2 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -10,6 +10,7 @@ using Newtonsoft.Json.Linq; using System.Threading.Tasks; using System.Diagnostics.Eventing.Reader; +using AzureDevOps.WorkItemClone.Repositories; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -71,8 +72,7 @@ await AnsiConsole.Progress() .StartAsync(async ctx => { // Define tasks - var task1 = ctx.AddTask("[bold]Stage 1[/]: Get Template Items", false); - var task2 = ctx.AddTask("[bold]Stage 2[/]: Load Template Items", false); + var task1 = ctx.AddTask("[bold]Stage 1+2[/]: Load Template Items", false); var task3 = ctx.AddTask("[bold]Stage 3[/]: Get Target Project", false); var task4 = ctx.AddTask("[bold]Stage 4[/]: Create Output Plan", false); var task5 = ctx.AddTask("[bold]Stage 5[/]: Create Output Plan Relations ", false); @@ -87,85 +87,21 @@ await AnsiConsole.Progress() System.IO.File.Delete(cacheTemplateWorkItemsFile); } - task1.MaxValue = 1; - CashedWorkItems templateWorkItems = null; - - - task1.StartTask(); - task2.StartTask(); - - - if (System.IO.File.Exists(cacheTemplateWorkItemsFile)) + IWorkItemRepository templateWor = new WorkItemRepository(config.CachePath, config.templateOrganization, config.templateProject, config.templateAccessToken, (int)config.templateParentId); + await foreach (var result in templateWor.GetWorkItemsFullAsync()) { - // load Cache - try + //AnsiConsole.WriteLine($"Stage 2: Processing {workItem.id}:`{workItem.fields.SystemTitle}`"); + task1.MaxValue = result.total; + if (result.total == result.processed) { - templateWorkItems = JsonConvert.DeserializeObject(System.IO.File.ReadAllText(cacheTemplateWorkItemsFile)); + task1.Increment(result.processed); + await Task.Delay(250); } - catch (Exception ex) - { - // failed to load - AnsiConsole.WriteLine($"Cache is moldy, reloading.."); - } - if (templateWorkItems != null) - { - //Test Cache date - QueryResults changedWorkItems = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() }, { "@changeddate", templateWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); - if (changedWorkItems.workItems.Length == 0) - { - AnsiConsole.WriteLine($"Stage 1: Checked template for changes. None Detected. Loading Cache"); - - // Load from Cache - - task1.Increment(1); - task1.Description = task1.Description + " (cache)"; - await Task.Delay(250); - task1.StopTask(); - ////////////////////// - task2.Increment(templateWorkItems.workitems.Count()); - task2.Description = task2.Description + " (cache)"; - AnsiConsole.WriteLine($"Stage 2: Loaded {templateWorkItems.workitems.Count()} work items from cache."); - } - else - { - AnsiConsole.WriteLine($"Cache is stale, reloading.."); - templateWorkItems = null; - } - } - } - - if (templateWorkItems == null) - { - // Get From Server - // -------------------------------------------------------------- - // Task 1: query for template work items - task1.StartTask(); - templateWorkItems = new CashedWorkItems() { workitems = new List(), queryDatetime = DateTime.Now }; - //AnsiConsole.WriteLine("Stage 1: Executing items from Query"); - QueryResults templateWorkItemLight; - templateWorkItemLight = await templateApi.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", config.templateParentId.ToString() } }); - AnsiConsole.WriteLine($"Stage 1: Query returned {templateWorkItemLight.workItems.Count()} items id's from the template."); + task1.Description = $"[bold]Stage 1[/]: Load Template Items ({result.loadingFrom})"; task1.Increment(1); - task1.StopTask(); - // -------------------------------------------------------------- - // Task 2: getting work items and their full data - task2.MaxValue = templateWorkItemLight.workItems.Count(); - task2.StartTask(); - await Task.Delay(250); - //AnsiConsole.WriteLine($"Stage 2: Loading {fakeItemsFromTemplateQuery.workItems.Count()} work items from template."); - await foreach (var workItem in templateApi.GetWorkItemsFullAsync(templateWorkItemLight.workItems)) - { - //AnsiConsole.WriteLine($"Stage 2: Processing {workItem.id}:`{workItem.fields.SystemTitle}`"); - templateWorkItems.workitems.Add(workItem); - task2.Increment(1); - } - System.IO.File.WriteAllText(cacheTemplateWorkItemsFile, JsonConvert.SerializeObject(templateWorkItems, Formatting.Indented)); - //AnsiConsole.WriteLine($"Stage 2: All {task2.MaxValue} work items loaded"); - await Task.Delay(250); - task2.StopTask(); } - await Task.Delay(250); + task1.StopTask(); // -------------------------------------------------------------- string targetProjectRunFile = $"{runCache}\\targetProject.json"; @@ -221,7 +157,7 @@ await AnsiConsole.Progress() await Task.Delay(250); //AnsiConsole.WriteLine($"Stage 4: First Pass generation of Work Items to build will merge the provided json work items with the data from the template."); buildItems = new List(); - await foreach (WorkItemToBuild witb in generateWorkItemsToBuildList(inputWorkItems, templateWorkItems.workitems, projectItem, config.targetProject)) + await foreach (WorkItemToBuild witb in generateWorkItemsToBuildList(inputWorkItems, templateWor.Data.workitems, projectItem, config.targetProject)) { // AnsiConsole.WriteLine($"Stage 4: processing {witb.guid}"); buildItems.Add(witb); @@ -236,7 +172,7 @@ await AnsiConsole.Progress() //AnsiConsole.WriteLine($"Stage 5: Second Pass generate relations."); task5.StartTask(); await Task.Delay(250); - await foreach (WorkItemToBuild witb in generateWorkItemsToBuildRelations(buildItems, templateWorkItems.workitems)) + await foreach (WorkItemToBuild witb in generateWorkItemsToBuildRelations(buildItems, templateWor.Data.workitems)) { //AnsiConsole.WriteLine($"Stage 5: processing {witb.guid} for output of {witb.relations.Count-1} relations"); task5.Increment(1); diff --git a/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs b/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs index ef2598c..3647954 100644 --- a/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs +++ b/AzureDevOps.WorkItemClone/Repositories/WorkItemRepo.cs @@ -11,12 +11,8 @@ namespace AzureDevOps.WorkItemClone.Repositories { public interface IWorkItemRepository { - IAsyncEnumerable<(int total, int processed)> GetWorkItemsFullAsync(); - //Task GetWorkItemByIdAsync(int id); - //Task> GetAllWorkItemAsync(); - //Task AddWorkItemAsync(WorkItemFull wif); - //Task UpdateWorkItemAsync(WorkItemFull wif); - //Task DeleteWorkItemAsync(int id); + CashedWorkItems Data {get;} + IAsyncEnumerable<(int total, int processed, string loadingFrom)> GetWorkItemsFullAsync(); } public interface IPersistantCache { @@ -29,15 +25,15 @@ public class WorkItemRepository : IWorkItemRepository public string OrganisationName { get; private set; } public string ProjectName { get; private set; } private string AccesToken { get; set; } - public string ParentId { get; private set; } + public int ParentId { get; private set; } private AzureDevOpsApi _context; private string cacheWorkItemsFile; - public CashedWorkItems WorkItems { get { return cachedWorkItems; } } + public CashedWorkItems Data { get { return cachedWorkItems; } } CashedWorkItems cachedWorkItems = null; - public WorkItemRepository(string cachePath, string organisationName, string projectName, string accessToken, string parentId) + public WorkItemRepository(string cachePath, string organisationName, string projectName, string accessToken, int parentId) { if (string.IsNullOrEmpty(organisationName)) { @@ -54,17 +50,17 @@ public WorkItemRepository(string cachePath, string organisationName, string proj throw new ArgumentNullException(nameof(accessToken)); } this.AccesToken = accessToken; - if (string.IsNullOrEmpty(ParentId)) + if (parentId == 0) { throw new ArgumentNullException(nameof(parentId)); } this.ParentId = parentId; - _context = new AzureDevOpsApi(organisationName, projectName, accessToken); + _context = new AzureDevOpsApi(accessToken, organisationName, projectName); cacheWorkItemsFile = $"{cachePath}\\cache-{organisationName}-{projectName}-{ParentId}.json"; } - public async IAsyncEnumerable<(int total, int processed)> GetWorkItemsFullAsync() + public async IAsyncEnumerable<(int total, int processed, string loadingFrom)> GetWorkItemsFullAsync() { if (System.IO.File.Exists(cacheWorkItemsFile)) { @@ -80,10 +76,10 @@ public WorkItemRepository(string cachePath, string organisationName, string proj if (cachedWorkItems != null) { //Test Cache date - QueryResults? changedWorkItems = await _context.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", ParentId }, { "@changeddate", cachedWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); + QueryResults? changedWorkItems = await _context.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id AND [System.ChangedDate] > '@changeddate' order by [System.CreatedDate] desc", new Dictionary() { { "@id", ParentId.ToString() }, { "@changeddate", cachedWorkItems.queryDatetime.AddDays(-1).ToString("yyyy-MM-dd") } }); if (changedWorkItems?.workItems.Length == 0) { - yield return (cachedWorkItems.workitems.Count(), cachedWorkItems.workitems.Count()); + yield return (cachedWorkItems.workitems.Count(), cachedWorkItems.workitems.Count(), "cache"); } else { @@ -93,9 +89,10 @@ public WorkItemRepository(string cachePath, string organisationName, string proj } if (cachedWorkItems == null) { - cachedWorkItems = new CashedWorkItems() { queryDatetime = DateTime.Now, workitems = new List() }; + QueryResults? templateWorkItemLight; templateWorkItemLight = await _context.GetWiqlQueryResults("Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id order by [System.CreatedDate] desc", new Dictionary() { { "@id", ParentId.ToString() } }); + cachedWorkItems = new CashedWorkItems() { queryDatetime = templateWorkItemLight.asOf, workitems = new List() }; int count = 1; foreach (var item in templateWorkItemLight?.workItems) { @@ -104,7 +101,7 @@ public WorkItemRepository(string cachePath, string organisationName, string proj { cachedWorkItems.workitems.Add(result); } - yield return (cachedWorkItems.workitems.Count(), count); + yield return (templateWorkItemLight.workItems.Count(), count, "server"); count++; } System.IO.File.WriteAllText(cacheWorkItemsFile, JsonConvert.SerializeObject(cachedWorkItems, Formatting.Indented)); From b8c0086ffa2de9045782e30f6112ac3a7a943305 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 13:00:26 +0100 Subject: [PATCH 08/11] refactor(WorkItemCloneCommand.cs): remove unused AzureDevOpsApi templateApi instance to improve code readability and performance --- .../Commands/WorkItemCloneCommand.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index c66f7c2..a70aa3f 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -32,8 +32,6 @@ public override async Task ExecuteAsync(CommandContext context, WorkItemClo AnsiConsole.MarkupLine($"[red]Run: [/] {config.RunName}"); string runCache = $"{config.CachePath}\\{config.RunName}"; DirectoryInfo outputPathInfo = CreateOutputPath(runCache); - - AzureDevOpsApi templateApi = CreateAzureDevOpsConnection(config.templateAccessToken, config.templateOrganization, config.templateProject); AzureDevOpsApi targetApi = CreateAzureDevOpsConnection(config.targetAccessToken, config.targetOrganization, config.targetProject); JArray inputWorkItems = DeserializeWorkItemList(config); From dfd2b88a98fee79da31e8ea9eb1f1a7a4e8a1942 Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 17:11:15 +0100 Subject: [PATCH 09/11] feat(WorkItemCloneCommand.cs): add support for custom query parameters in CreateProjectQuery feat(WorkItemCloneCommandSettings.cs): add new command options for targetQuery, targetQueryTitle, targetQueryFolder feat(WorkItemCommandBase.cs): add support for new command options in config feat(configuration.json): add new fields for targetQuery, targetQueryTitle, targetQueryFolder feat(AzureDevOpsApi.cs): add support for replacing parameters in query strings docs(README.md): update documentation to reflect new command options and configuration fields --- .../Commands/WorkItemCloneCommand.cs | 10 ++- .../Commands/WorkItemCloneCommandSettings.cs | 17 +++++ .../Commands/WorkItemCommandBase.cs | 3 + .../configuration.json | 6 +- AzureDevOps.WorkItemClone/AzureDevOpsApi.cs | 21 ++++- README.md | 76 ++++++++----------- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index a70aa3f..028dd43 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -226,7 +226,15 @@ await AnsiConsole.Progress() // Task 7: Create Query task7.MaxValue = 1; task7.StartTask(); - var query = await targetApi.CreateProjectQuery($"Project [{projectItem.id}]: {projectItem.fields.SystemTitle}", "Select [System.Id] From WorkItems Where [System.TeamProject] = '@project' AND [System.Parent] = @id", new Dictionary() { { "@id", config.templateParentId.ToString() } }); + + Dictionary queryParameters = new Dictionary() + { + { "@projectID", projectItem.id.ToString() }, + { "@projectTitle", projectItem.fields.SystemTitle }, + { "@projectTags", projectItem.fields.SystemTags }, + { "@RunName", config.RunName } + }; + var query = await targetApi.CreateProjectQuery(config.targetQueryTitle, config.targetQuery, queryParameters); task7.Increment(1); task7.StopTask(); diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs index 8e1434c..4c8b07e 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs @@ -42,6 +42,23 @@ internal class WorkItemCloneCommandSettings : BaseCommandSettings [CommandOption("--targetFalbackWit")] [DefaultValue("Deliverable")] public string? targetFalbackWit { get; set; } + + + [Description("The WIQL Query to use. You can use @projectID, @projectTitle, @projectTags to replace data from the project!")] + [CommandOption("--targetQuery")] + [DefaultValue("SELECT [System.Id], [System.WorkItemType], [System.Title], [System.AreaPath],[System.AssignedTo],[System.State] FROM workitems WHERE [System.Parent] = @projectID")] + public string? targetQuery { get; set; } + + [Description("The title to use for the query. You can use @projectID, @projectTitle, @projectTags, @RunName to replace data from the project!")] + [CommandOption("--targetQueryTitle")] + [DefaultValue("Project-@RunName - @projectTitle")] + public string? targetQueryTitle { get; set; } + + [Description("Must already Exist and be in the form 'Shared Queries/Folder1/Folder2'!")] + [CommandOption("--targetQueryFolder")] + [DefaultValue("Shared Queries")] + public string? targetQueryFolder { get; set; } + //------------------------------------------------ [Description("The access token for the template location")] [CommandOption("--templateAccessToken")] diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs index 74639e0..303ab8d 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs @@ -37,6 +37,9 @@ internal void CombineValuesFromConfigAndSettings(WorkItemCloneCommandSettings se config.targetParentId = EnsureIntAskIfMissing(config.targetParentId = settings.targetParentId != null ? settings.targetParentId : config.targetParentId, "Provide the target parent?"); config.targetFalbackWit = EnsureStringAskIfMissing(config.targetFalbackWit = settings.targetFalbackWit != null ? settings.targetFalbackWit : config.targetFalbackWit, "Provide the target fallback wit?"); + config.targetQueryTitle = EnsureStringAskIfMissing(config.targetQueryTitle = settings.targetQueryTitle != null ? settings.targetQueryTitle : config.targetQueryTitle, "Provide the target query title?"); + config.targetQueryFolder = EnsureStringAskIfMissing(config.targetQueryFolder = settings.targetQueryFolder != null ? settings.targetQueryFolder : config.targetQueryFolder, "Provide the target query folder?"); + config.targetQuery = EnsureStringAskIfMissing(config.targetQuery = settings.targetQuery != null ? settings.targetQuery : config.targetQuery, "Provide the target WIQL query?"); } diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json index 1fc1ff2..749472e 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json +++ b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json @@ -8,5 +8,9 @@ "templateAccessToken": null, "templateOrganization": "ABB-MO-ATE", "templateProject": "ABB Traction Template", - "templateParentId": 212315 + "templateParentId": 212315, + "targetQuery": "SELECT [System.Id], [System.WorkItemType], [System.Title], [System.AreaPath],[System.AssignedTo],[System.State] FROM workitems WHERE [System.Parent] = @projectID", + "targetQueryTitle": "Project-@RunName - @projectTitle", + "targetQueryFolder": "Shared Queries" + } diff --git a/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs b/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs index 07cc509..26826d5 100644 --- a/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs +++ b/AzureDevOps.WorkItemClone/AzureDevOpsApi.cs @@ -76,13 +76,23 @@ private string GetQueryString( string wiqlQuery, Dictionary par { wiqlQuery = "Select [System.Id], [System.Title], [System.State] From WorkItems Where [System.TeamProject] = '@project' order by [System.CreatedDate] desc"; } + wiqlQuery = ReplaceParamsInString(wiqlQuery, parameters); + return wiqlQuery; + } + private string ReplaceParamsInString(string text, Dictionary parameters) + { + if (string.IsNullOrEmpty(text)) + { + text = "Default"; + } foreach (var param in parameters) { - wiqlQuery = wiqlQuery.Replace(param.Key, param.Value); + text = text.Replace(param.Key, param.Value); } - return wiqlQuery; + return text; } + public async Task GetWiqlQueryResults() { string post = JsonConvert.SerializeObject(new { @@ -241,12 +251,15 @@ public async Task CreateProjectQuery(string queryName, string wiqlQuery, { ///POST https://dev.azure.com/{organization}/{project}/_apis/wit/queries/{query}?api-version=7.1-preview.2 wiqlQuery = GetQueryString(wiqlQuery, parameters); + queryName = ReplaceParamsInString(queryName, parameters); string post = JsonConvert.SerializeObject(new { + isFolder = false, name = queryName, - query = wiqlQuery + path = $"Shared Queries/{queryName}", + wiql = wiqlQuery }); - string apiCallUrl = $"https://dev.azure.com/{_account}/{_project}/_apis/wit/queries/Shared Queries/some/?api-version=7.2-preview.2"; + string apiCallUrl = $"https://dev.azure.com/{_account}/{_project}/_apis/wit/queries/Shared Queries/?api-version=7.2-preview.2"; var result = await GetObjectResult(apiCallUrl, post); return result.result; } diff --git a/README.md b/README.md index 9c0c5c0..26669c9 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ Clones work items from a template project to a target project incorproating a JS - `--targetOrganization` - The name of the organisation to clone work items to. - `--targetProject` - The name of the prject to clone work items to. - `--targetParentId` - All cloned work items will be come a child of this work item + - `--targetQuery` - The query to create in the target project. Default is `SELECT [System.Id], [System.WorkItemType], [System.Title], [System.AreaPath],[System.AssignedTo],[System.State] FROM workitems WHERE [System.Parent] = @projectID`. + - `--targetQueryTitle` - The title of the query to create in the target project. Default is `Project-@RunName - @projectTitle`. + - `--targetQueryFolder` - The folder to create the query in the target project. Default is `Shared Queries`. *Optional Parameters* - These are optional parameters that can be used to control the behaviour of the clone process. @@ -83,66 +86,51 @@ Clones work items from a template project to a target project incorproating a JS ```json { "CachePath": "./cache", - "inputJsonFile": "ADO_TESTProjPipline_V03.json", - "targetAccessToken": null, + "inputJsonFile": "TESTProjPipline_V03.json", + "targetAccessToken": "************************************", "targetOrganization": "nkdagility-preview", "targetProject": "Clone-Demo", "targetParentId": 540, - "templateAccessToken": null, - "templateOrganization": "Clone-MO-ATE", - "templateProject": "Clone Template" + "templateAccessToken": "************************************", + "templateOrganization": "orgname", + "templateProject": "template Project", + "templateParentId": 212315, + "targetQuery": "SELECT [System.Id], [System.WorkItemType], [System.Title], [System.AreaPath],[System.AssignedTo],[System.State] FROM workitems WHERE [System.Parent] = @projectID", + "targetQueryTitle": "Project-@RunName - @projectTitle", + "targetQueryFolder": "Shared Queries" } ``` - ## inputJsonFile Example +## Json Input File + + +The `id` is the ID of the template item. This will be used to specifiy Description, Acceptance Criteria, and dependancy relationsips. I the `id` is not specified a new work item will be created. + +The `fields` are the fields that will be used to create the work item. You can use any field ientifyer from Azure DevOps. ```json - [ +[ { "id": 213928, - "area": "TPL", - "tags": "Customer Document", "fields": { - "title": "Technical specification", - "product": "CC000_000A01" + "System.AreaPath": "Engineering Group\\ECH Group\\ECH TPL 1", + "System.Tags": "Customer Document", + "System.Title": "Technical specification", + "Custom.Product": "CC", + "Microsoft.VSTS.Scheduling.Effort": 12, + "Custom.TRA_Milestone": "E0.1" } }, { - "id": 213928, - "area": "TPL", - "tags": "Customer Document", + "id": "", "fields": { - "title": "Technical specification", - "product": "CC000_000A02" + "System.AreaPath": "Engineering Group\\ECH Group\\ECH TPL 1", + "System.Tags": "", + "System.Title": "E4.8 Assessment", + "Custom.Product": "", + "Microsoft.VSTS.Scheduling.Effort": 2, + "Custom.TRA_Milestone": "E4.8" } - } -] -``` - -proposed new format not yet adopted: - - - ```json - [ - { - "templateId": 213928, - "fields": [ - {"System.Title": "Technical specification"}, - {"Custom.Project": "CC000_000A01"}, - {"System.Tags": "Customer Document"}, - {"System.AreaPath": "#{targetProject}#\\TPL"} - ] - }, - { - "templateId": 213928, - "fields": [ - {"System.Title": "Technical specification"}, - {"Custom.Project": "CC000_000A02"}, - {"System.Tags": "Technical specification"}, - {"System.AreaPath": "#{targetProject}#\\TPL"} - ] - }, - } ] ``` From c58d5637ebc6d27775769bfadb614b02d9de99ae Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 17:11:33 +0100 Subject: [PATCH 10/11] style(README.md): remove unnecessary blank line to improve readability --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 26669c9..9fa6425 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ Clones work items from a template project to a target project incorproating a JS ``` - ### `init` Leads you through the process of creating a configuration file. From e2c3e78c93ef54047878a7cb7f66e7e6cf06de8b Mon Sep 17 00:00:00 2001 From: "Martin Hinshelwood nkdAgility.com" Date: Wed, 17 Jul 2024 17:16:20 +0100 Subject: [PATCH 11/11] docs(README.md): add detailed explanation for target query and its parameters to improve documentation clarity --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 9fa6425..6f9ffff 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,20 @@ Clones work items from a template project to a target project incorproating a JS - `--targetOrganization` - The name of the organisation to clone work items to. - `--targetProject` - The name of the prject to clone work items to. - `--targetParentId` - All cloned work items will be come a child of this work item + + *Target Query* - The target query is used to create a query in the target project to show the cloned work items. + - `--targetQuery` - The query to create in the target project. Default is `SELECT [System.Id], [System.WorkItemType], [System.Title], [System.AreaPath],[System.AssignedTo],[System.State] FROM workitems WHERE [System.Parent] = @projectID`. - `--targetQueryTitle` - The title of the query to create in the target project. Default is `Project-@RunName - @projectTitle`. - `--targetQueryFolder` - The folder to create the query in the target project. Default is `Shared Queries`. + You can use the following parameters: + + - *@projectID* - The ID of the target parent item + - *@projectTitle* - The title of the parent item + - *@projectTags* - the tags of the item + - *@RunName* - The name of the run + *Optional Parameters* - These are optional parameters that can be used to control the behaviour of the clone process. - `--NonInteractive` - Disables interactive mode. Default is `false`.