From 4238599d9d3d05b1f449559c3b7c2421ecf2cadc Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 11:21:41 +0100 Subject: [PATCH 01/37] BREAKING CHANGE: renamed aggregator-core project to aggregator-shared This properly reflect the meaning --- src/aggregator-cli.sln | 2 +- src/aggregator-cli/Rules/run.csx | 2 +- src/aggregator-cli/aggregator-cli.csproj | 2 +- src/aggregator-function/aggregator-function.csproj | 6 +++--- src/aggregator-function/test/run.csx | 2 +- .../AggregatorConfiguration.cs | 3 +++ src/{aggregator-core => aggregator-shared}/AssemblyInfo.cs | 2 +- src/{aggregator-core => aggregator-shared}/VstsEvents.cs | 3 +++ .../aggregator-shared.csproj} | 3 ++- src/unittests-core/unittests-core.csproj | 2 +- 10 files changed, 17 insertions(+), 10 deletions(-) rename src/{aggregator-core => aggregator-shared}/AggregatorConfiguration.cs (89%) rename src/{aggregator-core => aggregator-shared}/AssemblyInfo.cs (90%) rename src/{aggregator-core => aggregator-shared}/VstsEvents.cs (83%) rename src/{aggregator-core/aggregator-core.csproj => aggregator-shared/aggregator-shared.csproj} (84%) diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index a736aa6d..9af69856 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27703.2026 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-core", "aggregator-core\aggregator-core.csproj", "{B024DF56-E474-4A1A-BEA9-03A87F6D00F3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-shared", "aggregator-shared\aggregator-shared.csproj", "{B024DF56-E474-4A1A-BEA9-03A87F6D00F3}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-cli", "aggregator-cli\aggregator-cli.csproj", "{FFB65D93-8193-4DA7-820B-3CD4D1872ABA}" EndProject diff --git a/src/aggregator-cli/Rules/run.csx b/src/aggregator-cli/Rules/run.csx index 18cf7735..cbd770d7 100644 --- a/src/aggregator-cli/Rules/run.csx +++ b/src/aggregator-cli/Rules/run.csx @@ -1,5 +1,5 @@ #r "../bin/aggregator-function.dll" -#r "../bin/aggregator-core.dll" +#r "../bin/aggregator-shared.dll" using aggregator; diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index 7089f27a..f32bcccc 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -43,7 +43,7 @@ - + diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index 4ef5d5e0..e1591b68 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -16,9 +16,6 @@ - - - Always @@ -43,4 +40,7 @@ PreserveNewest + + + diff --git a/src/aggregator-function/test/run.csx b/src/aggregator-function/test/run.csx index 18cf7735..cbd770d7 100644 --- a/src/aggregator-function/test/run.csx +++ b/src/aggregator-function/test/run.csx @@ -1,5 +1,5 @@ #r "../bin/aggregator-function.dll" -#r "../bin/aggregator-core.dll" +#r "../bin/aggregator-shared.dll" using aggregator; diff --git a/src/aggregator-core/AggregatorConfiguration.cs b/src/aggregator-shared/AggregatorConfiguration.cs similarity index 89% rename from src/aggregator-core/AggregatorConfiguration.cs rename to src/aggregator-shared/AggregatorConfiguration.cs index 541c9c07..f6ee274f 100644 --- a/src/aggregator-core/AggregatorConfiguration.cs +++ b/src/aggregator-shared/AggregatorConfiguration.cs @@ -8,6 +8,9 @@ public enum VstsTokenType PAT = 1, } + /// + /// This class tracks the configuration data that CLI writes and Function runtime reads + /// public class AggregatorConfiguration { public AggregatorConfiguration() {} diff --git a/src/aggregator-core/AssemblyInfo.cs b/src/aggregator-shared/AssemblyInfo.cs similarity index 90% rename from src/aggregator-core/AssemblyInfo.cs rename to src/aggregator-shared/AssemblyInfo.cs index f01e13bd..73827072 100644 --- a/src/aggregator-core/AssemblyInfo.cs +++ b/src/aggregator-shared/AssemblyInfo.cs @@ -11,5 +11,5 @@ [assembly: AssemblyFileVersion("0.3.4.0")] [assembly: AssemblyInformationalVersion("0.3.4")] [assembly: AssemblyProduct("Aggregator CLI")] -[assembly: AssemblyTitle("Aggregator Core")] +[assembly: AssemblyTitle("Aggregator Shared")] [assembly: AssemblyVersion("0.3.4.0")] diff --git a/src/aggregator-core/VstsEvents.cs b/src/aggregator-shared/VstsEvents.cs similarity index 83% rename from src/aggregator-core/VstsEvents.cs rename to src/aggregator-shared/VstsEvents.cs index ac0c1bcd..095b1454 100644 --- a/src/aggregator-core/VstsEvents.cs +++ b/src/aggregator-shared/VstsEvents.cs @@ -5,6 +5,9 @@ namespace aggregator { + /// + /// This class tracks the VSTS/AzureDevOps Events exposed both in CLI and Rules + /// public class VstsEvents { // TODO this table should be visible in the help diff --git a/src/aggregator-core/aggregator-core.csproj b/src/aggregator-shared/aggregator-shared.csproj similarity index 84% rename from src/aggregator-core/aggregator-core.csproj rename to src/aggregator-shared/aggregator-shared.csproj index 2a779012..a26cc23b 100644 --- a/src/aggregator-core/aggregator-core.csproj +++ b/src/aggregator-shared/aggregator-shared.csproj @@ -2,8 +2,9 @@ netstandard2.0 - aggregator.core + aggregator false + aggregator-shared diff --git a/src/unittests-core/unittests-core.csproj b/src/unittests-core/unittests-core.csproj index 67dce70b..5f8bd182 100644 --- a/src/unittests-core/unittests-core.csproj +++ b/src/unittests-core/unittests-core.csproj @@ -15,7 +15,7 @@ - + From 8a742ca1865df79ef960ad0bffcc926b295e7cff Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 11:23:11 +0100 Subject: [PATCH 02/37] bump version number to underline breaking change --- src/aggregator-cli/AssemblyInfo.cs | 6 +++--- src/aggregator-function/AssemblyInfo.cs | 6 +++--- src/aggregator-function/aggregator-manifest.ini | 2 +- src/aggregator-shared/AssemblyInfo.cs | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/aggregator-cli/AssemblyInfo.cs b/src/aggregator-cli/AssemblyInfo.cs index f7e5afbd..fcfa99c9 100644 --- a/src/aggregator-cli/AssemblyInfo.cs +++ b/src/aggregator-cli/AssemblyInfo.cs @@ -8,8 +8,8 @@ [assembly: AssemblyConfiguration("Release")] #endif [assembly: AssemblyCopyright("TFS Aggregator Team")] -[assembly: AssemblyFileVersion("0.3.4.0")] -[assembly: AssemblyInformationalVersion("0.3.4")] +[assembly: AssemblyFileVersion("0.4.0.0")] +[assembly: AssemblyInformationalVersion("0.4.0")] [assembly: AssemblyProduct("Aggregator CLI")] [assembly: AssemblyTitle("Aggregator CLI")] -[assembly: AssemblyVersion("0.3.4.0")] +[assembly: AssemblyVersion("0.4.0.0")] diff --git a/src/aggregator-function/AssemblyInfo.cs b/src/aggregator-function/AssemblyInfo.cs index cfcd6267..1fb40c12 100644 --- a/src/aggregator-function/AssemblyInfo.cs +++ b/src/aggregator-function/AssemblyInfo.cs @@ -8,8 +8,8 @@ [assembly: AssemblyConfiguration("Release")] #endif [assembly: AssemblyCopyright("TFS Aggregator Team")] -[assembly: AssemblyFileVersion("0.3.4.0")] -[assembly: AssemblyInformationalVersion("0.3.4")] +[assembly: AssemblyFileVersion("0.4.0.0")] +[assembly: AssemblyInformationalVersion("0.4.0")] [assembly: AssemblyProduct("Aggregator CLI")] [assembly: AssemblyTitle("Aggregator Runtime")] -[assembly: AssemblyVersion("0.3.4.0")] +[assembly: AssemblyVersion("0.4.0.0")] diff --git a/src/aggregator-function/aggregator-manifest.ini b/src/aggregator-function/aggregator-manifest.ini index d8a320c2..b52b6281 100644 --- a/src/aggregator-function/aggregator-manifest.ini +++ b/src/aggregator-function/aggregator-manifest.ini @@ -1 +1 @@ -version=0.3.4 \ No newline at end of file +version=0.4.0 \ No newline at end of file diff --git a/src/aggregator-shared/AssemblyInfo.cs b/src/aggregator-shared/AssemblyInfo.cs index 73827072..ec63e80c 100644 --- a/src/aggregator-shared/AssemblyInfo.cs +++ b/src/aggregator-shared/AssemblyInfo.cs @@ -8,8 +8,8 @@ [assembly: AssemblyConfiguration("Release")] #endif [assembly: AssemblyCopyright("TFS Aggregator Team")] -[assembly: AssemblyFileVersion("0.3.4.0")] -[assembly: AssemblyInformationalVersion("0.3.4")] +[assembly: AssemblyFileVersion("0.4.0.0")] +[assembly: AssemblyInformationalVersion("0.4.0")] [assembly: AssemblyProduct("Aggregator CLI")] [assembly: AssemblyTitle("Aggregator Shared")] -[assembly: AssemblyVersion("0.3.4.0")] +[assembly: AssemblyVersion("0.4.0.0")] From f0372f474f99f89a20ed75a4cc3b8aad06130bec Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 12:02:52 +0100 Subject: [PATCH 03/37] Moved Rule code independent from Azure Function in its own project to enable unit-testing --- src/aggregator-cli.sln | 6 ++++++ .../aggregator-function.csproj | 1 + src/aggregator-ruleng/AssemblyInfo.cs | 15 ++++++++++++++ .../CoreFieldRefNames.cs | 0 .../CoreRelationRefNames.cs | 0 .../EngineContext.cs | 4 ++-- .../Engine => aggregator-ruleng}/Globals.cs | 0 .../IAggregatorLogger.cs} | 2 +- .../Engine => aggregator-ruleng}/Tracker.cs | 0 .../WorkItemId.cs | 0 .../WorkItemRelationWrapper.cs | 0 .../WorkItemRelationWrapperCollection.cs | 0 .../WorkItemStore.cs | 2 +- .../WorkItemWrapper.cs | 0 .../aggregator-ruleng.csproj | 20 +++++++++++++++++++ 15 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 src/aggregator-ruleng/AssemblyInfo.cs rename src/{aggregator-function/Engine => aggregator-ruleng}/CoreFieldRefNames.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/CoreRelationRefNames.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/EngineContext.cs (75%) rename src/{aggregator-function/Engine => aggregator-ruleng}/Globals.cs (100%) rename src/{aggregator-function/ILogger.cs => aggregator-ruleng/IAggregatorLogger.cs} (87%) rename src/{aggregator-function/Engine => aggregator-ruleng}/Tracker.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/WorkItemId.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/WorkItemRelationWrapper.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/WorkItemRelationWrapperCollection.cs (100%) rename src/{aggregator-function/Engine => aggregator-ruleng}/WorkItemStore.cs (98%) rename src/{aggregator-function/Engine => aggregator-ruleng}/WorkItemWrapper.cs (100%) create mode 100644 src/aggregator-ruleng/aggregator-ruleng.csproj diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index 9af69856..ffb96c18 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 15.0.27703.2026 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-shared", "aggregator-shared\aggregator-shared.csproj", "{B024DF56-E474-4A1A-BEA9-03A87F6D00F3}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-ruleng", "aggregator-ruleng\aggregator-ruleng.csproj", "{87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-cli", "aggregator-cli\aggregator-cli.csproj", "{FFB65D93-8193-4DA7-820B-3CD4D1872ABA}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-core", "unittests-core\unittests-core.csproj", "{4D4361EC-F361-4E63-8CAF-6913EF42ED6D}" @@ -47,6 +49,10 @@ Global {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.ActiveCfg = Release|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.Build.0 = Release|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index e1591b68..c1ff2645 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -41,6 +41,7 @@ + diff --git a/src/aggregator-ruleng/AssemblyInfo.cs b/src/aggregator-ruleng/AssemblyInfo.cs new file mode 100644 index 00000000..61c91eef --- /dev/null +++ b/src/aggregator-ruleng/AssemblyInfo.cs @@ -0,0 +1,15 @@ +using System; +using System.Reflection; + +[assembly: AssemblyCompany("TFS Aggregator Team")] +#if DEBUG +[assembly: AssemblyConfiguration("Debug")] +#else +[assembly: AssemblyConfiguration("Release")] +#endif +[assembly: AssemblyCopyright("TFS Aggregator Team")] +[assembly: AssemblyFileVersion("0.4.0.0")] +[assembly: AssemblyInformationalVersion("0.4.0")] +[assembly: AssemblyProduct("Aggregator CLI")] +[assembly: AssemblyTitle("Aggregator Rule Engine")] +[assembly: AssemblyVersion("0.4.0.0")] diff --git a/src/aggregator-function/Engine/CoreFieldRefNames.cs b/src/aggregator-ruleng/CoreFieldRefNames.cs similarity index 100% rename from src/aggregator-function/Engine/CoreFieldRefNames.cs rename to src/aggregator-ruleng/CoreFieldRefNames.cs diff --git a/src/aggregator-function/Engine/CoreRelationRefNames.cs b/src/aggregator-ruleng/CoreRelationRefNames.cs similarity index 100% rename from src/aggregator-function/Engine/CoreRelationRefNames.cs rename to src/aggregator-ruleng/CoreRelationRefNames.cs diff --git a/src/aggregator-function/Engine/EngineContext.cs b/src/aggregator-ruleng/EngineContext.cs similarity index 75% rename from src/aggregator-function/Engine/EngineContext.cs rename to src/aggregator-ruleng/EngineContext.cs index aacb7798..3cb282c9 100644 --- a/src/aggregator-function/Engine/EngineContext.cs +++ b/src/aggregator-ruleng/EngineContext.cs @@ -8,14 +8,14 @@ namespace aggregator.Engine { public class EngineContext { - internal EngineContext(WorkItemTrackingHttpClient client, IAggregatorLogger logger) + public EngineContext(WorkItemTrackingHttpClientBase client, IAggregatorLogger logger) { Client = client; Logger = logger; Tracker = new Tracker(); } - internal WorkItemTrackingHttpClient Client { get; } + internal WorkItemTrackingHttpClientBase Client { get; } internal IAggregatorLogger Logger { get; } internal Tracker Tracker { get; } } diff --git a/src/aggregator-function/Engine/Globals.cs b/src/aggregator-ruleng/Globals.cs similarity index 100% rename from src/aggregator-function/Engine/Globals.cs rename to src/aggregator-ruleng/Globals.cs diff --git a/src/aggregator-function/ILogger.cs b/src/aggregator-ruleng/IAggregatorLogger.cs similarity index 87% rename from src/aggregator-function/ILogger.cs rename to src/aggregator-ruleng/IAggregatorLogger.cs index 522dc69f..c7214022 100644 --- a/src/aggregator-function/ILogger.cs +++ b/src/aggregator-ruleng/IAggregatorLogger.cs @@ -4,7 +4,7 @@ namespace aggregator { - interface IAggregatorLogger + public interface IAggregatorLogger { void WriteVerbose(string message); diff --git a/src/aggregator-function/Engine/Tracker.cs b/src/aggregator-ruleng/Tracker.cs similarity index 100% rename from src/aggregator-function/Engine/Tracker.cs rename to src/aggregator-ruleng/Tracker.cs diff --git a/src/aggregator-function/Engine/WorkItemId.cs b/src/aggregator-ruleng/WorkItemId.cs similarity index 100% rename from src/aggregator-function/Engine/WorkItemId.cs rename to src/aggregator-ruleng/WorkItemId.cs diff --git a/src/aggregator-function/Engine/WorkItemRelationWrapper.cs b/src/aggregator-ruleng/WorkItemRelationWrapper.cs similarity index 100% rename from src/aggregator-function/Engine/WorkItemRelationWrapper.cs rename to src/aggregator-ruleng/WorkItemRelationWrapper.cs diff --git a/src/aggregator-function/Engine/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs similarity index 100% rename from src/aggregator-function/Engine/WorkItemRelationWrapperCollection.cs rename to src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs diff --git a/src/aggregator-function/Engine/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs similarity index 98% rename from src/aggregator-function/Engine/WorkItemStore.cs rename to src/aggregator-ruleng/WorkItemStore.cs index 3fbb8949..a7369026 100644 --- a/src/aggregator-function/Engine/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -61,7 +61,7 @@ public IList GetWorkItems(IEnumerable return GetWorkItems(ids); } - internal (int created, int updated) SaveChanges() + public (int created, int updated) SaveChanges() { int created = 0; int updated = 0; diff --git a/src/aggregator-function/Engine/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs similarity index 100% rename from src/aggregator-function/Engine/WorkItemWrapper.cs rename to src/aggregator-ruleng/WorkItemWrapper.cs diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj new file mode 100644 index 00000000..9c0495c8 --- /dev/null +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + aggregator.Engine + false + + + + DEBUG;TRACE + + + + + + + + + + From 4675a4559ce77f431a8f03a9eebe042ecab8836d Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 13:05:10 +0100 Subject: [PATCH 04/37] First draft of unit tests for the Rule Engine --- src/aggregator-cli.sln | 14 +- .../aggregator-function.csproj | 2 +- .../FakeWorkItemTrackingHttpClientTests.cs | 23 ++++ .../FakeWorkItemTrackingHttpClient.cs | 124 ++++++++++++++++++ .../FakesNStubs/MockAggregatorLogger.cs | 30 +++++ src/unittests-ruleng/WorkItemStoreTests.cs | 26 ++++ src/unittests-ruleng/unittests-ruleng.csproj | 21 +++ 7 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs create mode 100644 src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs create mode 100644 src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs create mode 100644 src/unittests-ruleng/WorkItemStoreTests.cs create mode 100644 src/unittests-ruleng/unittests-ruleng.csproj diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index ffb96c18..b34337c0 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -23,6 +23,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{1A84F5C1-A ..\doc\rule-examples.md = ..\doc\rule-examples.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "unittests-ruleng", "unittests-ruleng\unittests-ruleng.csproj", "{19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {B024DF56-E474-4A1A-BEA9-03A87F6D00F3}.Debug|Any CPU.Build.0 = Debug|Any CPU {B024DF56-E474-4A1A-BEA9-03A87F6D00F3}.Release|Any CPU.ActiveCfg = Release|Any CPU {B024DF56-E474-4A1A-BEA9-03A87F6D00F3}.Release|Any CPU.Build.0 = Release|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.Build.0 = Release|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -49,10 +55,10 @@ Global {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.ActiveCfg = Release|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.Build.0 = Release|Any CPU - {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {87B7E8EE-7C3B-450A-A319-18E2BA7D35D4}.Release|Any CPU.Build.0 = Release|Any CPU + {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index c1ff2645..1433eca0 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs new file mode 100644 index 00000000..17daabae --- /dev/null +++ b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs @@ -0,0 +1,23 @@ +using System; +using aggregator.unittests; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Xunit; + +namespace unittests_ruleng +{ + public class FakeWorkItemTrackingHttpClientTests + { + const string baseUrl = "https://dev.azure.com/fake-account/fake-project"; + + [Fact] + public void GetWorkItem_ById_Succeeds() + { + var sut = new FakeWorkItemTrackingHttpClient(new Uri(baseUrl), null); + + var wi = sut.GetWorkItemAsync(42, expand: WorkItemExpand.All).Result; + + Assert.NotNull(wi); + Assert.Equal(42, wi.Id); + } + } +} diff --git a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs new file mode 100644 index 00000000..205b5afb --- /dev/null +++ b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs @@ -0,0 +1,124 @@ +using Microsoft.TeamFoundation.WorkItemTracking.WebApi; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.Common; +using Microsoft.VisualStudio.Services.WebApi.Patch.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace aggregator.unittests +{ + internal class FakeWorkItemTrackingHttpClient : WorkItemTrackingHttpClientBase + { + + public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) + : base(baseUrl, credentials) + { + } + + public override Task GetWorkItemAsync(int id, IEnumerable fields = null, DateTime? asOf = null, WorkItemExpand? expand = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + if (expand == null) + { + throw new ArgumentNullException(nameof(expand)); + } + if (expand != WorkItemExpand.All) + { + throw new ArgumentException("Must be WorkItemExpand.All", nameof(expand)); + } + var t = new Task(() => new WorkItem() + { + Id = id, + Fields = new Dictionary() + { + { "System.WorkItemType", "Bug" }, + { "System.State", "Open" }, + { "System.TeamProject", "MyProject" } + }, + Rev = 99, + Relations = new List() + { + new WorkItemRelation + { + Rel = "System.LinkTypes.Hierarchy-Reverse", + Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/33" + }, + new WorkItemRelation + { + Rel = "System.LinkTypes.Hierarchy-Forward", + Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/77" + }, + new WorkItemRelation + { + Rel = "System.LinkTypes.Hierarchy-Forward", + Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/78" + } + } + }); + t.RunSynchronously(); + return t; + } + + public override Task GetWorkItemTypeAsync(Guid project, string type, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + var t = new Task(() => new WorkItemType() + { + Name = "Bug", + IsDisabled = false, + FieldInstances = new List() + { + new WorkItemTypeFieldInstance() + { + ReferenceName = "System.State", + IsIdentity = false, + AlwaysRequired = true + // MISSING: Data type! + } + }, + Transitions = new Dictionary() + { + { + "New", (new WorkItemStateTransition[] { + new WorkItemStateTransition() + { + To = "Active", Actions = new string[] { "" } + } + }) + }, + { + "Active", (new WorkItemStateTransition[] { + new WorkItemStateTransition() + { + To = "Closed", Actions = new string[] { "" } + } + }) + } + } + }); + t.RunSynchronously(); + return t; + } + + public override Task QueryByWiqlAsync(Wiql wiql, Guid project, bool? timePrecision = null, int? top = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + var t = new Task(() => new WorkItemQueryResult() { + QueryType = QueryType.Flat, + WorkItems = new List() + { + new WorkItemReference() + { + Id = 33, + Url = $"{BaseAddress.AbsoluteUri}/{project}/_apis/wit/workItems/33" + } + } + }); + + t.RunSynchronously(); + return t; + } + } +} diff --git a/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs b/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs new file mode 100644 index 00000000..aa5cb315 --- /dev/null +++ b/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text; +using aggregator; + +namespace aggregator.unittests +{ + class MockAggregatorLogger : IAggregatorLogger + { + public void WriteError(string message) + { + //no-op + } + + public void WriteInfo(string message) + { + //no-op + } + + public void WriteVerbose(string message) + { + //no-op + } + + public void WriteWarning(string message) + { + //no-op + } + } +} diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs new file mode 100644 index 00000000..e595bd31 --- /dev/null +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -0,0 +1,26 @@ +using System; +using aggregator.Engine; +using aggregator.unittests; +using Xunit; + +namespace unittests_ruleng +{ + public class WorkItemStoreTests + { + + [Fact] + public void GetWorkItem_ById_Succeeds() + { + const string baseUrl = "https://dev.azure.com/fake-account/fake-project"; + var client = new FakeWorkItemTrackingHttpClient(new Uri(baseUrl), null); + var logger = new MockAggregatorLogger(); + var context = new EngineContext(client, logger); + var sut = new WorkItemStore(context); + + var wi = sut.GetWorkItem(42); + + Assert.NotNull(wi); + Assert.Equal(42, wi.Id.Value); + } + } +} diff --git a/src/unittests-ruleng/unittests-ruleng.csproj b/src/unittests-ruleng/unittests-ruleng.csproj new file mode 100644 index 00000000..3c8387af --- /dev/null +++ b/src/unittests-ruleng/unittests-ruleng.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.1 + unittests_ruleng + + false + + + + + + + + + + + + + + From f58f58e2f91ff4eb562d740ac212438355c92e5b Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 14:22:58 +0100 Subject: [PATCH 05/37] Changed string from VSTS to Azure DevOps --- README.md | 26 +++++++++---------- src/aggregator-cli/ContextBuilder.cs | 4 +-- .../Instances/AggregatorInstances.cs | 12 ++++----- .../Instances/UninstallInstanceCommand.cs | 2 +- src/aggregator-cli/Logon/LogonVstsCommand.cs | 8 +++--- .../Mappings/AggregatorMappings.cs | 4 +-- .../Mappings/ListMappingsCommand.cs | 2 +- src/aggregator-cli/Mappings/MapRuleCommand.cs | 6 ++--- .../Mappings/UnmapRuleCommand.cs | 4 +-- .../AzureFunctionHandler.cs | 2 +- src/aggregator-function/RuleWrapper.cs | 10 +++---- 11 files changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index c7eb93c3..d97b62d1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is the successor to TFS Aggregator. The current Server Plugin version (2.x) will be maintained to support TFS. The Web Service flavor will be discontinued in favor of this (its deployment and configuration was too complex for most users). -The main scenario for Aggregator (3.x) is supporting VSTS and the cloud scenario. It will work for TFS as long as it is reachable from Internet. +The main scenario for Aggregator (3.x) is supporting Azure DevOps and the cloud scenario. It will work for TFS as long as it is reachable from Internet. > **This is an early version (alpha)**: we might change verbs and rule language before the final release! @@ -20,13 +20,13 @@ The main scenario for Aggregator (3.x) is supporting VSTS and the cloud scenario - Support for Deployment Slots for blue/green-style deployments - OAuth support to avoid maintain access tokens -- Additional VSTS events -- Additional VSTS objects +- Additional Azure DevOps events +- Additional Azure DevOps objects ## How it works An Aggregator Instance is an Azure Function Application in its own Resource Group, -sharing the same VSTS credential. You can have only one Application per Resource Group. +sharing the same Azure DevOps credential. You can have only one Application per Resource Group. If the Resource Group does not exists, Aggregator will try to create it. *Note*: The Instance name must be **unique** amongst all Aggregator Instances in Azure! @@ -36,20 +36,20 @@ To work, it uses an Aggregator Runtime. Aggregator checks its latest GitHub Release to ensure that Aggregator Runtime is up-to-date before uploading the function. *Note*: We use [Azure Functions Runtime](https://docs.microsoft.com/en-us/azure/azure-functions/functions-versions) 2.0 for C# which is still in Preview. -An Aggregator Mapping is a VSTS Service Hook for a specific work item event that invokes an Aggregator Rule i.e. the Azure Function hosting the Rule code. VSTS saves the Azure Function Key in the Service Hook configuration. +An Aggregator Mapping is a Azure DevOps Service Hook for a specific work item event that invokes an Aggregator Rule i.e. the Azure Function hosting the Rule code. Azure DevOps saves the Azure Function Key in the Service Hook configuration. -You can deploy the same Rule in many Instances or map the same VSTS event to many Rules: it is up how to organize. +You can deploy the same Rule in many Instances or map the same Azure DevOps event to many Rules: it is up how to organize. ## Authentication You must instruct Aggregator which credential to use. -To do this, run the `login.azure` and `login.vsts` commands. +To do this, run the `login.azure` and `login.ado` commands. -To create the credentials, you need an Azure Service Principal and a VSTS Personal Access Token. +To create the credentials, you need an Azure Service Principal and a Azure DevOps Personal Access Token. These documents will guide you * [Use portal to create an Azure Active Directory application and service principal that can access resources](https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal) -* [Create personal access tokens to authenticate access](https://docs.microsoft.com/en-us/vsts/organizations/accounts/use-personal-access-tokens-to-authenticate?view=vsts#create-personal-access-tokens-to-authenticate-access). +* [Create personal access tokens to authenticate access](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate). Logon credentials are stored locally and expire after 2 hours. @@ -71,7 +71,7 @@ To run Aggregator use Verb | Use --------------------|---------------------------------------- logon.azure | Logon into Azure. -logon.vsts | Logon into Visual Studio Team Services. +logon.ado | Logon into Azure DevOps. list.instances | Lists Aggregator instances. install.instance | Creates a new Aggregator instance in Azure. uninstall.instance | Destroy an Aggregator instance in Azure. @@ -79,9 +79,9 @@ list.rules | List the rule in existing Aggregator instance in Azure. add.rule | Add a rule to existing Aggregator instance in Azure. remove.rule | Remove a rule from existing Aggregator instance in Azure. configure.rule | Change a rule configuration and code. -list.mappings | Lists mappings from existing VSTS Projects to Aggregator Rules. -map.rule | Maps an Aggregator Rule to existing VSTS Projects. -unmap.rule | Unmaps an Aggregator Rule from a VSTS Project. +list.mappings | Lists mappings from existing Azure DevOps Projects to Aggregator Rules. +map.rule | Maps an Aggregator Rule to existing Azure DevOps Projects. +unmap.rule | Unmaps an Aggregator Rule from a Azure DevOps Project. help | Display more information on a specific command. version | Display version information. diff --git a/src/aggregator-cli/ContextBuilder.cs b/src/aggregator-cli/ContextBuilder.cs index 89544384..a5b8c924 100644 --- a/src/aggregator-cli/ContextBuilder.cs +++ b/src/aggregator-cli/ContextBuilder.cs @@ -59,12 +59,12 @@ internal async Task Build() if (vstsLogon) { - logger.WriteVerbose($"Authenticating to VSTS..."); + logger.WriteVerbose($"Authenticating to Azure DevOps..."); var (connection, reason) = VstsLogon.Load(); if (reason != LogonResult.Succeeded) { string msg = TranslateResult(reason); - throw new ApplicationException(string.Format(msg, "VSTS", "logon.vsts")); + throw new ApplicationException(string.Format(msg, "Azure DevOps", "logon.ado")); } vsts = await connection.LogonAsync(); logger.WriteInfo($"Connected to {vsts.Uri.Host}"); diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 77f33886..116d2882 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -125,13 +125,13 @@ await azure.ResourceGroups var vstsLogonData = VstsLogon.Load().connection; if (vstsLogonData.Mode == VstsTokenType.PAT) { - logger.WriteVerbose($"Saving VSTS token"); + logger.WriteVerbose($"Saving Azure DevOps token"); ok = await ChangeAppSettings(instance, vstsLogonData); - logger.WriteInfo($"VSTS token saved"); + logger.WriteInfo($"Azure DevOps token saved"); } else { - logger.WriteWarning($"VSTS token type {vstsLogonData.Mode} is unsupported"); + logger.WriteWarning($"Azure DevOps token type {vstsLogonData.Mode} is unsupported"); ok = false; } } @@ -178,13 +178,13 @@ internal async Task SetAuthentication(InstanceName instance, string locati var vstsLogonData = VstsLogon.Load().connection; if (vstsLogonData.Mode == VstsTokenType.PAT) { - logger.WriteVerbose($"Saving VSTS token"); + logger.WriteVerbose($"Saving Azure DevOps token"); ok = await ChangeAppSettings(instance, vstsLogonData); - logger.WriteInfo($"VSTS token saved"); + logger.WriteInfo($"Azure DevOps token saved"); } else { - logger.WriteWarning($"VSTS token type {vstsLogonData.Mode} is unsupported"); + logger.WriteWarning($"Azure DevOps token type {vstsLogonData.Mode} is unsupported"); ok = false; } return ok; diff --git a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs index e70dc157..ae30f2c4 100644 --- a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs @@ -15,7 +15,7 @@ class UninstallInstanceCommand : CommandBase [Option('l', "location", Required = true, HelpText = "Aggregator instance location (Azure region).")] public string Location { get; set; } - [Option('m', "dont-remove-mappings", Required = false, HelpText = "Do not remove mappings from VSTS (default is to remove them).")] + [Option('m', "dont-remove-mappings", Required = false, HelpText = "Do not remove mappings from Azure DevOps (default is to remove them).")] public bool Mappings { get; set; } internal override async Task RunAsync() diff --git a/src/aggregator-cli/Logon/LogonVstsCommand.cs b/src/aggregator-cli/Logon/LogonVstsCommand.cs index 8929dbb0..a2684d8a 100644 --- a/src/aggregator-cli/Logon/LogonVstsCommand.cs +++ b/src/aggregator-cli/Logon/LogonVstsCommand.cs @@ -7,7 +7,7 @@ namespace aggregator.cli { - [Verb("logon.vsts", HelpText = "Logon into Visual Studio Team Services.")] + [Verb("logon.ado", HelpText = "Logon into Azure DevOps.")] class LogonVstsCommand : CommandBase { [Option('u', "url", Required = true, HelpText = "Account/server URL, e.g. myaccount.visualstudio.com .")] @@ -16,7 +16,7 @@ class LogonVstsCommand : CommandBase [Option('m', "mode", Required = true, HelpText = "Logon mode (valid modes: PAT).")] public VstsTokenType Mode { get; set; } - [Option('t', "token", SetName = "PAT", HelpText = "VSTS Personal Authentication Token.")] + [Option('t', "token", SetName = "PAT", HelpText = "Azure DevOps Personal Authentication Token.")] public string Token { get; set; } internal override async Task RunAsync() @@ -32,11 +32,11 @@ internal override async Task RunAsync() }; string path = data.Save(); // now check for validity - context.Logger.WriteInfo($"Connecting to VSTS using {Mode} credential..."); + context.Logger.WriteInfo($"Connecting to Azure DevOps using {Mode} credential..."); var vsts = await data.LogonAsync(); if (vsts == null) { - context.Logger.WriteError("Invalid VSTS credentials"); + context.Logger.WriteError("Invalid Azure DevOps credentials"); return 2; } return 0; diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index ab683c01..31843f73 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -26,7 +26,7 @@ public AggregatorMappings(VssConnection vsts, IAzure azure, ILogger logger) internal async Task> ListAsync(InstanceName instance) { - logger.WriteVerbose($"Searching aggregator mappings in VSTS..."); + logger.WriteVerbose($"Searching aggregator mappings in Azure DevOps..."); var serviceHooksClient = vsts.GetClient(); var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(); var filteredSubs = subscriptions.Where(s @@ -145,7 +145,7 @@ internal async Task RemoveRuleAsync(InstanceName instance, string rule) internal async Task RemoveRuleEventAsync(string @event, InstanceName instance, string rule) { - logger.WriteInfo($"Querying the VSTS subscriptions for rule(s) {instance.PlainName}/{rule}"); + logger.WriteInfo($"Querying the Azure DevOps subscriptions for rule(s) {instance.PlainName}/{rule}"); var serviceHooksClient = vsts.GetClient(); var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(VstsEvents.PublisherId); var ruleSubs = subscriptions diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index 0ef81f43..2556d082 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -7,7 +7,7 @@ namespace aggregator.cli { - [Verb("list.mappings", HelpText = "Lists mappings from existing VSTS Projects to Aggregator Rules.")] + [Verb("list.mappings", HelpText = "Lists mappings from existing Azure DevOps Projects to Aggregator Rules.")] class ListMappingsCommand : CommandBase { [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] diff --git a/src/aggregator-cli/Mappings/MapRuleCommand.cs b/src/aggregator-cli/Mappings/MapRuleCommand.cs index c83fec1f..d9c7be41 100644 --- a/src/aggregator-cli/Mappings/MapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/MapRuleCommand.cs @@ -6,13 +6,13 @@ namespace aggregator.cli { - [Verb("map.rule", HelpText = "Maps an Aggregator Rule to existing VSTS Projects.")] + [Verb("map.rule", HelpText = "Maps an Aggregator Rule to existing Azure DevOps Projects.")] class MapRuleCommand : CommandBase { - [Option('p', "project", Required = true, HelpText = "VSTS project name.")] + [Option('p', "project", Required = true, HelpText = "Azure DevOps project name.")] public string Project { get; set; } - [Option('e', "event", Required = true, HelpText = "VSTS event.")] + [Option('e', "event", Required = true, HelpText = "Azure DevOps event.")] public string Event { get; set; } [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] diff --git a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs index 5c5a0f8a..91769efb 100644 --- a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs @@ -6,10 +6,10 @@ namespace aggregator.cli { - [Verb("unmap.rule", HelpText = "Unmaps an Aggregator Rule from a VSTS Project.")] + [Verb("unmap.rule", HelpText = "Unmaps an Aggregator Rule from a Azure DevOps Project.")] class UnmapRuleCommand : CommandBase { - [Option('e', "event", Required = true, HelpText = "VSTS event.")] + [Option('e', "event", Required = true, HelpText = "Azure DevOps event.")] public string Event { get; set; } [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index 26d23ed9..389a17df 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -62,7 +62,7 @@ public async Task Run(HttpRequestMessage req) { return req.CreateResponse(HttpStatusCode.BadRequest, new { - error = "Not a good VSTS post..." + error = "Not a good Azure DevOps post..." }); } diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index d1ef143d..194c45f2 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -43,19 +43,19 @@ internal async Task Execute(string aggregatorVersion, dynamic data) string eventType = data.eventType; int workItemId = (eventType != "workitem.updated") ? data.resource.id : data.resource.workItemId; - logger.WriteVerbose($"Connecting to VSTS using {configuration.VstsTokenType}..."); + logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.VstsTokenType}..."); var clientCredentials = default(VssCredentials); if (configuration.VstsTokenType == VstsTokenType.PAT) { clientCredentials = new VssBasicCredential(configuration.VstsTokenType.ToString(), configuration.VstsToken); } else { - logger.WriteError($"VSTS Token type {configuration.VstsTokenType} not supported!"); + logger.WriteError($"Azure DevOps Token type {configuration.VstsTokenType} not supported!"); throw new ArgumentOutOfRangeException(nameof(configuration.VstsTokenType)); } var vsts = new VssConnection(new Uri(collectionUrl), clientCredentials); await vsts.ConnectAsync(); - logger.WriteInfo($"Connected to VSTS"); + logger.WriteInfo($"Connected to Azure DevOps"); var witClient = vsts.GetClient(); var context = new Engine.EngineContext(witClient, logger); var store = new Engine.WorkItemStore(context); @@ -112,11 +112,11 @@ internal async Task Execute(string aggregatorVersion, dynamic data) var saveRes = store.SaveChanges(); if (saveRes.created + saveRes.updated > 0) { - logger.WriteInfo($"Changes saved to VSTS: {saveRes.created} created, {saveRes.updated} updated."); + logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); } else { - logger.WriteInfo($"No changes saved to VSTS."); + logger.WriteInfo($"No changes saved to Azure DevOps."); } return result.ReturnValue; From d6c95c9a87ee5830daeaec864185d5a8b49a4899 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 14:41:05 +0100 Subject: [PATCH 06/37] Renamed Vsts to DevOps in code --- doc/command-examples.md | 8 ++--- doc/rule-examples.md | 2 +- src/aggregator-cli/ContextBuilder.cs | 24 +++++++-------- .../Instances/AggregatorInstances.cs | 22 +++++++------- .../Instances/ConfigureInstanceCommand.cs | 2 +- .../Instances/InstallInstanceCommand.cs | 2 +- .../Instances/UninstallInstanceCommand.cs | 4 +-- .../Logon/{VstsLogon.cs => DevOpsLogon.cs} | 12 ++++---- ...onVstsCommand.cs => LogonDevOpsCommand.cs} | 10 +++---- .../Mappings/AggregatorMappings.cs | 30 +++++++++---------- .../Mappings/ListMappingsCommand.cs | 4 +-- src/aggregator-cli/Mappings/MapRuleCommand.cs | 6 ++-- .../Mappings/UnmapRuleCommand.cs | 4 +-- src/aggregator-cli/Program.cs | 6 ++-- src/aggregator-cli/Rules/RemoveRuleCommand.cs | 4 +-- .../AzureFunctionHandler.cs | 4 +-- src/aggregator-function/RuleWrapper.cs | 16 +++++----- .../AggregatorConfiguration.cs | 16 +++++----- src/aggregator-shared/VstsEvents.cs | 2 +- .../FakeWorkItemTrackingHttpClientTests.cs | 5 ++-- src/unittests-ruleng/WorkItemStoreTests.cs | 4 +-- 21 files changed, 93 insertions(+), 94 deletions(-) rename src/aggregator-cli/Logon/{VstsLogon.cs => DevOpsLogon.cs} (78%) rename src/aggregator-cli/Logon/{LogonVstsCommand.cs => LogonDevOpsCommand.cs} (84%) diff --git a/doc/command-examples.md b/doc/command-examples.md index e48f2ac1..2c3b2e05 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -5,7 +5,7 @@ Remember that the Instance name must be unique in Azure. ``` # logon logon.azure --subscription 9c********08 --client 5a********b6 --password P@assword1 --tenant 3c********1d -logon.vsts --url https://someaccount.visualstudio.com --mode PAT --token 2**************************************q +logon.ado --url https://someaccount.visualstudio.com --mode PAT --token 2**************************************q # create an Azure Function Application install.instance --verbose --name my1 --location westeurope @@ -17,7 +17,7 @@ add.rule --verbose --instance my1 --name test2 --file test\test2.rule add.rule --verbose --instance my1 --name test3 --file test\test3.rule list.rules --verbose --instance my1 -# adds two Service Hook to VSTS, each invoking a different rule +# adds two Service Hook to Azure DevOps, each invoking a different rule map.rule --verbose --project SampleProject --event workitem.created --instance my1 --rule test1 map.rule --verbose --project SampleProject --event workitem.updated --instance my1 --rule test2 list.mappings --verbose --instance my1 @@ -29,10 +29,10 @@ configure.rule --verbose --instance my1 --name test1 --enable # update the code of a rule configure.rule --verbose --instance my1 --name test --update test.rule -# updates the VSTS credential stored by the rules +# updates the Azure DevOps credential stored by the rules configure.instance --authentication -# remove a Service Hook from VSTS +# remove a Service Hook from Azure DevOps unmap.rule --verbose --event workitem.created --instance my1 --rule test1 # deletes two Azure Functions diff --git a/doc/rule-examples.md b/doc/rule-examples.md index e369a01a..6290ce9a 100644 --- a/doc/rule-examples.md +++ b/doc/rule-examples.md @@ -12,7 +12,7 @@ $"Hello { self.WorkItemType } #{ self.Id } - { self.Title }!" This is more similar to classic TFS Aggregator. It move a parent work item to Closed state, if all children are closed. -The major difference is the navigation: `Parent` and `Children` properties do not returns work items but relation. You have to explicitly query VSTS to retrieve the referenced work items. +The major difference is the navigation: `Parent` and `Children` properties do not returns work items but relation. You have to explicitly query Azure DevOps to retrieve the referenced work items. ``` string message = ""; diff --git a/src/aggregator-cli/ContextBuilder.cs b/src/aggregator-cli/ContextBuilder.cs index a5b8c924..c834536c 100644 --- a/src/aggregator-cli/ContextBuilder.cs +++ b/src/aggregator-cli/ContextBuilder.cs @@ -12,12 +12,12 @@ internal class CommandContext { internal ILogger Logger { get; private set; } internal IAzure Azure { get; private set; } - internal VssConnection Vsts { get; private set; } - internal CommandContext(ILogger logger, IAzure azure, VssConnection vsts) + internal VssConnection Devops { get; private set; } + internal CommandContext(ILogger logger, IAzure azure, VssConnection devops) { Logger = logger; Azure = azure; - Vsts = vsts; + Devops = devops; } } @@ -25,7 +25,7 @@ internal class ContextBuilder { ILogger logger; bool azureLogon = false; - bool vstsLogon = false; + bool devopsLogon = false; internal ContextBuilder(ILogger logger) => this.logger = logger; @@ -34,15 +34,15 @@ internal ContextBuilder WithAzureLogon() azureLogon = true; return this; } - internal ContextBuilder WithVstsLogon() + internal ContextBuilder WithDevOpsLogon() { - vstsLogon = true; + devopsLogon = true; return this; } internal async Task Build() { IAzure azure = null; - VssConnection vsts = null; + VssConnection devops = null; if (azureLogon) { @@ -57,20 +57,20 @@ internal async Task Build() logger.WriteInfo($"Connected to subscription {azure.SubscriptionId}"); } - if (vstsLogon) + if (devopsLogon) { logger.WriteVerbose($"Authenticating to Azure DevOps..."); - var (connection, reason) = VstsLogon.Load(); + var (connection, reason) = DevOpsLogon.Load(); if (reason != LogonResult.Succeeded) { string msg = TranslateResult(reason); throw new ApplicationException(string.Format(msg, "Azure DevOps", "logon.ado")); } - vsts = await connection.LogonAsync(); - logger.WriteInfo($"Connected to {vsts.Uri.Host}"); + devops = await connection.LogonAsync(); + logger.WriteInfo($"Connected to {devops.Uri.Host}"); } - return new CommandContext(logger, azure, vsts); + return new CommandContext(logger, azure, devops); } private string TranslateResult(LogonResult reason) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 116d2882..7dae11ca 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -122,23 +122,23 @@ await azure.ResourceGroups bool ok = await package.UpdateVersion(requiredVersion, instance, azure); if (ok) { - var vstsLogonData = VstsLogon.Load().connection; - if (vstsLogonData.Mode == VstsTokenType.PAT) + var devopsLogonData = DevOpsLogon.Load().connection; + if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, vstsLogonData); + ok = await ChangeAppSettings(instance, devopsLogonData); logger.WriteInfo($"Azure DevOps token saved"); } else { - logger.WriteWarning($"Azure DevOps token type {vstsLogonData.Mode} is unsupported"); + logger.WriteWarning($"Azure DevOps token type {devopsLogonData.Mode} is unsupported"); ok = false; } } return ok; } - internal async Task ChangeAppSettings(InstanceName instance, VstsLogon vstsLogonData) + internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon devopsLogonData) { var webFunctionApp = await azure .AppServices @@ -148,8 +148,8 @@ internal async Task ChangeAppSettings(InstanceName instance, VstsLogon vst instance.FunctionAppName); var configuration = new AggregatorConfiguration { - VstsTokenType = vstsLogonData.Mode, - VstsToken = vstsLogonData.Token + DevOpsTokenType = devopsLogonData.Mode, + DevOpsToken = devopsLogonData.Token }; configuration.Write(webFunctionApp); return true; @@ -175,16 +175,16 @@ internal async Task Remove(InstanceName instance, string location) internal async Task SetAuthentication(InstanceName instance, string location) { bool ok; - var vstsLogonData = VstsLogon.Load().connection; - if (vstsLogonData.Mode == VstsTokenType.PAT) + var devopsLogonData = DevOpsLogon.Load().connection; + if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, vstsLogonData); + ok = await ChangeAppSettings(instance, devopsLogonData); logger.WriteInfo($"Azure DevOps token saved"); } else { - logger.WriteWarning($"Azure DevOps token type {vstsLogonData.Mode} is unsupported"); + logger.WriteWarning($"Azure DevOps token type {devopsLogonData.Mode} is unsupported"); ok = false; } return ok; diff --git a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs index c6ee339a..56a94ef4 100644 --- a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs +++ b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs @@ -24,7 +24,7 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() // need the token, so we can save it in the app settings + .WithDevOpsLogon() // need the token, so we can save it in the app settings .Build(); var instances = new AggregatorInstances(context.Azure, context.Logger); var instance = new InstanceName(Name); diff --git a/src/aggregator-cli/Instances/InstallInstanceCommand.cs b/src/aggregator-cli/Instances/InstallInstanceCommand.cs index 2bb684f6..751943b8 100644 --- a/src/aggregator-cli/Instances/InstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/InstallInstanceCommand.cs @@ -22,7 +22,7 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() // need the token, so we can save it in the app settings + .WithDevOpsLogon() // need the token, so we can save it in the app settings .Build(); var instances = new AggregatorInstances(context.Azure, context.Logger); var instance = new InstanceName(Name); diff --git a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs index ae30f2c4..b6c2d44f 100644 --- a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs @@ -22,7 +22,7 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() + .WithDevOpsLogon() .Build(); var instance = new InstanceName(Name); @@ -30,7 +30,7 @@ internal override async Task RunAsync() bool ok; if (!Mappings) { - var mappings = new AggregatorMappings(context.Vsts, context.Azure, context.Logger); + var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); ok = await mappings.RemoveInstanceAsync(instance); } diff --git a/src/aggregator-cli/Logon/VstsLogon.cs b/src/aggregator-cli/Logon/DevOpsLogon.cs similarity index 78% rename from src/aggregator-cli/Logon/VstsLogon.cs rename to src/aggregator-cli/Logon/DevOpsLogon.cs index c2327acd..035d7706 100644 --- a/src/aggregator-cli/Logon/VstsLogon.cs +++ b/src/aggregator-cli/Logon/DevOpsLogon.cs @@ -8,12 +8,12 @@ namespace aggregator.cli { - class VstsLogon : LogonDataBase + class DevOpsLogon : LogonDataBase { private static readonly string LogonDataTag = "davs"; public string Url { get; set; } - public VstsTokenType Mode { get; set; } + public DevOpsTokenType Mode { get; set; } public string Token { get; set; } public string Save() @@ -21,9 +21,9 @@ public string Save() return new LogonDataStore(LogonDataTag).Save(this); } - public static (VstsLogon connection, LogonResult reason) Load() + public static (DevOpsLogon connection, LogonResult reason) Load() { - var result = new LogonDataStore(LogonDataTag).Load(); + var result = new LogonDataStore(LogonDataTag).Load(); return (result.connection, result.reason); } @@ -32,10 +32,10 @@ public async Task LogonAsync() var clientCredentials = default(VssCredentials); switch (Mode) { - case VstsTokenType.Integrated: + case DevOpsTokenType.Integrated: clientCredentials = new VssCredentials(); break; - case VstsTokenType.PAT: + case DevOpsTokenType.PAT: clientCredentials=new VssBasicCredential("pat", Token); break; default: diff --git a/src/aggregator-cli/Logon/LogonVstsCommand.cs b/src/aggregator-cli/Logon/LogonDevOpsCommand.cs similarity index 84% rename from src/aggregator-cli/Logon/LogonVstsCommand.cs rename to src/aggregator-cli/Logon/LogonDevOpsCommand.cs index a2684d8a..4705ea9c 100644 --- a/src/aggregator-cli/Logon/LogonVstsCommand.cs +++ b/src/aggregator-cli/Logon/LogonDevOpsCommand.cs @@ -8,13 +8,13 @@ namespace aggregator.cli { [Verb("logon.ado", HelpText = "Logon into Azure DevOps.")] - class LogonVstsCommand : CommandBase + class LogonDevOpsCommand : CommandBase { [Option('u', "url", Required = true, HelpText = "Account/server URL, e.g. myaccount.visualstudio.com .")] public string Url { get; set; } [Option('m', "mode", Required = true, HelpText = "Logon mode (valid modes: PAT).")] - public VstsTokenType Mode { get; set; } + public DevOpsTokenType Mode { get; set; } [Option('t', "token", SetName = "PAT", HelpText = "Azure DevOps Personal Authentication Token.")] public string Token { get; set; } @@ -24,7 +24,7 @@ internal override async Task RunAsync() var context = await Context.Build(); - var data = new VstsLogon() + var data = new DevOpsLogon() { Url = this.Url, Mode = this.Mode, @@ -33,8 +33,8 @@ internal override async Task RunAsync() string path = data.Save(); // now check for validity context.Logger.WriteInfo($"Connecting to Azure DevOps using {Mode} credential..."); - var vsts = await data.LogonAsync(); - if (vsts == null) + var devops = await data.LogonAsync(); + if (devops == null) { context.Logger.WriteError("Invalid Azure DevOps credentials"); return 2; diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index 31843f73..fb64c667 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -13,13 +13,13 @@ namespace aggregator.cli { internal class AggregatorMappings { - private VssConnection vsts; + private VssConnection devops; private readonly IAzure azure; private readonly ILogger logger; - public AggregatorMappings(VssConnection vsts, IAzure azure, ILogger logger) + public AggregatorMappings(VssConnection devops, IAzure azure, ILogger logger) { - this.vsts = vsts; + this.devops = devops; this.azure = azure; this.logger = logger; } @@ -27,14 +27,14 @@ public AggregatorMappings(VssConnection vsts, IAzure azure, ILogger logger) internal async Task> ListAsync(InstanceName instance) { logger.WriteVerbose($"Searching aggregator mappings in Azure DevOps..."); - var serviceHooksClient = vsts.GetClient(); + var serviceHooksClient = devops.GetClient(); var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(); var filteredSubs = subscriptions.Where(s - => s.PublisherId == VstsEvents.PublisherId + => s.PublisherId == DevOpsEvents.PublisherId && s.ConsumerInputs["url"].ToString().StartsWith( instance.FunctionAppUrl) ); - var projectClient = vsts.GetClient(); + var projectClient = devops.GetClient(); var projects = await projectClient.GetProjects(); var projectsDict = projects.ToDictionary(p => p.Id); @@ -57,8 +57,8 @@ public AggregatorMappings(VssConnection vsts, IAzure azure, ILogger logger) internal async Task Add(string projectName, string @event, InstanceName instance, string ruleName) { - logger.WriteVerbose($"Reading VSTS project data..."); - var projectClient = vsts.GetClient(); + logger.WriteVerbose($"Reading Azure DevOps project data..."); + var projectClient = devops.GetClient(); var project = await projectClient.GetProject(projectName); logger.WriteInfo($"Project {projectName} data read."); @@ -67,11 +67,11 @@ internal async Task Add(string projectName, string @event, InstanceName in (string ruleUrl, string ruleKey) = await rules.GetInvocationUrlAndKey(instance, ruleName); logger.WriteInfo($"{ruleName} Function Key retrieved."); - var serviceHooksClient = vsts.GetClient(); + var serviceHooksClient = devops.GetClient(); // check if the subscription already exists and bail out var query = new SubscriptionsQuery { - PublisherId = VstsEvents.PublisherId, + PublisherId = DevOpsEvents.PublisherId, ConsumerInputFilters = new InputFilter[] { new InputFilter { Conditions = new List { @@ -93,7 +93,7 @@ internal async Task Add(string projectName, string @event, InstanceName in return Guid.Empty; } - // see https://docs.microsoft.com/en-us/vsts/service-hooks/consumers?toc=%2Fvsts%2Fintegrate%2Ftoc.json&bc=%2Fvsts%2Fintegrate%2Fbreadcrumb%2Ftoc.json&view=vsts#web-hooks + // see https://docs.microsoft.com/en-us/azure/devops/service-hooks/consumers?toc=%2Fvsts%2Fintegrate%2Ftoc.json&bc=%2Fvsts%2Fintegrate%2Fbreadcrumb%2Ftoc.json&view=vsts#web-hooks var subscriptionParameters = new Subscription() { ConsumerId = "webHooks", @@ -108,12 +108,12 @@ internal async Task Add(string projectName, string @event, InstanceName in { "detailedMessagesToSend", "none" }, }, EventType = @event, - PublisherId = VstsEvents.PublisherId, + PublisherId = DevOpsEvents.PublisherId, PublisherInputs = new Dictionary { { "projectId", project.Id.ToString() }, /* TODO consider offering additional filters using the following - { "tfsSubscriptionId", vsts.ServerId }, + { "tfsSubscriptionId", devops.ServerId }, { "teamId", null }, // Filter events to include only work items under the specified area path. { "areaPath", null }, @@ -146,8 +146,8 @@ internal async Task RemoveRuleAsync(InstanceName instance, string rule) internal async Task RemoveRuleEventAsync(string @event, InstanceName instance, string rule) { logger.WriteInfo($"Querying the Azure DevOps subscriptions for rule(s) {instance.PlainName}/{rule}"); - var serviceHooksClient = vsts.GetClient(); - var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(VstsEvents.PublisherId); + var serviceHooksClient = devops.GetClient(); + var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(DevOpsEvents.PublisherId); var ruleSubs = subscriptions // TODO can we trust this equality? // && s.ActionDescription == $"To host {instance.DnsHostName}" diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index 2556d082..c8d48d5d 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -16,10 +16,10 @@ class ListMappingsCommand : CommandBase internal override async Task RunAsync() { var context = await Context - .WithVstsLogon() + .WithDevOpsLogon() .Build(); var instance = new InstanceName(Instance); - var mappings = new AggregatorMappings(context.Vsts, /*HACK*/null, context.Logger); + var mappings = new AggregatorMappings(context.Devops, /*HACK*/null, context.Logger); bool any = false; foreach (var item in await mappings.ListAsync(instance)) { diff --git a/src/aggregator-cli/Mappings/MapRuleCommand.cs b/src/aggregator-cli/Mappings/MapRuleCommand.cs index d9c7be41..51cdb3f8 100644 --- a/src/aggregator-cli/Mappings/MapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/MapRuleCommand.cs @@ -25,10 +25,10 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() + .WithDevOpsLogon() .Build(); - var mappings = new AggregatorMappings(context.Vsts, context.Azure, context.Logger); - bool ok = VstsEvents.IsValidEvent(Event); + var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); + bool ok = DevOpsEvents.IsValidEvent(Event); if (!ok) { context.Logger.WriteError($"Invalid event type."); diff --git a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs index 91769efb..98a823c4 100644 --- a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs @@ -22,10 +22,10 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() + .WithDevOpsLogon() .Build(); var instance = new InstanceName(Instance); - var mappings = new AggregatorMappings(context.Vsts, context.Azure, context.Logger); + var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleEventAsync(Event, instance, Rule); return ok ? 0 : 1; } diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index b251414a..c66abc63 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -7,7 +7,7 @@ namespace aggregator.cli /* Ideas for verbs and options: - logon.vsts --url URL --mode MODE --token TOKEN --slot SLOT + logon.ado --url URL --mode MODE --token TOKEN --slot SLOT to use different credentials configure.instance --slot SLOT --swap --avzone ZONE add a deployment slot with the option to specify an availability zone, the swap option will set the new slot as primary @@ -32,7 +32,7 @@ static int Main(string[] args) settings.CaseInsensitiveEnumValues = true; }); var parserResult = parser.ParseArguments(args, - typeof(LogonAzureCommand), typeof(LogonVstsCommand), + typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), typeof(ConfigureRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) @@ -40,7 +40,7 @@ static int Main(string[] args) int rc = -1; parserResult .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) + .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) diff --git a/src/aggregator-cli/Rules/RemoveRuleCommand.cs b/src/aggregator-cli/Rules/RemoveRuleCommand.cs index 59735b46..cf76c17b 100644 --- a/src/aggregator-cli/Rules/RemoveRuleCommand.cs +++ b/src/aggregator-cli/Rules/RemoveRuleCommand.cs @@ -19,10 +19,10 @@ internal override async Task RunAsync() { var context = await Context .WithAzureLogon() - .WithVstsLogon() + .WithDevOpsLogon() .Build(); var instance = new InstanceName(Instance); - var mappings = new AggregatorMappings(context.Vsts, context.Azure, context.Logger); + var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleAsync(instance, Name); var rules = new AggregatorRules(context.Azure, context.Logger); diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index 389a17df..bc960b09 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -57,8 +57,8 @@ public async Task Run(HttpRequestMessage req) string eventType = data.eventType; // sanity check - if (!VstsEvents.IsValidEvent(eventType) - || data.publisherId != VstsEvents.PublisherId) + if (!DevOpsEvents.IsValidEvent(eventType) + || data.publisherId != DevOpsEvents.PublisherId) { return req.CreateResponse(HttpStatusCode.BadRequest, new { diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index 194c45f2..8535f2c4 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -43,20 +43,20 @@ internal async Task Execute(string aggregatorVersion, dynamic data) string eventType = data.eventType; int workItemId = (eventType != "workitem.updated") ? data.resource.id : data.resource.workItemId; - logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.VstsTokenType}..."); + logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); var clientCredentials = default(VssCredentials); - if (configuration.VstsTokenType == VstsTokenType.PAT) + if (configuration.DevOpsTokenType == DevOpsTokenType.PAT) { - clientCredentials = new VssBasicCredential(configuration.VstsTokenType.ToString(), configuration.VstsToken); + clientCredentials = new VssBasicCredential(configuration.DevOpsTokenType.ToString(), configuration.DevOpsToken); } else { - logger.WriteError($"Azure DevOps Token type {configuration.VstsTokenType} not supported!"); - throw new ArgumentOutOfRangeException(nameof(configuration.VstsTokenType)); + logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); + throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); } - var vsts = new VssConnection(new Uri(collectionUrl), clientCredentials); - await vsts.ConnectAsync(); + var devops = new VssConnection(new Uri(collectionUrl), clientCredentials); + await devops.ConnectAsync(); logger.WriteInfo($"Connected to Azure DevOps"); - var witClient = vsts.GetClient(); + var witClient = devops.GetClient(); var context = new Engine.EngineContext(witClient, logger); var store = new Engine.WorkItemStore(context); var self = store.GetWorkItem(workItemId); diff --git a/src/aggregator-shared/AggregatorConfiguration.cs b/src/aggregator-shared/AggregatorConfiguration.cs index f6ee274f..31bba5c3 100644 --- a/src/aggregator-shared/AggregatorConfiguration.cs +++ b/src/aggregator-shared/AggregatorConfiguration.cs @@ -2,7 +2,7 @@ namespace aggregator { - public enum VstsTokenType + public enum DevOpsTokenType { Integrated = 0, PAT = 1, @@ -18,9 +18,9 @@ public AggregatorConfiguration() {} static public AggregatorConfiguration Read(Microsoft.Extensions.Configuration.IConfiguration config) { var ac = new AggregatorConfiguration(); - Enum.TryParse(config["Aggregator_VstsTokenType"], out VstsTokenType vtt); - ac.VstsTokenType = vtt; - ac.VstsToken = config["Aggregator_VstsToken"]; + Enum.TryParse(config["Aggregator_VstsTokenType"], out DevOpsTokenType vtt); + ac.DevOpsTokenType = vtt; + ac.DevOpsToken = config["Aggregator_VstsToken"]; return ac; } @@ -28,12 +28,12 @@ public void Write(Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) { webApp .Update() - .WithAppSetting("Aggregator_VstsTokenType", VstsTokenType.ToString()) - .WithAppSetting("Aggregator_VstsToken", VstsToken) + .WithAppSetting("Aggregator_VstsTokenType", DevOpsTokenType.ToString()) + .WithAppSetting("Aggregator_VstsToken", DevOpsToken) .Apply(); } - public VstsTokenType VstsTokenType { get; set; } - public string VstsToken { get; set; } + public DevOpsTokenType DevOpsTokenType { get; set; } + public string DevOpsToken { get; set; } } } diff --git a/src/aggregator-shared/VstsEvents.cs b/src/aggregator-shared/VstsEvents.cs index 095b1454..761e7595 100644 --- a/src/aggregator-shared/VstsEvents.cs +++ b/src/aggregator-shared/VstsEvents.cs @@ -8,7 +8,7 @@ namespace aggregator /// /// This class tracks the VSTS/AzureDevOps Events exposed both in CLI and Rules /// - public class VstsEvents + public class DevOpsEvents { // TODO this table should be visible in the help static string[] validValues = new string[] { diff --git a/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs index 17daabae..b4033aac 100644 --- a/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs +++ b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs @@ -7,12 +7,11 @@ namespace unittests_ruleng { public class FakeWorkItemTrackingHttpClientTests { - const string baseUrl = "https://dev.azure.com/fake-account/fake-project"; - [Fact] public void GetWorkItem_ById_Succeeds() { - var sut = new FakeWorkItemTrackingHttpClient(new Uri(baseUrl), null); + var baseUrl = new Uri("https://dev.azure.com/fake-account/fake-project"); + var sut = new FakeWorkItemTrackingHttpClient(baseUrl, null); var wi = sut.GetWorkItemAsync(42, expand: WorkItemExpand.All).Result; diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index e595bd31..089bd7e3 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -11,8 +11,8 @@ public class WorkItemStoreTests [Fact] public void GetWorkItem_ById_Succeeds() { - const string baseUrl = "https://dev.azure.com/fake-account/fake-project"; - var client = new FakeWorkItemTrackingHttpClient(new Uri(baseUrl), null); + var baseUrl = new Uri("https://dev.azure.com/fake-account/fake-project"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); var context = new EngineContext(client, logger); var sut = new WorkItemStore(context); From e2cf538577b8803bff2e65b3c4a5f723e59c0890 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 27 Oct 2018 23:13:40 +0100 Subject: [PATCH 07/37] Refactored so most of the Execute code is now in the RuleEngine class --- src/aggregator-function/RuleWrapper.cs | 78 +++-------- .../aggregator-function.csproj | 1 - src/aggregator-ruleng/RuleEngine.cs | 81 ++++++++++++ src/aggregator-ruleng/Tracker.cs | 6 +- .../aggregator-ruleng.csproj | 2 + .../FakeWorkItemTrackingHttpClientTests.cs | 11 +- .../FakeWorkItemTrackingHttpClient.cs | 125 ++++++++++++++---- .../FakesNStubs/MockAggregatorLogger.cs | 18 ++- src/unittests-ruleng/RuleTests.cs | 78 +++++++++++ src/unittests-ruleng/WorkItemStoreTests.cs | 20 ++- 10 files changed, 322 insertions(+), 98 deletions(-) create mode 100644 src/aggregator-ruleng/RuleEngine.cs create mode 100644 src/unittests-ruleng/RuleTests.cs diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index 8535f2c4..9290720b 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -53,73 +53,27 @@ internal async Task Execute(string aggregatorVersion, dynamic data) logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); } - var devops = new VssConnection(new Uri(collectionUrl), clientCredentials); - await devops.ConnectAsync(); - logger.WriteInfo($"Connected to Azure DevOps"); - var witClient = devops.GetClient(); - var context = new Engine.EngineContext(witClient, logger); - var store = new Engine.WorkItemStore(context); - var self = store.GetWorkItem(workItemId); - logger.WriteInfo($"Initial WorkItem {workItemId} retrieved from {collectionUrl}"); - - string ruleFilePath = Path.Combine(functionDirectory, $"{ruleName}.rule"); - if (!File.Exists(ruleFilePath)) + using (var devops = new VssConnection(new Uri(collectionUrl), clientCredentials)) { - logger.WriteError($"Rule code not found at {ruleFilePath}"); - return "Rule file not found!"; - } - - logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - string ruleCode = File.ReadAllText(ruleFilePath); - - logger.WriteInfo($"Executing Rule..."); - var globals = new Engine.Globals { - self = self, - store = store - }; + await devops.ConnectAsync(); + logger.WriteInfo($"Connected to Azure DevOps"); + using (var witClient = devops.GetClient()) + { + string ruleFilePath = Path.Combine(functionDirectory, $"{ruleName}.rule"); + if (!File.Exists(ruleFilePath)) + { + logger.WriteError($"Rule code not found at {ruleFilePath}"); + return "Rule file not found!"; + } - var types = new List() { - typeof(object), - typeof(System.Linq.Enumerable), - typeof(System.Collections.Generic.CollectionExtensions) - }; - var references = types.ConvertAll(t => t.Assembly).Distinct(); + logger.WriteVerbose($"Rule code found at {ruleFilePath}"); + string ruleCode = File.ReadAllText(ruleFilePath); - var scriptOptions = ScriptOptions.Default - .WithEmitDebugInformation(true) - .WithReferences(references) - // Add namespaces - .WithImports("System","System.Linq","System.Collections.Generic"); - var roslynScript = CSharpScript.Create( - code: ruleCode, - options: scriptOptions, - globalsType: typeof(Engine.Globals)); - var result = await roslynScript.RunAsync(globals); - if (result.Exception != null) - { - logger.WriteError($"Rule failed with {result.Exception}"); - } - else if(result.ReturnValue != null) - { - logger.WriteInfo($"Rule succeeded with {result.ReturnValue}"); - } - else - { - logger.WriteInfo($"Rule succeeded, no return value"); - } + var engine = new Engine.RuleEngine(logger, ruleCode); - logger.WriteVerbose($"Post-execution, save any change..."); - var saveRes = store.SaveChanges(); - if (saveRes.created + saveRes.updated > 0) - { - logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); + return await engine.ExecuteAsync(collectionUrl, workItemId, witClient); + } } - else - { - logger.WriteInfo($"No changes saved to Azure DevOps."); - } - - return result.ReturnValue; } } } diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index 1433eca0..ae0036e8 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -9,7 +9,6 @@ DEBUG;TRACE - diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs new file mode 100644 index 00000000..0c42743e --- /dev/null +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi; + +namespace aggregator.Engine +{ + public class RuleEngine + { + private readonly IAggregatorLogger logger; + private readonly Script roslynScript; + + public RuleEngine(IAggregatorLogger logger, string ruleCode) + { + this.logger = logger; + + var types = new List() { + typeof(object), + typeof(System.Linq.Enumerable), + typeof(System.Collections.Generic.CollectionExtensions) + }; + var references = types.ConvertAll(t => t.Assembly).Distinct(); + + var scriptOptions = ScriptOptions.Default + .WithEmitDebugInformation(true) + .WithReferences(references) + // Add namespaces + .WithImports("System", "System.Linq", "System.Collections.Generic"); + this.roslynScript = CSharpScript.Create( + code: ruleCode, + options: scriptOptions, + globalsType: typeof(Globals)); + } + + public async Task ExecuteAsync(string collectionUrl, int workItemId, WorkItemTrackingHttpClientBase witClient) + { + var context = new EngineContext(witClient, logger); + var store = new WorkItemStore(context); + var self = store.GetWorkItem(workItemId); + logger.WriteInfo($"Initial WorkItem {workItemId} retrieved from {collectionUrl}"); + + var globals = new Globals + { + self = self, + store = store + }; + + logger.WriteInfo($"Executing Rule..."); + var result = await roslynScript.RunAsync(globals); + if (result.Exception != null) + { + logger.WriteError($"Rule failed with {result.Exception}"); + } + else if (result.ReturnValue != null) + { + logger.WriteInfo($"Rule succeeded with {result.ReturnValue}"); + } + else + { + logger.WriteInfo($"Rule succeeded, no return value"); + } + + logger.WriteVerbose($"Post-execution, save any change..."); + var saveRes = store.SaveChanges(); + if (saveRes.created + saveRes.updated > 0) + { + logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); + } + else + { + logger.WriteInfo($"No changes saved to Azure DevOps."); + } + + return result.ReturnValue; + } + } +} diff --git a/src/aggregator-ruleng/Tracker.cs b/src/aggregator-ruleng/Tracker.cs index aafba88c..8538f9d8 100644 --- a/src/aggregator-ruleng/Tracker.cs +++ b/src/aggregator-ruleng/Tracker.cs @@ -71,7 +71,11 @@ internal IList LoadWorkItems( .GroupBy(k => tracked.ContainsKey(k)) .ToDictionary(g => g.Key, g => g.ToList()); - var inMemory = tracked.Where(w => groups[true].Contains(w.Key)).Select(w => w.Value.Current); + var inMemory = tracked + .Where(w => groups.ContainsKey(true) + ? groups[true].Contains(w.Key) + : false) + .Select(w => w.Value.Current); var loaded = loader(groups[false].Select(k => k.Value)); return inMemory.Union(loaded).ToList(); diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index 9c0495c8..f2cc7855 100644 --- a/src/aggregator-ruleng/aggregator-ruleng.csproj +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -11,6 +11,8 @@ + + diff --git a/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs index b4033aac..9f55eeb3 100644 --- a/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs +++ b/src/unittests-ruleng/FakeWorkItemTrackingHttpClientTests.cs @@ -11,12 +11,13 @@ public class FakeWorkItemTrackingHttpClientTests public void GetWorkItem_ById_Succeeds() { var baseUrl = new Uri("https://dev.azure.com/fake-account/fake-project"); - var sut = new FakeWorkItemTrackingHttpClient(baseUrl, null); + using (var sut = new FakeWorkItemTrackingHttpClient(baseUrl, null)) + { + var wi = sut.GetWorkItemAsync(42, expand: WorkItemExpand.All).Result; - var wi = sut.GetWorkItemAsync(42, expand: WorkItemExpand.All).Result; - - Assert.NotNull(wi); - Assert.Equal(42, wi.Id); + Assert.NotNull(wi); + Assert.Equal(42, wi.Id); + } } } } diff --git a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs index 205b5afb..57a4aaa3 100644 --- a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs +++ b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs @@ -4,6 +4,7 @@ using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Text; @@ -14,50 +15,128 @@ namespace aggregator.unittests { internal class FakeWorkItemTrackingHttpClient : WorkItemTrackingHttpClientBase { + Dictionary> workItemFactories = new Dictionary>(); public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) : base(baseUrl, credentials) { - } - - public override Task GetWorkItemAsync(int id, IEnumerable fields = null, DateTime? asOf = null, WorkItemExpand? expand = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) - { - if (expand == null) - { - throw new ArgumentNullException(nameof(expand)); - } - if (expand != WorkItemExpand.All) - { - throw new ArgumentException("Must be WorkItemExpand.All", nameof(expand)); - } - var t = new Task(() => new WorkItem() + string vase = baseUrl.AbsoluteUri; + workItemFactories.Add(1, () => new WorkItem() { - Id = id, + Id = 1, Fields = new Dictionary() { - { "System.WorkItemType", "Bug" }, + { "System.WorkItemType", "User Story" }, { "System.State", "Open" }, - { "System.TeamProject", "MyProject" } + { "System.TeamProject", "example-project" }, + { "System.Title", "Hello" }, }, - Rev = 99, + Rev = 12, Relations = new List() { new WorkItemRelation { - Rel = "System.LinkTypes.Hierarchy-Reverse", - Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/33" + Rel = "System.LinkTypes.Hierarchy-Forward", + Url = $"{vase}/example-project/_apis/wit/workItems/42" }, new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Forward", - Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/77" - }, + Url = $"{vase}/example-project/_apis/wit/workItems/99" + } + } + }); + + workItemFactories.Add(42, () => new WorkItem() + { + Id = 42, + Fields = new Dictionary() + { + { "System.WorkItemType", "Bug" }, + { "System.State", "Open" }, + { "System.TeamProject", "example-project" }, + { "System.Title", "Hello" }, + }, + Rev = 3, + Relations = new List() + { new WorkItemRelation { - Rel = "System.LinkTypes.Hierarchy-Forward", - Url = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems/78" + Rel = "System.LinkTypes.Hierarchy-Reverse", + Url = $"{vase}/example-project/_apis/wit/workItems/1" + } + } + }); + + workItemFactories.Add(99, () => new WorkItem() + { + Id = 99, + Fields = new Dictionary() + { + { "System.WorkItemType", "Bug" }, + { "System.State", "Open" }, + { "System.TeamProject", "example-project" }, + { "System.Title", "Hello" }, + }, + Rev = 3, + Relations = new List() + { + new WorkItemRelation + { + Rel = "System.LinkTypes.Hierarchy-Reverse", + Url = $"{vase}/example-project/_apis/wit/workItems/1" + } + } + }); + } + + public override Task GetWorkItemAsync(int id, IEnumerable fields = null, DateTime? asOf = null, WorkItemExpand? expand = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + Debug.WriteLine($"FakeWorkItemTrackingHttpClient.GetWorkItemAsync({id})"); + if (expand == null) + { + throw new ArgumentNullException(nameof(expand)); + } + if (expand != WorkItemExpand.All) + { + throw new ArgumentException("Must be WorkItemExpand.All", nameof(expand)); + } + + if (workItemFactories.ContainsKey(id)) + { + var t = new Task(workItemFactories[id]); + t.RunSynchronously(); + return t; + } else + { + return null; + } + } + + public override Task> GetWorkItemsAsync(IEnumerable ids, IEnumerable fields = null, DateTime? asOf = null, WorkItemExpand? expand = null, WorkItemErrorPolicy? errorPolicy = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + string sid = ids.Aggregate(string.Empty, (s, i) => s + "," + i.ToString()); + Debug.WriteLine($"FakeWorkItemTrackingHttpClient.GetWorkItemsAsync({sid})"); + if (expand == null) + { + throw new ArgumentNullException(nameof(expand)); + } + if (expand != WorkItemExpand.All) + { + throw new ArgumentException("Must be WorkItemExpand.All", nameof(expand)); + } + + var t = new Task>(() => { + var result = new List(); + foreach (var id in ids) + { + if (workItemFactories.ContainsKey(id)) + { + var wi = workItemFactories[id](); + result.Add(wi); } } + return result; }); t.RunSynchronously(); return t; diff --git a/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs b/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs index aa5cb315..cca705c0 100644 --- a/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs +++ b/src/unittests-ruleng/FakesNStubs/MockAggregatorLogger.cs @@ -5,26 +5,36 @@ namespace aggregator.unittests { + class MockLogEntry + { + internal string Level { get; set; } + internal string Message { get; set; } + } + class MockAggregatorLogger : IAggregatorLogger { + List log = new List(10); + public void WriteError(string message) { - //no-op + log.Add(new MockLogEntry { Level = "Error", Message = message }); } public void WriteInfo(string message) { - //no-op + log.Add(new MockLogEntry { Level = "Info", Message = message }); } public void WriteVerbose(string message) { - //no-op + log.Add(new MockLogEntry { Level = "Verbose", Message = message }); } public void WriteWarning(string message) { - //no-op + log.Add(new MockLogEntry { Level = "Warning", Message = message }); } + + public MockLogEntry[] GetMessages() => log.ToArray(); } } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs new file mode 100644 index 00000000..d8b0d4ea --- /dev/null +++ b/src/unittests-ruleng/RuleTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Text; +using aggregator.Engine; +using aggregator.unittests; +using Xunit; + +namespace unittests_ruleng +{ + public class RuleTests + { + [Fact] + public async void HelloWorldRule_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-account"; + var baseUrl = new Uri($"{collectionUrl}/fake-project"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 42; + string ruleCode = @" +return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; +"; + + var engine = new RuleEngine(logger, ruleCode); + string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + + Assert.Equal("Hello Bug #42 - Hello!", result); + } + + [Fact] + public async void Parent_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-account"; + var baseUrl = new Uri(collectionUrl); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 42; + string ruleCode = @" +string message = """"; +var parent = self.Parent; +if (parent != null) +{ + message = $""Parent is {parent.Id}""; +} +return message; +"; + + var engine = new RuleEngine(logger, ruleCode); + string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + + Assert.Equal("Parent is 1", result); + } + + [Fact] + public async void Children_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-account"; + var baseUrl = new Uri(collectionUrl); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 1; + string ruleCode = @" +string message = ""Children are ""; +var parent = self; +var children = parent.Children; +foreach (var child in children) { + message += $"",{child.Id}""; +} +return message; +"; + + var engine = new RuleEngine(logger, ruleCode); + string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + + Assert.Equal("Children are ,42,99", result); + } + } +} diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 089bd7e3..af76f7f6 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -7,11 +7,10 @@ namespace unittests_ruleng { public class WorkItemStoreTests { - [Fact] public void GetWorkItem_ById_Succeeds() { - var baseUrl = new Uri("https://dev.azure.com/fake-account/fake-project"); + var baseUrl = new Uri("https://dev.azure.com/fake-account"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); var context = new EngineContext(client, logger); @@ -22,5 +21,22 @@ public void GetWorkItem_ById_Succeeds() Assert.NotNull(wi); Assert.Equal(42, wi.Id.Value); } + + [Fact] + public void GetWorkItems_ByIds_Succeeds() + { + var baseUrl = new Uri("https://dev.azure.com/fake-account"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + var context = new EngineContext(client, logger); + var sut = new WorkItemStore(context); + + var wis = sut.GetWorkItems(new int[] { 42, 99 }); + + Assert.NotEmpty(wis); + Assert.Equal(2, wis.Count); + Assert.Contains(wis, (x) => x.Id.Value == 42); + Assert.Contains(wis, (x) => x.Id.Value == 99); + } } } From 7decc2da62a9f07ceac0c3c6c8042f8ea1562238 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Mon, 29 Oct 2018 22:09:24 +0000 Subject: [PATCH 08/37] work for NewWorkItem --- README.md | 4 +- src/aggregator-cli.sln | 3 +- .../Instances/AggregatorInstances.cs | 2 +- .../Mappings/ListMappingsCommand.cs | 3 +- .../AzureFunctionHandler.cs | 17 +++- src/aggregator-function/ForwarderLogger.cs | 3 + src/aggregator-function/RuleWrapper.cs | 13 ++- src/aggregator-function/test/test.rule | 29 +++---- src/aggregator-ruleng/DirectivesParser.cs | 85 ++++++++++++++++++ src/aggregator-ruleng/EngineContext.cs | 4 +- src/aggregator-ruleng/RuleEngine.cs | 71 +++++++++++---- src/aggregator-ruleng/Tracker.cs | 8 +- src/aggregator-ruleng/WorkItemId.cs | 6 +- .../WorkItemRelationWrapper.cs | 2 +- .../WorkItemRelationWrapperCollection.cs | 7 +- src/aggregator-ruleng/WorkItemStore.cs | 27 ++++-- src/aggregator-ruleng/WorkItemWrapper.cs | 57 ++++++------ src/rule-language.md | 78 +++++++++++++++++ .../FakeWorkItemTrackingHttpClient.cs | 60 +++++++++---- src/unittests-ruleng/RuleTests.cs | 87 ++++++++++++++----- src/unittests-ruleng/WorkItemStoreTests.cs | 57 +++++++++++- 21 files changed, 492 insertions(+), 131 deletions(-) create mode 100644 src/aggregator-ruleng/DirectivesParser.cs create mode 100644 src/rule-language.md diff --git a/README.md b/README.md index d97b62d1..e7f98f72 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # aggregator-cli -![](https://tfsaggregator.visualstudio.com/_apis/public/build/definitions/1cca877b-3e26-4880-b5b8-79e4b10fbfb4/16/badge) +[![Build status: master](https://dev.azure.com/TfsAggregator/Aggregator3/_apis/build/status/Aggregator3-CI?branchName=master)](https://dev.azure.com/TfsAggregator/Aggregator3/_build/latest?definitionId=16) +[![Build status](https://dev.azure.com/TfsAggregator/Aggregator3/_apis/build/status/Aggregator3-CI)](https://dev.azure.com/TfsAggregator/Aggregator3/_build/latest?definitionId=16) + This is the successor to TFS Aggregator. The current Server Plugin version (2.x) will be maintained to support TFS. diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index b34337c0..a2a41e72 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -21,9 +21,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{1A84F5C1-A ..\doc\log-streaming-from-azure-portal.png = ..\doc\log-streaming-from-azure-portal.png ..\README.md = ..\README.md ..\doc\rule-examples.md = ..\doc\rule-examples.md + rule-language.md = rule-language.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "unittests-ruleng", "unittests-ruleng\unittests-ruleng.csproj", "{19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-ruleng", "unittests-ruleng\unittests-ruleng.csproj", "{19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 7dae11ca..5236c9ed 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -71,7 +71,7 @@ await azure.ResourceGroups logger.WriteInfo($"Resource group {rgName} created."); } - // TODO the template should create a Storage account and/or a Key Vault + // IDEA the template should create a Storage account and/or a Key Vault var resourceName = "aggregator.cli.Instances.instance-template.json"; string armTemplateString; var assembly = Assembly.GetExecutingAssembly(); diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index c8d48d5d..7f18fa99 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -19,7 +19,8 @@ internal override async Task RunAsync() .WithDevOpsLogon() .Build(); var instance = new InstanceName(Instance); - var mappings = new AggregatorMappings(context.Devops, /*HACK*/null, context.Logger); + // HACK we pass null as the next calls do not use the Azure connection + var mappings = new AggregatorMappings(context.Devops, null, context.Logger); bool any = false; foreach (var item in await mappings.ListAsync(instance)) { diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index bc960b09..de66630f 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; +using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -28,13 +29,12 @@ public async Task Run(HttpRequestMessage req) { log.LogDebug($"Context: {context.InvocationId} {context.FunctionName} {context.FunctionDirectory} {context.FunctionAppDirectory}"); - // TODO check expected version - string aggregatorVersion = null; + var aggregatorVersion = GetCustomAttribute()?.InformationalVersion; try { string rule = context.FunctionName; - log.LogInformation($"Welcome to rule '{rule}'"); + log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{rule}'"); } catch (Exception ex) { @@ -77,7 +77,7 @@ public async Task Run(HttpRequestMessage req) var wrapper = new RuleWrapper(configuration, logger, context.FunctionName, context.FunctionDirectory); try { - string execResult = await wrapper.Execute(aggregatorVersion, data); + string execResult = await wrapper.Execute(data); if (string.IsNullOrEmpty(execResult)) { @@ -106,5 +106,14 @@ public async Task Run(HttpRequestMessage req) return resp; } } + + private static T GetCustomAttribute() + where T : Attribute + { + return System.Reflection.Assembly + .GetExecutingAssembly() + .GetCustomAttributes(typeof(T), false) + .FirstOrDefault() as T; + } } } diff --git a/src/aggregator-function/ForwarderLogger.cs b/src/aggregator-function/ForwarderLogger.cs index 8f9cfcc9..aaf0b4ea 100644 --- a/src/aggregator-function/ForwarderLogger.cs +++ b/src/aggregator-function/ForwarderLogger.cs @@ -3,6 +3,9 @@ namespace aggregator { + /// + /// Forwards to Azure Function logging subsystem + /// internal class ForwarderLogger : IAggregatorLogger { private Microsoft.Extensions.Logging.ILogger log; diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index 9290720b..97c6b4d5 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -32,16 +32,12 @@ public RuleWrapper(AggregatorConfiguration configuration, IAggregatorLogger logg this.functionDirectory = functionDirectory; } - internal async Task Execute(string aggregatorVersion, dynamic data) + internal async Task Execute(dynamic data) { - if (string.IsNullOrEmpty(aggregatorVersion)) - { - aggregatorVersion = "0.1"; - } - string collectionUrl = data.resourceContainers.collection.baseUrl; string eventType = data.eventType; int workItemId = (eventType != "workitem.updated") ? data.resource.id : data.resource.workItemId; + Guid teamProject = data.resourceContainers.project.id; logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); var clientCredentials = default(VssCredentials); @@ -53,6 +49,7 @@ internal async Task Execute(string aggregatorVersion, dynamic data) logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); } + using (var devops = new VssConnection(new Uri(collectionUrl), clientCredentials)) { await devops.ConnectAsync(); @@ -67,11 +64,11 @@ internal async Task Execute(string aggregatorVersion, dynamic data) } logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - string ruleCode = File.ReadAllText(ruleFilePath); + string[] ruleCode = File.ReadAllLines(ruleFilePath); var engine = new Engine.RuleEngine(logger, ruleCode); - return await engine.ExecuteAsync(collectionUrl, workItemId, witClient); + return await engine.ExecuteAsync(collectionUrl, teamProject, workItemId, witClient); } } } diff --git a/src/aggregator-function/test/test.rule b/src/aggregator-function/test/test.rule index 0045cd73..db3f6454 100644 --- a/src/aggregator-function/test/test.rule +++ b/src/aggregator-function/test/test.rule @@ -1,17 +1,10 @@ -/* -if (self.Parent != null) -{ - var parent = store.GetWorkItem(self.Parent); - var children = store.GetWorkItems(parent.Children); - if (children.All(c => c.State == "Closed")) - { - parent.State = "Closed"; - } - parent.Description = parent.Description + " aggregator was here."; -} -return $"Hello { self.WorkItemType } #{ self.Id } - { self.Title }!"; +.lang=C# + +/* return self.PreviousRevision.PreviousRevision.Description; */ + +/* if (self.ParentLink != null) { var parent = self.Parent; @@ -19,11 +12,15 @@ if (self.ParentLink != null) if (children.All(c => c.State == "Closed")) { parent.State = "Closed"; - return "Had to close parent"; + return "Had to close parent"; } else { - return "Not all children are closed"; - } + return "Not all children are closed"; + } //parent.Description = parent.Description + " aggregator was here."; } else { - return "No parent!"; + return "No parent!"; } +*/ + +var newChild = store.NewWorkItem("Task"); +newChild.Title = "Brand new"; diff --git a/src/aggregator-ruleng/DirectivesParser.cs b/src/aggregator-ruleng/DirectivesParser.cs new file mode 100644 index 00000000..baf8d778 --- /dev/null +++ b/src/aggregator-ruleng/DirectivesParser.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace aggregator.Engine +{ + /// + /// Scan the top lines of a script looking for directives, whose lines start with a . + /// + internal class DirectivesParser + { + int firstCodeLine = 0; + private readonly IAggregatorLogger logger; + string[] ruleCode; + + internal DirectivesParser(IAggregatorLogger logger, string[] ruleCode) + { + this.ruleCode = ruleCode; + this.logger = logger; + + //defaults + Language = Languages.Csharp; + } + + /// + /// Grab directives + /// + internal bool Parse() + { + while (firstCodeLine < ruleCode.Length + && ruleCode[firstCodeLine].Length > 0 + && ruleCode[firstCodeLine][0] == '.') + { + string directive = ruleCode[firstCodeLine].Substring(1); + var parts = directive.Split('='); + + switch (parts[0].ToLowerInvariant()) + { + case "lang": + case "language": + if (parts.Length < 2) + { + logger.WriteWarning($"Unrecognized directive {directive}"); + return false; + } + else + { + switch (parts[1].ToUpperInvariant()) + { + case "C#": + case "CS": + case "CSHARP": + Language = Languages.Csharp; + break; + default: + logger.WriteWarning($"Unrecognized language {parts[1]}"); + return false; + } + } + break; + default: + logger.WriteWarning($"Unrecognized directive {directive}"); + return false; + }//switch + + firstCodeLine++; + }//while + return true; + } + + internal string GetRuleCode() + { + return string.Join(Environment.NewLine, ruleCode, firstCodeLine, ruleCode.Length - firstCodeLine); + } + + // directives + + internal enum Languages + { + Csharp + } + + internal Languages Language { get; private set; } + } +} diff --git a/src/aggregator-ruleng/EngineContext.cs b/src/aggregator-ruleng/EngineContext.cs index 3cb282c9..1b60e1f1 100644 --- a/src/aggregator-ruleng/EngineContext.cs +++ b/src/aggregator-ruleng/EngineContext.cs @@ -8,13 +8,15 @@ namespace aggregator.Engine { public class EngineContext { - public EngineContext(WorkItemTrackingHttpClientBase client, IAggregatorLogger logger) + public EngineContext(WorkItemTrackingHttpClientBase client, Guid projectId, IAggregatorLogger logger) { Client = client; Logger = logger; Tracker = new Tracker(); + ProjectId = projectId; } + public Guid ProjectId { get; internal set; } internal WorkItemTrackingHttpClientBase Client { get; } internal IAggregatorLogger Logger { get; } internal Tracker Tracker { get; } diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index 0c42743e..ea5b27f7 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CSharp.Scripting; @@ -9,36 +8,74 @@ namespace aggregator.Engine { + public enum EngineState + { + Unknown, + Success, + Error + } + + /// + /// Entry point to execute rules, indipendent of environment + /// public class RuleEngine { private readonly IAggregatorLogger logger; private readonly Script roslynScript; - public RuleEngine(IAggregatorLogger logger, string ruleCode) + public RuleEngine(IAggregatorLogger logger, string[] ruleCode) { + State = EngineState.Unknown; + this.logger = logger; - var types = new List() { + var directives = new DirectivesParser(logger, ruleCode); + if (!directives.Parse()) + { + State = EngineState.Error; + return; + } + + if (directives.Language == DirectivesParser.Languages.Csharp) + { + var types = new List() { typeof(object), typeof(System.Linq.Enumerable), typeof(System.Collections.Generic.CollectionExtensions) }; - var references = types.ConvertAll(t => t.Assembly).Distinct(); + var references = types.ConvertAll(t => t.Assembly).Distinct(); + + var scriptOptions = ScriptOptions.Default + .WithEmitDebugInformation(true) + .WithReferences(references) + // Add namespaces + .WithImports("System", "System.Linq", "System.Collections.Generic"); - var scriptOptions = ScriptOptions.Default - .WithEmitDebugInformation(true) - .WithReferences(references) - // Add namespaces - .WithImports("System", "System.Linq", "System.Collections.Generic"); - this.roslynScript = CSharpScript.Create( - code: ruleCode, - options: scriptOptions, - globalsType: typeof(Globals)); + this.roslynScript = CSharpScript.Create( + code: directives.GetRuleCode(), + options: scriptOptions, + globalsType: typeof(Globals)); + } + else + { + logger.WriteError($"Cannot execute rule: language is not supported."); + State = EngineState.Error; + } } - public async Task ExecuteAsync(string collectionUrl, int workItemId, WorkItemTrackingHttpClientBase witClient) + /// + /// State is used by unit tests + /// + public EngineState State { get; private set; } + + public async Task ExecuteAsync(string collectionUrl, Guid projectId, int workItemId, WorkItemTrackingHttpClientBase witClient) { - var context = new EngineContext(witClient, logger); + if (State == EngineState.Error) + { + return string.Empty; + } + + var context = new EngineContext(witClient, projectId, logger); var store = new WorkItemStore(context); var self = store.GetWorkItem(workItemId); logger.WriteInfo($"Initial WorkItem {workItemId} retrieved from {collectionUrl}"); @@ -54,6 +91,7 @@ public async Task ExecuteAsync(string collectionUrl, int workItemId, Wor if (result.Exception != null) { logger.WriteError($"Rule failed with {result.Exception}"); + State = EngineState.Error; } else if (result.ReturnValue != null) { @@ -63,9 +101,10 @@ public async Task ExecuteAsync(string collectionUrl, int workItemId, Wor { logger.WriteInfo($"Rule succeeded, no return value"); } + State = EngineState.Success; logger.WriteVerbose($"Post-execution, save any change..."); - var saveRes = store.SaveChanges(); + var saveRes = await store.SaveChanges(); if (saveRes.created + saveRes.updated > 0) { logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); diff --git a/src/aggregator-ruleng/Tracker.cs b/src/aggregator-ruleng/Tracker.cs index 8538f9d8..1b82d202 100644 --- a/src/aggregator-ruleng/Tracker.cs +++ b/src/aggregator-ruleng/Tracker.cs @@ -22,12 +22,18 @@ internal TrackedWrapper(int id, WorkItemWrapper wrapper) } } + private int watermark = -1; IDictionary, TrackedWrapper> tracked = new Dictionary, TrackedWrapper>(); internal Tracker() { } + internal int GetNextWatermark() + { + return watermark--; + } + internal void TrackExisting(WorkItemWrapper workItemWrapper) { if (IsTracked(workItemWrapper)) @@ -88,7 +94,7 @@ internal bool IsTracked(WorkItemWrapper workItemWrapper) internal IEnumerable NewWorkItems => tracked - .Where(w => !w.Value.Current.IsReadOnly && w.Value.Current.IsDirty && w.Value.Current.IsNew) + .Where(w => !w.Value.Current.IsReadOnly && w.Value.Current.IsNew) .Select(w=>w.Value.Current); internal IEnumerable ChangedWorkItems diff --git a/src/aggregator-ruleng/WorkItemId.cs b/src/aggregator-ruleng/WorkItemId.cs index 90fadc93..dfc88ae1 100644 --- a/src/aggregator-ruleng/WorkItemId.cs +++ b/src/aggregator-ruleng/WorkItemId.cs @@ -6,10 +6,8 @@ namespace aggregator.Engine { public class TemporaryWorkItemId : WorkItemId { - private static int watermark = -1; - - public TemporaryWorkItemId() - : base(watermark--) + internal TemporaryWorkItemId(Tracker tracker) + : base(tracker.GetNextWatermark()) { } } diff --git a/src/aggregator-ruleng/WorkItemRelationWrapper.cs b/src/aggregator-ruleng/WorkItemRelationWrapper.cs index fe7121e8..102ba2ce 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapper.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapper.cs @@ -23,7 +23,7 @@ internal WorkItemRelationWrapper(WorkItemWrapper item, string type, string url, { Rel = type, Url = url, - Attributes = { { "comment", comment } } + Attributes = new Dictionary { { "comment", comment } } }; } diff --git a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs index d3087397..1dd97226 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs @@ -43,10 +43,9 @@ private void AddRelation(WorkItemRelationWrapper item) { rel = item.Rel, url = item.Url, - attributes = new - { - comment = item.Attributes["comment"] - } + attributes = item.Attributes != null && item.Attributes.ContainsKey("comment") + ? new { comment = item.Attributes["comment"] } + : null } }); diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index a7369026..085f19a1 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace aggregator.Engine { @@ -61,16 +62,32 @@ public IList GetWorkItems(IEnumerable return GetWorkItems(ids); } - public (int created, int updated) SaveChanges() + public WorkItemWrapper NewWorkItem(string workItemType) + { + var item = new WorkItem() + { + Fields = new Dictionary() { + { CoreFieldRefNames.WorkItemType, workItemType }, + { CoreFieldRefNames.TeamProject, _context.ProjectId.ToString() } + }, + Relations = new List(), + Links = new Microsoft.VisualStudio.Services.WebApi.ReferenceLinks() + }; + var wrapper = new WorkItemWrapper(_context, item); + _context.Logger.WriteVerbose($"Made new workitem with temporary id {wrapper.Id.Value}"); + return wrapper; + } + + public async Task<(int created, int updated)> SaveChanges() { int created = 0; int updated = 0; foreach (var item in _context.Tracker.NewWorkItems) { - _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {item.TeamProject}"); - _context.Client.CreateWorkItemAsync( + _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {_context.ProjectId}"); + var wi = await _context.Client.CreateWorkItemAsync( item.Changes, - item.TeamProject, + _context.ProjectId, item.WorkItemType ); created++; @@ -79,7 +96,7 @@ public IList GetWorkItems(IEnumerable foreach (var item in _context.Tracker.ChangedWorkItems) { _context.Logger.WriteInfo($"Updating workitem {item.Id}"); - _context.Client.UpdateWorkItemAsync( + var wi = await _context.Client.UpdateWorkItemAsync( item.Changes, item.Id.Value ); diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index da93a0b4..14f396fd 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -33,13 +33,7 @@ internal WorkItemWrapper(EngineContext context, WorkItem item) } else { - Id = new TemporaryWorkItemId(); - Changes.Add(new JsonPatchOperation() - { - Operation = Operation.Test, - Path = "/id", - Value = Id - }); + Id = new TemporaryWorkItemId(_context.Tracker); _context.Tracker.TrackNew(this); } } @@ -48,19 +42,13 @@ public WorkItemWrapper(EngineContext context, string project, string type) { _context = context; - Id = new TemporaryWorkItemId(); + Id = new TemporaryWorkItemId(_context.Tracker); _item = new WorkItem(); _item.Fields[CoreFieldRefNames.TeamProject] = project; _item.Fields[CoreFieldRefNames.WorkItemType] = type; _item.Fields[CoreFieldRefNames.Id] = Id.Value; - Changes.Add(new JsonPatchOperation() - { - Operation = Operation.Test, - Path = "/id", - Value = Id - }); _context.Tracker.TrackNew(this); } @@ -68,19 +56,13 @@ public WorkItemWrapper(EngineContext context, WorkItemWrapper template, string t { _context = context; - Id = new TemporaryWorkItemId(); + Id = new TemporaryWorkItemId(_context.Tracker); _item = new WorkItem(); _item.Fields[CoreFieldRefNames.TeamProject] = template.TeamProject; _item.Fields[CoreFieldRefNames.WorkItemType] = type; _item.Fields[CoreFieldRefNames.Id] = Id.Value; - Changes.Add(new JsonPatchOperation() - { - Operation = Operation.Test, - Path = "/id", - Value = Id - }); _context.Tracker.TrackNew(this); } @@ -195,6 +177,14 @@ public WorkItemWrapper Parent } } + public void AddChild(WorkItemWrapper newChild) + { + var rel = new WorkItemRelation { Url = newChild.Url, Rel = CoreRelationRefNames.Children }; + _item.Relations.Add(rel); + var rels = new WorkItemRelationWrapperCollection(this, _item.Relations); + rels.Add(new WorkItemRelationWrapper(this, rel)); + } + public WorkItemId Id { get; @@ -393,13 +383,26 @@ private void SetFieldValue(string field, object value) throw new InvalidOperationException("Work item is read-only."); } - _item.Fields[field] = value; - Changes.Add(new JsonPatchOperation() + if (_item.Fields.ContainsKey(field)) { - Operation = Operation.Add, - Path = "/fields/" + field, - Value = value - }); + _item.Fields[field] = value; + Changes.Add(new JsonPatchOperation() + { + Operation = Operation.Replace, + Path = "/fields/" + field, + Value = value + }); + } + else + { + _item.Fields.Add(field, value); + Changes.Add(new JsonPatchOperation() + { + Operation = Operation.Add, + Path = "/fields/" + field, + Value = value + }); + } IsDirty = true; } diff --git a/src/rule-language.md b/src/rule-language.md new file mode 100644 index 00000000..c35a30aa --- /dev/null +++ b/src/rule-language.md @@ -0,0 +1,78 @@ +# Directives + +`.lang=C#` +`.language=Csharp` + +# WorkItem Object + +WorkItem PreviousRevision +IEnumerable Revisions +IEnumerable Relations +IEnumerable ChildrenLinks +IEnumerable Children +IEnumerable RelatedLinks +IEnumerable Hyperlinks +WorkItemRelation ParentLink +WorkItem Parent +WorkItemId Id +int Rev +string Url +string WorkItemType +string State +int AreaId +string AreaPath +string AssignedTo +int AttachedFileCount +string AuthorizedAs +string ChangedBy +DateTime? ChangedDate +string CreatedBy +DateTime? CreatedDate +string Description +int ExternalLinkCount +string History +int HyperLinkCount +int IterationId +string IterationPath +string Reason +int RelatedLinkCount +DateTime? RevisedDate +DateTime? AuthorizedDate +string TeamProject +string Tags +string Title +double Watermark +bool IsDeleted +bool IsReadOnly +bool IsNew +bool IsDirty +object this[string field] + +# WorkItemStore Object + +WorkItem GetWorkItem(int id) +WorkItem GetWorkItem(WorkItemRelation item) + +IList GetWorkItems(IEnumerable ids) +IList GetWorkItems(IEnumerable collection) + +# WorkItemRelationCollection + +IEnumerator GetEnumerator() +Add(WorkItemRelation item) +AddLink(string type, string url, string comment) +AddHyperlink(string url, string comment = null) +AddRelatedLink(WorkItem item, string comment = null) +AddRelatedLink(string url, string comment = null) +Clear() +bool Contains(WorkItemRelation item) +bool Remove(WorkItemRelation item) +int Count +bool IsReadOnly + +# WorkItemRelation + +string Title +string Rel +string Url +IDictionary Attributes \ No newline at end of file diff --git a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs index 57a4aaa3..c4f069a2 100644 --- a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs +++ b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs @@ -15,13 +15,13 @@ namespace aggregator.unittests { internal class FakeWorkItemTrackingHttpClient : WorkItemTrackingHttpClientBase { - Dictionary> workItemFactories = new Dictionary>(); + Dictionary> workItemFactory = new Dictionary>(); public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) : base(baseUrl, credentials) { - string vase = baseUrl.AbsoluteUri; - workItemFactories.Add(1, () => new WorkItem() + string workItemsBaseUrl = $"{baseUrl.AbsoluteUri}/example-project/_apis/wit/workItems"; + workItemFactory.Add(1, () => new WorkItem() { Id = 1, Fields = new Dictionary() @@ -37,17 +37,18 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Forward", - Url = $"{vase}/example-project/_apis/wit/workItems/42" + Url = $"{workItemsBaseUrl}/42" }, new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Forward", - Url = $"{vase}/example-project/_apis/wit/workItems/99" + Url = $"{workItemsBaseUrl}/99" } - } + }, + Url = $"{workItemsBaseUrl}/1" }); - workItemFactories.Add(42, () => new WorkItem() + workItemFactory.Add(42, () => new WorkItem() { Id = 42, Fields = new Dictionary() @@ -63,12 +64,13 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Reverse", - Url = $"{vase}/example-project/_apis/wit/workItems/1" + Url = $"{workItemsBaseUrl}/1" } - } + }, + Url = $"{workItemsBaseUrl}/42" }); - workItemFactories.Add(99, () => new WorkItem() + workItemFactory.Add(99, () => new WorkItem() { Id = 99, Fields = new Dictionary() @@ -84,9 +86,10 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) new WorkItemRelation { Rel = "System.LinkTypes.Hierarchy-Reverse", - Url = $"{vase}/example-project/_apis/wit/workItems/1" + Url = $"{workItemsBaseUrl}/1" } - } + }, + Url = $"{workItemsBaseUrl}/99" }); } @@ -102,9 +105,9 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) throw new ArgumentException("Must be WorkItemExpand.All", nameof(expand)); } - if (workItemFactories.ContainsKey(id)) + if (workItemFactory.ContainsKey(id)) { - var t = new Task(workItemFactories[id]); + var t = new Task(workItemFactory[id]); t.RunSynchronously(); return t; } else @@ -130,9 +133,9 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) var result = new List(); foreach (var id in ids) { - if (workItemFactories.ContainsKey(id)) + if (workItemFactory.ContainsKey(id)) { - var wi = workItemFactories[id](); + var wi = workItemFactory[id](); result.Add(wi); } } @@ -199,5 +202,30 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) t.RunSynchronously(); return t; } + + static int newId = 100; + public override Task CreateWorkItemAsync(JsonPatchDocument document, Guid project, string type, bool? validateOnly = null, bool? bypassRules = null, bool? suppressNotifications = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + string workItemsBaseUrl = $"{BaseAddress.AbsoluteUri}/example-project/_apis/wit/workItems"; + + workItemFactory.Add(newId, () => new WorkItem() + { + Id = newId, + Fields = new Dictionary() + { + { "System.WorkItemType", type }, + { "System.State", "New" }, + { "System.TeamProject", "example-project" }, + { "System.Title", "Hello" }, + }, + Rev = 1, + Relations = new List(), + Url = $"{workItemsBaseUrl}/{newId}" + }); + + var t = new Task(workItemFactory[newId]); + t.RunSynchronously(); + return t; + } } } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index d8b0d4ea..e3a68aa9 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -7,13 +7,22 @@ namespace unittests_ruleng { + static class StringExtensions + { + internal static string[] Mince(this string ruleCode) + { + return ruleCode.Split(Environment.NewLine); + } + } + public class RuleTests { [Fact] public async void HelloWorldRule_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-account"; - var baseUrl = new Uri($"{collectionUrl}/fake-project"); + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); int workItemId = 42; @@ -21,17 +30,57 @@ public async void HelloWorldRule_Succeeds() return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; "; - var engine = new RuleEngine(logger, ruleCode); - string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); Assert.Equal("Hello Bug #42 - Hello!", result); } + [Fact] + public async void LanguageDirective_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 42; + string ruleCode = @".lang=CS +return string.Empty; +"; + + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + + Assert.Equal(EngineState.Success, engine.State); + Assert.Equal(string.Empty, result); + } + + [Fact] + public async void LanguageDirective_Fails() + { + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 42; + string ruleCode = @".lang=WHAT +return string.Empty; +"; + + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + + Assert.Equal(EngineState.Error, engine.State); + } + [Fact] public async void Parent_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-account"; - var baseUrl = new Uri(collectionUrl); + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); int workItemId = 42; @@ -45,32 +94,30 @@ public async void Parent_Succeeds() return message; "; - var engine = new RuleEngine(logger, ruleCode); - string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); Assert.Equal("Parent is 1", result); } [Fact] - public async void Children_Succeeds() + public async void New_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-account"; - var baseUrl = new Uri(collectionUrl); + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); int workItemId = 1; string ruleCode = @" -string message = ""Children are ""; -var parent = self; -var children = parent.Children; -foreach (var child in children) { - message += $"",{child.Id}""; -} -return message; +var wi = store.NewWorkItem(); +wi.Title = ""Brand new""; +var rel = new WorkItemRelationWrapper(wi, CoreRelationRefNames.Parent, self.Url); +self.ChildrenLinks.AddRelation(rel); "; - var engine = new RuleEngine(logger, ruleCode); - string result = await engine.ExecuteAsync(collectionUrl, workItemId, client); + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); Assert.Equal("Children are ,42,99", result); } diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index af76f7f6..0f3d10b6 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using aggregator.Engine; using aggregator.unittests; using Xunit; @@ -10,10 +11,12 @@ public class WorkItemStoreTests [Fact] public void GetWorkItem_ById_Succeeds() { - var baseUrl = new Uri("https://dev.azure.com/fake-account"); + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, logger); + var context = new EngineContext(client, projectId, logger); var sut = new WorkItemStore(context); var wi = sut.GetWorkItem(42); @@ -25,10 +28,12 @@ public void GetWorkItem_ById_Succeeds() [Fact] public void GetWorkItems_ByIds_Succeeds() { - var baseUrl = new Uri("https://dev.azure.com/fake-account"); + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, logger); + var context = new EngineContext(client, projectId, logger); var sut = new WorkItemStore(context); var wis = sut.GetWorkItems(new int[] { 42, 99 }); @@ -38,5 +43,49 @@ public void GetWorkItems_ByIds_Succeeds() Assert.Contains(wis, (x) => x.Id.Value == 42); Assert.Contains(wis, (x) => x.Id.Value == 99); } + + [Fact] + public void NewWorkItem_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + var context = new EngineContext(client, projectId, logger); + var sut = new WorkItemStore(context); + + var wi = sut.NewWorkItem("Task"); + wi.Title = "Brand new"; + var save = sut.SaveChanges().Result; + + Assert.NotNull(wi); + Assert.True(wi.IsNew); + Assert.Equal(1, save.created); + Assert.Equal(0, save.updated); + Assert.Equal(-1, wi.Id.Value); + } + + [Fact] + public void AddChild_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + var context = new EngineContext(client, projectId, logger); + var sut = new WorkItemStore(context); + + var parent = sut.GetWorkItem(1); + var newChild = sut.NewWorkItem("Task"); + newChild.Title = "Brand new"; + parent.AddChild(newChild); + + Assert.NotNull(newChild); + Assert.True(newChild.IsNew); + Assert.Equal(-1, newChild.Id.Value); + Assert.Equal(3, parent.Relations.Count()); + } } } From 3fa068d9ce98d5bd45dd54254705ad8aac78a31e Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 30 Oct 2018 20:03:20 +0000 Subject: [PATCH 09/37] Fix issue about mapping multiple project to the same rule --- src/aggregator-cli/Mappings/AggregatorMappings.cs | 13 +++++++++++++ src/aggregator-cli/Program.cs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index fb64c667..3399ea4d 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -72,6 +72,19 @@ internal async Task Add(string projectName, string @event, InstanceName in // check if the subscription already exists and bail out var query = new SubscriptionsQuery { PublisherId = DevOpsEvents.PublisherId, + PublisherInputFilters= new InputFilter[] { + new InputFilter { + Conditions = new List { + new InputFilterCondition + { + InputId = "projectId", + InputValue = project.Id.ToString(), + Operator = InputFilterOperator.Equals, + CaseSensitive = false + } + } + } + }, ConsumerInputFilters = new InputFilter[] { new InputFilter { Conditions = new List { diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index c66abc63..8a7b0339 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -7,6 +7,15 @@ namespace aggregator.cli /* Ideas for verbs and options: + --resourceGroup RESOURCEGROUP + wherever you specify the Resource Group for the instance + + set.defaults --instance INSTANCE --resourceGroup RESOURCEGROUP --project PROJECT + mappings from a project + + list.mappings --project PROJECT + mappings from a project + logon.ado --url URL --mode MODE --token TOKEN --slot SLOT to use different credentials configure.instance --slot SLOT --swap --avzone ZONE @@ -15,9 +24,10 @@ to use different credentials use `azure.AppServices.WebApps.GetByResourceGroup(instance.ResourceGroupName,instance.FunctionAppName).OutboundIPAddresses` configure.rule --verbose --instance INSTANCE --name RULE --file FILE --slot SLOT change rule code on a single deployment slot - invoke.rule --verbose --local --ruleSource FILE --event EVENT --workItemId WORK_ITEM_ID + + invoke.rule --verbose --dryrun --local --ruleSource FILE --event EVENT --workItemId WORK_ITEM_ID runs rule code locally - invoke.rule --verbose --instance INSTANCE --rule RULE --event EVENT --workItemId WORK_ITEM_ID --slot SLOT + invoke.rule --verbose --dryrun --instance INSTANCE --rule RULE --event EVENT --workItemId WORK_ITEM_ID --slot SLOT emulates the event on the rule */ From 314a417386571d9febb8bc37a6ec94a8f2b00618 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 30 Oct 2018 22:43:37 +0000 Subject: [PATCH 10/37] Added support for --resourceGroup option --- .../Instances/AggregatorInstances.cs | 14 +++++++ .../Instances/ConfigureInstanceCommand.cs | 5 ++- .../Instances/InstallInstanceCommand.cs | 5 ++- src/aggregator-cli/Instances/InstanceName.cs | 19 ++++++---- .../Instances/ListInstancesCommand.cs | 37 ++++++++++++++++--- .../Instances/UninstallInstanceCommand.cs | 5 ++- .../Mappings/ListMappingsCommand.cs | 5 ++- src/aggregator-cli/Mappings/MapRuleCommand.cs | 5 ++- .../Mappings/UnmapRuleCommand.cs | 5 ++- src/aggregator-cli/Rules/AddRuleCommand.cs | 5 ++- .../Rules/ConfigureRuleCommand.cs | 5 ++- src/aggregator-cli/Rules/ListRulesCommand.cs | 5 ++- src/aggregator-cli/Rules/RemoveRuleCommand.cs | 5 ++- 13 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 5236c9ed..8dd5efbb 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -58,6 +58,20 @@ public async Task> ListByLocationAsync(string location) return result; } + internal async Task> ListInResourceGroupAsync(string resourceGroup) + { + var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(resourceGroup); + + var result = new List(); + foreach (var app in apps) + { + result.Add( + InstanceName.FromFunctionAppName(app.Name).PlainName); + } + return result; + } + + internal async Task Add(InstanceName instance, string location, string requiredVersion) { string rgName = instance.ResourceGroupName; diff --git a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs index 56a94ef4..9429f7dc 100644 --- a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs +++ b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs @@ -15,6 +15,9 @@ internal class ConfigureInstanceCommand : CommandBase [Option('l', "location", Required = true, HelpText = "Aggregator instance location (Azure region).")] public string Location { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('a', "authentication", SetName = "auth", Required = true, HelpText = "Refresh authentication data.")] public bool Authentication { get; set; } // TODO add --swap.slot to support App Service Deployment Slots @@ -27,7 +30,7 @@ internal override async Task RunAsync() .WithDevOpsLogon() // need the token, so we can save it in the app settings .Build(); var instances = new AggregatorInstances(context.Azure, context.Logger); - var instance = new InstanceName(Name); + var instance = new InstanceName(Name, ResourceGroup); bool ok = false; if (Authentication) { diff --git a/src/aggregator-cli/Instances/InstallInstanceCommand.cs b/src/aggregator-cli/Instances/InstallInstanceCommand.cs index 751943b8..4c502698 100644 --- a/src/aggregator-cli/Instances/InstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/InstallInstanceCommand.cs @@ -15,6 +15,9 @@ class InstallInstanceCommand : CommandBase [Option('l', "location", Required = true, HelpText = "Aggregator instance location (Azure region).")] public string Location { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + [Option("requiredVersion", Required = false, HelpText = "Version of Aggregator Runtime required.")] public string RequiredVersion { get; set; } @@ -25,7 +28,7 @@ internal override async Task RunAsync() .WithDevOpsLogon() // need the token, so we can save it in the app settings .Build(); var instances = new AggregatorInstances(context.Azure, context.Logger); - var instance = new InstanceName(Name); + var instance = new InstanceName(Name, ResourceGroup); bool ok = await instances.Add(instance, Location, RequiredVersion); return ok ? 0 : 1; } diff --git a/src/aggregator-cli/Instances/InstanceName.cs b/src/aggregator-cli/Instances/InstanceName.cs index 8e4267c1..9ba9d49e 100644 --- a/src/aggregator-cli/Instances/InstanceName.cs +++ b/src/aggregator-cli/Instances/InstanceName.cs @@ -9,29 +9,34 @@ internal class InstanceName const string resourceGroupPrefix = "aggregator-"; const string functionAppSuffix = "aggregator"; private readonly string name; + private readonly string resourceGroup; + // used only in ListInstances public static string ResourceGroupInstancePrefix => resourceGroupPrefix; - internal InstanceName(string name) + public InstanceName(string name, string resourceGroup) { this.name = name; + this.resourceGroup = string.IsNullOrEmpty(resourceGroup) + ? resourceGroupPrefix + name + : resourceGroup; } + // used only in ListInstances public static InstanceName FromResourceGroupName(string rgName) { - return new InstanceName(rgName.Remove(0, ResourceGroupInstancePrefix.Length)); + return new InstanceName(rgName.Remove(0, ResourceGroupInstancePrefix.Length), null); } - public static InstanceName FromFunctionAppUrl(string url) + // used only in ListInstances + public static InstanceName FromFunctionAppName(string appName) { - string host = new Uri(url).Host; - host = host.Substring(0, host.IndexOf('.')); - return new InstanceName(host.Remove(host.Length - functionAppSuffix.Length)); + return new InstanceName(appName.Remove(appName.Length - functionAppSuffix.Length), null); } public string PlainName => name; - internal string ResourceGroupName => resourceGroupPrefix + name; + internal string ResourceGroupName => resourceGroup; internal string FunctionAppName=> name + functionAppSuffix; diff --git a/src/aggregator-cli/Instances/ListInstancesCommand.cs b/src/aggregator-cli/Instances/ListInstancesCommand.cs index 3bb6569b..2e4c4f0a 100644 --- a/src/aggregator-cli/Instances/ListInstancesCommand.cs +++ b/src/aggregator-cli/Instances/ListInstancesCommand.cs @@ -11,21 +11,30 @@ class ListInstancesCommand : CommandBase [Option('l', "location", Required = false, HelpText = "Aggregator instance location (Azure region).")] public string Location { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + internal override async Task RunAsync() { var context = await Context .WithAzureLogon() .Build(); var instances = new AggregatorInstances(context.Azure, context.Logger); - if (string.IsNullOrEmpty(Location)) + if (!string.IsNullOrEmpty(Location)) { - context.Logger.WriteVerbose($"Searching aggregator instances in subscription..."); - return await ListAllAsync(context, instances); + context.Logger.WriteVerbose($"Searching aggregator instances in {Location}..."); + return await ListByLocationAsync(context, instances); + + } + else if (!string.IsNullOrEmpty(ResourceGroup)) + { + context.Logger.WriteVerbose($"Searching aggregator instances in {ResourceGroup}..."); + return await ListInResourceGroupAsync(context, instances); } else { - context.Logger.WriteVerbose($"Searching aggregator instances in {Location}..."); - return await ListByLocationAsync(context, instances); + context.Logger.WriteVerbose($"Searching aggregator instances in subscription..."); + return await ListAllAsync(context, instances); } } @@ -47,6 +56,24 @@ private async Task ListByLocationAsync(CommandContext context, AggregatorIn return 0; } + private async Task ListInResourceGroupAsync(CommandContext context, AggregatorInstances instances) + { + var found = await instances.ListInResourceGroupAsync(ResourceGroup); + bool any = false; + foreach (var name in found) + { + context.Logger.WriteOutput( + name, + (data) => $"Instance {name}"); + any = true; + } + if (!any) + { + context.Logger.WriteInfo("No aggregator instances found."); + } + return 0; + } + private static async Task ListAllAsync(CommandContext context, AggregatorInstances instances) { var found = await instances.ListAllAsync(); diff --git a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs index b6c2d44f..6177bf08 100644 --- a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs @@ -15,6 +15,9 @@ class UninstallInstanceCommand : CommandBase [Option('l', "location", Required = true, HelpText = "Aggregator instance location (Azure region).")] public string Location { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('m', "dont-remove-mappings", Required = false, HelpText = "Do not remove mappings from Azure DevOps (default is to remove them).")] public bool Mappings { get; set; } @@ -25,7 +28,7 @@ internal override async Task RunAsync() .WithDevOpsLogon() .Build(); - var instance = new InstanceName(Name); + var instance = new InstanceName(Name, ResourceGroup); bool ok; if (!Mappings) diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index 7f18fa99..a484e025 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -13,12 +13,15 @@ class ListMappingsCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + internal override async Task RunAsync() { var context = await Context .WithDevOpsLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); // HACK we pass null as the next calls do not use the Azure connection var mappings = new AggregatorMappings(context.Devops, null, context.Logger); bool any = false; diff --git a/src/aggregator-cli/Mappings/MapRuleCommand.cs b/src/aggregator-cli/Mappings/MapRuleCommand.cs index 51cdb3f8..148b5321 100644 --- a/src/aggregator-cli/Mappings/MapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/MapRuleCommand.cs @@ -18,6 +18,9 @@ class MapRuleCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } @@ -34,7 +37,7 @@ internal override async Task RunAsync() context.Logger.WriteError($"Invalid event type."); return 2; } - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var id = await mappings.Add(Project, Event, instance, Rule); return id.Equals(Guid.Empty) ? 1 : 0; } diff --git a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs index 98a823c4..c4df1bda 100644 --- a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs @@ -15,6 +15,9 @@ class UnmapRuleCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } @@ -24,7 +27,7 @@ internal override async Task RunAsync() .WithAzureLogon() .WithDevOpsLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleEventAsync(Event, instance, Rule); return ok ? 0 : 1; diff --git a/src/aggregator-cli/Rules/AddRuleCommand.cs b/src/aggregator-cli/Rules/AddRuleCommand.cs index fb4a317f..ab948258 100644 --- a/src/aggregator-cli/Rules/AddRuleCommand.cs +++ b/src/aggregator-cli/Rules/AddRuleCommand.cs @@ -9,6 +9,9 @@ namespace aggregator.cli [Verb("add.rule", HelpText = "Add a rule to existing Aggregator instance in Azure.")] class AddRuleCommand : CommandBase { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } @@ -23,7 +26,7 @@ internal override async Task RunAsync() var context = await Context .WithAzureLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool ok = await rules.AddAsync(instance, Name, File); return ok ? 0 : 1; diff --git a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs index a2626850..036013ee 100644 --- a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs +++ b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs @@ -9,6 +9,9 @@ namespace aggregator.cli [Verb("configure.rule", HelpText = "Change a rule configuration.")] class ConfigureRuleCommand : CommandBase { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } @@ -31,7 +34,7 @@ internal override async Task RunAsync() var context = await Context .WithAzureLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool ok = false; if (Disable || Enable) diff --git a/src/aggregator-cli/Rules/ListRulesCommand.cs b/src/aggregator-cli/Rules/ListRulesCommand.cs index 28ddd586..a56b2b34 100644 --- a/src/aggregator-cli/Rules/ListRulesCommand.cs +++ b/src/aggregator-cli/Rules/ListRulesCommand.cs @@ -9,6 +9,9 @@ namespace aggregator.cli [Verb("list.rules", HelpText = "List the rule in existing Aggregator instance in Azure.")] class ListRulesCommand : CommandBase { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } @@ -17,7 +20,7 @@ internal override async Task RunAsync() var context = await Context .WithAzureLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool any = false; foreach (var item in await rules.ListAsync(instance)) diff --git a/src/aggregator-cli/Rules/RemoveRuleCommand.cs b/src/aggregator-cli/Rules/RemoveRuleCommand.cs index cf76c17b..4c307731 100644 --- a/src/aggregator-cli/Rules/RemoveRuleCommand.cs +++ b/src/aggregator-cli/Rules/RemoveRuleCommand.cs @@ -9,6 +9,9 @@ namespace aggregator.cli [Verb("remove.rule", HelpText = "Remove a rule from existing Aggregator instance in Azure.")] class RemoveRuleCommand : CommandBase { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } @@ -21,7 +24,7 @@ internal override async Task RunAsync() .WithAzureLogon() .WithDevOpsLogon() .Build(); - var instance = new InstanceName(Instance); + var instance = new InstanceName(Instance, ResourceGroup); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleAsync(instance, Name); From 72fdea9e29e911487ee8fa1323b37d7bd6dc78c2 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 31 Oct 2018 20:20:30 +0000 Subject: [PATCH 11/37] Added Project option to unmap.rule verb to fix #10 --- .../Mappings/AggregatorMappings.cs | 19 ++++++++++++++----- .../Mappings/UnmapRuleCommand.cs | 5 ++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index 3399ea4d..b1263ffa 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -148,15 +148,15 @@ internal async Task Add(string projectName, string @event, InstanceName in internal async Task RemoveInstanceAsync(InstanceName instance) { - return await RemoveRuleEventAsync("*", instance, "*"); + return await RemoveRuleEventAsync("*", instance, "*", "*"); } internal async Task RemoveRuleAsync(InstanceName instance, string rule) { - return await RemoveRuleEventAsync("*", instance, rule); + return await RemoveRuleEventAsync("*", instance, "*", rule); } - internal async Task RemoveRuleEventAsync(string @event, InstanceName instance, string rule) + internal async Task RemoveRuleEventAsync(string @event, InstanceName instance, string projectName, string rule) { logger.WriteInfo($"Querying the Azure DevOps subscriptions for rule(s) {instance.PlainName}/{rule}"); var serviceHooksClient = devops.GetClient(); @@ -170,6 +170,15 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta { ruleSubs = ruleSubs.Where(s => s.EventType == @event); } + if (projectName != "*") + { + logger.WriteVerbose($"Reading Azure DevOps project data..."); + var projectClient = devops.GetClient(); + var project = await projectClient.GetProject(projectName); + logger.WriteInfo($"Project {projectName} data read."); + + ruleSubs = ruleSubs.Where(s => s.PublisherInputs["projectId"] == project.Id.ToString()); + } if (rule != "*") { ruleSubs = ruleSubs @@ -178,9 +187,9 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta } foreach (var ruleSub in ruleSubs) { - logger.WriteVerbose($"Deleting subscription {ruleSub.EventDescription}..."); + logger.WriteVerbose($"Deleting subscription {ruleSub.EventDescription} {ruleSub.EventType}..."); await serviceHooksClient.DeleteSubscriptionAsync(ruleSub.Id); - logger.WriteInfo($"Subscription {ruleSub.EventDescription} deleted."); + logger.WriteInfo($"Subscription {ruleSub.EventDescription} {ruleSub.EventType} deleted."); } return true; diff --git a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs index c4df1bda..019fe187 100644 --- a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs @@ -18,6 +18,9 @@ class UnmapRuleCommand : CommandBase [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] public string ResourceGroup { get; set; } + [Option('p', "project", Required = false, Default = "*", HelpText = "Azure DevOps project name.")] + public string Project { get; set; } + [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } @@ -29,7 +32,7 @@ internal override async Task RunAsync() .Build(); var instance = new InstanceName(Instance, ResourceGroup); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); - bool ok = await mappings.RemoveRuleEventAsync(Event, instance, Rule); + bool ok = await mappings.RemoveRuleEventAsync(Event, instance, Project, Rule); return ok ? 0 : 1; } } From 2a81d4df1b46b4d335627f06a13b06e640b1b041 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 31 Oct 2018 21:17:36 +0000 Subject: [PATCH 12/37] list.mappings extended to filter by project --- src/aggregator-cli/Instances/InstanceName.cs | 8 +++++++ .../Mappings/AggregatorMappings.cs | 22 +++++++++++++------ .../Mappings/ListMappingsCommand.cs | 9 +++++--- src/aggregator-cli/Program.cs | 8 +++---- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/aggregator-cli/Instances/InstanceName.cs b/src/aggregator-cli/Instances/InstanceName.cs index 9ba9d49e..5ee2a6b1 100644 --- a/src/aggregator-cli/Instances/InstanceName.cs +++ b/src/aggregator-cli/Instances/InstanceName.cs @@ -34,6 +34,14 @@ public static InstanceName FromFunctionAppName(string appName) return new InstanceName(appName.Remove(appName.Length - functionAppSuffix.Length), null); } + // used only in mappings.ListAsync + public static InstanceName FromFunctionAppUrl(string url) + { + string host = new Uri(url).Host; + host = host.Substring(0, host.IndexOf('.')); + return new InstanceName(host.Remove(host.Length - functionAppSuffix.Length), null); + } + public string PlainName => name; internal string ResourceGroupName => resourceGroup; diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index b1263ffa..9cab0e43 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -24,16 +24,20 @@ public AggregatorMappings(VssConnection devops, IAzure azure, ILogger logger) this.logger = logger; } - internal async Task> ListAsync(InstanceName instance) + internal async Task> ListAsync(InstanceName instance, string projectName) { logger.WriteVerbose($"Searching aggregator mappings in Azure DevOps..."); var serviceHooksClient = devops.GetClient(); var subscriptions = await serviceHooksClient.QuerySubscriptionsAsync(); - var filteredSubs = subscriptions.Where(s - => s.PublisherId == DevOpsEvents.PublisherId - && s.ConsumerInputs["url"].ToString().StartsWith( - instance.FunctionAppUrl) - ); + var filteredSubs = instance != null + ? subscriptions.Where(s + => s.PublisherId == DevOpsEvents.PublisherId + && s.ConsumerInputs["url"].ToString().StartsWith( + instance.FunctionAppUrl)) + : subscriptions.Where(s + => s.PublisherId == DevOpsEvents.PublisherId + // HACK + && s.ConsumerInputs["url"].ToString().IndexOf("aggregator.azurewebsites.net") > 8); var projectClient = devops.GetClient(); var projects = await projectClient.GetProjects(); var projectsDict = projects.ToDictionary(p => p.Id); @@ -44,10 +48,14 @@ public AggregatorMappings(VssConnection devops, IAzure azure, ILogger logger) var foundProject = projectsDict[ new Guid(subscription.PublisherInputs["projectId"]) ]; + if (!string.IsNullOrEmpty(projectName) && foundProject.Name != projectName) + { + continue; + } // HACK need to factor the URL<->rule_name string ruleUrl = subscription.ConsumerInputs["url"].ToString(); string ruleName = ruleUrl.Substring(ruleUrl.LastIndexOf('/')); - string ruleFullName = instance.PlainName + ruleName; + string ruleFullName = InstanceName.FromFunctionAppUrl(ruleUrl).PlainName + ruleName; result.Add( (ruleFullName, foundProject.Name, subscription.EventType, subscription.Status.ToString()) ); diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index a484e025..124414ee 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -10,22 +10,25 @@ namespace aggregator.cli [Verb("list.mappings", HelpText = "Lists mappings from existing Azure DevOps Projects to Aggregator Rules.")] class ListMappingsCommand : CommandBase { - [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] + [Option('i', "instance", Required = false, HelpText = "Aggregator instance name.")] public string Instance { get; set; } [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] public string ResourceGroup { get; set; } + [Option('p', "project", Required = false, Default = "", HelpText = "Azure DevOps project name.")] + public string Project { get; set; } + internal override async Task RunAsync() { var context = await Context .WithDevOpsLogon() .Build(); - var instance = new InstanceName(Instance, ResourceGroup); + var instance = string.IsNullOrEmpty(Instance) ? null : new InstanceName(Instance, ResourceGroup); // HACK we pass null as the next calls do not use the Azure connection var mappings = new AggregatorMappings(context.Devops, null, context.Logger); bool any = false; - foreach (var item in await mappings.ListAsync(instance)) + foreach (var item in await mappings.ListAsync(instance, Project)) { context.Logger.WriteOutput( item, diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 8a7b0339..6b235e6e 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -13,9 +13,6 @@ namespace aggregator.cli set.defaults --instance INSTANCE --resourceGroup RESOURCEGROUP --project PROJECT mappings from a project - list.mappings --project PROJECT - mappings from a project - logon.ado --url URL --mode MODE --token TOKEN --slot SLOT to use different credentials configure.instance --slot SLOT --swap --avzone ZONE @@ -41,12 +38,13 @@ static int Main(string[] args) // fails see https://github.com/commandlineparser/commandline/issues/198 settings.CaseInsensitiveEnumValues = true; }); - var parserResult = parser.ParseArguments(args, + var types = new Type[] { typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), typeof(ConfigureRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) - ); + }; + var parserResult = parser.ParseArguments(args, types); int rc = -1; parserResult .WithParsed(cmd => rc = cmd.Run()) From 973dacbb5fe82d733f99d9abfd7fbdbccad1a144 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 31 Oct 2018 22:38:55 +0000 Subject: [PATCH 13/37] moved rule update to its own command and refreshed the list of sample commands --- doc/command-examples.md | 36 ++++++++++++------ src/aggregator-cli/Program.cs | 4 +- .../Rules/ConfigureRuleCommand.cs | 10 ----- src/aggregator-cli/Rules/UpdateRuleCommand.cs | 38 +++++++++++++++++++ 4 files changed, 66 insertions(+), 22 deletions(-) create mode 100644 src/aggregator-cli/Rules/UpdateRuleCommand.cs diff --git a/doc/command-examples.md b/doc/command-examples.md index 2c3b2e05..8fbbb837 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -9,36 +9,50 @@ logon.ado --url https://someaccount.visualstudio.com --mode PAT --token 2******* # create an Azure Function Application install.instance --verbose --name my1 --location westeurope +install.instance --name my3 --resourceGroup myRG1 --location westeurope --requiredVersion latest +# search instances in the Azure subscription list.instances +# search instances in the Azure Resource Group +list.instances --resourceGroup myRG1 -# create three Azure Functions +# create two Azure Functions add.rule --verbose --instance my1 --name test1 --file test\test1.rule add.rule --verbose --instance my1 --name test2 --file test\test2.rule -add.rule --verbose --instance my1 --name test3 --file test\test3.rule list.rules --verbose --instance my1 +# create Azure Function in specified App and Resource Group +add.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 --file test\test3.rule + # adds two Service Hook to Azure DevOps, each invoking a different rule map.rule --verbose --project SampleProject --event workitem.created --instance my1 --rule test1 map.rule --verbose --project SampleProject --event workitem.updated --instance my1 --rule test2 list.mappings --verbose --instance my1 +list.mappings --verbose --project SampleProject +list.mappings --instance my1 --project SampleProject -# disable a rule +# disable an existing rule configure.rule --verbose --instance my1 --name test1 --disable # re-enable a rule configure.rule --verbose --instance my1 --name test1 --enable -# update the code of a rule -configure.rule --verbose --instance my1 --name test --update test.rule +# update the code and runtime of a rule +update.rule --verbose --instance my1 --name test1 --file test1.rule --requiredVersion 0.4.0 +update.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 --file test\test3.rule -# updates the Azure DevOps credential stored by the rules -configure.instance --authentication +# updates the Azure DevOps credential stored in Azure Function and used by rules to connect back +configure.instance --name my1 --location westeurope --authentication +configure.instance --name my3 --resourceGroup myRG1 --location westeurope --authentication # remove a Service Hook from Azure DevOps unmap.rule --verbose --event workitem.created --instance my1 --rule test1 +# remove a Service Hook from Azure DevOps +unmap.rule --verbose --event workitem.updated --project SampleProject --instance my1 --rule test2 -# deletes two Azure Functions +# deletes an Azure Function and all Service Hooks referring to it remove.rule --verbose --instance my1 --name test1 -remove.rule --verbose --instance my1 --name test2 +remove.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 -# delete the Azure Function Application -uninstall.instance --verbose --name my1 --location westeurope +# delete the Azure Function Application leaving the Service Hooks in place +uninstall.instance --name my1 --location westeurope --dont-remove-mappings +# delete the Azure Function Application and any Service Hooks referring to it +uninstall.instance --verbose --instance my3 --resourceGroup myRG1 ``` \ No newline at end of file diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 6b235e6e..e66bcb9f 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -41,7 +41,8 @@ static int Main(string[] args) var types = new Type[] { typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), - typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), typeof(ConfigureRuleCommand), + typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), + typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) }; var parserResult = parser.ParseArguments(args, types); @@ -57,6 +58,7 @@ static int Main(string[] args) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) + .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) diff --git a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs index 036013ee..40fb620a 100644 --- a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs +++ b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs @@ -18,17 +18,11 @@ class ConfigureRuleCommand : CommandBase [Option('n', "name", Required = true, HelpText = "Aggregator rule name.")] public string Name { get; set; } - [Option('u', "update", SetName = "update", HelpText = "Update the runtime and rule code.")] - public string Update { get; set; } - [Option('d', "disable", SetName = "disable", HelpText = "Disable the rule.")] public bool Disable { get; set; } [Option('e', "enable", SetName = "enable", HelpText = "Enable the rule.")] public bool Enable { get; set; } - [Option("requiredVersion", Required = false, HelpText = "Version of Aggregator Runtime required.")] - public string RequiredVersion { get; set; } - internal override async Task RunAsync() { var context = await Context @@ -41,10 +35,6 @@ internal override async Task RunAsync() { ok = await rules.EnableAsync(instance, Name, Disable); } - if (!string.IsNullOrEmpty(Update)) - { - ok = await rules.UpdateAsync(instance, Name, Update, RequiredVersion); - } return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/UpdateRuleCommand.cs b/src/aggregator-cli/Rules/UpdateRuleCommand.cs new file mode 100644 index 00000000..4618f947 --- /dev/null +++ b/src/aggregator-cli/Rules/UpdateRuleCommand.cs @@ -0,0 +1,38 @@ +using CommandLine; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace aggregator.cli +{ + [Verb("update.rule", HelpText = "Update a rule code and/or runtime.")] + class UpdateRuleCommand : CommandBase + { + [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instance.")] + public string ResourceGroup { get; set; } + + [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] + public string Instance { get; set; } + + [Option('n', "name", Required = true, HelpText = "Aggregator rule name.")] + public string Name { get; set; } + + [Option('f', "file", Required = true, HelpText = "Aggregator rule code.")] + public string File { get; set; } + + [Option("requiredVersion", Required = false, HelpText = "Version of Aggregator Runtime required.")] + public string RequiredVersion { get; set; } + + internal override async Task RunAsync() + { + var context = await Context + .WithAzureLogon() + .Build(); + var instance = new InstanceName(Instance, ResourceGroup); + var rules = new AggregatorRules(context.Azure, context.Logger); + bool ok = await rules.UpdateAsync(instance, Name, File, RequiredVersion); + return ok ? 0 : 1; + } + } +} From 7e9250c06ba02502f4dca140932f64896d17f809 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Wed, 31 Oct 2018 23:14:13 +0000 Subject: [PATCH 14/37] Check on uploaded runtime version and better messages --- .../Instances/FunctionRuntimePackage.cs | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 06d29684..148859e0 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -39,29 +39,67 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHub(requiredVersion); if (rel_name[0] == 'v') rel_name = rel_name.Substring(1); var requiredRuntimeVer = SemVersion.Parse(rel_name); - logger.WriteInfo($"Latest Runtime package version is {requiredRuntimeVer} (released on {rel_when})."); + logger.WriteVerbose($"Latest Runtime package version is {requiredRuntimeVer} (released on {rel_when})."); string localPackageVersion = GetLocalPackageVersion(RuntimePackageFile); var localRuntimeVer = SemVersion.Parse(localPackageVersion); - logger.WriteInfo($"Cached Runtime package version is {localRuntimeVer}."); - if (ShouldUpdate(requiredRuntimeVer, localRuntimeVer)) + logger.WriteVerbose($"Cached Runtime package version is {localRuntimeVer}."); + + // TODO check the uploaded version before overwriting? + string manifestVersion = "0.0.0"; + logger.WriteVerbose($"Retrieving functions runtime"); + var kudu = new KuduApi(instance, azure, logger); + using (var client = new HttpClient()) + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/vfs/site/wwwroot/aggregator-manifest.ini")) + using (var response = await client.SendAsync(request)) { - logger.WriteVerbose($"Downloading runtime package {rel_name}"); - await Download(rel_url); - logger.WriteInfo($"Runtime package downloaded."); + string manifest = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + // HACK refactor so that there is a single class parsing the manifest + var parts = manifest.Split('='); + if (parts[0] == "version") + manifestVersion = parts[1]; + } + else + { + logger.WriteError($"{response.ReasonPhrase}"); + } } + var uploadedRuntimeVer = SemVersion.Parse(manifestVersion); + logger.WriteVerbose($"Function Runtime version is {uploadedRuntimeVer}."); - logger.WriteVerbose($"Uploading runtime package to {instance.DnsHostName}"); - bool ok = await UploadRuntimeZip(instance, azure); - if (ok) + if (requiredRuntimeVer > uploadedRuntimeVer || localRuntimeVer > uploadedRuntimeVer) { - logger.WriteInfo($"Runtime package uploaded to {instance.PlainName}."); + if (requiredRuntimeVer > localRuntimeVer) + { + logger.WriteVerbose($"Downloading runtime package {rel_name}"); + await Download(rel_url); + logger.WriteInfo($"Runtime package downloaded."); + } + else + { + logger.WriteInfo($"Using cached runtime package {localRuntimeVer}"); + } + + logger.WriteVerbose($"Uploading runtime package to {instance.DnsHostName}"); + bool ok = await UploadRuntimeZip(instance, azure); + if (ok) + { + logger.WriteInfo($"Runtime package uploaded to {instance.PlainName}."); + } + else + { + logger.WriteError($"Failed uploading Runtime to {instance.DnsHostName}."); + } + return ok; } else { - logger.WriteError($"Failed uploading Runtime to {instance.DnsHostName}."); + logger.WriteInfo($"Nothing to update."); + return true; } - return ok; } private string GetLocalPackageVersion(string runtimePackageFile) @@ -86,11 +124,6 @@ private string GetLocalPackageVersion(string runtimePackageFile) return manifestVersion; } - private bool ShouldUpdate(SemVersion lastRuntimeVer, SemVersion currentCliVer) - { - return lastRuntimeVer > currentCliVer; - } - private async Task<(string name, DateTimeOffset? when, string url)> FindVersionInGitHub(string tag = "latest") { var githubClient = new GitHubClient(new ProductHeaderValue("aggregator-cli", infoVersion)); From 3a79fdc1b9f580ff64e839fe1f609b6a30e44cdc Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 1 Nov 2018 09:44:12 -0400 Subject: [PATCH 15/37] update uninstall.instance to only remove the resource group if no other app functions exist in it. --- .../Instances/AggregatorInstances.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 8dd5efbb..674a4d31 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -175,13 +175,26 @@ internal async Task Remove(InstanceName instance, string location) logger.WriteVerbose($"Searching instance {instance.PlainName}..."); if (await azure.ResourceGroups.ContainAsync(rgName)) { - logger.WriteVerbose($"Deleting resource group {rgName}"); - await azure.ResourceGroups.DeleteByNameAsync(rgName); - logger.WriteInfo($"Resource group {rgName} deleted."); + var functionApp = await azure.AppServices.FunctionApps.GetByResourceGroupAsync(rgName, instance.FunctionAppName); + if (functionApp != null) + { + logger.WriteVerbose($"Deleting instance {functionApp.Name} in resource group {rgName}."); + await azure.AppServices.FunctionApps.DeleteByIdAsync(functionApp.Id); + logger.WriteVerbose($"instance {functionApp.Name} deleted."); + } + + var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(rgName); + if (apps == null || apps.Count() == 0) + { + logger.WriteVerbose($"Deleting resource group {rgName}"); + await azure.ResourceGroups.DeleteByNameAsync(rgName); + logger.WriteInfo($"Resource group {rgName} deleted."); + } } else { - logger.WriteWarning($"Instance {instance.PlainName} not found in {location}."); + logger.WriteWarning($"Resource Group {rgName} not found in {location}."); + return false; } return true; } From 585348954266f575c60181992be87999c403709f Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 1 Nov 2018 20:31:00 +0000 Subject: [PATCH 16/37] Do not delete custom resource groups, also check on creation --- .../Instances/AggregatorInstances.cs | 52 ++++++++++++------- src/aggregator-cli/Instances/InstanceName.cs | 2 + 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 674a4d31..5f7e3343 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -75,8 +75,15 @@ internal async Task> ListInResourceGroupAsync(string resourc internal async Task Add(InstanceName instance, string location, string requiredVersion) { string rgName = instance.ResourceGroupName; + logger.WriteVerbose($"Checking if Resource Group {rgName} already exists"); if (!await azure.ResourceGroups.ContainAsync(rgName)) { + if (instance.IsCustom) + { + logger.WriteError($"Resource group {rgName} is custom and cannot be created."); + return false; + } + logger.WriteVerbose($"Creating resource group {rgName}"); await azure.ResourceGroups .Define(rgName) @@ -173,29 +180,38 @@ internal async Task Remove(InstanceName instance, string location) { string rgName = instance.ResourceGroupName; logger.WriteVerbose($"Searching instance {instance.PlainName}..."); - if (await azure.ResourceGroups.ContainAsync(rgName)) + bool rgFound = await azure.ResourceGroups.ContainAsync(rgName); + if (!rgFound) { - var functionApp = await azure.AppServices.FunctionApps.GetByResourceGroupAsync(rgName, instance.FunctionAppName); - if (functionApp != null) - { - logger.WriteVerbose($"Deleting instance {functionApp.Name} in resource group {rgName}."); - await azure.AppServices.FunctionApps.DeleteByIdAsync(functionApp.Id); - logger.WriteVerbose($"instance {functionApp.Name} deleted."); - } - - var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(rgName); - if (apps == null || apps.Count() == 0) - { - logger.WriteVerbose($"Deleting resource group {rgName}"); - await azure.ResourceGroups.DeleteByNameAsync(rgName); - logger.WriteInfo($"Resource group {rgName} deleted."); - } + logger.WriteWarning($"Resource Group {rgName} not found in {location}."); + return false; } - else + var functionApp = await azure.AppServices.FunctionApps.GetByResourceGroupAsync(rgName, instance.FunctionAppName); + if (functionApp == null) { - logger.WriteWarning($"Resource Group {rgName} not found in {location}."); + logger.WriteWarning($"Instance {functionApp.Name} not found in resource group {rgName}."); return false; } + + logger.WriteVerbose($"Deleting instance {functionApp.Name} in resource group {rgName}."); + await azure.AppServices.FunctionApps.DeleteByIdAsync(functionApp.Id); + logger.WriteInfo($"Instance {functionApp.Name} deleted."); + + // we delete the RG only if was made by us + logger.WriteVerbose($"Checking if last instance in resource group {rgName}"); + var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(rgName); + if (apps == null || apps.Count() == 0) + { + if (instance.IsCustom) + { + logger.WriteWarning($"Resource group {rgName} is custom and won't be deleted."); + return true; + } + + logger.WriteVerbose($"Deleting empty resource group {rgName}"); + await azure.ResourceGroups.DeleteByNameAsync(rgName); + logger.WriteInfo($"Resource group {rgName} deleted."); + } return true; } diff --git a/src/aggregator-cli/Instances/InstanceName.cs b/src/aggregator-cli/Instances/InstanceName.cs index 5ee2a6b1..de8cf6ab 100644 --- a/src/aggregator-cli/Instances/InstanceName.cs +++ b/src/aggregator-cli/Instances/InstanceName.cs @@ -46,6 +46,8 @@ public static InstanceName FromFunctionAppUrl(string url) internal string ResourceGroupName => resourceGroup; + internal bool IsCustom => resourceGroup != resourceGroupPrefix + name; + internal string FunctionAppName=> name + functionAppSuffix; internal string DnsHostName => $"{FunctionAppName}.azurewebsites.net"; From 763e795f9ae5ed12d131627a5b32b4ce6814d4fd Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 3 Nov 2018 13:17:32 +0000 Subject: [PATCH 17/37] fixes errors in examples --- doc/command-examples.md | 2 +- doc/rule-examples.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/command-examples.md b/doc/command-examples.md index 8fbbb837..ddacc0d5 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -54,5 +54,5 @@ remove.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 # delete the Azure Function Application leaving the Service Hooks in place uninstall.instance --name my1 --location westeurope --dont-remove-mappings # delete the Azure Function Application and any Service Hooks referring to it -uninstall.instance --verbose --instance my3 --resourceGroup myRG1 +uninstall.instance --verbose --name my3 --resourceGroup myRG1 --location westeurope ``` \ No newline at end of file diff --git a/doc/rule-examples.md b/doc/rule-examples.md index 6290ce9a..7deec773 100644 --- a/doc/rule-examples.md +++ b/doc/rule-examples.md @@ -16,10 +16,10 @@ The major difference is the navigation: `Parent` and `Children` properties do no ``` string message = ""; -if (self.Parent != null) +var parent = self.Parent; +if (parent != null) { - var parent = store.GetWorkItem(self.Parent); - var children = store.GetWorkItems(parent.Children); + var children = parent.Children; if (children.All(c => c.State == "Closed")) { parent.State = "Closed"; From dfb703192395c777d9ebbd2b3c7eb73bd888ecda Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 3 Nov 2018 13:53:15 +0000 Subject: [PATCH 18/37] more commmand examples --- doc/command-examples.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/command-examples.md b/doc/command-examples.md index ddacc0d5..e7f898de 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -26,6 +26,9 @@ add.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 --file test # adds two Service Hook to Azure DevOps, each invoking a different rule map.rule --verbose --project SampleProject --event workitem.created --instance my1 --rule test1 map.rule --verbose --project SampleProject --event workitem.updated --instance my1 --rule test2 +map.rule --verbose --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 + + list.mappings --verbose --instance my1 list.mappings --verbose --project SampleProject list.mappings --instance my1 --project SampleProject @@ -44,8 +47,8 @@ configure.instance --name my3 --resourceGroup myRG1 --location westeurope --auth # remove a Service Hook from Azure DevOps unmap.rule --verbose --event workitem.created --instance my1 --rule test1 -# remove a Service Hook from Azure DevOps unmap.rule --verbose --event workitem.updated --project SampleProject --instance my1 --rule test2 +unmap.rule --verbose --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 # deletes an Azure Function and all Service Hooks referring to it remove.rule --verbose --instance my1 --name test1 From 95f58c6f093d03604822cf68a964ac9d818a3249 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 3 Nov 2018 15:01:31 +0000 Subject: [PATCH 19/37] fixed broken, incomplete test --- src/unittests-ruleng/RuleTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index e3a68aa9..55a8db9a 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -110,16 +110,18 @@ public async void New_Succeeds() var logger = new MockAggregatorLogger(); int workItemId = 1; string ruleCode = @" -var wi = store.NewWorkItem(); +var wi = store.NewWorkItem(""Task""); wi.Title = ""Brand new""; -var rel = new WorkItemRelationWrapper(wi, CoreRelationRefNames.Parent, self.Url); -self.ChildrenLinks.AddRelation(rel); "; var engine = new RuleEngine(logger, ruleCode.Mince()); string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); - Assert.Equal("Children are ,42,99", result); + Assert.Null(result); + Assert.Contains( + logger.GetMessages(), + m => m.Message == "Changes saved to Azure DevOps: 1 created, 0 updated." + && m.Level == "Info"); } } } From b870bdc32e1843c76db8f18e51493e966bd8cfb9 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 4 Nov 2018 11:45:50 +0000 Subject: [PATCH 20/37] relations starts to make sense --- .../WorkItemRelationWrapperCollection.cs | 12 +++++++ src/aggregator-ruleng/WorkItemWrapper.cs | 35 ++++++++++-------- src/rule-language.md | 36 +++++++++++++++---- .../FakeWorkItemTrackingHttpClient.cs | 7 ++++ src/unittests-ruleng/RuleTests.cs | 26 ++++++++++++++ src/unittests-ruleng/WorkItemStoreTests.cs | 4 ++- 6 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs index 1dd97226..ff9ee4d9 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs @@ -89,6 +89,18 @@ public void Add(WorkItemRelationWrapper item) AddRelation(item); } + public void AddChild(WorkItemWrapper child) + { + var r = new WorkItemRelationWrapper(child, CoreRelationRefNames.Children, child.Url, string.Empty); + AddRelation(r); + } + + public void AddParent(WorkItemWrapper parent) + { + var r = new WorkItemRelationWrapper(parent, CoreRelationRefNames.Parent, parent.Url, string.Empty); + AddRelation(r); + } + public void AddLink(string type, string url, string comment) { AddRelation(new WorkItemRelationWrapper( diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 14f396fd..4747d891 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -12,6 +12,8 @@ public class WorkItemWrapper { private EngineContext _context; private WorkItem _item; + private WorkItemRelationWrapperCollection _relationCollection; + private readonly JsonPatchDocument _changes = new JsonPatchDocument(); private readonly bool _isReadOnly = false; @@ -19,6 +21,7 @@ internal WorkItemWrapper(EngineContext context, WorkItem item) { _context = context; _item = item; + _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); if (item.Id.HasValue) { @@ -48,6 +51,7 @@ public WorkItemWrapper(EngineContext context, string project, string type) _item.Fields[CoreFieldRefNames.TeamProject] = project; _item.Fields[CoreFieldRefNames.WorkItemType] = type; _item.Fields[CoreFieldRefNames.Id] = Id.Value; + _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); _context.Tracker.TrackNew(this); } @@ -62,6 +66,7 @@ public WorkItemWrapper(EngineContext context, WorkItemWrapper template, string t _item.Fields[CoreFieldRefNames.TeamProject] = template.TeamProject; _item.Fields[CoreFieldRefNames.WorkItemType] = type; _item.Fields[CoreFieldRefNames.Id] = Id.Value; + _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); _context.Tracker.TrackNew(this); } @@ -81,6 +86,7 @@ internal WorkItemWrapper(EngineContext context, WorkItem item, bool isReadOnly) }); _isReadOnly = isReadOnly; _item = item; + _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); _context.Tracker.TrackRevision(this); } @@ -114,11 +120,20 @@ public IEnumerable Revisions } } - public IEnumerable Relations + public IEnumerable RelationLinks { get { - return new WorkItemRelationWrapperCollection(this, _item.Relations); + return _relationCollection; + } + } + + + public WorkItemRelationWrapperCollection Relations + { + get + { + return _relationCollection; } } @@ -126,7 +141,7 @@ public IEnumerable ChildrenLinks { get { - return new WorkItemRelationWrapperCollection(this, _item.Relations) + return _relationCollection .Where(rel => rel.Rel == CoreRelationRefNames.Children); } } @@ -144,7 +159,7 @@ public IEnumerable RelatedLinks { get { - return new WorkItemRelationWrapperCollection(this, _item.Relations) + return _relationCollection .Where(rel => rel.Rel == CoreRelationRefNames.Related); } } @@ -153,7 +168,7 @@ public IEnumerable Hyperlinks { get { - return new WorkItemRelationWrapperCollection(this, _item.Relations) + return _relationCollection .Where(rel => rel.Rel == CoreRelationRefNames.Hyperlink); } } @@ -162,7 +177,7 @@ public WorkItemRelationWrapper ParentLink { get { - return new WorkItemRelationWrapperCollection(this, _item.Relations) + return _relationCollection .Where(rel => rel.Rel == CoreRelationRefNames.Parent) .SingleOrDefault(); } @@ -177,14 +192,6 @@ public WorkItemWrapper Parent } } - public void AddChild(WorkItemWrapper newChild) - { - var rel = new WorkItemRelation { Url = newChild.Url, Rel = CoreRelationRefNames.Children }; - _item.Relations.Add(rel); - var rels = new WorkItemRelationWrapperCollection(this, _item.Relations); - rels.Add(new WorkItemRelationWrapper(this, rel)); - } - public WorkItemId Id { get; diff --git a/src/rule-language.md b/src/rule-language.md index c35a30aa..e4aed811 100644 --- a/src/rule-language.md +++ b/src/rule-language.md @@ -3,17 +3,30 @@ `.lang=C#` `.language=Csharp` + + # WorkItem Object +## Revisions WorkItem PreviousRevision IEnumerable Revisions -IEnumerable Relations + +## Relations +IEnumerable RelationLinks +WorkItemRelationCollection Relations IEnumerable ChildrenLinks IEnumerable Children -IEnumerable RelatedLinks -IEnumerable Hyperlinks WorkItemRelation ParentLink WorkItem Parent + +## Links +IEnumerable RelatedLinks +IEnumerable Hyperlinks +int ExternalLinkCount +int HyperLinkCount +int RelatedLinkCount + +## Fields WorkItemId Id int Rev string Url @@ -22,20 +35,16 @@ string State int AreaId string AreaPath string AssignedTo -int AttachedFileCount string AuthorizedAs string ChangedBy DateTime? ChangedDate string CreatedBy DateTime? CreatedDate string Description -int ExternalLinkCount string History -int HyperLinkCount int IterationId string IterationPath string Reason -int RelatedLinkCount DateTime? RevisedDate DateTime? AuthorizedDate string TeamProject @@ -48,6 +57,11 @@ bool IsNew bool IsDirty object this[string field] +## Attachments +int AttachedFileCount + + + # WorkItemStore Object WorkItem GetWorkItem(int id) @@ -56,10 +70,16 @@ WorkItem GetWorkItem(WorkItemRelation item) IList GetWorkItems(IEnumerable ids) IList GetWorkItems(IEnumerable collection) +WorkItemWrapper NewWorkItem(string workItemType) + + + # WorkItemRelationCollection IEnumerator GetEnumerator() Add(WorkItemRelation item) +AddChild(WorkItemWrapper child) +AddParent(WorkItemWrapper parent) AddLink(string type, string url, string comment) AddHyperlink(string url, string comment = null) AddRelatedLink(WorkItem item, string comment = null) @@ -70,6 +90,8 @@ bool Remove(WorkItemRelation item) int Count bool IsReadOnly + + # WorkItemRelation string Title diff --git a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs index c4f069a2..136706e3 100644 --- a/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs +++ b/src/unittests-ruleng/FakesNStubs/FakeWorkItemTrackingHttpClient.cs @@ -227,5 +227,12 @@ public FakeWorkItemTrackingHttpClient(Uri baseUrl, VssCredentials credentials) t.RunSynchronously(); return t; } + + public override Task UpdateWorkItemAsync(JsonPatchDocument document, int id, bool? validateOnly = null, bool? bypassRules = null, bool? suppressNotifications = null, object userState = null, CancellationToken cancellationToken = default(CancellationToken)) + { + var t = new Task(() => new WorkItem()); + t.RunSynchronously(); + return t; + } } } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index 55a8db9a..ef28eaa8 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -123,5 +123,31 @@ public async void New_Succeeds() m => m.Message == "Changes saved to Azure DevOps: 1 created, 0 updated." && m.Level == "Info"); } + + [Fact] + public async void AddChild_Succeeds() + { + string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + var baseUrl = new Uri($"{collectionUrl}"); + var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); + var logger = new MockAggregatorLogger(); + int workItemId = 1; + string ruleCode = @" +var parent = self; +var newChild = store.NewWorkItem(""Task""); +newChild.Title = ""Brand new""; +parent.Relations.AddChild(newChild); +"; + + var engine = new RuleEngine(logger, ruleCode.Mince()); + string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + + Assert.Null(result); + Assert.Contains( + logger.GetMessages(), + m => m.Message == "Changes saved to Azure DevOps: 1 created, 1 updated." + && m.Level == "Info"); + } } } diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 0f3d10b6..3154f32d 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -78,9 +78,11 @@ public void AddChild_Succeeds() var sut = new WorkItemStore(context); var parent = sut.GetWorkItem(1); + Assert.Equal(2, parent.Relations.Count()); + var newChild = sut.NewWorkItem("Task"); newChild.Title = "Brand new"; - parent.AddChild(newChild); + parent.Relations.AddChild(newChild); Assert.NotNull(newChild); Assert.True(newChild.IsNew); From 88182cca76ddb03b34958b2a235f260671ff0959 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 6 Nov 2018 22:57:06 +0000 Subject: [PATCH 21/37] Fixes If-Match issue on update.rule --- doc/contributor-on-rg.png | Bin 0 -> 17836 bytes src/aggregator-cli/Program.cs | 13 ++++--- src/aggregator-cli/Rules/AggregatorRules.cs | 41 ++++++++++++++++---- src/aggregator-cli/aggregator-cli.csproj | 3 ++ src/aggregator-cli/test/test3b.rule | 2 + 5 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 doc/contributor-on-rg.png create mode 100644 src/aggregator-cli/test/test3b.rule diff --git a/doc/contributor-on-rg.png b/doc/contributor-on-rg.png new file mode 100644 index 0000000000000000000000000000000000000000..c6a41bfe9d369ed779e578b0c19272721340b35f GIT binary patch literal 17836 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 LN>W7> ListAsync(InstanceName instance) } } + internal Task InvokeLocalAsync(string @event, int workItemId, string source) + { + throw new NotImplementedException(); + } + internal static string GetInvocationUrl(InstanceName instance, string rule) { return $"{instance.FunctionAppUrl}/api/{rule}"; @@ -109,7 +114,7 @@ internal async Task AddAsync(InstanceName instance, string name, string fi bool ok = await UploadRuleFiles(instance, name, baseDirPath); if (ok) { - logger.WriteInfo($"{name} files uploaded to {instance.PlainName}."); + logger.WriteInfo($"All {name} files uploaded to {instance.PlainName}."); } CleanupRuleFiles(baseDirPath); logger.WriteInfo($"Cleaned local working directory."); @@ -162,27 +167,45 @@ Puts a file at path. PUT /api/vfs/{path}/ Creates a directory at path. The path can be nested, e.g. `folder1/folder2`. + + Note: when updating or deleting a file, ETag behavior will apply. You can pass a If-Match: "*" header to disable the ETag check. */ var kudu = new KuduApi(instance, azure, logger); string relativeUrl = $"api/vfs/site/wwwroot/{name}/"; var instances = new AggregatorInstances(azure, logger); - logger.WriteVerbose($"Creating function {name} in {instance.PlainName}..."); using (var client = new HttpClient()) { - using (var request = await kudu.GetRequestAsync(HttpMethod.Put, relativeUrl)) + bool exists = false; + + // check if function already exists + using (var request = await kudu.GetRequestAsync(HttpMethod.Head, relativeUrl)) { + logger.WriteVerbose($"Checking if function {name} already exists in {instance.PlainName}..."); using (var response = await client.SendAsync(request)) { - bool ok = response.IsSuccessStatusCode; - if (!ok) + exists = response.IsSuccessStatusCode; + } + } + + if (!exists) + { + logger.WriteVerbose($"Creating function {name} in {instance.PlainName}..."); + using (var request = await kudu.GetRequestAsync(HttpMethod.Put, relativeUrl)) + { + using (var response = await client.SendAsync(request)) { - logger.WriteError($"Upload failed with {response.ReasonPhrase}"); - return ok; + bool ok = response.IsSuccessStatusCode; + if (!ok) + { + logger.WriteError($"Upload failed with {response.ReasonPhrase}"); + return ok; + } } } + logger.WriteInfo($"Function {name} created."); } - logger.WriteInfo($"Function {name} created."); + var files = Directory.EnumerateFiles(baseDirPath, "*", SearchOption.AllDirectories); foreach (var file in files) { @@ -190,6 +213,8 @@ Puts a file at path. string fileUrl = $"{relativeUrl}{Path.GetFileName(file)}"; using (var request = await kudu.GetRequestAsync(HttpMethod.Put, fileUrl)) { + //HACK -> request.Headers.IfMatch.Add(new EntityTagHeaderValue("*", false)); <- won't work + request.Headers.Add("If-Match", "*"); request.Content = new StringContent(File.ReadAllText(file)); using (var response = await client.SendAsync(request)) { diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index f32bcccc..f0aa0516 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -53,6 +53,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/aggregator-cli/test/test3b.rule b/src/aggregator-cli/test/test3b.rule new file mode 100644 index 00000000..12859fc8 --- /dev/null +++ b/src/aggregator-cli/test/test3b.rule @@ -0,0 +1,2 @@ +.language=Csharp +return self.PreviousRevision.PreviousRevision.Description; From 60e543787bc694a516469fd608bec1876231155a Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Tue, 6 Nov 2018 23:01:23 +0000 Subject: [PATCH 22/37] Fixes Verb 'configure.instance' is not recognized --- src/aggregator-cli/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 5a68a0e4..564ea467 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -44,6 +44,7 @@ static int Main(string[] args) var types = new Type[] { typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), + typeof(ConfigureInstanceCommand), typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) From 1fd36cedf972613999baafcec4dcb5aa5efec41a Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 8 Nov 2018 22:50:31 +0000 Subject: [PATCH 23/37] better logging --- src/aggregator-cli/Instances/AggregatorInstances.cs | 9 ++++++++- src/aggregator-cli/Instances/FunctionRuntimePackage.cs | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 5f7e3343..4eb3e87b 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -148,7 +148,14 @@ await azure.ResourceGroups { logger.WriteVerbose($"Saving Azure DevOps token"); ok = await ChangeAppSettings(instance, devopsLogonData); - logger.WriteInfo($"Azure DevOps token saved"); + if (ok) + { + logger.WriteInfo($"Azure DevOps token saved"); + } + else + { + logger.WriteError($"Failed to save Azure DevOps token"); + } } else { diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 148859e0..a938806e 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -64,7 +64,7 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins } else { - logger.WriteError($"{response.ReasonPhrase}"); + logger.WriteWarning($"Cannot read aggregator-manifest.ini: {response.ReasonPhrase}"); } } var uploadedRuntimeVer = SemVersion.Parse(manifestVersion); @@ -97,7 +97,7 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins } else { - logger.WriteInfo($"Nothing to update."); + logger.WriteInfo($"Runtime package is up to date."); return true; } } From ecc11cf703f1b48df233f84b39d2814285ba42f0 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 8 Nov 2018 22:51:16 +0000 Subject: [PATCH 24/37] remove spurious test function --- .../aggregator-function.csproj | 9 ------- src/aggregator-function/test/function.json | 16 ------------ src/aggregator-function/test/run.csx | 10 ------- src/aggregator-function/test/test.rule | 26 ------------------- 4 files changed, 61 deletions(-) delete mode 100644 src/aggregator-function/test/function.json delete mode 100644 src/aggregator-function/test/run.csx delete mode 100644 src/aggregator-function/test/test.rule diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index ae0036e8..8f17bed5 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -29,15 +29,6 @@ Never - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - diff --git a/src/aggregator-function/test/function.json b/src/aggregator-function/test/function.json deleted file mode 100644 index 69b083a4..00000000 --- a/src/aggregator-function/test/function.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "bindings": [ - { - "type": "httpTrigger", - "direction": "in", - "webHookType": "genericJson", - "name": "req" - }, - { - "type": "http", - "direction": "out", - "name": "res" - } - ], - "disabled": false -} \ No newline at end of file diff --git a/src/aggregator-function/test/run.csx b/src/aggregator-function/test/run.csx deleted file mode 100644 index cbd770d7..00000000 --- a/src/aggregator-function/test/run.csx +++ /dev/null @@ -1,10 +0,0 @@ -#r "../bin/aggregator-function.dll" -#r "../bin/aggregator-shared.dll" - -using aggregator; - -public static async Task Run(HttpRequestMessage req, ILogger logger, ExecutionContext context) -{ - var handler = new AzureFunctionHandler(logger, context); - return await handler.Run(req); -} diff --git a/src/aggregator-function/test/test.rule b/src/aggregator-function/test/test.rule deleted file mode 100644 index db3f6454..00000000 --- a/src/aggregator-function/test/test.rule +++ /dev/null @@ -1,26 +0,0 @@ -.lang=C# - -/* -return self.PreviousRevision.PreviousRevision.Description; -*/ - -/* -if (self.ParentLink != null) -{ - var parent = self.Parent; - var children = parent.Children; - if (children.All(c => c.State == "Closed")) - { - parent.State = "Closed"; - return "Had to close parent"; - } else { - return "Not all children are closed"; - } - //parent.Description = parent.Description + " aggregator was here."; -} else { - return "No parent!"; -} -*/ - -var newChild = store.NewWorkItem("Task"); -newChild.Title = "Brand new"; From 7b99e3623dcd8037d4118c2dead7ad2c4c8d4403 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 8 Nov 2018 22:54:28 +0000 Subject: [PATCH 25/37] checks if GutHub release exists --- .../Instances/FunctionRuntimePackage.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index a938806e..1063501d 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -31,12 +31,22 @@ internal FunctionRuntimePackage(ILogger logger) internal async Task UpdateVersion(string requiredVersion, InstanceName instance, IAzure azure) { + string tag = requiredVersion; if (string.IsNullOrWhiteSpace(requiredVersion)) { - requiredVersion = "latest"; + tag = requiredVersion = "latest"; + } + else + { + tag = requiredVersion[0] != 'v' ? "v" + requiredVersion : requiredVersion; } logger.WriteVerbose($"Checking runtime package versions in GitHub"); - (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHub(requiredVersion); + (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHub(tag); + if (string.IsNullOrEmpty(rel_name)) + { + logger.WriteError($"Requested runtime {requiredVersion} version does not exists."); + return false; + } if (rel_name[0] == 'v') rel_name = rel_name.Substring(1); var requiredRuntimeVer = SemVersion.Parse(rel_name); logger.WriteVerbose($"Latest Runtime package version is {requiredRuntimeVer} (released on {rel_when})."); From 1ddff248a642bab65cd565cf2829ea03fbc10b28 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 8 Nov 2018 22:55:23 +0000 Subject: [PATCH 26/37] IdentityRef raw --- src/aggregator-ruleng/RuleEngine.cs | 10 ++++++++-- src/aggregator-ruleng/WorkItemWrapper.cs | 17 +++++++++-------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index ea5b27f7..72674579 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -41,7 +41,8 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode) var types = new List() { typeof(object), typeof(System.Linq.Enumerable), - typeof(System.Collections.Generic.CollectionExtensions) + typeof(System.Collections.Generic.CollectionExtensions), + typeof(Microsoft.VisualStudio.Services.WebApi.IdentityRef) }; var references = types.ConvertAll(t => t.Assembly).Distinct(); @@ -49,7 +50,12 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode) .WithEmitDebugInformation(true) .WithReferences(references) // Add namespaces - .WithImports("System", "System.Linq", "System.Collections.Generic"); + .WithImports( + "System", + "System.Linq", + "System.Collections.Generic", + "Microsoft.VisualStudio.Services.WebApi" + ); this.roslynScript = CSharpScript.Create( code: directives.GetRuleCode(), diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 4747d891..2d60555c 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -1,5 +1,6 @@ using Microsoft.TeamFoundation.WorkItemTracking.WebApi; using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.WebApi; using Microsoft.VisualStudio.Services.WebApi.Patch; using Microsoft.VisualStudio.Services.WebApi.Patch.Json; using System; @@ -231,9 +232,9 @@ public string AreaPath set { SetFieldValue(CoreFieldRefNames.AreaPath, value); } } - public string AssignedTo + public IdentityRef AssignedTo { - get { return GetFieldValue(CoreFieldRefNames.AssignedTo); } + get { return GetFieldValue(CoreFieldRefNames.AssignedTo); } set { SetFieldValue(CoreFieldRefNames.AssignedTo, value); } } @@ -243,15 +244,15 @@ public int AttachedFileCount set { SetFieldValue(CoreFieldRefNames.AttachedFileCount, value); } } - public string AuthorizedAs + public IdentityRef AuthorizedAs { - get { return GetFieldValue(CoreFieldRefNames.AuthorizedAs); } + get { return GetFieldValue(CoreFieldRefNames.AuthorizedAs); } set { SetFieldValue(CoreFieldRefNames.AuthorizedAs, value); } } - public string ChangedBy + public IdentityRef ChangedBy { - get { return GetFieldValue(CoreFieldRefNames.ChangedBy); } + get { return GetFieldValue(CoreFieldRefNames.ChangedBy); } set { SetFieldValue(CoreFieldRefNames.ChangedBy, value); } } @@ -261,9 +262,9 @@ public DateTime? ChangedDate set { SetFieldValue(CoreFieldRefNames.ChangedDate, value); } } - public string CreatedBy + public IdentityRef CreatedBy { - get { return GetFieldValue(CoreFieldRefNames.CreatedBy); } + get { return GetFieldValue(CoreFieldRefNames.CreatedBy); } set { SetFieldValue(CoreFieldRefNames.CreatedBy, value); } } From 6c657b1c85dc18c4be1d779f9eaa80136a92b4a6 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 8 Nov 2018 22:55:54 +0000 Subject: [PATCH 27/37] doc updated --- README.md | 15 +++++++--- {src => doc}/rule-language.md | 25 ++++++++++++---- doc/test-matrix.md | 56 +++++++++++++++++++++++++++++++++++ src/aggregator-cli.sln | 4 ++- 4 files changed, 90 insertions(+), 10 deletions(-) rename {src => doc}/rule-language.md (85%) create mode 100644 doc/test-matrix.md diff --git a/README.md b/README.md index e7f98f72..74eb0939 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ The PAT is stored in the Azure Function settings: **whoever has access to the Re The Service Principal must have Contributor permission to the Azure Subscription. In alternative, pre-create the `aggregator-` Resource Group in Azure and give the service account Contributor permission to the Resource Group. +![Permission on existing Resource Group](doc/contributor-on-rg.png) +If you go this route, you must add the `--resourceGroup` to all commands requiring an instance. The `instance` parameter prefixes `aggregator-` to identify the Resource Group. ## Usage @@ -77,20 +79,25 @@ logon.ado | Logon into Azure DevOps. list.instances | Lists Aggregator instances. install.instance | Creates a new Aggregator instance in Azure. uninstall.instance | Destroy an Aggregator instance in Azure. +configure.instance | Configures an existing Aggregator instance. list.rules | List the rule in existing Aggregator instance in Azure. add.rule | Add a rule to existing Aggregator instance in Azure. remove.rule | Remove a rule from existing Aggregator instance in Azure. -configure.rule | Change a rule configuration and code. +configure.rule | Change a rule configuration. +update.rule | Update a rule code and/or runtime. +invoke.rule | Executes a rule locally or in an existing Aggregator instance. list.mappings | Lists mappings from existing Azure DevOps Projects to Aggregator Rules. map.rule | Maps an Aggregator Rule to existing Azure DevOps Projects. unmap.rule | Unmaps an Aggregator Rule from a Azure DevOps Project. help | Display more information on a specific command. version | Display version information. -## Examples +You can see a few Command examples in [Sample Aggregator CLI usage](doc/command-examples.md), see also [Manual Tests](doc/test-matrix.md). -You can see a few Command examples in [Sample Aggregator CLI usage](doc/command-examples.md). -You can see a few Rule examples in [Rule Examples](doc/rule-examples.md). +## Rule language + +See [Rule Language](doc/rule-language.md) for a list of objects and properties to use. +For examples see [Rule Examples](doc/rule-examples.md). ## Troubleshooting diff --git a/src/rule-language.md b/doc/rule-language.md similarity index 85% rename from src/rule-language.md rename to doc/rule-language.md index e4aed811..965cc6ed 100644 --- a/src/rule-language.md +++ b/doc/rule-language.md @@ -34,11 +34,11 @@ string WorkItemType string State int AreaId string AreaPath -string AssignedTo -string AuthorizedAs -string ChangedBy +IdentityRef AssignedTo +IdentityRef AuthorizedAs +IdentityRef ChangedBy DateTime? ChangedDate -string CreatedBy +IdentityRef CreatedBy DateTime? CreatedDate string Description string History @@ -97,4 +97,19 @@ bool IsReadOnly string Title string Rel string Url -IDictionary Attributes \ No newline at end of file +IDictionary Attributes + + + +# IdentityRef + +string DirectoryAlias +string DisplayName +string Id +string ImageUrl +bool Inactive +bool IsAadIdentity +bool IsContainer +string ProfileUrl +string UniqueName +string Url \ No newline at end of file diff --git a/doc/test-matrix.md b/doc/test-matrix.md new file mode 100644 index 00000000..0bf426c8 --- /dev/null +++ b/doc/test-matrix.md @@ -0,0 +1,56 @@ +| Scenario 1 | Pass/Fail | Expected | +|---------------------------------------------------------------------------------------------------------------|:---------:|----------------------------------------------------------------------| +| logon.azure SUBSCRIPTION_OWNER | 0.4.0 | | +| logon.ado ACCOUNT_ADMIN | 0.4.0 | | +| install.instance --name my1 --location westeurope | 0.4.0 | Creates Resource Group and Function App | +| list.instances | 0.4.0 | | +| list.instances --resourceGroup aggregator-my1 | 0.4.0 | | +| add.rule --instance my1 --name test1 --file test\test1.rule | 0.4.0 | Add Function | +| add.rule --instance my1 --name test2 --file test\test2.rule | 0.4.0 | Add Function | +| list.rules --instance my1 | 0.4.0 | | +| map.rule --project SampleProject --event workitem.created --instance my1 --rule test1 | 0.4.0 | Add Subscription to AzDO | +| map.rule --project SampleProject --event workitem.updated --instance my1 --rule test2 | 0.4.0 | Add Subscription to AzDO | +| list.mappings --instance my1 | 0.4.0 | | +| list.mappings --project SampleProject | 0.4.0 | | +| list.mappings --instance my1 --project SampleProject | 0.4.0 | | +| configure.rule --instance my1 --name test1 --disable | 0.4.0 | Function is disabled | +| configure.rule --instance my1 --name test1 --enable | 0.4.0 | Function is enabled | +| update.rule --instance my1 --name test1 --file test\test1.rule --requiredVersion 99.99.99 | 0.4.0 | Must fail because version does not exists | +| update.rule --instance my1 --name test1 --file test\test1.rule --requiredVersion 0.3.3 | 0.4.0 | Uploads only the rule | +| configure.instance --name my1 --location westeurope --authentication | 0.4.0 | | +| unmap.rule --event workitem.created --instance my1 --rule test1 | 0.4.0 | Subscription removed | +| unmap.rule --event workitem.updated --project SampleProject --instance my1 --rule test2 | 0.4.0 | Subscription removed | +| remove.rule --instance my1 --name test1 | 0.4.0 | Function removed | +| uninstall.instance --name my1 --location westeurope --dont-remove-mappings | 0.4.0 | Remove the entire Resource Group leaving the Subscriptions in AzDO | + +| Scenario 2 | Pass/Fail | Expected | +|---------------------------------------------------------------------------------------------------------------|:---------:|----------------------------------------------------------------------| +| logon.azure RG_CONTRIBUTOR | 0.4.0 | | +| logon.ado ACCOUNT_ADMIN | 0.4.0 | | +| install.instance --name my3 --resourceGroup myRG1 --location westeurope --requiredVersion latest | 0.4.0 | Add Function App to existing Resource Group with most recent runtime | +| list.instances --resourceGroup myRG1 | 0.4.0 | | +| add.rule --instance my3 --resourceGroup myRG1 --name test3 --file test\test3.rule | 0.4.0 | Add Function | +| list.rules --instance my3 --resourceGroup myRG1 | 0.4.0 | | +| map.rule --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 | 0.4.0 | Add Subscription to AzDO | +| list.mappings --instance my3 --resourceGroup myRG1 | 0.4.0 | | +| list.mappings --project SampleProject | 0.4.0 | | +| list.mappings --instance my3 --resourceGroup myRG1 --project SampleProject | 0.4.0 | | +| configure.rule --instance my3 --resourceGroup myRG1 --name test3 --disable | 0.4.0 | Function is disabled | +| configure.rule --instance my3 --resourceGroup myRG1 --name test3 --enable | 0.4.0 | Function is enabled | +| update.rule --instance my3 --resourceGroup myRG1 --name test3 --file test\test3b.rule | 0.4.0 | Function code has additional line | +| configure.instance --name my3 --resourceGroup myRG1 --location westeurope --authentication | 0.4.0 | | +| unmap.rule --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 | 0.4.0 | Subscription removed | +| remove.rule --instance my3 --resourceGroup myRG1 --name test3 | 0.4.0 | Function removed | +| uninstall.instance --name my3 --resourceGroup myRG1 --location westeurope | 0.4.0 | Function app removed | + +| Scenario 3 | Pass/Fail | Expected | +|---------------------------------------------------------------------------------------------------------------|:---------:|----------------------------------------------------------------------| +| logon.azure RG_CONTRIBUTOR | 0.4.0 | | +| logon.ado ACCOUNT_ADMIN | 0.4.0 | | +| install.instance --name my3 --resourceGroup myRG1 --location westeurope | 0.4.0 | Add Function App to existing Resource Group | +| list.instances --resourceGroup myRG1 | 0.4.0 | | +| add.rule --instance my3 --resourceGroup myRG1 --name test3 --file test\test3.rule | 0.4.0 | Add Function | +| list.rules --instance my3 --resourceGroup myRG1 | 0.4.0 | | +| map.rule --project SampleProject --event workitem.created --instance my3 --resourceGroup myRG1 --rule test3 | 0.4.0 | Add Subscription to AzDO | +| list.mappings --instance my3 --resourceGroup myRG1 | 0.4.0 | | +| uninstall.instance --name my3 --resourceGroup myRG1 --location westeurope | 0.4.0 | Removes the Function and the Subscriptions | \ No newline at end of file diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index a2a41e72..c5a5fa7e 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -18,10 +18,12 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{1A84F5C1-ADA4-4DD1-A0D9-03635DF9FC56}" ProjectSection(SolutionItems) = preProject ..\doc\command-examples.md = ..\doc\command-examples.md + ..\doc\contributor-on-rg.png = ..\doc\contributor-on-rg.png ..\doc\log-streaming-from-azure-portal.png = ..\doc\log-streaming-from-azure-portal.png ..\README.md = ..\README.md ..\doc\rule-examples.md = ..\doc\rule-examples.md - rule-language.md = rule-language.md + ..\doc\rule-language.md = ..\doc\rule-language.md + ..\doc\test-matrix.md = ..\doc\test-matrix.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-ruleng", "unittests-ruleng\unittests-ruleng.csproj", "{19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}" From 654b34ec8470f5d5547020b264f01f232a8a9f47 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 10 Nov 2018 12:22:46 +0000 Subject: [PATCH 28/37] invoke.rule command --- src/aggregator-cli/Program.cs | 3 +- src/aggregator-cli/Rules/AggregatorRules.cs | 63 +++++++++++++++++-- .../Rules/EngineWrapperLogger.cs | 32 ++++++++++ src/aggregator-cli/Rules/InvokeRuleCommand.cs | 56 +++++++++++++++++ src/aggregator-cli/aggregator-cli.csproj | 1 + src/aggregator-cli/test/test2.rule | 4 +- src/aggregator-ruleng/RuleEngine.cs | 3 +- src/aggregator-ruleng/WorkItemStore.cs | 38 +++++++---- src/unittests-ruleng/WorkItemStoreTests.cs | 2 +- 9 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 src/aggregator-cli/Rules/EngineWrapperLogger.cs create mode 100644 src/aggregator-cli/Rules/InvokeRuleCommand.cs diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 564ea467..9f74716d 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -46,7 +46,7 @@ static int Main(string[] args) typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), typeof(ConfigureInstanceCommand), typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), - typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), + typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(InvokeRuleCommand), typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) }; var parserResult = parser.ParseArguments(args, types); @@ -63,6 +63,7 @@ static int Main(string[] args) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) + .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) .WithParsed(cmd => rc = cmd.Run()) diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index 2619a772..46ebb580 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -13,6 +13,10 @@ using Microsoft.Azure.Management.ResourceManager.Fluent; using Microsoft.Azure.Management.ResourceManager.Fluent.Authentication; using Microsoft.IdentityModel.Clients.ActiveDirectory; +using Microsoft.TeamFoundation.Core.WebApi; +using Microsoft.TeamFoundation.WorkItemTracking.WebApi; +using Microsoft.VisualStudio.Services.Common; +using Microsoft.VisualStudio.Services.WebApi; using Newtonsoft.Json; namespace aggregator.cli @@ -58,11 +62,6 @@ internal async Task> ListAsync(InstanceName instance) } } - internal Task InvokeLocalAsync(string @event, int workItemId, string source) - { - throw new NotImplementedException(); - } - internal static string GetInvocationUrl(InstanceName instance, string rule) { return $"{instance.FunctionAppUrl}/api/{rule}"; @@ -278,5 +277,59 @@ internal async Task UpdateAsync(InstanceName instance, string name, string } return ok; } + + internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun) + { + if (!File.Exists(ruleFilePath)) + { + logger.WriteError($"Rule code not found at {ruleFilePath}"); + return false; + } + + var devopsLogonData = DevOpsLogon.Load().connection; + + logger.WriteVerbose($"Connecting to Azure DevOps using {devopsLogonData.Mode}..."); + var clientCredentials = default(VssCredentials); + if (devopsLogonData.Mode == DevOpsTokenType.PAT) + { + clientCredentials = new VssBasicCredential(devopsLogonData.Mode.ToString(), devopsLogonData.Token); + } + else + { + logger.WriteError($"Azure DevOps Token type {devopsLogonData.Mode} not supported!"); + throw new ArgumentOutOfRangeException(nameof(devopsLogonData.Mode)); + } + + string collectionUrl = devopsLogonData.Url; + using (var devops = new VssConnection(new Uri(collectionUrl), clientCredentials)) + { + await devops.ConnectAsync(); + logger.WriteInfo($"Connected to Azure DevOps"); + + Guid teamProjectId; + using (var projectClient = devops.GetClient()) + { + logger.WriteVerbose($"Reading Azure DevOps project data..."); + var project = await projectClient.GetProject(projectName); + logger.WriteInfo($"Project {projectName} data read."); + teamProjectId = project.Id; + } + + using (var witClient = devops.GetClient()) + { + logger.WriteVerbose($"Rule code found at {ruleFilePath}"); + string[] ruleCode = File.ReadAllLines(ruleFilePath); + + var engineLogger = new EngineWrapperLogger(logger); + var engine = new Engine.RuleEngine(engineLogger, ruleCode); + engine.DryRun = dryRun; + + string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, workItemId, witClient); + logger.WriteInfo($"Rule returned '{result}'"); + + return true; + } + } + } } } diff --git a/src/aggregator-cli/Rules/EngineWrapperLogger.cs b/src/aggregator-cli/Rules/EngineWrapperLogger.cs new file mode 100644 index 00000000..e304baa5 --- /dev/null +++ b/src/aggregator-cli/Rules/EngineWrapperLogger.cs @@ -0,0 +1,32 @@ +namespace aggregator.cli +{ + internal class EngineWrapperLogger : IAggregatorLogger + { + private ILogger logger; + + public EngineWrapperLogger(ILogger logger) + { + this.logger = logger; + } + + public void WriteError(string message) + { + logger.WriteError(message); + } + + public void WriteInfo(string message) + { + logger.WriteInfo(message); + } + + public void WriteVerbose(string message) + { + logger.WriteVerbose(message); + } + + public void WriteWarning(string message) + { + logger.WriteWarning(message); + } + } +} \ No newline at end of file diff --git a/src/aggregator-cli/Rules/InvokeRuleCommand.cs b/src/aggregator-cli/Rules/InvokeRuleCommand.cs new file mode 100644 index 00000000..7509b023 --- /dev/null +++ b/src/aggregator-cli/Rules/InvokeRuleCommand.cs @@ -0,0 +1,56 @@ +using CommandLine; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace aggregator.cli +{ + [Verb("invoke.rule", HelpText = "Executes a rule locally or in an existing Aggregator instance.")] + class InvokeRuleCommand : CommandBase + { + [Option('d', "dryrun", Required = false, Default = true, HelpText = "Real or non-committing run.")] + public bool DryRun { get; set; } + + [Option('p', "project", Required = true, HelpText = "Azure DevOps project name.")] + public string Project { get; set; } + + [Option('e', "event", Required = true, HelpText = "Event to emulate.")] + public string Event { get; set; } + + [Option('w', "workItemId", Required = true, HelpText = "Id of workitem for the emulated event.")] + public int WorkItemId { get; set; } + + [Option('n', "local", SetName = "Local", Required = true, HelpText = "Rule run locally.")] + public bool Local { get; set; } + + [Option('s', "source", SetName = "Local", Required = true, HelpText = "Aggregator rule code.")] + public string Source { get; set; } + + [Option('i', "instance", SetName = "Remote", Required = true, HelpText = "Aggregator instance name.")] + public string Instance { get; set; } + + [Option('g', "resourceGroup", SetName = "Remote", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] + public string ResourceGroup { get; set; } + + internal override async Task RunAsync() + { + var context = await Context + .WithAzureLogon() + .WithDevOpsLogon() + .Build(); + var rules = new AggregatorRules(context.Azure, context.Logger); + if (Local) + { + bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun); + return ok ? 0 : 1; + } + else + { + var instance = new InstanceName(Instance, ResourceGroup); + context.Logger.WriteWarning("Not implemented yet."); + return 2; + } + } + } +} diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index f0aa0516..6f045ad4 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -43,6 +43,7 @@ + diff --git a/src/aggregator-cli/test/test2.rule b/src/aggregator-cli/test/test2.rule index 720a28ca..4f88eac4 100644 --- a/src/aggregator-cli/test/test2.rule +++ b/src/aggregator-cli/test/test2.rule @@ -1,8 +1,8 @@ string message = ""; if (self.Parent != null) { - var parent = store.GetWorkItem(self.Parent); - var children = store.GetWorkItems(parent.Children); + var parent = self.Parent; + var children = parent.Children; if (children.All(c => c.State == "Closed")) { parent.State = "Closed"; diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index 72674579..3e254def 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -73,6 +73,7 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode) /// State is used by unit tests /// public EngineState State { get; private set; } + public bool DryRun { get; set; } public async Task ExecuteAsync(string collectionUrl, Guid projectId, int workItemId, WorkItemTrackingHttpClientBase witClient) { @@ -110,7 +111,7 @@ public async Task ExecuteAsync(string collectionUrl, Guid projectId, int State = EngineState.Success; logger.WriteVerbose($"Post-execution, save any change..."); - var saveRes = await store.SaveChanges(); + var saveRes = await store.SaveChanges(!this.DryRun); if (saveRes.created + saveRes.updated > 0) { logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index 085f19a1..1b5c4223 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -78,28 +78,42 @@ public WorkItemWrapper NewWorkItem(string workItemType) return wrapper; } - public async Task<(int created, int updated)> SaveChanges() + public async Task<(int created, int updated)> SaveChanges(bool commit) { int created = 0; int updated = 0; foreach (var item in _context.Tracker.NewWorkItems) { - _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {_context.ProjectId}"); - var wi = await _context.Client.CreateWorkItemAsync( - item.Changes, - _context.ProjectId, - item.WorkItemType - ); + if (commit) + { + _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {_context.ProjectId}"); + var wi = await _context.Client.CreateWorkItemAsync( + item.Changes, + _context.ProjectId, + item.WorkItemType + ); + } + else + { + _context.Logger.WriteInfo($"Dry-run mode: should create a {item.WorkItemType} workitem in {_context.ProjectId}"); + } created++; } foreach (var item in _context.Tracker.ChangedWorkItems) { - _context.Logger.WriteInfo($"Updating workitem {item.Id}"); - var wi = await _context.Client.UpdateWorkItemAsync( - item.Changes, - item.Id.Value - ); + if (commit) + { + _context.Logger.WriteInfo($"Updating workitem {item.Id}"); + var wi = await _context.Client.UpdateWorkItemAsync( + item.Changes, + item.Id.Value + ); + } + else + { + _context.Logger.WriteInfo($"Dry-run mode: should update workitem {item.Id} in {_context.ProjectId}"); + } updated++; } return (created, updated); diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 3154f32d..494f5645 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -57,7 +57,7 @@ public void NewWorkItem_Succeeds() var wi = sut.NewWorkItem("Task"); wi.Title = "Brand new"; - var save = sut.SaveChanges().Result; + var save = sut.SaveChanges(true).Result; Assert.NotNull(wi); Assert.True(wi.IsNew); From e1838859415710628b0ad05cb98d19ee6cdf2c93 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 10 Nov 2018 18:22:19 +0000 Subject: [PATCH 29/37] e2e integration tests for CLI --- src/aggregator-cli.sln | 16 +-- src/aggregator-cli/Program.cs | 4 +- .../End2EndScenarioBase.cs | 37 +++++ .../Scenario3_MultiInstance.cs | 129 ++++++++++++++++++ .../integrationtests-cli.csproj} | 15 +- src/integrationtests-cli/logon-data.json | 8 ++ src/integrationtests-cli/test4.rule | 1 + src/integrationtests-cli/test5.rule | 1 + 8 files changed, 197 insertions(+), 14 deletions(-) create mode 100644 src/integrationtests-cli/End2EndScenarioBase.cs create mode 100644 src/integrationtests-cli/Scenario3_MultiInstance.cs rename src/{unittests-cli/unittests-cli.csproj => integrationtests-cli/integrationtests-cli.csproj} (54%) create mode 100644 src/integrationtests-cli/logon-data.json create mode 100644 src/integrationtests-cli/test4.rule create mode 100644 src/integrationtests-cli/test5.rule diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index c5a5fa7e..96804fd4 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -9,9 +9,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-ruleng", "aggreg EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-cli", "aggregator-cli\aggregator-cli.csproj", "{FFB65D93-8193-4DA7-820B-3CD4D1872ABA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-core", "unittests-core\unittests-core.csproj", "{4D4361EC-F361-4E63-8CAF-6913EF42ED6D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "unittests-cli", "unittests-cli\unittests-cli.csproj", "{2DFEB5D5-A70E-4C43-B180-586FAA52E2DD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "integrationtests-cli", "integrationtests-cli\integrationtests-cli.csproj", "{D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-function", "aggregator-function\aggregator-function.csproj", "{6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}" EndProject @@ -46,14 +44,6 @@ Global {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Release|Any CPU.ActiveCfg = Release|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Release|Any CPU.Build.0 = Release|Any CPU - {4D4361EC-F361-4E63-8CAF-6913EF42ED6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4D4361EC-F361-4E63-8CAF-6913EF42ED6D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4D4361EC-F361-4E63-8CAF-6913EF42ED6D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4D4361EC-F361-4E63-8CAF-6913EF42ED6D}.Release|Any CPU.Build.0 = Release|Any CPU - {2DFEB5D5-A70E-4C43-B180-586FAA52E2DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2DFEB5D5-A70E-4C43-B180-586FAA52E2DD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2DFEB5D5-A70E-4C43-B180-586FAA52E2DD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2DFEB5D5-A70E-4C43-B180-586FAA52E2DD}.Release|Any CPU.Build.0 = Release|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -62,6 +52,10 @@ Global {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Debug|Any CPU.Build.0 = Debug|Any CPU {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.ActiveCfg = Release|Any CPU {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.Build.0 = Release|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 9f74716d..31f21f96 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -31,9 +31,9 @@ runs rule code locally emulates the event on the rule */ - class Program + public class Program { - static int Main(string[] args) + public static int Main(string[] args) { var parser = new Parser(settings => { diff --git a/src/integrationtests-cli/End2EndScenarioBase.cs b/src/integrationtests-cli/End2EndScenarioBase.cs new file mode 100644 index 00000000..dda58387 --- /dev/null +++ b/src/integrationtests-cli/End2EndScenarioBase.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using Xunit.Abstractions; + +namespace integrationtests.cli +{ + public abstract class End2EndScenarioBase + { + private readonly ITestOutputHelper xunitOutput; + + public End2EndScenarioBase(ITestOutputHelper output) + { + this.xunitOutput = output; + } + + protected (int rc, string output) RunAggregatorCommand(string commandLine) + { + var args = commandLine.Split(' '); + + var save_out = Console.Out; + var save_err = Console.Error; + var buffered = new StringWriter(); + Console.SetOut(buffered); + Console.SetError(buffered); + + int rc = aggregator.cli.Program.Main(args); + + Console.SetOut(save_out); + Console.SetError(save_err); + + string output = buffered.ToString(); + xunitOutput.WriteLine(output); + + return (rc, output); + } + } +} \ No newline at end of file diff --git a/src/integrationtests-cli/Scenario3_MultiInstance.cs b/src/integrationtests-cli/Scenario3_MultiInstance.cs new file mode 100644 index 00000000..64d3dad9 --- /dev/null +++ b/src/integrationtests-cli/Scenario3_MultiInstance.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Xunit; +using Xunit.Abstractions; +using XUnitPriorityOrderer; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +// addset the custom test's collection orderer +[assembly: TestCollectionOrderer(CollectionPriorityOrderer.TypeName, CollectionPriorityOrderer.AssembyName)] + +namespace integrationtests.cli +{ + [TestCaseOrderer(CasePriorityOrderer.TypeName, CasePriorityOrderer.AssembyName)] + public class Scenario3_MultiInstance : End2EndScenarioBase + { + public Scenario3_MultiInstance(ITestOutputHelper output) + : base(output) + { + } + + const string location = "westeurope"; + const string resourceGroup = "test-aggregator45"; + const string project = "WorkItemTracking"; + + [Fact, Order(1)] + void Logon() + { + dynamic data = Newtonsoft.Json.JsonConvert.DeserializeObject(File.ReadAllText("logon-data.json")); + + (int rc, string output) = RunAggregatorCommand( + $"logon.azure --subscription {data.subscription} --client {data.client} --password {data.password} --tenant {data.tenant}"); + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + (int rc2, string output2) = RunAggregatorCommand( + $"logon.ado --url {data.devopsUrl} --mode PAT --token {data.pat}"); + Assert.Equal(0, rc2); + Assert.DoesNotContain("] Failed!", output2); + } + + [Theory, Order(2)] + [InlineData("my4")] + [InlineData("my5")] + void InstallInstances(string instance) + { + (int rc, string output) = RunAggregatorCommand($"install.instance --name {instance} --resourceGroup {resourceGroup} --location {location}"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + } + + [Fact, Order(3)] + void ListInstances() + { + (int rc, string output) = RunAggregatorCommand($"list.instances --resourceGroup {resourceGroup}"); + + Assert.Equal(0, rc); + Assert.Contains("Instance my4", output); + Assert.Contains("Instance my5", output); + } + + [Theory, Order(4)] + [InlineData("my4", "test4")] + [InlineData("my5", "test5")] + void AddRules(string instance, string rule) + { + (int rc, string output) = RunAggregatorCommand($"add.rule --instance {instance} --resourceGroup {resourceGroup} --name {rule} --file {rule}.rule"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + } + + [Theory, Order(5)] + [InlineData("my4", "test4")] + [InlineData("my5", "test5")] + void ListRules(string instance, string rule) + { + (int rc, string output) = RunAggregatorCommand($"list.rules --instance {instance} --resourceGroup {resourceGroup}"); + + Assert.Equal(0, rc); + Assert.Contains($"Rule {instance}/{rule}", output); + Assert.DoesNotContain("] Failed!", output); + } + + [Theory, Order(6)] + [InlineData("my4", "test4")] + [InlineData("my5", "test5")] + void MapRules(string instance, string rule) + { + (int rc, string output) = RunAggregatorCommand($"map.rule --project {project} --event workitem.created --instance {instance} --resourceGroup {resourceGroup} --rule {rule}"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + } + + [Theory, Order(7)] + [InlineData("my4", "test4")] + [InlineData("my5", "test5")] + void ListMappings(string instance, string rule) + { + (int rc, string output) = RunAggregatorCommand($"list.mappings --instance {instance} --resourceGroup {resourceGroup}"); + + Assert.Equal(0, rc); + Assert.Contains($"invokes rule {instance}/{rule}", output); + Assert.DoesNotContain("] Failed!", output); + } + + [Theory, Order(8)] + [InlineData("my4")] + void UninstallInstances(string instance) + { + (int rc, string output) = RunAggregatorCommand($"uninstall.instance --name {instance} --resourceGroup {resourceGroup} --location {location}"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + } + + [Fact, Order(9)] + void ListInstancesAfterUninstall() + { + (int rc, string output) = RunAggregatorCommand($"list.instances --resourceGroup {resourceGroup}"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("Instance my4", output); + Assert.Contains("Instance my5", output); + } + } +} diff --git a/src/unittests-cli/unittests-cli.csproj b/src/integrationtests-cli/integrationtests-cli.csproj similarity index 54% rename from src/unittests-cli/unittests-cli.csproj rename to src/integrationtests-cli/integrationtests-cli.csproj index 86afc14e..234285a4 100644 --- a/src/unittests-cli/unittests-cli.csproj +++ b/src/integrationtests-cli/integrationtests-cli.csproj @@ -2,7 +2,7 @@ netcoreapp2.1 - unittests_cli + integrationtests.cli false @@ -11,6 +11,7 @@ + @@ -18,4 +19,16 @@ + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/src/integrationtests-cli/logon-data.json b/src/integrationtests-cli/logon-data.json new file mode 100644 index 00000000..4df3f829 --- /dev/null +++ b/src/integrationtests-cli/logon-data.json @@ -0,0 +1,8 @@ +{ + "subscription": "guid", + "client": "guid", + "password": "password", + "tenant": "guid", + "devopsUrl": "https://dev.azure.com/organization", + "pat": "PAT" +} diff --git a/src/integrationtests-cli/test4.rule b/src/integrationtests-cli/test4.rule new file mode 100644 index 00000000..2a1c2c37 --- /dev/null +++ b/src/integrationtests-cli/test4.rule @@ -0,0 +1 @@ +$"Hello { self.WorkItemType } #{ self.Id } from Rule 4!" \ No newline at end of file diff --git a/src/integrationtests-cli/test5.rule b/src/integrationtests-cli/test5.rule new file mode 100644 index 00000000..8c91087b --- /dev/null +++ b/src/integrationtests-cli/test5.rule @@ -0,0 +1 @@ +$"Hello { self.WorkItemType } #{ self.Id } from Rule 5!" \ No newline at end of file From 1240027fa762ccfc303ecb1a63d6496a7d9a5792 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 10 Nov 2018 18:23:49 +0000 Subject: [PATCH 30/37] fix bug on --requiredVersion latest --- .../Instances/FunctionRuntimePackage.cs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 1063501d..4eb74916 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -31,15 +31,14 @@ internal FunctionRuntimePackage(ILogger logger) internal async Task UpdateVersion(string requiredVersion, InstanceName instance, IAzure azure) { - string tag = requiredVersion; - if (string.IsNullOrWhiteSpace(requiredVersion)) - { - tag = requiredVersion = "latest"; - } - else - { - tag = requiredVersion[0] != 'v' ? "v" + requiredVersion : requiredVersion; - } + string tag = string.IsNullOrWhiteSpace(requiredVersion) + ? "latest" + : (requiredVersion != "latest" + ? (requiredVersion[0] != 'v' + ? "v" + requiredVersion + : requiredVersion) + : requiredVersion); + logger.WriteVerbose($"Checking runtime package versions in GitHub"); (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHub(tag); if (string.IsNullOrEmpty(rel_name)) From 5635a20b2f9dfb5ec89f1299faa35c9c6e5b1541 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 11 Nov 2018 21:26:52 +0000 Subject: [PATCH 31/37] doc edit --- .gitignore | 2 ++ doc/build-and-test.md | 16 ++++++++++++++++ doc/command-examples.md | 3 +++ doc/service-principal.png | Bin 0 -> 41249 bytes 4 files changed, 21 insertions(+) create mode 100644 doc/build-and-test.md create mode 100644 doc/service-principal.png diff --git a/.gitignore b/.gitignore index 5a17d1b3..7bf3c0dc 100644 --- a/.gitignore +++ b/.gitignore @@ -332,6 +332,8 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ + +## Specific to this repo secrets/ src/aggregator-cli/FunctionRuntime.zip *.exe diff --git a/doc/build-and-test.md b/doc/build-and-test.md new file mode 100644 index 00000000..30e24a58 --- /dev/null +++ b/doc/build-and-test.md @@ -0,0 +1,16 @@ +# Build +Visual Studio 2017 15.8.9 +Azure Functions and Web Jobs Tools + +# Debug + +## CLI +Set `aggregator-cli` as Start-up project +Use the project properties to set the Command line arguments + +## Runtime +Set `aggregator-function` as Start-up project +Use **Postman** or similar tool to send the request at http://localhost:7071/api/name_of_rule + +# Integration tests +`git update-index --assume-unchanged src/integrationtests-cli/logon-data.json` and edit the file content diff --git a/doc/command-examples.md b/doc/command-examples.md index e7f898de..538ff2d7 100644 --- a/doc/command-examples.md +++ b/doc/command-examples.md @@ -58,4 +58,7 @@ remove.rule --verbose --instance my3 --resourceGroup myRG1 --name test3 uninstall.instance --name my1 --location westeurope --dont-remove-mappings # delete the Azure Function Application and any Service Hooks referring to it uninstall.instance --verbose --name my3 --resourceGroup myRG1 --location westeurope + +# run rule locally, no change is sent to Azure DevOps +invoke.rule --dryrun --project SampleProject --event workitem.created --workItemId 14 --local --source test\test2.rule ``` \ No newline at end of file diff --git a/doc/service-principal.png b/doc/service-principal.png new file mode 100644 index 0000000000000000000000000000000000000000..5c8577913746dc338d1351c2325f83fbcca11973 GIT binary patch literal 41249 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⩔r0~yDZ7RbP2~? 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 Ts_h3}5q8wt!K&iW`D_0HtHS(s literal 0 HcmV?d00001 From 7ceb2076ae46ca951a1ea813f75feba02adf1a29 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sun, 11 Nov 2018 21:27:52 +0000 Subject: [PATCH 32/37] Rewrote SaveChanges to use batch API: to be tested --- src/aggregator-cli.sln | 9 +- src/aggregator-cli/Rules/AggregatorRules.cs | 4 +- src/aggregator-function/RuleWrapper.cs | 5 +- src/aggregator-ruleng/BatchRequest.cs | 14 ++ src/aggregator-ruleng/EngineContext.cs | 6 +- src/aggregator-ruleng/RuleEngine.cs | 4 +- .../WorkItemBatchPostResponse.cs | 21 +++ src/aggregator-ruleng/WorkItemStore.cs | 126 +++++++++++++++++- src/unittests-ruleng/RuleTests.cs | 49 ++----- src/unittests-ruleng/WorkItemStoreTests.cs | 35 ++--- 10 files changed, 201 insertions(+), 72 deletions(-) create mode 100644 src/aggregator-ruleng/BatchRequest.cs create mode 100644 src/aggregator-ruleng/WorkItemBatchPostResponse.cs diff --git a/src/aggregator-cli.sln b/src/aggregator-cli.sln index 96804fd4..4b4df8a4 100644 --- a/src/aggregator-cli.sln +++ b/src/aggregator-cli.sln @@ -15,6 +15,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "aggregator-function", "aggr EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{1A84F5C1-ADA4-4DD1-A0D9-03635DF9FC56}" ProjectSection(SolutionItems) = preProject + ..\doc\build-and-test.md = ..\doc\build-and-test.md ..\doc\command-examples.md = ..\doc\command-examples.md ..\doc\contributor-on-rg.png = ..\doc\contributor-on-rg.png ..\doc\log-streaming-from-azure-portal.png = ..\doc\log-streaming-from-azure-portal.png @@ -44,6 +45,10 @@ Global {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Debug|Any CPU.Build.0 = Debug|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Release|Any CPU.ActiveCfg = Release|Any CPU {FFB65D93-8193-4DA7-820B-3CD4D1872ABA}.Release|Any CPU.Build.0 = Release|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.Build.0 = Release|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Debug|Any CPU.Build.0 = Debug|Any CPU {6ADD44D3-3B38-4D2F-BBFF-242E5F2E787C}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -52,10 +57,6 @@ Global {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Debug|Any CPU.Build.0 = Debug|Any CPU {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.ActiveCfg = Release|Any CPU {19AE3176-D15F-4A2B-AB49-1B9DE6EA3EE4}.Release|Any CPU.Build.0 = Release|Any CPU - {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D1FB85EA-CEEC-4F02-97E9-27D6C8AAE760}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index 46ebb580..bdde47bb 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -307,12 +307,14 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in logger.WriteInfo($"Connected to Azure DevOps"); Guid teamProjectId; + string teamProjectName; using (var projectClient = devops.GetClient()) { logger.WriteVerbose($"Reading Azure DevOps project data..."); var project = await projectClient.GetProject(projectName); logger.WriteInfo($"Project {projectName} data read."); teamProjectId = project.Id; + teamProjectName = project.Name; } using (var witClient = devops.GetClient()) @@ -324,7 +326,7 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in var engine = new Engine.RuleEngine(engineLogger, ruleCode); engine.DryRun = dryRun; - string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, workItemId, witClient); + string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, devopsLogonData.Token, workItemId, witClient); logger.WriteInfo($"Rule returned '{result}'"); return true; diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index 97c6b4d5..d9ed624a 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -37,7 +37,8 @@ internal async Task Execute(dynamic data) string collectionUrl = data.resourceContainers.collection.baseUrl; string eventType = data.eventType; int workItemId = (eventType != "workitem.updated") ? data.resource.id : data.resource.workItemId; - Guid teamProject = data.resourceContainers.project.id; + Guid teamProjectId = data.resourceContainers.project.id; + string teamProjectName = data.resourceContainers.project.name; logger.WriteVerbose($"Connecting to Azure DevOps using {configuration.DevOpsTokenType}..."); var clientCredentials = default(VssCredentials); @@ -68,7 +69,7 @@ internal async Task Execute(dynamic data) var engine = new Engine.RuleEngine(logger, ruleCode); - return await engine.ExecuteAsync(collectionUrl, teamProject, workItemId, witClient); + return await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, configuration.DevOpsToken, workItemId, witClient); } } } diff --git a/src/aggregator-ruleng/BatchRequest.cs b/src/aggregator-ruleng/BatchRequest.cs new file mode 100644 index 00000000..18c6e7fa --- /dev/null +++ b/src/aggregator-ruleng/BatchRequest.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace aggregator.Engine +{ + public class BatchRequest + { + public string method { get; set; } + public Dictionary headers { get; set; } + public object[] body { get; set; } + public string uri { get; set; } + } +} diff --git a/src/aggregator-ruleng/EngineContext.cs b/src/aggregator-ruleng/EngineContext.cs index 1b60e1f1..9eba75cd 100644 --- a/src/aggregator-ruleng/EngineContext.cs +++ b/src/aggregator-ruleng/EngineContext.cs @@ -8,15 +8,19 @@ namespace aggregator.Engine { public class EngineContext { - public EngineContext(WorkItemTrackingHttpClientBase client, Guid projectId, IAggregatorLogger logger) + public EngineContext(WorkItemTrackingHttpClientBase client, Guid projectId, string projectName, string personalAccessToken, IAggregatorLogger logger) { Client = client; Logger = logger; Tracker = new Tracker(); ProjectId = projectId; + ProjectName = projectName; + PersonalAccessToken = personalAccessToken; } public Guid ProjectId { get; internal set; } + public string ProjectName { get; internal set; } + public string PersonalAccessToken { get; internal set; } internal WorkItemTrackingHttpClientBase Client { get; } internal IAggregatorLogger Logger { get; } internal Tracker Tracker { get; } diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index 3e254def..bb43dd8c 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -75,14 +75,14 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode) public EngineState State { get; private set; } public bool DryRun { get; set; } - public async Task ExecuteAsync(string collectionUrl, Guid projectId, int workItemId, WorkItemTrackingHttpClientBase witClient) + public async Task ExecuteAsync(string collectionUrl, Guid projectId, string projectName, string personalAccessToken, int workItemId, WorkItemTrackingHttpClientBase witClient) { if (State == EngineState.Error) { return string.Empty; } - var context = new EngineContext(witClient, projectId, logger); + var context = new EngineContext(witClient, projectId, projectName, personalAccessToken, logger); var store = new WorkItemStore(context); var self = store.GetWorkItem(workItemId); logger.WriteInfo($"Initial WorkItem {workItemId} retrieved from {collectionUrl}"); diff --git a/src/aggregator-ruleng/WorkItemBatchPostResponse.cs b/src/aggregator-ruleng/WorkItemBatchPostResponse.cs new file mode 100644 index 00000000..bb024f8f --- /dev/null +++ b/src/aggregator-ruleng/WorkItemBatchPostResponse.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; + +namespace aggregator.Engine +{ + public class WorkItemBatchPostResponse + { + public int count { get; set; } + [JsonProperty("value")] + public List values { get; set; } + + public class Value + { + public int code { get; set; } + public Dictionary headers { get; set; } + public string body { get; set; } + } + } +} diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index 1b5c4223..68dfe271 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -1,7 +1,13 @@ using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; +using Microsoft.VisualStudio.Services.WebApi.Patch.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; using System.Threading.Tasks; namespace aggregator.Engine @@ -42,7 +48,7 @@ public IList GetWorkItems(IEnumerable ids) return _context.Tracker.LoadWorkItems(ids, (workItemIds) => { string idList2 = workItemIds.Aggregate("", (s, i) => s += $",{i}"); - _context.Logger.WriteVerbose($"Loading workitems {idList2.Substring(1)}"); + _context.Logger.WriteInfo($"Loading workitems {idList2.Substring(1)}"); var items = _context.Client.GetWorkItemsAsync(workItemIds, expand: WorkItemExpand.All).Result; return items.ConvertAll(i => new WorkItemWrapper(_context, i)); }); @@ -78,7 +84,7 @@ public WorkItemWrapper NewWorkItem(string workItemType) return wrapper; } - public async Task<(int created, int updated)> SaveChanges(bool commit) + public async Task<(int created, int updated)> OLD_SaveChanges(bool commit) { int created = 0; int updated = 0; @@ -118,5 +124,121 @@ public WorkItemWrapper NewWorkItem(string workItemType) } return (created, updated); } + + public async Task<(int created, int updated)> SaveChanges(bool commit) + { + // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs + // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1 + + const string ApiVersion = "api-version=4.1"; + + int created = _context.Tracker.NewWorkItems.Count(); + int updated = _context.Tracker.ChangedWorkItems.Count(); + + string baseUriString = _context.Client.BaseAddress.AbsoluteUri; + + BatchRequest[] batchRequests = new BatchRequest[created + updated]; + Dictionary headers = new Dictionary() { + { "Content-Type", "application/json-patch+json" } + }; + string credentials = Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); + + int index = 0; + + foreach (var item in _context.Tracker.NewWorkItems) + { + _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {_context.ProjectName}"); + + batchRequests[index++] = new BatchRequest + { + method = "PATCH", + uri = $"/{_context.ProjectName}/_apis/wit/workitems/{item.WorkItemType}?{ApiVersion}", + headers = headers, + body = item.Changes.ToArray() + }; + } + foreach (var item in _context.Tracker.ChangedWorkItems) + { + _context.Logger.WriteInfo($"Found a request to update workitem {item.Id.Value} in {_context.ProjectName}"); + + batchRequests[index++] = new BatchRequest + { + method = "PATCH", + uri = $"/{_context.ProjectName}/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", + headers = headers, + body = item.Changes.ToArray() + }; + } + var converters = new JsonConverter[] { new JsonPatchOperationConverter() }; + string requestBody = JsonConvert.SerializeObject(batchRequests, Formatting.Indented, converters); + + + if (commit) + { + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + + var batchRequest = new StringContent(requestBody, Encoding.UTF8, "application/json"); + var method = new HttpMethod("POST"); + + // send the request + var request = new HttpRequestMessage(method, $"{baseUriString}_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; + var response = client.SendAsync(request).Result; + + if (response.IsSuccessStatusCode) + { + var stringResponse = response.Content.ReadAsStringAsync(); + WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; + //return batchResponse; + } + }//using + } + else + { + _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps."); + _context.Logger.WriteVerbose(requestBody); + }//if + + return (created, updated); + } + } + + class JsonPatchOperationConverter : JsonConverter + { + public override bool CanRead => false; + + public override JsonPatchOperation ReadJson(JsonReader reader, Type objectType, JsonPatchOperation existingValue, bool hasExistingValue, JsonSerializer serializer) + { + throw new NotImplementedException("Unnecessary because CanRead is false. The type will skip the converter."); + } + + public override void WriteJson(JsonWriter writer, JsonPatchOperation value, JsonSerializer serializer) + { + JToken t = JToken.FromObject(value); + + if (t.Type != JTokenType.Object) + { + t.WriteTo(writer); + } + else + { + writer.WriteStartObject(); + writer.WritePropertyName("op"); + writer.WriteValue(value.Operation.ToString().ToLower()); + writer.WritePropertyName("path"); + writer.WriteValue(value.Path); + if (!string.IsNullOrEmpty(value.From)) + { + writer.WritePropertyName("from"); + writer.WriteValue(value.From); + } + writer.WritePropertyName("value"); + writer.WriteValue(value.Value); + writer.WriteEndObject(); + } + } } } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index ef28eaa8..c0472e15 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -17,21 +17,23 @@ internal static string[] Mince(this string ruleCode) public class RuleTests { + const string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + const string projectName = "test-project"; + const string personalAccessToken = "***personalAccessToken***"; + FakeWorkItemTrackingHttpClient client = new FakeWorkItemTrackingHttpClient(new Uri($"{collectionUrl}"), null); + MockAggregatorLogger logger = new MockAggregatorLogger(); + [Fact] public async void HelloWorldRule_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 42; string ruleCode = @" return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal("Hello Bug #42 - Hello!", result); } @@ -39,18 +41,13 @@ public async void HelloWorldRule_Succeeds() [Fact] public async void LanguageDirective_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 42; string ruleCode = @".lang=CS return string.Empty; "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal(EngineState.Success, engine.State); Assert.Equal(string.Empty, result); @@ -59,18 +56,13 @@ public async void LanguageDirective_Succeeds() [Fact] public async void LanguageDirective_Fails() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 42; string ruleCode = @".lang=WHAT return string.Empty; "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal(EngineState.Error, engine.State); } @@ -78,11 +70,6 @@ public async void LanguageDirective_Fails() [Fact] public async void Parent_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 42; string ruleCode = @" string message = """"; @@ -95,7 +82,7 @@ public async void Parent_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal("Parent is 1", result); } @@ -103,11 +90,6 @@ public async void Parent_Succeeds() [Fact] public async void New_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 1; string ruleCode = @" var wi = store.NewWorkItem(""Task""); @@ -115,7 +97,7 @@ public async void New_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Null(result); Assert.Contains( @@ -127,11 +109,6 @@ public async void New_Succeeds() [Fact] public async void AddChild_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); int workItemId = 1; string ruleCode = @" var parent = self; @@ -141,7 +118,7 @@ public async void AddChild_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince()); - string result = await engine.ExecuteAsync(collectionUrl, projectId, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Null(result); Assert.Contains( diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 494f5645..5c8687c7 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -8,15 +8,17 @@ namespace unittests_ruleng { public class WorkItemStoreTests { + const string collectionUrl = "https://dev.azure.com/fake-organization"; + Guid projectId = Guid.NewGuid(); + const string projectName = "test-project"; + const string personalAccessToken = "***personalAccessToken***"; + FakeWorkItemTrackingHttpClient client = new FakeWorkItemTrackingHttpClient(new Uri($"{collectionUrl}"), null); + MockAggregatorLogger logger = new MockAggregatorLogger(); + [Fact] public void GetWorkItem_ById_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, projectId, logger); + var context = new EngineContext(client, projectId, projectName, personalAccessToken, logger); var sut = new WorkItemStore(context); var wi = sut.GetWorkItem(42); @@ -28,12 +30,7 @@ public void GetWorkItem_ById_Succeeds() [Fact] public void GetWorkItems_ByIds_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, projectId, logger); + var context = new EngineContext(client, projectId, projectName, personalAccessToken, logger); var sut = new WorkItemStore(context); var wis = sut.GetWorkItems(new int[] { 42, 99 }); @@ -47,12 +44,7 @@ public void GetWorkItems_ByIds_Succeeds() [Fact] public void NewWorkItem_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, projectId, logger); + var context = new EngineContext(client, projectId, projectName, personalAccessToken, logger); var sut = new WorkItemStore(context); var wi = sut.NewWorkItem("Task"); @@ -69,12 +61,7 @@ public void NewWorkItem_Succeeds() [Fact] public void AddChild_Succeeds() { - string collectionUrl = "https://dev.azure.com/fake-organization"; - Guid projectId = Guid.NewGuid(); - var baseUrl = new Uri($"{collectionUrl}"); - var client = new FakeWorkItemTrackingHttpClient(baseUrl, null); - var logger = new MockAggregatorLogger(); - var context = new EngineContext(client, projectId, logger); + var context = new EngineContext(client, projectId, projectName, personalAccessToken, logger); var sut = new WorkItemStore(context); var parent = sut.GetWorkItem(1); From d0e32933dc9fb86c401ccb28bf015f68004a68af Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Thu, 15 Nov 2018 22:41:14 +0000 Subject: [PATCH 33/37] Support for Batch save mode --- .../Instances/AggregatorInstances.cs | 11 +-- .../Instances/ConfigureInstanceCommand.cs | 6 +- src/aggregator-cli/Rules/AggregatorRules.cs | 4 +- src/aggregator-cli/Rules/InvokeRuleCommand.cs | 7 +- src/aggregator-cli/aggregator-cli.csproj | 3 + src/aggregator-cli/test/test4.rule | 8 +++ src/aggregator-function/RuleWrapper.cs | 2 +- src/aggregator-ruleng/RuleEngine.cs | 10 +-- src/aggregator-ruleng/WorkItemStore.cs | 67 +++++++++++++++---- src/aggregator-ruleng/WorkItemWrapper.cs | 18 +++++ .../aggregator-ruleng.csproj | 4 ++ .../AggregatorConfiguration.cs | 13 ++++ src/unittests-ruleng/RuleTests.cs | 17 ++--- src/unittests-ruleng/WorkItemStoreTests.cs | 3 +- 14 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 src/aggregator-cli/test/test4.rule diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index 4eb3e87b..74e084f0 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -147,7 +147,7 @@ await azure.ResourceGroups if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, devopsLogonData); + ok = await ChangeAppSettings(instance, devopsLogonData, SaveMode.Default); if (ok) { logger.WriteInfo($"Azure DevOps token saved"); @@ -166,7 +166,7 @@ await azure.ResourceGroups return ok; } - internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon devopsLogonData) + internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon devopsLogonData, SaveMode saveMode) { var webFunctionApp = await azure .AppServices @@ -177,7 +177,8 @@ internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon d var configuration = new AggregatorConfiguration { DevOpsTokenType = devopsLogonData.Mode, - DevOpsToken = devopsLogonData.Token + DevOpsToken = devopsLogonData.Token, + SaveMode = saveMode }; configuration.Write(webFunctionApp); return true; @@ -222,14 +223,14 @@ internal async Task Remove(InstanceName instance, string location) return true; } - internal async Task SetAuthentication(InstanceName instance, string location) + internal async Task ChangeAppSettings(InstanceName instance, string location, SaveMode saveMode) { bool ok; var devopsLogonData = DevOpsLogon.Load().connection; if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, devopsLogonData); + ok = await ChangeAppSettings(instance, devopsLogonData, saveMode); logger.WriteInfo($"Azure DevOps token saved"); } else diff --git a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs index 9429f7dc..71229107 100644 --- a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs +++ b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs @@ -20,6 +20,10 @@ internal class ConfigureInstanceCommand : CommandBase [Option('a', "authentication", SetName = "auth", Required = true, HelpText = "Refresh authentication data.")] public bool Authentication { get; set; } + + [Option('m', "saveMode", SetName = "save", Required = false, HelpText = "Save behaviour.")] + public SaveMode SaveMode { get; set; } + // TODO add --swap.slot to support App Service Deployment Slots @@ -34,7 +38,7 @@ internal override async Task RunAsync() bool ok = false; if (Authentication) { - ok = await instances.SetAuthentication(instance, Location); + ok = await instances.ChangeAppSettings(instance, Location, SaveMode); } else { context.Logger.WriteError($"Unsupported command option(s)"); diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index bdde47bb..10129a62 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -278,7 +278,7 @@ internal async Task UpdateAsync(InstanceName instance, string name, string return ok; } - internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun) + internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode) { if (!File.Exists(ruleFilePath)) { @@ -323,7 +323,7 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in string[] ruleCode = File.ReadAllLines(ruleFilePath); var engineLogger = new EngineWrapperLogger(logger); - var engine = new Engine.RuleEngine(engineLogger, ruleCode); + var engine = new Engine.RuleEngine(engineLogger, ruleCode, saveMode); engine.DryRun = dryRun; string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, devopsLogonData.Token, workItemId, witClient); diff --git a/src/aggregator-cli/Rules/InvokeRuleCommand.cs b/src/aggregator-cli/Rules/InvokeRuleCommand.cs index 7509b023..b26e26cb 100644 --- a/src/aggregator-cli/Rules/InvokeRuleCommand.cs +++ b/src/aggregator-cli/Rules/InvokeRuleCommand.cs @@ -9,7 +9,7 @@ namespace aggregator.cli [Verb("invoke.rule", HelpText = "Executes a rule locally or in an existing Aggregator instance.")] class InvokeRuleCommand : CommandBase { - [Option('d', "dryrun", Required = false, Default = true, HelpText = "Real or non-committing run.")] + [Option('d', "dryrun", Required = false, Default = false, HelpText = "Real or non-committing run.")] public bool DryRun { get; set; } [Option('p', "project", Required = true, HelpText = "Azure DevOps project name.")] @@ -27,6 +27,9 @@ class InvokeRuleCommand : CommandBase [Option('s', "source", SetName = "Local", Required = true, HelpText = "Aggregator rule code.")] public string Source { get; set; } + [Option('m', "saveMode", SetName = "Local", Required = false, HelpText = "Save behaviour.")] + public SaveMode SaveMode { get; set; } + [Option('i', "instance", SetName = "Remote", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } @@ -42,7 +45,7 @@ internal override async Task RunAsync() var rules = new AggregatorRules(context.Azure, context.Logger); if (Local) { - bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun); + bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun, SaveMode); return ok ? 0 : 1; } else diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index 6f045ad4..596080b4 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -54,6 +54,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/aggregator-cli/test/test4.rule b/src/aggregator-cli/test/test4.rule new file mode 100644 index 00000000..1dd8618a --- /dev/null +++ b/src/aggregator-cli/test/test4.rule @@ -0,0 +1,8 @@ +.language=Csharp + +var parent = self; +var newChild = store.NewWorkItem("Task"); +newChild.Title = "Brand new child"; +parent.Relations.AddChild(newChild); + +return parent.Title; diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index d9ed624a..c296c64f 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -67,7 +67,7 @@ internal async Task Execute(dynamic data) logger.WriteVerbose($"Rule code found at {ruleFilePath}"); string[] ruleCode = File.ReadAllLines(ruleFilePath); - var engine = new Engine.RuleEngine(logger, ruleCode); + var engine = new Engine.RuleEngine(logger, ruleCode, configuration.SaveMode); return await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, configuration.DevOpsToken, workItemId, witClient); } diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index bb43dd8c..d99ce5fa 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -22,12 +22,14 @@ public class RuleEngine { private readonly IAggregatorLogger logger; private readonly Script roslynScript; + private readonly SaveMode saveMode; - public RuleEngine(IAggregatorLogger logger, string[] ruleCode) + public RuleEngine(IAggregatorLogger logger, string[] ruleCode, SaveMode mode) { State = EngineState.Unknown; this.logger = logger; + this.saveMode = mode; var directives = new DirectivesParser(logger, ruleCode); if (!directives.Parse()) @@ -110,11 +112,11 @@ public async Task ExecuteAsync(string collectionUrl, Guid projectId, str } State = EngineState.Success; - logger.WriteVerbose($"Post-execution, save any change..."); - var saveRes = await store.SaveChanges(!this.DryRun); + logger.WriteVerbose($"Post-execution, save any change (mode {saveMode})..."); + var saveRes = await store.SaveChanges(saveMode, !this.DryRun); if (saveRes.created + saveRes.updated > 0) { - logger.WriteInfo($"Changes saved to Azure DevOps: {saveRes.created} created, {saveRes.updated} updated."); + logger.WriteInfo($"Changes saved to Azure DevOps (mode {saveMode}): {saveRes.created} created, {saveRes.updated} updated."); } else { diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index 68dfe271..c5935eec 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -81,10 +81,28 @@ public WorkItemWrapper NewWorkItem(string workItemType) }; var wrapper = new WorkItemWrapper(_context, item); _context.Logger.WriteVerbose($"Made new workitem with temporary id {wrapper.Id.Value}"); + //HACK + string baseUriString = _context.Client.BaseAddress.AbsoluteUri; + item.Url = $"{baseUriString}/_apis/wit/workitems/{wrapper.Id.Value}"; return wrapper; } - public async Task<(int created, int updated)> OLD_SaveChanges(bool commit) + public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit) + { + switch (mode) + { + case SaveMode.Default: + goto case SaveMode.Batch; + case SaveMode.Item: + return await SaveChanges_ByItem(commit); + case SaveMode.Batch: + return await SaveChanges_Batch(commit); + default: + throw new ApplicationException($"Unsupported save mode: {mode}."); + } + } + + private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit) { int created = 0; int updated = 0; @@ -125,10 +143,11 @@ public WorkItemWrapper NewWorkItem(string workItemType) return (created, updated); } - public async Task<(int created, int updated)> SaveChanges(bool commit) + private async Task<(int created, int updated)> SaveChanges_Batch(bool commit) { // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1 + // BUG this code won't work if there is a relation between a new (id<0) work item and an existing one (id>0): it is an API limit const string ApiVersion = "api-version=4.1"; @@ -152,9 +171,11 @@ public WorkItemWrapper NewWorkItem(string workItemType) batchRequests[index++] = new BatchRequest { method = "PATCH", - uri = $"/{_context.ProjectName}/_apis/wit/workitems/{item.WorkItemType}?{ApiVersion}", + uri = $"{baseUriString}/_apis/wit/workitems/${item.WorkItemType}?{ApiVersion}", headers = headers, - body = item.Changes.ToArray() + body = item.Changes + .Where(c=>c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) + .ToArray() }; } foreach (var item in _context.Tracker.ChangedWorkItems) @@ -164,14 +185,16 @@ public WorkItemWrapper NewWorkItem(string workItemType) batchRequests[index++] = new BatchRequest { method = "PATCH", - uri = $"/{_context.ProjectName}/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", + uri = $"{baseUriString}/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", headers = headers, - body = item.Changes.ToArray() + body = item.Changes + .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) + .ToArray() }; } var converters = new JsonConverter[] { new JsonPatchOperationConverter() }; string requestBody = JsonConvert.SerializeObject(batchRequests, Formatting.Indented, converters); - + _context.Logger.WriteVerbose(requestBody); if (commit) { @@ -185,21 +208,37 @@ public WorkItemWrapper NewWorkItem(string workItemType) var method = new HttpMethod("POST"); // send the request - var request = new HttpRequestMessage(method, $"{baseUriString}_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; + var request = new HttpRequestMessage(method, $"{baseUriString}/_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; var response = client.SendAsync(request).Result; if (response.IsSuccessStatusCode) { - var stringResponse = response.Content.ReadAsStringAsync(); WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; - //return batchResponse; + string stringResponse = JsonConvert.SerializeObject(batchResponse); + _context.Logger.WriteVerbose(stringResponse); + bool succeeded = true; + foreach (var batchElement in batchResponse.values) + { + if (batchElement.code != 200) + { + _context.Logger.WriteError($"Save failed: {batchElement.body}"); + succeeded = false; + } + } + if (!succeeded) + throw new ApplicationException($"Save failed."); + } + else + { + string stringResponse = await response.Content.ReadAsStringAsync(); + _context.Logger.WriteError($"Save failed: {stringResponse}"); + throw new ApplicationException($"Save failed: {response.ReasonPhrase}."); } }//using } else { _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps."); - _context.Logger.WriteVerbose(requestBody); }//if return (created, updated); @@ -236,9 +275,11 @@ public override void WriteJson(JsonWriter writer, JsonPatchOperation value, Json writer.WriteValue(value.From); } writer.WritePropertyName("value"); - writer.WriteValue(value.Value); + //writer.WriteValue(value.Value); + t = JToken.FromObject(value.Value); + t.WriteTo(writer); writer.WriteEndObject(); } } } -} +} \ No newline at end of file diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 2d60555c..401b1f97 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -38,6 +38,12 @@ internal WorkItemWrapper(EngineContext context, WorkItem item) else { Id = new TemporaryWorkItemId(_context.Tracker); + Changes.Add(new JsonPatchOperation() + { + Operation = Operation.Add, + Path = "/id", + Value = Id.Value + }); _context.Tracker.TrackNew(this); } } @@ -54,6 +60,12 @@ public WorkItemWrapper(EngineContext context, string project, string type) _item.Fields[CoreFieldRefNames.Id] = Id.Value; _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); + Changes.Add(new JsonPatchOperation() + { + Operation = Operation.Add, + Path = "/id", + Value = Id.Value + }); _context.Tracker.TrackNew(this); } @@ -69,6 +81,12 @@ public WorkItemWrapper(EngineContext context, WorkItemWrapper template, string t _item.Fields[CoreFieldRefNames.Id] = Id.Value; _relationCollection = new WorkItemRelationWrapperCollection(this, _item.Relations); + Changes.Add(new JsonPatchOperation() + { + Operation = Operation.Add, + Path = "/id", + Value = Id.Value + }); _context.Tracker.TrackNew(this); } diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index f2cc7855..5a755dde 100644 --- a/src/aggregator-ruleng/aggregator-ruleng.csproj +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -19,4 +19,8 @@ + + + + diff --git a/src/aggregator-shared/AggregatorConfiguration.cs b/src/aggregator-shared/AggregatorConfiguration.cs index 31bba5c3..abef957e 100644 --- a/src/aggregator-shared/AggregatorConfiguration.cs +++ b/src/aggregator-shared/AggregatorConfiguration.cs @@ -8,6 +8,14 @@ public enum DevOpsTokenType PAT = 1, } + public enum SaveMode + { + Default = 0, + Item = 1, + Batch = 2, + TwoPhases = 3 + } + /// /// This class tracks the configuration data that CLI writes and Function runtime reads /// @@ -21,6 +29,9 @@ static public AggregatorConfiguration Read(Microsoft.Extensions.Configuration.IC Enum.TryParse(config["Aggregator_VstsTokenType"], out DevOpsTokenType vtt); ac.DevOpsTokenType = vtt; ac.DevOpsToken = config["Aggregator_VstsToken"]; + ac.SaveMode = Enum.TryParse(config["Aggregator_SaveMode"], out SaveMode sm) + ? sm + : SaveMode.Item; return ac; } @@ -30,10 +41,12 @@ public void Write(Microsoft.Azure.Management.AppService.Fluent.IWebApp webApp) .Update() .WithAppSetting("Aggregator_VstsTokenType", DevOpsTokenType.ToString()) .WithAppSetting("Aggregator_VstsToken", DevOpsToken) + .WithAppSetting("Aggregator_SaveMode", SaveMode.ToString()) .Apply(); } public DevOpsTokenType DevOpsTokenType { get; set; } public string DevOpsToken { get; set; } + public SaveMode SaveMode { get; set; } } } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index c0472e15..2d19b936 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using aggregator; using aggregator.Engine; using aggregator.unittests; using Xunit; @@ -32,7 +33,7 @@ public async void HelloWorldRule_Succeeds() return $""Hello { self.WorkItemType } #{ self.Id } - { self.Title }!""; "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal("Hello Bug #42 - Hello!", result); @@ -46,7 +47,7 @@ public async void LanguageDirective_Succeeds() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal(EngineState.Success, engine.State); @@ -61,7 +62,7 @@ public async void LanguageDirective_Fails() return string.Empty; "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal(EngineState.Error, engine.State); @@ -81,7 +82,7 @@ public async void Parent_Succeeds() return message; "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Equal("Parent is 1", result); @@ -96,13 +97,13 @@ public async void New_Succeeds() wi.Title = ""Brand new""; "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Null(result); Assert.Contains( logger.GetMessages(), - m => m.Message == "Changes saved to Azure DevOps: 1 created, 0 updated." + m => m.Message == "Changes saved to Azure DevOps (mode Item): 1 created, 0 updated." && m.Level == "Info"); } @@ -117,13 +118,13 @@ public async void AddChild_Succeeds() parent.Relations.AddChild(newChild); "; - var engine = new RuleEngine(logger, ruleCode.Mince()); + var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Item); string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); Assert.Null(result); Assert.Contains( logger.GetMessages(), - m => m.Message == "Changes saved to Azure DevOps: 1 created, 1 updated." + m => m.Message == "Changes saved to Azure DevOps (mode Item): 1 created, 1 updated." && m.Level == "Info"); } } diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 5c8687c7..cba26515 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using aggregator; using aggregator.Engine; using aggregator.unittests; using Xunit; @@ -49,7 +50,7 @@ public void NewWorkItem_Succeeds() var wi = sut.NewWorkItem("Task"); wi.Title = "Brand new"; - var save = sut.SaveChanges(true).Result; + var save = sut.SaveChanges(SaveMode.Item, true).Result; Assert.NotNull(wi); Assert.True(wi.IsNew); From 13164b434f02ef35b886e7def346c5183cb18e4c Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 17 Nov 2018 12:33:42 +0000 Subject: [PATCH 34/37] Two pass supports the new item linked to an existing item Code is ugly but works --- src/aggregator-ruleng/RelationPatch.cs | 9 + .../WorkItemRelationWrapperCollection.cs | 2 +- src/aggregator-ruleng/WorkItemStore.cs | 196 +++++++++++++++++- src/aggregator-ruleng/WorkItemWrapper.cs | 42 ++++ .../aggregator-ruleng.csproj | 1 + 5 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 src/aggregator-ruleng/RelationPatch.cs diff --git a/src/aggregator-ruleng/RelationPatch.cs b/src/aggregator-ruleng/RelationPatch.cs new file mode 100644 index 00000000..c5b0fb0f --- /dev/null +++ b/src/aggregator-ruleng/RelationPatch.cs @@ -0,0 +1,9 @@ +namespace aggregator.Engine +{ + internal class RelationPatch + { + public string rel { get; set; } + public string url { get; set; } + public object attributes { get; set; } + } +} \ No newline at end of file diff --git a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs index ff9ee4d9..4a18c54c 100644 --- a/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs +++ b/src/aggregator-ruleng/WorkItemRelationWrapperCollection.cs @@ -39,7 +39,7 @@ private void AddRelation(WorkItemRelationWrapper item) { Operation = Operation.Add, Path = "/relations/-", - Value = new + Value = new RelationPatch { rel = item.Rel, url = item.Url, diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index c5935eec..1fafb293 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -92,11 +92,14 @@ public WorkItemWrapper NewWorkItem(string workItemType) switch (mode) { case SaveMode.Default: - goto case SaveMode.Batch; + _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}."); + goto case SaveMode.TwoPhases; case SaveMode.Item: return await SaveChanges_ByItem(commit); case SaveMode.Batch: return await SaveChanges_Batch(commit); + case SaveMode.TwoPhases: + return await SaveChanges_TwoPhases(commit); default: throw new ApplicationException($"Unsupported save mode: {mode}."); } @@ -171,10 +174,10 @@ public WorkItemWrapper NewWorkItem(string workItemType) batchRequests[index++] = new BatchRequest { method = "PATCH", - uri = $"{baseUriString}/_apis/wit/workitems/${item.WorkItemType}?{ApiVersion}", + uri = $"/{_context.ProjectName}/_apis/wit/workitems/${item.WorkItemType}?{ApiVersion}", headers = headers, body = item.Changes - .Where(c=>c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) + .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) .ToArray() }; } @@ -185,7 +188,7 @@ public WorkItemWrapper NewWorkItem(string workItemType) batchRequests[index++] = new BatchRequest { method = "PATCH", - uri = $"{baseUriString}/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", + uri = $"/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", headers = headers, body = item.Changes .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) @@ -214,7 +217,188 @@ public WorkItemWrapper NewWorkItem(string workItemType) if (response.IsSuccessStatusCode) { WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; - string stringResponse = JsonConvert.SerializeObject(batchResponse); + string stringResponse = JsonConvert.SerializeObject(batchResponse, Formatting.Indented); + _context.Logger.WriteVerbose(stringResponse); + bool succeeded = true; + foreach (var batchElement in batchResponse.values) + { + if (batchElement.code != 200) + { + _context.Logger.WriteError($"Save failed: {batchElement.body}"); + succeeded = false; + } + } + if (!succeeded) + throw new ApplicationException($"Save failed."); + } + else + { + string stringResponse = await response.Content.ReadAsStringAsync(); + _context.Logger.WriteError($"Save failed: {stringResponse}"); + throw new ApplicationException($"Save failed: {response.ReasonPhrase}."); + } + }//using + } + else + { + _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps."); + }//if + + return (created, updated); + } + + private async Task<(int created, int updated)> SaveChanges_TwoPhases(bool commit) + { + // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs + // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1 + // The workitembatchupdate API has a huge limit: + // it fails adding a relation between a new (id<0) work item and an existing one (id>0) + + const string ApiVersion = "api-version=4.1"; + string baseUriString = _context.Client.BaseAddress.AbsoluteUri; + Dictionary headers = new Dictionary() { + { "Content-Type", "application/json-patch+json" } + }; + string credentials = Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); + var converters = new JsonConverter[] { new JsonPatchOperationConverter() }; + + int created = _context.Tracker.NewWorkItems.Count(); + int updated = _context.Tracker.ChangedWorkItems.Count(); + + BatchRequest[] newWorkItemsBatchRequests = new BatchRequest[created]; + int index = 0; + foreach (var item in _context.Tracker.NewWorkItems) + { + _context.Logger.WriteInfo($"Found a request for a new {item.WorkItemType} workitem in {_context.ProjectName}"); + + newWorkItemsBatchRequests[index++] = new BatchRequest + { + method = "PATCH", + uri = $"/{_context.ProjectName}/_apis/wit/workitems/${item.WorkItemType}?{ApiVersion}", + headers = headers, + body = item.Changes + .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test) + // remove relations as we might incour in API failure + .Where(c => c.Path != "/relations/-") + .ToArray() + }; + } + string requestBody = JsonConvert.SerializeObject(newWorkItemsBatchRequests, Formatting.Indented, converters); + _context.Logger.WriteVerbose($"New workitem(s) batch request:"); + _context.Logger.WriteVerbose(requestBody); + + if (commit) + { + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + + var batchRequest = new StringContent(requestBody, Encoding.UTF8, "application/json"); + var method = new HttpMethod("POST"); + + // send the request + var request = new HttpRequestMessage(method, $"{baseUriString}/_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; + var response = client.SendAsync(request).Result; + + if (response.IsSuccessStatusCode) + { + WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; + string stringResponse = JsonConvert.SerializeObject(batchResponse, Formatting.Indented); + _context.Logger.WriteVerbose($"New workitem(s) batch response:"); + _context.Logger.WriteVerbose(stringResponse); + bool succeeded = true; + foreach (var batchElement in batchResponse.values) + { + if (batchElement.code != 200) + { + _context.Logger.WriteError($"Save failed: {batchElement.body}"); + succeeded = false; + } + } + if (!succeeded) + { + throw new ApplicationException($"Save failed."); + } + else + { + _context.Logger.WriteVerbose($"Updating work item ids..."); + // Fix back + var realIds = new Dictionary(); + index = 0; + foreach (var item in _context.Tracker.NewWorkItems) + { + int oldId = item.Id.Value; + // the response order matches the request order + string createdWorkitemJson = batchResponse.values[index++].body; + dynamic createdWorkitemResult = JsonConvert.DeserializeObject(createdWorkitemJson); + int newId = createdWorkitemResult.id; + item.ReplaceIdAndResetChanges(item.Id.Value, newId); + realIds.Add(oldId, newId); + } + foreach (var item in _context.Tracker.ChangedWorkItems) + { + item.RemapIdReferences(realIds); + } + } + } + else + { + string stringResponse = await response.Content.ReadAsStringAsync(); + _context.Logger.WriteError($"Save failed: {stringResponse}"); + throw new ApplicationException($"Save failed: {response.ReasonPhrase}."); + } + }//using + } + else + { + _context.Logger.WriteWarning($"Dry-run mode: no updates sent to Azure DevOps."); + }//if + + var batchRequests = new List(); + var allWorkItems = _context.Tracker.NewWorkItems.Concat(_context.Tracker.ChangedWorkItems); + foreach (var item in allWorkItems) + { + var changes = item.Changes + .Where(c => c.Operation != Microsoft.VisualStudio.Services.WebApi.Patch.Operation.Test); + if (changes.Any()) + { + _context.Logger.WriteInfo($"Found a request to update workitem {item.Id.Value} in {_context.ProjectName}"); + + batchRequests.Add(new BatchRequest + { + method = "PATCH", + uri = $"/_apis/wit/workitems/{item.Id.Value}?{ApiVersion}", + headers = headers, + body = changes.ToArray() + }); + } + } + + requestBody = JsonConvert.SerializeObject(batchRequests.ToArray(), Formatting.Indented, converters); + _context.Logger.WriteVerbose($"Update workitem(s) batch request:"); + _context.Logger.WriteVerbose(requestBody); + + if (commit) + { + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); + + var batchRequest = new StringContent(requestBody, Encoding.UTF8, "application/json"); + var method = new HttpMethod("POST"); + + // send the request + var request = new HttpRequestMessage(method, $"{baseUriString}/_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; + var response = client.SendAsync(request).Result; + + if (response.IsSuccessStatusCode) + { + WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; + string stringResponse = JsonConvert.SerializeObject(batchResponse, Formatting.Indented); _context.Logger.WriteVerbose(stringResponse); bool succeeded = true; foreach (var batchElement in batchResponse.values) @@ -245,6 +429,7 @@ public WorkItemWrapper NewWorkItem(string workItemType) } } + class JsonPatchOperationConverter : JsonConverter { public override bool CanRead => false; @@ -275,7 +460,6 @@ public override void WriteJson(JsonWriter writer, JsonPatchOperation value, Json writer.WriteValue(value.From); } writer.WritePropertyName("value"); - //writer.WriteValue(value.Value); t = JToken.FromObject(value.Value); t.WriteTo(writer); writer.WriteEndObject(); diff --git a/src/aggregator-ruleng/WorkItemWrapper.cs b/src/aggregator-ruleng/WorkItemWrapper.cs index 401b1f97..1f4100a6 100644 --- a/src/aggregator-ruleng/WorkItemWrapper.cs +++ b/src/aggregator-ruleng/WorkItemWrapper.cs @@ -214,6 +214,7 @@ public WorkItemWrapper Parent public WorkItemId Id { get; + private set; } public int Rev @@ -439,5 +440,46 @@ private T GetFieldValue(string field) ? (T)_item.Fields[field] : default(T); } + + internal void ReplaceIdAndResetChanges(int oldId, int newId) + { + if (oldId >= 0) throw new ArgumentOutOfRangeException(nameof(oldId)); + + Id = new PermanentWorkItemId(newId); + + var candidates = Changes.Where(op => op.Path == "/relations/-"); + foreach (var op in candidates) + { + var patch = op.Value as RelationPatch; + string url = patch.url; + int pos = url.LastIndexOf('/') + 1; + int relId = int.Parse(url.Substring(pos)); + if (relId == oldId) + { + patch.url = url.Substring(0, pos) + newId.ToString(); + break; + } + } + + Changes.RemoveAll(op => op.Path.StartsWith("/fields/") || op.Path == "/id"); + } + + internal void RemapIdReferences(IDictionary realIds) + { + var candidates = Changes.Where(op => op.Path == "/relations/-"); + foreach (var op in candidates) + { + var patch = op.Value as RelationPatch; + string url = patch.url; + int pos = url.LastIndexOf('/') + 1; + int relId = int.Parse(url.Substring(pos)); + if (realIds.ContainsKey(relId)) + { + int newId = realIds[relId]; + string newUrl = url.Substring(0, pos) + newId.ToString(); + patch.url = newUrl; + } + } + } } } diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index 5a755dde..fbdc930a 100644 --- a/src/aggregator-ruleng/aggregator-ruleng.csproj +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -12,6 +12,7 @@ + From 3db2c40018c6097436e6ea118821c9a58d9dd0d1 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 17 Nov 2018 12:40:01 +0000 Subject: [PATCH 35/37] Updated NuGet packages --- src/aggregator-cli/aggregator-cli.csproj | 4 ++-- src/aggregator-function/aggregator-function.csproj | 2 +- src/aggregator-ruleng/aggregator-ruleng.csproj | 2 +- src/aggregator-shared/aggregator-shared.csproj | 2 +- src/integrationtests-cli/integrationtests-cli.csproj | 7 +++++-- src/unittests-ruleng/unittests-ruleng.csproj | 7 +++++-- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/aggregator-cli/aggregator-cli.csproj b/src/aggregator-cli/aggregator-cli.csproj index 596080b4..366648ce 100644 --- a/src/aggregator-cli/aggregator-cli.csproj +++ b/src/aggregator-cli/aggregator-cli.csproj @@ -30,8 +30,8 @@ - - + + diff --git a/src/aggregator-function/aggregator-function.csproj b/src/aggregator-function/aggregator-function.csproj index 8f17bed5..1c588720 100644 --- a/src/aggregator-function/aggregator-function.csproj +++ b/src/aggregator-function/aggregator-function.csproj @@ -9,7 +9,7 @@ DEBUG;TRACE - + diff --git a/src/aggregator-ruleng/aggregator-ruleng.csproj b/src/aggregator-ruleng/aggregator-ruleng.csproj index fbdc930a..4ce61346 100644 --- a/src/aggregator-ruleng/aggregator-ruleng.csproj +++ b/src/aggregator-ruleng/aggregator-ruleng.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/aggregator-shared/aggregator-shared.csproj b/src/aggregator-shared/aggregator-shared.csproj index a26cc23b..4eff7b4a 100644 --- a/src/aggregator-shared/aggregator-shared.csproj +++ b/src/aggregator-shared/aggregator-shared.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/integrationtests-cli/integrationtests-cli.csproj b/src/integrationtests-cli/integrationtests-cli.csproj index 234285a4..15c01bc9 100644 --- a/src/integrationtests-cli/integrationtests-cli.csproj +++ b/src/integrationtests-cli/integrationtests-cli.csproj @@ -9,8 +9,11 @@ - - + + + all + runtime; build; native; contentfiles; analyzers + diff --git a/src/unittests-ruleng/unittests-ruleng.csproj b/src/unittests-ruleng/unittests-ruleng.csproj index 3c8387af..9b12ed8f 100644 --- a/src/unittests-ruleng/unittests-ruleng.csproj +++ b/src/unittests-ruleng/unittests-ruleng.csproj @@ -9,8 +9,11 @@ - - + + + all + runtime; build; native; contentfiles; analyzers + From 8b2b9dae42ee34bd740611c40f728a147d6d8684 Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 17 Nov 2018 13:27:52 +0000 Subject: [PATCH 36/37] Updated docs --- README.md | 19 +++-- doc/build-and-test.md | 13 +++- doc/rule-examples.md | 10 +++ doc/rule-language.md | 159 ++++++++++++++++++++++-------------------- 4 files changed, 112 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 74eb0939..daf2a8b2 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ The Web Service flavor will be discontinued in favor of this (its deployment and The main scenario for Aggregator (3.x) is supporting Azure DevOps and the cloud scenario. It will work for TFS as long as it is reachable from Internet. -> **This is an early version (alpha)**: we might change verbs and rule language before the final release! +> **This is an early version (beta)**: we might change verbs and rule language before the final release! +*Note*: The documentation is limited to this page and the content of the `doc` folder. ## Major features -- use of new REST API +- use of new Azure DevOps REST API - simple deployment via CLI tool -- similar model for Rules +- Rule object model similar to v2 ## Planned features @@ -40,7 +41,7 @@ Aggregator checks its latest GitHub Release to ensure that Aggregator Runtime is An Aggregator Mapping is a Azure DevOps Service Hook for a specific work item event that invokes an Aggregator Rule i.e. the Azure Function hosting the Rule code. Azure DevOps saves the Azure Function Key in the Service Hook configuration. -You can deploy the same Rule in many Instances or map the same Azure DevOps event to many Rules: it is up how to organize. +You can deploy the same Rule in many Instances or map the same Azure DevOps event to many Rules: it is up to you choosing the best way to organize. ## Authentication @@ -49,19 +50,17 @@ To do this, run the `login.azure` and `login.ado` commands. To create the credentials, you need an Azure Service Principal and a Azure DevOps Personal Access Token. -These documents will guide you +These documents will guide you in creating the credentials * [Use portal to create an Azure Active Directory application and service principal that can access resources](https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-create-service-principal-portal) * [Create personal access tokens to authenticate access](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate). Logon credentials are stored locally and expire after 2 hours. -The PAT is stored in the Azure Function settings: **whoever has access to the Resource Group can read it!** +The PAT is also stored in the Azure Function settings: **whoever has access to the Resource Group can read it!** -The Service Principal must have Contributor permission to the Azure Subscription. -In alternative, pre-create the `aggregator-` Resource Group in Azure and give the service account Contributor permission to the Resource Group. +The Service Principal must have Contributor permission to the Azure Subscription or, in alternative, pre-create the Resource Group in Azure and give the service account Contributor permission to the Resource Group. ![Permission on existing Resource Group](doc/contributor-on-rg.png) -If you go this route, you must add the `--resourceGroup` to all commands requiring an instance. -The `instance` parameter prefixes `aggregator-` to identify the Resource Group. +If you go this route, remember add the `--resourceGroup` to all commands requiring an instance, otherwise the `instance` parameter adds an `aggregator-` prefixe to find the Resource Group. ## Usage diff --git a/doc/build-and-test.md b/doc/build-and-test.md index 30e24a58..9a6923bb 100644 --- a/doc/build-and-test.md +++ b/doc/build-and-test.md @@ -1,12 +1,19 @@ # Build -Visual Studio 2017 15.8.9 -Azure Functions and Web Jobs Tools +Building locally requires +- Visual Studio 2017 15.8.9 +- Azure Functions and Web Jobs Tools # Debug +## Custom/development Aggregator runtime +In Visual Studio, `src\aggregator-function\Directory.Build.targets` will automatically package and copy the runtime needed by CLI. +You might have to change the version number in `src\aggregator-function\aggregator-manifest.ini` to force your local version. + +You can also use the *Pack* right-click command on the `aggregator-function` project and make sure to copy the created zip into your CLI directory so it uploads the correct one when creating an instance. + ## CLI Set `aggregator-cli` as Start-up project -Use the project properties to set the Command line arguments +Use the Visual Studio Project properties to set the Command line arguments ## Runtime Set `aggregator-function` as Start-up project diff --git a/doc/rule-examples.md b/doc/rule-examples.md index 7deec773..54f50923 100644 --- a/doc/rule-examples.md +++ b/doc/rule-examples.md @@ -41,3 +41,13 @@ return message; ``` return self.PreviousRevision.PreviousRevision.Description; ``` + +# Create new Work Item +``` +var parent = self; +var newChild = store.NewWorkItem("Task"); +newChild.Title = "Brand new child"; +parent.Relations.AddChild(newChild); + +return parent.Title; +``` diff --git a/doc/rule-language.md b/doc/rule-language.md index 965cc6ed..aa9474fa 100644 --- a/doc/rule-language.md +++ b/doc/rule-language.md @@ -8,108 +8,115 @@ # WorkItem Object ## Revisions -WorkItem PreviousRevision -IEnumerable Revisions +Navigate to previous versions of the work item. +`WorkItem PreviousRevision` +`IEnumerable Revisions` ## Relations -IEnumerable RelationLinks -WorkItemRelationCollection Relations -IEnumerable ChildrenLinks -IEnumerable Children -WorkItemRelation ParentLink -WorkItem Parent +Navigate to related work items. +`IEnumerable RelationLinks` +`WorkItemRelationCollection Relations` +`IEnumerable ChildrenLinks` +`IEnumerable Children` +`WorkItemRelation ParentLink` +`WorkItem Parent` ## Links -IEnumerable RelatedLinks -IEnumerable Hyperlinks -int ExternalLinkCount -int HyperLinkCount -int RelatedLinkCount +Navigate links to non-workitem objects. +`IEnumerable RelatedLinks` +`IEnumerable Hyperlinks` +`int ExternalLinkCount` +`int HyperLinkCount` +`int RelatedLinkCount` ## Fields -WorkItemId Id -int Rev -string Url -string WorkItemType -string State -int AreaId -string AreaPath -IdentityRef AssignedTo -IdentityRef AuthorizedAs -IdentityRef ChangedBy -DateTime? ChangedDate -IdentityRef CreatedBy -DateTime? CreatedDate -string Description -string History -int IterationId -string IterationPath -string Reason -DateTime? RevisedDate -DateTime? AuthorizedDate -string TeamProject -string Tags -string Title -double Watermark -bool IsDeleted -bool IsReadOnly -bool IsNew -bool IsDirty -object this[string field] +Data fields of the work item. +`WorkItemId Id` Read-only. +`int Rev` Read-only. +`string Url` Read-only. +`string WorkItemType` Read-only. +`string State` +`int AreaId` +`string AreaPath` +`IdentityRef AssignedTo` +`IdentityRef AuthorizedAs` +`IdentityRef ChangedBy` +`DateTime? ChangedDate` +`IdentityRef CreatedBy` +`DateTime? CreatedDate` +`string Description` +`string History` +`int IterationId` +`string IterationPath` +`string Reason` +`DateTime? RevisedDate` +`DateTime? AuthorizedDate` +`string TeamProject` +`string Tags` +`string Title` +`double Watermark` Read-only. +`bool IsDeleted` Read-only. +`bool IsReadOnly` Read-only, returns `true` if work item cannot be modified. +`bool IsNew` Read-only. +`bool IsDirty` Read-only, returns `true` if work item changed after retrieval. +`object this[string field]` access to non-core fields. ## Attachments -int AttachedFileCount +`int AttachedFileCount` # WorkItemStore Object +Retrival, creation and removal of work items. -WorkItem GetWorkItem(int id) -WorkItem GetWorkItem(WorkItemRelation item) +`WorkItem GetWorkItem(int id)` +`WorkItem GetWorkItem(WorkItemRelation item)` -IList GetWorkItems(IEnumerable ids) -IList GetWorkItems(IEnumerable collection) +`IList GetWorkItems(IEnumerable ids)` +`IList GetWorkItems(IEnumerable collection)` -WorkItemWrapper NewWorkItem(string workItemType) +`WorkItemWrapper NewWorkItem(string workItemType)` # WorkItemRelationCollection +Navigate and modify related objects. -IEnumerator GetEnumerator() -Add(WorkItemRelation item) -AddChild(WorkItemWrapper child) -AddParent(WorkItemWrapper parent) -AddLink(string type, string url, string comment) -AddHyperlink(string url, string comment = null) -AddRelatedLink(WorkItem item, string comment = null) -AddRelatedLink(string url, string comment = null) -Clear() -bool Contains(WorkItemRelation item) -bool Remove(WorkItemRelation item) -int Count -bool IsReadOnly +`IEnumerator GetEnumerator()` +`Add(WorkItemRelation item)` +`AddChild(WorkItemWrapper child)` +`AddParent(WorkItemWrapper parent)` +`AddLink(string type, string url, string comment)` +`AddHyperlink(string url, string comment = null)` +`AddRelatedLink(WorkItem item, string comment = null)` +`AddRelatedLink(string url, string comment = null)` +`Clear()` +`bool Contains(WorkItemRelation item)` +`bool Remove(WorkItemRelation item)` +`int Count` +`bool IsReadOnly` # WorkItemRelation -string Title -string Rel -string Url -IDictionary Attributes +`string Title` +`string Rel` +`string Url` +`IDictionary Attributes` # IdentityRef - -string DirectoryAlias -string DisplayName -string Id -string ImageUrl -bool Inactive -bool IsAadIdentity -bool IsContainer -string ProfileUrl -string UniqueName -string Url \ No newline at end of file +Represents a User identity. + +`string DirectoryAlias` +`string DisplayName` +`string Id` +`string ImageUrl` +`bool Inactive` +`bool IsAadIdentity` +`bool IsContainer` +`string ProfileUrl` +`string UniqueName` +`string Url` From 721e649f021f32908a7cffbe8eed91f4f8b2e86c Mon Sep 17 00:00:00 2001 From: Giulio Vian Date: Sat, 17 Nov 2018 13:42:31 +0000 Subject: [PATCH 37/37] Add test to cleanup --- src/integrationtests-cli/Scenario3_MultiInstance.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/integrationtests-cli/Scenario3_MultiInstance.cs b/src/integrationtests-cli/Scenario3_MultiInstance.cs index 64d3dad9..9e82fa7c 100644 --- a/src/integrationtests-cli/Scenario3_MultiInstance.cs +++ b/src/integrationtests-cli/Scenario3_MultiInstance.cs @@ -125,5 +125,15 @@ void ListInstancesAfterUninstall() Assert.DoesNotContain("Instance my4", output); Assert.Contains("Instance my5", output); } + + [Theory, Order(10)] + [InlineData("my5", "test5")] + void UnmapRules(string instance, string rule) + { + (int rc, string output) = RunAggregatorCommand($"unmap.rule --project {project} --event workitem.created --instance {instance} --resourceGroup {resourceGroup} --rule {rule}"); + + Assert.Equal(0, rc); + Assert.DoesNotContain("] Failed!", output); + } } }