diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/AzureDevOps.WorkItemClone.ConsoleUI.csproj b/AzureDevOps.WorkItemClone.ConsoleUI/AzureDevOps.WorkItemClone.ConsoleUI.csproj index 696d323..29887c9 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/AzureDevOps.WorkItemClone.ConsoleUI.csproj +++ b/AzureDevOps.WorkItemClone.ConsoleUI/AzureDevOps.WorkItemClone.ConsoleUI.csproj @@ -30,6 +30,7 @@ + @@ -40,6 +41,9 @@ Always + + Never + Always diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/CommandSettings.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/CommandSettings.cs index 7a73c33..446a623 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/CommandSettings.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/CommandSettings.cs @@ -6,6 +6,7 @@ using System.Text; using Newtonsoft.Json; using System.Threading.Tasks; +using YamlDotNet.Serialization; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -14,7 +15,7 @@ internal class BaseCommandSettings : CommandSettings [Description("Pre configure paramiters using this config file. Run `Init` to create it.")] [CommandOption("--config|--configFile")] [DefaultValue("configuration.json")] - [JsonIgnore] + [JsonIgnore, YamlIgnore] public string? configFile { get; set; } } } diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs index 9386840..9d29659 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommand.cs @@ -11,6 +11,7 @@ using System.Threading.Tasks; using System.Diagnostics.Eventing.Reader; using AzureDevOps.WorkItemClone.Repositories; +using YamlDotNet.Serialization; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -18,10 +19,15 @@ internal class WorkItemCloneCommand : WorkItemCommandBase ExecuteAsync(CommandContext context, WorkItemCloneCommandSettings settingsFromCmd) { + if (!FileStoreCheckExtensionMatchesFormat(settingsFromCmd.configFile, settingsFromCmd.ConfigFormat)) + { + AnsiConsole.MarkupLine($"[bold red]The file extension of {settingsFromCmd.configFile} does not match the format {settingsFromCmd.ConfigFormat.ToString()} selected! Please rerun with the correct format You can use --configFormat JSON or update your file to YAML[/]"); + return -1; + } WorkItemCloneCommandSettings config = null; - if (File.Exists(settingsFromCmd.configFile)) + if (FileStoreExist(settingsFromCmd.configFile, settingsFromCmd.ConfigFormat)) { - config = LoadWorkItemCloneCommandSettingsFromFile(settingsFromCmd.configFile); + config = FileStoreLoad(settingsFromCmd.configFile, settingsFromCmd.ConfigFormat); } if (config == null) { @@ -34,7 +40,7 @@ public override async Task ExecuteAsync(CommandContext context, WorkItemClo DirectoryInfo outputPathInfo = CreateOutputPath(runCache); AzureDevOpsApi targetApi = CreateAzureDevOpsConnection(config.targetAccessToken, config.targetOrganization, config.targetProject); - JArray inputWorkItems = DeserializeWorkItemList(config); + JArray workItemControlList = DeserializeControlFile(config); // -------------------------------------------------------------- WriteOutSettings(config); @@ -96,7 +102,7 @@ await AnsiConsole.Progress() task1.Increment(result.processed); await Task.Delay(250); } - task1.Description = $"[bold]Stage 1[/]: Load Template Items ({result.loadingFrom})"; + task1.Description = $"[bold]Stage 1+2[/]: Load Template Items ({result.loadingFrom})"; task1.Increment(1); } task1.StopTask(); @@ -150,12 +156,12 @@ await AnsiConsole.Progress() else { // Task 4: First Pass generation of Work Items to build - task4.MaxValue = inputWorkItems.Count(); + task4.MaxValue = workItemControlList.Count(); task4.StartTask(); 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, templateWor.Data.workitems, projectItem, config.targetProject)) + await foreach (WorkItemToBuild witb in generateWorkItemsToBuildList(workItemControlList, templateWor.Data.workitems, projectItem, config.targetProject)) { // AnsiConsole.WriteLine($"Stage 4: processing {witb.guid}"); buildItems.Add(witb); @@ -166,7 +172,7 @@ await AnsiConsole.Progress() //AnsiConsole.WriteLine($"Stage 4: Completed first pass."); // -------------------------------------------------------------- // Task 5: Second Pass Add Relations - task5.MaxValue = inputWorkItems.Count(); + task5.MaxValue = workItemControlList.Count(); //AnsiConsole.WriteLine($"Stage 5: Second Pass generate relations."); task5.StartTask(); await Task.Delay(250); diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs index 1bc5a06..4e72cfb 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCloneCommandSettings.cs @@ -1,30 +1,43 @@ using Spectre.Console.Cli; using System.ComponentModel; using Newtonsoft.Json; +using YamlDotNet.Serialization; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { + public enum ConfigFormats + { + JSON, + YAML + } + internal class WorkItemCloneCommandSettings : BaseCommandSettings { + //------------------------------------------------ [Description("Execute with no user interaction required.")] [CommandOption("--NonInteractive")] - [JsonIgnore] + [JsonIgnore, YamlIgnore] public bool NonInteractive { get; set; } [Description("Clear any cache if there is any")] [CommandOption("--ClearCache")] - [JsonIgnore] + [JsonIgnore, YamlIgnore] public bool ClearCache { get; set; } [Description("Use this run name to execute. This will create a unique folder under the CachePath for storing run specific data and status. Defaults to yyyyyMMddHHmmss.")] [CommandOption("--RunName")] - [JsonIgnore] + [JsonIgnore, YamlIgnore] public string? RunName { get; set; } + [Description("Use this run name to execute. This will create a unique folder under the CachePath for storing run specific data and status. Defaults to yyyyyMMddHHmmss.")] + [CommandOption("--configFormat")] + [DefaultValue(ConfigFormats.JSON)] + [JsonIgnore, YamlIgnore] + public ConfigFormats ConfigFormat { get; set; } //------------------------------------------------ [CommandOption("--outputPath|--cachePath")] [DefaultValue("./cache")] public string? CachePath { get; set; } //------------------------------------------------ - [CommandOption("--jsonFile|--inputJsonFile")] - public string? inputJsonFile { get; set; } + [CommandOption("--jsonFile|--inputJsonFile|--controlFile")] + public string? controlFile { get; set; } //------------------------------------------------ [Description("The access token for the target location")] [CommandOption("--targetAccessToken")] diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs index 303ab8d..eac265a 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemCommandBase.cs @@ -11,6 +11,9 @@ using System.Text; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.Serialization; +using Microsoft.SqlServer.Server; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -23,7 +26,7 @@ internal void CombineValuesFromConfigAndSettings(WorkItemCloneCommandSettings se config.ClearCache = settings.ClearCache; config.RunName = settings.RunName != null ? settings.RunName : DateTime.Now.ToString("yyyyyMMddHHmmss"); config.configFile = EnsureFileAskIfMissing(config.configFile = settings.configFile != null ? settings.configFile : config.configFile, "Where is the config file to load?"); - config.inputJsonFile = EnsureFileAskIfMissing(config.inputJsonFile = settings.inputJsonFile != null ? settings.inputJsonFile : config.inputJsonFile, "Where is the JSON File?"); + config.controlFile = EnsureFileAskIfMissing(config.controlFile = settings.controlFile != null ? settings.controlFile : config.controlFile, "Where is the Control File?"); config.CachePath = EnsureFolderAskIfMissing(config.CachePath = settings.CachePath != null ? settings.CachePath : config.CachePath, "What is the cache path?"); config.templateOrganization = EnsureStringAskIfMissing(config.templateOrganization = settings.templateOrganization != null ? settings.templateOrganization : config.templateOrganization, "What is the template organisation?"); @@ -79,26 +82,26 @@ private JArray DeserializeWorkItemList(string jsonFile) return configWorkItems; } - internal JArray DeserializeWorkItemList(WorkItemCloneCommandSettings config) + internal JArray DeserializeControlFile(WorkItemCloneCommandSettings config) { string CachedRunJson = System.IO.Path.Combine(config.CachePath, config.RunName, "input.json"); if (System.IO.File.Exists(CachedRunJson)) { // Load From Run Cache - config.inputJsonFile = CachedRunJson; + config.controlFile = CachedRunJson; return DeserializeWorkItemList(CachedRunJson); } else { // Load new - config.inputJsonFile = EnsureFileAskIfMissing(config.inputJsonFile, "Where is the JSON File?"); - if (!System.IO.File.Exists(config.inputJsonFile)) + config.controlFile = EnsureFileAskIfMissing(config.controlFile, "Where is the JSON File?"); + if (!System.IO.File.Exists(config.controlFile)) { AnsiConsole.MarkupLine("[red]Error:[/] No JSON file was found."); - throw new Exception(config.inputJsonFile + " not found."); + throw new Exception(config.controlFile + " not found."); } JArray inputWorkItems; - inputWorkItems= DeserializeWorkItemList(config.inputJsonFile); + inputWorkItems= DeserializeWorkItemList(config.controlFile); System.IO.File.WriteAllText(CachedRunJson, JsonConvert.SerializeObject(inputWorkItems, Formatting.Indented)); return inputWorkItems; } @@ -137,16 +140,73 @@ internal string EnsureStringAskIfMissing(string value, string message) } - internal ConfigurationSettings LoadConfigFile(string? configFile) + public TypeToLoad FileStoreLoad(string configFile, ConfigFormats format) { - ConfigurationSettings configSettings = System.Text.Json.JsonSerializer.Deserialize(System.IO.File.ReadAllText(configFile)); - return configSettings; + TypeToLoad loadedFromFile; + configFile = FileStoreEnsureExtension(configFile, format); + if (!System.IO.File.Exists(configFile)) + { + AnsiConsole.MarkupLine("[red]Error:[/] No file was found."); + throw new Exception(configFile + " not found."); + } + string content = System.IO.File.ReadAllText(configFile); + switch (format) + { + case ConfigFormats.JSON: + loadedFromFile = JsonConvert.DeserializeObject(content); + break; + case ConfigFormats.YAML: + var deserializer = new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); + loadedFromFile = deserializer.Deserialize(content); /* compile error */ + break; + default: + throw new Exception("Unknown format"); + } + return loadedFromFile; + } + + public bool FileStoreExist(string? configFile, ConfigFormats format) + { + return System.IO.File.Exists(FileStoreEnsureExtension(configFile, format)); + } + public bool FileStoreCheckExtensionMatchesFormat(string? configFile, ConfigFormats format) + { + if (Path.GetExtension(configFile).ToLower() != $".{format.ToString().ToLower()}") + { + return false; + } + return true; + } + + public string FileStoreEnsureExtension(string? configFile, ConfigFormats format) + { + if (Path.GetExtension(configFile).ToLower() != $".{format.ToString().ToLower()}") + { + var original = configFile; + configFile = Path.ChangeExtension(configFile, format.ToString().ToLower()); + AnsiConsole.MarkupLine($"[green]Info:[/] Changed name of {original} to {configFile} "); + } + return configFile; } - internal WorkItemCloneCommandSettings LoadWorkItemCloneCommandSettingsFromFile(string? configFile) + public string FileStoreSave(string configFile, TypeToSave content , ConfigFormats format) { - WorkItemCloneCommandSettings configSettings = System.Text.Json.JsonSerializer.Deserialize(System.IO.File.ReadAllText(configFile)); - return configSettings; + string output; + switch (format) + { + case ConfigFormats.JSON: + output = JsonConvert.SerializeObject(content, Formatting.Indented); + break; + case ConfigFormats.YAML: + var serializer = new SerializerBuilder().Build(); + output = serializer.Serialize(content); + break; + default: + throw new Exception("Unknown format"); + } + configFile = FileStoreEnsureExtension(configFile, format); + System.IO.File.WriteAllText(configFile, output); + return output; } internal string EnsureFileAskIfMissing(string? filename, string message = "What file should we load?") @@ -155,17 +215,12 @@ internal string EnsureFileAskIfMissing(string? filename, string message = "What { filename = AnsiConsole.Prompt( - new TextPrompt("Where is the config File?") + new TextPrompt(message) .Validate(configFile - => !string.IsNullOrWhiteSpace(configFile) && System.IO.File.Exists(configFile) + => !string.IsNullOrWhiteSpace(configFile) ? ValidationResult.Success() : ValidationResult.Error("[yellow]Invalid config file[/]"))); } - if (!System.IO.File.Exists(filename)) - { - AnsiConsole.MarkupLine("[red]Error:[/] No file was found."); - throw new Exception(filename + " not found."); - } return filename; } @@ -197,11 +252,6 @@ internal string EnsureConfigFileAskIfMissing(string? configFile) ? ValidationResult.Success() : ValidationResult.Error("[yellow]Invalid config file[/]"))); } - if (!System.IO.File.Exists(configFile)) - { - AnsiConsole.MarkupLine("[red]Error:[/] No JSON file was found."); - throw new Exception(configFile + " not found."); - } return configFile; } @@ -216,7 +266,7 @@ internal void WriteOutSettings(WorkItemCloneCommandSettings config) .AddEmptyRow() .AddRow("configFile", config.configFile != null ? config.configFile : "NOT SET") .AddRow("CachePath", config.CachePath != null ? config.CachePath : "NOT SET") - .AddRow("inputJsonFile", config.inputJsonFile != null ? config.inputJsonFile : "NOT SET") + .AddRow("controlFile", config.controlFile != null ? config.controlFile : "NOT SET") .AddEmptyRow() .AddRow( "templateAccessToken", "***************") .AddRow("templateOrganization", config.templateOrganization != null ? config.templateOrganization : "NOT SET") @@ -228,7 +278,11 @@ internal void WriteOutSettings(WorkItemCloneCommandSettings config) .AddRow("targetProject", config.targetProject != null ? config.targetProject : "NOT SET") .AddRow("targetParentId", config.targetParentId != null ? config.targetParentId.ToString() : "NOT SET") .AddRow("targetFalbackWit", config.targetFalbackWit != null ? config.targetFalbackWit : "NOT SET") - + .AddEmptyRow() + .AddRow("targetQueryTitle", config.targetQueryTitle != null ? config.targetQueryTitle : "NOT SET") + .AddRow("targetQueryFolder", config.targetQueryFolder != null ? config.targetQueryFolder : "NOT SET") + .AddRow("targetQuery", config.targetQuery != null ? config.targetQuery.Replace("]", "]]").Replace("[", "[[") : "NOT SET") + ); } diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemInitCommand.cs b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemInitCommand.cs index 91604bc..51bf7a4 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemInitCommand.cs +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Commands/WorkItemInitCommand.cs @@ -5,6 +5,7 @@ using Spectre.Console; using Spectre.Console.Cli; using System.Linq; +using Microsoft.SqlServer.Server; namespace AzureDevOps.WorkItemClone.ConsoleUI.Commands { @@ -12,10 +13,14 @@ internal class WorkItemInitCommand : WorkItemCommandBase ExecuteAsync(CommandContext context, WorkItemCloneCommandSettings settings) { - + if (!FileStoreCheckExtensionMatchesFormat(settings.configFile, settings.ConfigFormat)) + { + AnsiConsole.MarkupLine($"[bold red]The file extension of {settings.configFile} does not match the format {settings.ConfigFormat.ToString()} selected! Please rerun with the correct format You can use --configFormat JSON or update your file to YAML[/]"); + return -1; + } var configFile = EnsureConfigFileAskIfMissing(settings.configFile); WorkItemCloneCommandSettings config = null; - if (File.Exists(configFile)) + if (FileStoreExist(configFile, settings.ConfigFormat)) { var proceedWithSettings = AnsiConsole.Prompt( new SelectionPrompt { Converter = value => value ? "Yes" : "No" } @@ -23,7 +28,7 @@ public override async Task ExecuteAsync(CommandContext context, WorkItemClo .AddChoices(true, false)); if (proceedWithSettings) { - config = LoadWorkItemCloneCommandSettingsFromFile(configFile); + config = FileStoreLoad(configFile, settings.ConfigFormat); } } if (config == null) @@ -34,12 +39,9 @@ public override async Task ExecuteAsync(CommandContext context, WorkItemClo WriteOutSettings(config); + FileStoreSave(configFile, config, settings.ConfigFormat); - - - System.IO.File.WriteAllText(configFile, JsonConvert.SerializeObject(config, Formatting.Indented)); - - AnsiConsole.WriteLine("Settings saved to {configFile}!"); + AnsiConsole.WriteLine($"Settings saved to {configFile}!"); return 0; } diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/Properties/launchSettings.json b/AzureDevOps.WorkItemClone.ConsoleUI/Properties/launchSettings.json index b1deeaf..9c50c4b 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/Properties/launchSettings.json +++ b/AzureDevOps.WorkItemClone.ConsoleUI/Properties/launchSettings.json @@ -6,7 +6,7 @@ }, "Clone": { "commandName": "Project", - "commandLineArgs": "clone --cachePath ..\\..\\..\\..\\..\\.cache\\ --configFile ..\\..\\..\\..\\..\\.cache\\configuration-test.json --jsonFile ..\\..\\..\\..\\..\\TestData\\tst_jsonj_export_v20.json --NonInteractive" + "commandLineArgs": "clone --cachePath ..\\..\\..\\..\\..\\.cache\\ --configFile ..\\..\\..\\..\\..\\.cache\\configuration-test.yaml --jsonFile ..\\..\\..\\..\\..\\TestData\\tst_jsonj_export_v20.json --NonInteractive " }, "empty": { "commandName": "Project" @@ -18,6 +18,10 @@ "--help": { "commandName": "Project", "commandLineArgs": "clone --help" + }, + "Clone YAML": { + "commandName": "Project", + "commandLineArgs": "clone --configFormat YAML --cachePath ..\\..\\..\\..\\..\\.cache\\ --configFile ..\\..\\..\\..\\..\\.cache\\configuration-test.yaml --jsonFile ..\\..\\..\\..\\..\\TestData\\tst_jsonj_export_v20.json --NonInteractive " } } } \ No newline at end of file diff --git a/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json index 749472e..4eeb0ca 100644 --- a/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json +++ b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.json @@ -1,6 +1,6 @@ { "CachePath": "./cache", - "inputJsonFile": "ADO_TESTProjPipline_V03.json", + "controlFile": "ADO_TESTProjPipline_V03.json", "targetAccessToken": null, "targetOrganization": "nkdagility-preview", "targetProject": "ABB-Demo", @@ -12,5 +12,4 @@ "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.ConsoleUI/configuration.yaml b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.yaml new file mode 100644 index 0000000..745334f --- /dev/null +++ b/AzureDevOps.WorkItemClone.ConsoleUI/configuration.yaml @@ -0,0 +1,40 @@ +CachePath: "./cache" +controlFile: "ADO_TESTProjPipline_V03.json" +targetAccessToken": null +targetOrganization": "nkdagility-preview" +targetProject": "ABB-Demo" +targetParentId": 540 +templateAccessToken": null +templateOrganization": "ABB-MO-ATE" +templateProject": "ABB Traction Template" +templateParentId": 212315 +targetQuery": | + SELECT + [Custom.Product], + [System.Title], + [System.Description], + [Custom.DeadlineDate], + [System.AreaPath], + [System.AssignedTo], + [System.State], + [Custom.Notes], + [System.WorkItemType], + [Custom.TRA_Milestone] + FROM workitemLinks + WHERE + ( + [Source].[System.Id] = 000000 + OR [Source].[System.Parent] = 000000 + OR [Source].[System.Tags] CONTAINS 'xxxx xxxx' + ) + AND ( + [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward' + ) + AND ( + [Target].[System.Parent] = 000000 + OR [Target].[System.Tags] CONTAINS 'xxxx xxxx' + ) + ORDER BY [Custom.DeadlineDate] + MODE (Recursive) +targetQueryTitle": "Project-@RunName - @projectTitle" +targetQueryFolder": "Shared Queries"