├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── assets ├── logo │ ├── azure-functions.png │ └── dotnet-core-logo.png ├── manager-automation-hq-banner.png ├── manager-automation-lq-banner.jpg └── screenshots │ ├── app-insights-trace-log.png │ ├── azure-functions-app-settings.png │ ├── functions.png │ ├── google-chat-id.png │ ├── in-sprint-alt1.png │ ├── in-sprint-alt2.png │ ├── in-sprint-alt3.png │ ├── last-day-sprint-reminders.png │ ├── managers-only-reminders.png │ ├── new-sprint-reminders.png │ └── pull-request-reminders.png └── src ├── SampleWorkItem.json ├── TarikGuney.ManagerAutomation.FunctionApp ├── Actors │ ├── ActivateWorkItemActor.cs │ ├── DescriptionActor.cs │ ├── DescriptiveTitlesActor.cs │ ├── EstimateWorkItemsActor.cs │ ├── GreatPreviousIterationActor.cs │ ├── GreatWorkActor.cs │ ├── LongCodeCompleteActor.cs │ ├── OpenWorkItemsActor.cs │ ├── PassedDueWorkItemsActor.cs │ ├── PendingPullRequestsActor.cs │ └── StillActiveWorkItemsActor.cs ├── AutoFacModules │ ├── ConfigurationModule.cs │ ├── CurrentIterationModule.cs │ ├── ManagersReportModule.cs │ └── RetrospectiveModule.cs ├── CommMessages │ ├── ActorResponse.cs │ ├── AnalysisCompleteResponse.cs │ └── StartAnalysisRequest.cs ├── CurrentIterationAutomationFunction.cs ├── Helpers │ ├── DateDiffHelper.cs │ └── IterationHelper.cs ├── IterationWorkItemRetrievers │ ├── AzureDevOpsAllPullRequestsRetriever.cs │ ├── AzureDevOpsIterationWorkItemsRetriever.cs │ ├── IIterationWorkItemsRetriever.cs │ └── IPullRequestsRetriever.cs ├── Managers │ ├── CurrentIterationManager.cs │ ├── ProgressReportManager.cs │ └── RetrospectiveManager.cs ├── ManagersReportAutomationFunction.cs ├── MessageSenders │ ├── CurrentIterationGoogleChatMessageSender.cs │ ├── ICurrentIterationMessageSender.cs │ ├── ILastDayOfCurrentIterationMessageSender.cs │ ├── IManagersReportMessageSender.cs │ ├── IMessageSender.cs │ ├── IRetrospectiveMessageSender.cs │ ├── LastDayOfCurrentIterationGoogleChatMessageSender.cs │ ├── ManagersReportGoogleChatMessageSender.cs │ └── RetrospectiveGoogleChatMessageSender.cs ├── RetrospectiveAutomationFunction.cs ├── SettingsModels │ ├── AzureDevOpsSettings.cs │ ├── CurrentIterationInfo.cs │ ├── DevOpsChatUserMap.cs │ ├── EngineeringManagerInfo.cs │ ├── GoogleChatSettings.cs │ ├── IterationInfo.cs │ ├── IterationTimeFrame.cs │ └── PreviousIterationInfo.cs ├── TarikGuney.ManagerAutomation.FunctionApp.csproj ├── WorkItemMessage.cs └── host.json ├── TarikGuney.ManagerAutomation.UnitTests ├── TarikGuney.ManagerAutomation.UnitTests.csproj └── UnitTest1.cs ├── TarikGuney.ManagerAutomation.sln └── global.json /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | local.settings.json 6 | 7 | # User-specific files 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Build results 17 | [Dd]ebug/ 18 | [Dd]ebugPublic/ 19 | [Rr]elease/ 20 | [Rr]eleases/ 21 | x64/ 22 | x86/ 23 | bld/ 24 | [Bb]in/ 25 | [Oo]bj/ 26 | [Ll]og/ 27 | 28 | # Visual Studio 2015 cache/options directory 29 | .vs/ 30 | # Uncomment if you have tasks that create the project's static files in wwwroot 31 | #wwwroot/ 32 | 33 | # MSTest test Results 34 | [Tt]est[Rr]esult*/ 35 | [Bb]uild[Ll]og.* 36 | 37 | # NUNIT 38 | *.VisualState.xml 39 | TestResult.xml 40 | 41 | # Build Results of an ATL Project 42 | [Dd]ebugPS/ 43 | [Rr]eleasePS/ 44 | dlldata.c 45 | 46 | # DNX 47 | project.lock.json 48 | project.fragment.lock.json 49 | artifacts/ 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # NCrunch 117 | _NCrunch_* 118 | .*crunch*.local.xml 119 | nCrunchTemp_* 120 | 121 | # MightyMoose 122 | *.mm.* 123 | AutoTest.Net/ 124 | 125 | # Web workbench (sass) 126 | .sass-cache/ 127 | 128 | # Installshield output folder 129 | [Ee]xpress/ 130 | 131 | # DocProject is a documentation generator add-in 132 | DocProject/buildhelp/ 133 | DocProject/Help/*.HxT 134 | DocProject/Help/*.HxC 135 | DocProject/Help/*.hhc 136 | DocProject/Help/*.hhk 137 | DocProject/Help/*.hhp 138 | DocProject/Help/Html2 139 | DocProject/Help/html 140 | 141 | # Click-Once directory 142 | publish/ 143 | 144 | # Publish Web Output 145 | *.[Pp]ublish.xml 146 | *.azurePubxml 147 | # TODO: Comment the next line if you want to checkin your web deploy settings 148 | # but database connection strings (with potential passwords) will be unencrypted 149 | #*.pubxml 150 | *.publishproj 151 | 152 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 153 | # checkin your Azure Web App publish settings, but sensitive information contained 154 | # in these scripts will be unencrypted 155 | PublishScripts/ 156 | 157 | # NuGet Packages 158 | *.nupkg 159 | # The packages folder can be ignored because of Package Restore 160 | **/packages/* 161 | # except build/, which is used as an MSBuild target. 162 | !**/packages/build/ 163 | # Uncomment if necessary however generally it will be regenerated when needed 164 | #!**/packages/repositories.config 165 | # NuGet v3's project.json files produces more ignoreable files 166 | *.nuget.props 167 | *.nuget.targets 168 | 169 | # Microsoft Azure Build Output 170 | csx/ 171 | *.build.csdef 172 | 173 | # Microsoft Azure Emulator 174 | ecf/ 175 | rcf/ 176 | 177 | # Windows Store app package directories and files 178 | AppPackages/ 179 | BundleArtifacts/ 180 | Package.StoreAssociation.xml 181 | _pkginfo.txt 182 | 183 | # Visual Studio cache files 184 | # files ending in .cache can be ignored 185 | *.[Cc]ache 186 | # but keep track of directories ending in .cache 187 | !*.[Cc]ache/ 188 | 189 | # Others 190 | ClientBin/ 191 | ~$* 192 | *~ 193 | *.dbmdl 194 | *.dbproj.schemaview 195 | *.jfm 196 | *.pfx 197 | *.publishsettings 198 | node_modules/ 199 | orleans.codegen.cs 200 | 201 | # Since there are multiple workflows, uncomment next line to ignore bower_components 202 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 203 | #bower_components/ 204 | 205 | # RIA/Silverlight projects 206 | Generated_Code/ 207 | 208 | # Backup & report files from converting an old project file 209 | # to a newer Visual Studio version. Backup files are not needed, 210 | # because we have git ;-) 211 | _UpgradeReport_Files/ 212 | Backup*/ 213 | UpgradeLog*.XML 214 | UpgradeLog*.htm 215 | 216 | # SQL Server files 217 | *.mdf 218 | *.ldf 219 | 220 | # Business Intelligence projects 221 | *.rdl.data 222 | *.bim.layout 223 | *.bim_*.settings 224 | 225 | # Microsoft Fakes 226 | FakesAssemblies/ 227 | 228 | # GhostDoc plugin setting file 229 | *.GhostDoc.xml 230 | 231 | # Node.js Tools for Visual Studio 232 | .ntvs_analysis.dat 233 | 234 | # Visual Studio 6 build log 235 | *.plg 236 | 237 | # Visual Studio 6 workspace options file 238 | *.opt 239 | 240 | # Visual Studio LightSwitch build output 241 | **/*.HTMLClient/GeneratedArtifacts 242 | **/*.DesktopClient/GeneratedArtifacts 243 | **/*.DesktopClient/ModelManifest.xml 244 | **/*.Server/GeneratedArtifacts 245 | **/*.Server/ModelManifest.xml 246 | _Pvt_Extensions 247 | 248 | # Paket dependency manager 249 | .paket/paket.exe 250 | paket-files/ 251 | 252 | # FAKE - F# Make 253 | .fake/ 254 | 255 | # JetBrains Rider 256 | .idea/ 257 | *.sln.iml 258 | 259 | # CodeRush 260 | .cr/ 261 | 262 | # Python Tools for Visual Studio (PTVS) 263 | __pycache__/ 264 | *.pyc -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "src/TarikGuney.ManagerAutomation.FunctionApp/secrets"] 2 | path = src/TarikGuney.ManagerAutomation.FunctionApp/secrets 3 | url = git@github.com:tarikguney/manager-automation-secrets 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Developed with the following technologies by [@tarikguney](https://github.com/tarikguney) 2 | 3 | ![](./assets/logo/azure-functions.png) ![](./assets/logo/dotnet-core-logo.png) 4 | 5 | ![](./assets/manager-automation-hq-banner.png) 6 | 7 | Table of Contents 8 | ================= 9 | 10 | * [Table of Contents](#table-of-contents) 11 | * [What is it?](#what-is-it) 12 | * [What's new in Version 2](#whats-new-in-version-2) 13 | * [Underlying Design Principle](#underlying-design-principle) 14 | * [How does it look?](#how-does-it-look) 15 | * [In-Sprint action reminders](#in-sprint-action-reminders) 16 | * [End-of-sprint action reminders](#end-of-sprint-action-reminders) 17 | * [New Sprint action reminders](#new-sprint-action-reminders) 18 | * [Pull Request Reminders](#pull-request-reminders) 19 | * [Managers-only reminders](#managers-only-reminders) 20 | * [Reporting](#reporting) 21 | * [What's being logged?](#whats-being-logged) 22 | * [Real-world log samples from the console](#real-world-log-samples-from-the-console) 23 | * [Where to find the logs in Application Insights?](#where-to-find-the-logs-in-application-insights) 24 | * [The Complete List of Reminders](#the-complete-list-of-reminders) 25 | * [Current Iteration Automation](#current-iteration-automation) 26 | * [End of the Sprint Reminders](#end-of-the-sprint-reminders) 27 | * [Retrospective Automation](#retrospective-automation) 28 | * [Managers-only Automation](#managers-only-automation) 29 | * [Setup and Configuration](#setup-and-configuration) 30 | * [Dependencies](#dependencies) 31 | * [Publishing to Azure Functions](#publishing-to-azure-functions) 32 | * [App Settings](#app-settings) 33 | * [Available settings](#available-settings) 34 | * [Where to put the app settings?](#where-to-put-the-app-settings) 35 | * [Explanation of each setting](#explanation-of-each-setting) 36 | * [AzureDevOps__ApiKey](#azuredevops__apikey) 37 | * [AzureDevOps__Organization](#azuredevops__organization) 38 | * [AzureDevOps__Project](#azuredevops__project) 39 | * [AzureDevOps__Team](#azuredevops__team) 40 | * [AzureDevOpsUsersMapToGoogleChat](#azuredevopsusersmaptogooglechat) 41 | * [EngineeringManagerInfo__AzureDevOpsEmail](#engineeringmanagerinfo__azuredevopsemail) 42 | * [EngineeringManagerInfo__GoogleChatUserId](#engineeringmanagerinfo__googlechatuserid) 43 | * [EngineeringManagerInfo__ManagerRemindersGoogleWebhookUrl](#engineeringmanagerinfo__managerremindersgooglewebhookurl) 44 | * [GoogleChat__WebhookUrl](#googlechat__webhookurl) 45 | * [WEBSITE_TIME_ZONE](#website_time_zone) 46 | * [Finding Google Chat Id (UserId)](#finding-google-chat-id-userid) 47 | * [FAQ](#faq) 48 | 49 | 50 | 51 | 52 | 53 | # What is it? 54 | **As an engineering manager, you realize that a lot of your time goes to repetitive tasks which also include guiding your team in keeping the sprint board up-to-date with enough level of details for an accurate work tracking.** There could be many reasons why you find yourself in this situation, but regardless of the reason, Manager Automation will take some of that burden off your shoulders. It will continuously analyze the current Sprint's board. It will remind the team of the missing information in the form of actionable reminders that are important for the smooth execution of every Sprint. **Note that these reminders play an essential role in guiding the team and new hires towards the expectations of the team and company culture.** 55 | # What's new in Version 2 56 | 57 | There is already a project of mine named [Manager Automation](https://github.com/tarikguney/manager-automation), but as I was learning [Akka.NET](https://getakka.net), I decided to re-write the entire project in Akka. The original version was written in [TPL Dataflow](https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/dataflow-task-parallel-library), which is a great library; but overall, I found the [Actor Model](https://en.wikipedia.org/wiki/Actor_model) constructs more straightforward in Akka than TPL Dataflow. Akka also offers more constructs for the development teams to focus on the business results rather than re-inventing various existing concepts. Some of the areas Akka.NET offers better development support that I am interested in this project are: 58 | 59 | 1. [Dependency Injection](https://getakka.net/articles/actors/dependency-injection.html) 60 | 61 | 2. [Streaming](https://getakka.net/articles/streams/introduction.html) 62 | 63 | 3. [Testing Actors](https://getakka.net/articles/actors/testing-actor-systems.html) 64 | 65 | My newly acquired experience from this re-write of this project in Akka.NET will be incorporated into [Projekt Hive](https://github.com/tarikguney/projekt-hive), which is planned to be a complete re-imagination of [Manager Automation](https://www.github.com/tarikguney/manager-automation) from the ground up. 66 | 67 | If you are interested in Akka.NET and want to see a project being re-written with actors, then follow this project. You can compare the original commit to the latest commit to see the evolution of the project. It is probably a rare moment to see a live project, that is originally written with TPL Dataflow, being re-written in Akka.NET. So, enjoy, and don't forget to leave your comments and suggestions on the [Discussions](https://github.com/tarikguney/manager-automation-v2/discussions) page. 68 | 69 | # Underlying Design Principle 70 | The communication of the reminders in the solution is intentionally done in a public team chat room. Besides being public, the messages are kept straightforward too. It communicates the required actions through Google Chat (and Slack, etc., in the future) in simple and concise words. It is visible and repetitive, as these are perhaps some of the most effective factors to incorporate cultural elements into a team's daily life. Rare communication of hard-to-remember tasks will not make it into the team culture early enough. They have to be communicated frequently in a place where they are most visible to make them easy to follow by the team to become a part of the team culture. 71 | 72 | # How does it look? 73 | 74 | There are some screenshots of the messages below for you to see what's being communicated. Note that the source code will be the best place to check for the full list of messages and communication for now. 75 | 76 | ## In-Sprint action reminders 77 | Manager Automation service sends daily reminders as demonstrated in the screenshots below. These series of reminders ensure the necessary actions being taken by the team members. The reminders use Google Chat's name mentioning feature to help the targeted team members to get on-time and relevant notifications through the Google Chat. 78 | 79 | 80 | 81 | 82 | 83 | ## End-of-sprint action reminders 84 | Manager Automation service embeds specific situational facts in the reminder text. For example, it changes its greeting when it is the last day of the Sprint to draw people's attention to the fact that the Sprint is about to end, and there might be some missing work in the Sprint. It also guides the team members on how the incomplete work must be handled based on the time available and committed time for the work item estimated by the engineer. 85 | 86 | 87 | 88 | ## New Sprint action reminders 89 | When it is the first day of the new Sprint, the automation service will analyze the previous Sprint one more time and bring up any issues that are left unresolved. 90 | 91 | 92 | 93 | ## Pull Request Reminders 94 | 95 | Reading the existing PRs from the Azure DevsOps, and sending the engineering team daily reminders to ensure the developed code gets merged into the main branch. This increases the awareness of work completion among the team. At the end of the day, it is only the merged code that actually turns into a business value. 96 | 97 | Pull Request Reminders Screenshot 98 | 99 | ## Managers-only reminders 100 | As an engineering manager, it is often crucial to be aware of the work's progression on time and catch the delay before becoming a big problem. There might be many reasons why work is delayed. For instance, engineering might be blocked. Regardless of the reason, if a task is taking an unreasonable amount of time, you will need to reach out and see what's going on and how you can help the team member with the progression of the work. The Manager Automation service collects this information for you -- the engineering manager -- and reports them daily in a private Google Chat room. 101 | 102 | 103 | 104 | # Reporting 105 | 106 | Reporting is one of the most important capabilities of the Manager Automation tool. The daily reminders are also sent to the [**Azure Application Insights**](https://docs.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview) for a further review by the team and the engineering manager. You can write up reports using these logs to better understand the team's performance and improvement over time. It is up to you when it comes to how to use these reporting capabilities, but a recommended approach is to help the team grow by helping them see what areas they need to work to improve. For instance, if they are not good at estimating the work items, they can objectively see this problem area in the reports and work to improve it over a course of time. 107 | 108 | The reporting is a fairly new addition to the manager automation, hence, the documentation lacks enough level of details and screenshots. However, it is simple to use when you integrate the Application Insights to your Azure Functions application. Ensure that you select an Application Insights instance (or create a new one) when you first create your function application. 109 | 110 | ## What's being logged? 111 | 112 | These are the logs you will see in the Application Insights logs: 113 | 114 | ``` 115 | BOARD: Missing description for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}. 116 | 117 | BOARD: Unclear title for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}. 118 | 119 | BOARD: Missing story point for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}. 120 | 121 | BOARD: Closed everything from the previous sprint by the first day of the current sprint {currentIteration}. Assigned to {userEmail}. 122 | 123 | BOARD: Closed everything in the current sprint {currentIteration}. Assigned to {userEmail}. 124 | 125 | BOARD: Pending in incomplete state of {currentState} for {pendingForDays} days. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}. 126 | 127 | BOARD: Still open in {currentState} state. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}." 128 | 129 | BOARD: Still in active state. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}. 130 | 131 | CODE: Pending pull request \"{pullRequestTitle}:{pullRequestId}\". Created by {createdBy} on {createdDate}. 132 | ``` 133 | Note that all these logs start with `BOARD` to help you distinguish these log entries from the other sort of logs easily. 134 | 135 | The log messages above are taken directly from the code. As you might have guessed, they follow [**structural logging**](https://softwareengineering.stackexchange.com/a/312586/3613) pattern; meaning that they can easily be parsed and parameterized with the metadata they carry for each fields as represented by `{ }` as seen in `{workItemId}`. 136 | 137 | ## Real-world log samples from the console 138 | 139 | Samples from console environment: 140 | ``` 141 | [2020-12-26T21:36:32.640Z] BOARD: Missing story point for "12:Create the profile edit page". Assigned to atarikguney@gmail.com in Sprint 3. 142 | [2020-12-26T21:36:32.643Z] BOARD: Pending in incomplete state of Resolved for 28 days. Story "19:This is an active item". Assigned to atarikguney@gmail.com in Sprint 3. 143 | ``` 144 | 145 | More documentation and examples will follow as this is one of the most critical functionalities of Manager Automation tool. 146 | 147 | ## Where to find the logs in Application Insights? 148 | 149 | The log entries above are logged as trace. This is default log type in Azure Functions runtime. Therefore, when you go to the Log tab on Azure Portal, search the log entries in the trace section. You can also create dashboard for more convenient view. Check out the image below, it will show you how to access the Log and how to perform a simple search to find the relevant log entries for further reporting needs: 150 | 151 | 152 | 153 | # The Complete List of Reminders 154 | This function application consists of three primary functions: 155 | 1. Current Iteration Automation 156 | 1. End of iteration Automation 157 | 1. Retrospective Automation 158 | 1. Managers-only Reminders 159 | 160 | ## Current Iteration Automation 161 | Most of the reminders go out as part of the current iteration automation executions. This automation analyses the current iteration (aka. Sprint) using Azure DevOps API from various points and sends reminders to the responsible parties to make sure they are addressed. These checkpoints are: 162 | - Missing description in work items (defects and user stories) 163 | - Indescriptive work item titles 164 | - Work items without story points 165 | - Work items in Resolve state for more than 12 hours 166 | - Assigned but not activated work items. Reminds the responsible parties to activate the work item they are working. 167 | - Congratulate the ones with all closed work items. 168 | 169 | These reminders are sent out twice every day, one in the morning and one in the afternoon. 170 | 171 | ## End of the Sprint Reminders 172 | On the last day of the Sprint, the reminders change. In addition to the ones above, it also automates: 173 | 174 | - If there are work items that are still active, it reminds the possible action items. 175 | 176 | ## Retrospective Automation 177 | On the first day of the Sprint, this automation checks if there is any remaining work from the previous Sprint. It runs the existing checks and reports them differently, which is more appropriate for the first day of the next Sprint. 178 | 179 | ## Managers-only Automation 180 | 181 | - Due date reminders. 182 | 183 | # Setup and Configuration 184 | ## Dependencies 185 | This project is developed for **[Azure Functions](https://azure.microsoft.com/en-us/services/functions) with [**.NET Core 3.1**](https://dotnet.microsoft.com/)**. Hence, some basic knowledge in this technology stack is helpful. Also, this service assumes that you are using **[Azure DevOps](https://dev.azure.com)** as the project planning/tracking environment and **[Google Chat](https://chat.google.com)** as the communication medium among your team members. 186 | ## Publishing to Azure Functions 187 | Deploying this source code to the Azure Function is easy. This project uses the Library version of Azure Functions. Check out these steps to learn how to publish Azure Functions: [Publish to Azure](https://docs.microsoft.com/en-us/azure/azure-functions/functions-develop-vs#publish-to-azure). 188 | 189 | Once you deploy this function app to Azure Functions, these are the functions that will appear on the Azure Portal: 190 | 191 | 192 | 193 | ## App Settings 194 | 195 | To complete app settings, you need various pieces of information from Azure DevOps and Google Chat. 196 | 197 | In addition to the default app settings that come with the Azure Function creation, there are some custom settings that the source code depends on as follows. All of them are required! 198 | 199 | ### Available settings 200 | 201 | ```json 202 | [ 203 | { 204 | "name": "AzureDevOps__ApiKey", 205 | "value": "PERSONAL-ACCESS-TOKEN-FROM-AZURE-DEVOPS", 206 | "slotSetting": false 207 | }, 208 | { 209 | "name": "AzureDevOps__Organization", 210 | "value": "ORGANIZATION-NAME-OF-AZURE-DEVOPS", 211 | "slotSetting": false 212 | }, 213 | { 214 | "name": "AzureDevOps__Project", 215 | "value": "PROJECT-NAME-FROM-AZURE-DEVOPS", 216 | "slotSetting": false 217 | }, 218 | { 219 | "name": "AzureDevOps__Team", 220 | "value": "TEAM-NAME-FROM-AZURE-DEVOPS", 221 | "slotSetting": false 222 | }, 223 | { 224 | "name": "AzureDevOpsUsersMapToGoogleChat", 225 | "value": "AZURE-DEVOPS-USER1-EMAIL:GOOGLE-CHAT-ID-1;AZURE-DEVOPS-USER2-EMAIL:GOOGLE-CHAT-ID-2", 226 | "slotSetting": false 227 | }, 228 | { 229 | "name": "EngineeringManagerInfo__AzureDevOpsEmail", 230 | "value": "ENGINEERING-MANAGER-EMAIL-FROM-AZURE-DEVOPS", 231 | "slotSetting": false 232 | }, 233 | { 234 | "name": "EngineeringManagerInfo__GoogleChatUserId", 235 | "value": "ENGINEERING-MANAGER-GOOGLE-CHAT-ID", 236 | "slotSetting": false 237 | }, 238 | { 239 | "name": "EngineeringManagerInfo__ManagerRemindersGoogleWebhookUrl", 240 | "value": "MANAGERS-ONLY-REMINDERS-ROOM-WEBHOOK-URL", 241 | "slotSetting": false 242 | }, 243 | { 244 | "name": "GoogleChat__WebhookUrl", 245 | "value": "GOOGLE-CHAT-ROOM-WEBHOOK-URL", 246 | "slotSetting": false 247 | }, 248 | { 249 | "name": "WEBSITE_TIME_ZONE", 250 | "value": "Mountain Standard Time", 251 | "slotSetting": false 252 | } 253 | ] 254 | ``` 255 | 256 | ### Where to put the app settings? 257 | 258 | Don't store the application settings in `appsettings.json` file. I recommend `appsettings.json` file only for local development since Azure Functions App have a better place to put the configuration settings via Azure Portal. Visit the `Configuration` link in your Functions instance as shown below: 259 | 260 | 261 | 262 | This way, you don't have re-publish the function app when you change a setting. 263 | 264 | ### Explanation of each setting 265 | 266 | Note that the double underscore (`__`) in the setting names have a [special meaning in .NET Core configurations](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-5.0#environment-variables). Basically, they are used to group the settings in the environment variables. 267 | 268 | #### `AzureDevOps__ApiKey` 269 | 270 | This is your personal access token. Check out this page to see how to get it from Azure DevOps: [**Create a PAT**](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page#create-a-pat). When creating your PAT, you can define the scopes this token can access. Keep it limited to the following scopes for most safety: 271 | 272 | 1. Choose “Custom Defined” 273 | 2. Code > Read 274 | 3. Project and Team > Read 275 | 4. Work Items > Read 276 | 277 | Then, follow these steps to generate the API key you need for this setting: 278 | 1. Use this format: `:personal-access-token` --> Don't forget to add the colon at the beginning. 279 | 2. Encode it with Base64String 280 | 3. Use the resulting string as the value for this setting. 281 | 282 | #### `AzureDevOps__Organization` 283 | 284 | This is the organization of your Azure DevOps. You can infer this value from the URL: `https://dev.azure.com/{organization}`. The first segment in the path of the DevOps URL is the organization. Simply use that value. 285 | 286 | #### `AzureDevOps__Project` 287 | 288 | You can create multiple projects in Azure DevOps, and this setting requires one of them under which you sprint boards, etc. are all defined. When you go to your organization URL `dev.azure.com/{organization}`, you will be presented with the projects. Simply choose the one you desire and copy their names as they appear on that screen to use as the value of this setting. 289 | 290 | #### `AzureDevOps__Team` 291 | 292 | You can define teams in Azure DevOps, and boards are associated with teams. Use that team name as the value for this setting. Check out this page to learn more about teams: [**Add team, go from one default team to others**](https://docs.microsoft.com/en-us/azure/devops/organizations/settings/add-teams?view=azure-devops&tabs=preview-page) 293 | 294 | #### `AzureDevOpsUsersMapToGoogleChat` 295 | 296 | In order to determine whom to send the message to, there should be some mapping between Azure DevOps users and Google Chat users. Google Chat Webhook API only works with Google Chat User Ids, and this setting is to map between these values. Map all of the team members in this setting as the recommended approach. Otherwise, some members may not be properly mentioned in Google Chat. This value accepts multiple mappings separated by semi-colon (`;`). Check out this link to see how to extract Google Chat Id: [Finding Google Chat Id (UserId)](#finding-google-chat-id-userid) 297 | 298 | `AZURE-DEVOPS-USER1-EMAIL:GOOGLE-CHAT-ID-1` would translate to `michael.smith@gmail.com:2333181726262`in a real-world setting. Another example with multiple mappings would be as follows: `michael.smith@gmail.com:2333181726262;tarik.guney@fakeaddress.com:23344556311`. Note the `;` as the separator of these mappings. You can add as many as you want. 299 | 300 | #### `EngineeringManagerInfo__AzureDevOpsEmail` 301 | 302 | Team and project/engineering managers receive different messages. Therefore, project/engineering manager information is asked separately. Use your DevOps email as the value of this setting. 303 | 304 | #### `EngineeringManagerInfo__GoogleChatUserId` 305 | 306 | Similar to other Google Chat Ids, use the Google Chat Id of the project/engineering manager as the value of this settings. Find out more at [Finding Google Chat Id (UserId)](#finding-google-chat-id-userid) 307 | 308 | #### `EngineeringManagerInfo__ManagerRemindersGoogleWebhookUrl` 309 | 310 | Google Chat has webhooks to receive messages through. It is really easy to create Webhook URLs through Google Chat as explained here [Using incoming webhooks](https://developers.google.com/hangouts/chat/how-tos/webhooks). 311 | 312 | This particular setting is asking for a private room webhook to send [Managers-only Automation](#managers-only-automation) messages. 313 | 314 | #### `GoogleChat__WebhookUrl` 315 | 316 | Google Chat has webhooks to receive messages through. It is really easy to create Webhook URLs through Google Chat as explained here [Using incoming webhooks](https://developers.google.com/hangouts/chat/how-tos/webhooks). 317 | 318 | This particular setting is asking for the team room webhook to send automation messages. 319 | 320 | #### `WEBSITE_TIME_ZONE` 321 | 322 | This is a pre-defined setting understood by Azure Functions, and is required for determining the time zone for the automation to determine how to calculate time and days to send the messages out. You can find all of the available options here: [Time Zones](https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-vista/cc749073(v=ws.10)#time-zones). Use the values from the left side as they appear, with spaces and such. For example: `Mountain Standard Time`. 323 | 324 | ### Finding Google Chat Id (UserId) 325 | 326 | Google Chat Id is used in the following configuration settings and is important for notifying the right team members through Google Chat. 327 | 328 | ```json 329 | { 330 | "name": "AzureDevOpsUsersMapToGoogleChat", 331 | "value": "AZURE-DEVOPS-USER1-EMAIL:GOOGLE-CHAT-ID-1;AZURE-DEVOPS-USER2-EMAIL:GOOGLE-CHAT-ID-2", 332 | "slotSetting": false 333 | }, 334 | { 335 | "name": "EngineeringManagerInfo__GoogleChatUserId", 336 | "value": "ENGINEERING-MANAGER-GOOGLE-CHAT-ID", 337 | "slotSetting": false 338 | } 339 | ``` 340 | It is not super straightforward and intuitive to find out what this value is for each team member. It is not an an exposed value on the Google Chat UI; therefore, you need to use the tools like Google Chrome Developer Tools to extract it. You have to copy the numbers next to `user/human/` value in `data-member-id` HTML attribute as shown in the screenshot below: 341 | 342 | 343 | 344 | # FAQ 345 | 346 | **Where are the unit tests?** 347 | 348 | I originally developed this tool as a small utility service to automate some of my work, but it grew quickly as I realize the potential of this tool. I will be adding unit tests later on. 349 | 350 | **Where did the idea come from?** 351 | 352 | From my own experiences. I noticed that I could not scale my time if I didn't automate some of my daily tasks, and this project was born. In general, I like developing tools that make my live easier, and I love tools that offer practical impact and improvements to my daily life. You can check out my other projects at [github.com/tarikguney](https://github.com/tarikguney) to explore my other tools that were developed with this simple idea in mind. Since, there are lots of people like myself sharing the same challenges, developing a tool for myself ends up helping a lot of other people out there. 353 | 354 | 355 | **What's the roadmap/future of this project?** 356 | 357 | I have learned quite a bit from this project, and I will be maintaining this project time to time. But, my next project, that will take this simple idea a lot farther, will be [**Projekt Hive**](https://github.com/tarikguney/projekt-hive). I will be incorporating what I have learned so far and more into Projekt Hive. 358 | 359 | **How can I contribute?** 360 | 361 | There are multiple areas where contribution is needed: 362 | 363 | 1. Adding more message targets like Slack, Discord, etc. similar to Google Chat. 364 | 2. Adding unit tests! 365 | 3. Bringing the project to a more unit testable state. I used .NET DataFlow pattern which is little challenging when it comes to Dependency Injection. However, I like to keep the actor pattern in place since it is really suitable for this type of projects. 366 | 4. Test it and log bugs in the issues page 367 | 368 | Since this is my personal side project, I cannot immediately answer questions or review PRs but I will do my best. So, please feel free to contribute but just be a little patient with me. 369 | 370 | 371 | 372 | -------------------------------------------------------------------------------- /assets/logo/azure-functions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/logo/azure-functions.png -------------------------------------------------------------------------------- /assets/logo/dotnet-core-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/logo/dotnet-core-logo.png -------------------------------------------------------------------------------- /assets/manager-automation-hq-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/manager-automation-hq-banner.png -------------------------------------------------------------------------------- /assets/manager-automation-lq-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/manager-automation-lq-banner.jpg -------------------------------------------------------------------------------- /assets/screenshots/app-insights-trace-log.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/app-insights-trace-log.png -------------------------------------------------------------------------------- /assets/screenshots/azure-functions-app-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/azure-functions-app-settings.png -------------------------------------------------------------------------------- /assets/screenshots/functions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/functions.png -------------------------------------------------------------------------------- /assets/screenshots/google-chat-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/google-chat-id.png -------------------------------------------------------------------------------- /assets/screenshots/in-sprint-alt1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/in-sprint-alt1.png -------------------------------------------------------------------------------- /assets/screenshots/in-sprint-alt2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/in-sprint-alt2.png -------------------------------------------------------------------------------- /assets/screenshots/in-sprint-alt3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/in-sprint-alt3.png -------------------------------------------------------------------------------- /assets/screenshots/last-day-sprint-reminders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/last-day-sprint-reminders.png -------------------------------------------------------------------------------- /assets/screenshots/managers-only-reminders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/managers-only-reminders.png -------------------------------------------------------------------------------- /assets/screenshots/new-sprint-reminders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/new-sprint-reminders.png -------------------------------------------------------------------------------- /assets/screenshots/pull-request-reminders.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tarikguney/manager-automation-v2/ae406c0346baec826a4af27851db484bde05cc69/assets/screenshots/pull-request-reminders.png -------------------------------------------------------------------------------- /src/SampleWorkItem.json: -------------------------------------------------------------------------------- 1 | { 2 | "count": 2, 3 | "value": [ 4 | { 5 | "id": 11, 6 | "rev": 3, 7 | "fields": { 8 | "System.Id": 11, 9 | "System.AreaId": 14, 10 | "System.AreaPath": "Azure Functions", 11 | "System.TeamProject": "Azure Functions", 12 | "System.NodeName": "Azure Functions", 13 | "System.AreaLevel1": "Azure Functions", 14 | "System.Rev": 3, 15 | "System.AuthorizedDate": "2020-10-16T16:53:54.473Z", 16 | "System.RevisedDate": "9999-01-01T00:00:00Z", 17 | "System.IterationId": 15, 18 | "System.IterationPath": "Azure Functions\\Sprint 2", 19 | "System.IterationLevel1": "Azure Functions", 20 | "System.IterationLevel2": "Sprint 2", 21 | "System.WorkItemType": "User Story", 22 | "System.State": "New", 23 | "System.Reason": "New", 24 | "System.AssignedTo": { 25 | "displayName": "Tarik Guney", 26 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 27 | "_links": { 28 | "avatar": { 29 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 30 | } 31 | }, 32 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 33 | "uniqueName": "atarikguney@gmail.com", 34 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 35 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 36 | }, 37 | "System.CreatedDate": "2020-10-14T16:04:19.173Z", 38 | "System.CreatedBy": { 39 | "displayName": "Tarik Guney", 40 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 41 | "_links": { 42 | "avatar": { 43 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 44 | } 45 | }, 46 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 47 | "uniqueName": "atarikguney@gmail.com", 48 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 49 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 50 | }, 51 | "System.ChangedDate": "2020-10-16T16:53:54.473Z", 52 | "System.ChangedBy": { 53 | "displayName": "Tarik Guney", 54 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 55 | "_links": { 56 | "avatar": { 57 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 58 | } 59 | }, 60 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 61 | "uniqueName": "atarikguney@gmail.com", 62 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 63 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 64 | }, 65 | "System.AuthorizedAs": { 66 | "displayName": "Tarik Guney", 67 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 68 | "_links": { 69 | "avatar": { 70 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 71 | } 72 | }, 73 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 74 | "uniqueName": "atarikguney@gmail.com", 75 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 76 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 77 | }, 78 | "System.PersonId": 72169719, 79 | "System.Watermark": 23, 80 | "System.CommentCount": 0, 81 | "System.Title": "Create the user profile page", 82 | "System.BoardColumn": "New", 83 | "System.BoardColumnDone": false, 84 | "Microsoft.VSTS.Common.StateChangeDate": "2020-10-14T16:04:19.173Z", 85 | "Microsoft.VSTS.Common.Priority": 2, 86 | "Microsoft.VSTS.Common.ValueArea": "Business", 87 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_System.ExtensionMarker": true, 88 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_Kanban.Column": "New", 89 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_Kanban.Column.Done": false 90 | }, 91 | "url": "https://dev.azure.com/atarikguney/5008ed37-39a9-4c99-bbdc-fcfa9282f424/_apis/wit/workItems/11" 92 | }, 93 | { 94 | "id": 12, 95 | "rev": 6, 96 | "fields": { 97 | "System.Id": 12, 98 | "System.AreaId": 14, 99 | "System.AreaPath": "Azure Functions", 100 | "System.TeamProject": "Azure Functions", 101 | "System.NodeName": "Azure Functions", 102 | "System.AreaLevel1": "Azure Functions", 103 | "System.Rev": 6, 104 | "System.AuthorizedDate": "2020-10-16T16:53:54.473Z", 105 | "System.RevisedDate": "9999-01-01T00:00:00Z", 106 | "System.IterationId": 15, 107 | "System.IterationPath": "Azure Functions\\Sprint 2", 108 | "System.IterationLevel1": "Azure Functions", 109 | "System.IterationLevel2": "Sprint 2", 110 | "System.WorkItemType": "User Story", 111 | "System.State": "New", 112 | "System.Reason": "New", 113 | "System.AssignedTo": { 114 | "displayName": "Tarik Guney", 115 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 116 | "_links": { 117 | "avatar": { 118 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 119 | } 120 | }, 121 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 122 | "uniqueName": "atarikguney@gmail.com", 123 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 124 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 125 | }, 126 | "System.CreatedDate": "2020-10-14T16:04:41.533Z", 127 | "System.CreatedBy": { 128 | "displayName": "Tarik Guney", 129 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 130 | "_links": { 131 | "avatar": { 132 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 133 | } 134 | }, 135 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 136 | "uniqueName": "atarikguney@gmail.com", 137 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 138 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 139 | }, 140 | "System.ChangedDate": "2020-10-16T16:53:54.473Z", 141 | "System.ChangedBy": { 142 | "displayName": "Tarik Guney", 143 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 144 | "_links": { 145 | "avatar": { 146 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 147 | } 148 | }, 149 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 150 | "uniqueName": "atarikguney@gmail.com", 151 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 152 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 153 | }, 154 | "System.AuthorizedAs": { 155 | "displayName": "Tarik Guney", 156 | "url": "https://spsprodcus2.vssps.visualstudio.com/A53d7da5a-3070-4ed6-94d8-67304e463b68/_apis/Identities/a3588167-b8df-4070-b04a-b7ce837e699c", 157 | "_links": { 158 | "avatar": { 159 | "href": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 160 | } 161 | }, 162 | "id": "a3588167-b8df-4070-b04a-b7ce837e699c", 163 | "uniqueName": "atarikguney@gmail.com", 164 | "imageUrl": "https://dev.azure.com/atarikguney/_apis/GraphProfile/MemberAvatars/msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh", 165 | "descriptor": "msa.YzFhZjAzZjYtNDY0Ny03YWE5LWEwNGQtNzVlMzRlMzE1N2Vh" 166 | }, 167 | "System.PersonId": 72169719, 168 | "System.Watermark": 23, 169 | "System.CommentCount": 0, 170 | "System.Title": "Create the profile edit page", 171 | "System.BoardColumn": "New", 172 | "System.BoardColumnDone": false, 173 | "Microsoft.VSTS.Scheduling.StoryPoints": 2.0, 174 | "Microsoft.VSTS.Common.StateChangeDate": "2020-10-14T16:04:41.533Z", 175 | "Microsoft.VSTS.Common.Priority": 2, 176 | "Microsoft.VSTS.Common.StackRank": 1999955279.0, 177 | "Microsoft.VSTS.Common.ValueArea": "Business", 178 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_System.ExtensionMarker": true, 179 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_Kanban.Column": "New", 180 | "WEF_CD17F1AD5A3A4147BAE15B6D7B4EBF5A_Kanban.Column.Done": false, 181 | "System.Description": "
As an authenticated and authorized user, I want to be able to update my profile page so that I can keep my profile information current.
" 182 | }, 183 | "url": "https://dev.azure.com/atarikguney/5008ed37-39a9-4c99-bbdc-fcfa9282f424/_apis/wit/workItems/12" 184 | } 185 | ] 186 | } -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/ActivateWorkItemActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Akka.Actor; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using Newtonsoft.Json.Linq; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.SettingsModels; 10 | 11 | namespace TarikGuney.ManagerAutomation.Actors 12 | { 13 | /// 14 | /// During the current sprint/iteration, this actors finds the engineers who has not activated 15 | /// any of their assigned work items. 16 | /// 17 | public class ActivateWorkItemActor : ReceiveActor 18 | { 19 | private readonly List _devOpsChatUserMaps; 20 | private readonly CurrentIterationInfo _currentIteration; 21 | private readonly ILogger _logger; 22 | 23 | public ActivateWorkItemActor(IOptions> devOpsChatUserMapsOptions, 24 | IOptions currentIterationOptions, ILogger logger) 25 | { 26 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 27 | _logger = logger; 28 | _currentIteration = currentIterationOptions.Value; 29 | Receive>(HandleIncomingWorkItems); 30 | } 31 | 32 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 33 | { 34 | var offendingWorkItems = workItems 35 | .Where(wi => wi["fields"] is JObject fields && 36 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 37 | .Value()) && 38 | fields.ContainsKey("System.AssignedTo")).ToLookup( 39 | wi => wi["fields"]["System.AssignedTo"]["uniqueName"].Value(), t => t); 40 | 41 | if (!offendingWorkItems.Any()) 42 | { 43 | Sender.Tell(new ActorResponse>(new List(), false)); 44 | } 45 | 46 | var messages = new List(); 47 | 48 | foreach (var offendingWorkItem in offendingWorkItems) 49 | { 50 | // Check if there is any active work item. 51 | if (offendingWorkItem.Any(a => 52 | a["fields"]["System.State"].Value() 53 | .Equals("Active", StringComparison.InvariantCultureIgnoreCase))) 54 | { 55 | continue; 56 | } 57 | 58 | // Check if there is any new item to activate in the first place. 59 | // Don't need to send any notification when all of the work items are complete or in resolve state. 60 | if (!offendingWorkItem.Any(a => 61 | a["fields"]["System.State"].Value() 62 | .Equals("New", StringComparison.InvariantCultureIgnoreCase))) 63 | { 64 | continue; 65 | } 66 | 67 | var userDisplayName = offendingWorkItem.First()["fields"]?["System.CreatedBy"]?["displayName"] 68 | ?.Value(); 69 | var userEmail = offendingWorkItem.Key; 70 | var devOpsGoogleChatUserMap = 71 | _devOpsChatUserMaps.SingleOrDefault(t => 72 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 73 | 74 | var chatDisplayName = devOpsGoogleChatUserMap == null 75 | ? userDisplayName 76 | : $""; 77 | 78 | _logger.LogInformation( 79 | "BOARD: Not activated any work item by {userEmail} in {currentIteration}.", 80 | userEmail, _currentIteration.Name); 81 | 82 | messages.Add( 83 | $"{chatDisplayName}, *activate the work item* you are working on."); 84 | } 85 | 86 | Sender.Tell(new ActorResponse>(messages, true)); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/DescriptionActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.SettingsModels; 11 | 12 | namespace TarikGuney.ManagerAutomation.Actors 13 | { 14 | /// 15 | /// During the current sprint, this actor finds the work items without any 16 | /// description. 17 | /// 18 | public class DescriptionActor : ReceiveActor 19 | { 20 | private readonly ILogger _logger; 21 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 22 | private readonly List _devOpsChatUserMaps; 23 | private readonly CurrentIterationInfo _currentIteration; 24 | 25 | public DescriptionActor(IOptions azureDevOpsSettingsOptions, 26 | IOptions> devOpsChatUserMapsOptions, 27 | IOptions currentIterationOptions, ILogger logger) 28 | { 29 | _logger = logger; 30 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 31 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 32 | _currentIteration = currentIterationOptions.Value; 33 | 34 | Receive>(HandleIncomingWorkItems); 35 | } 36 | 37 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 38 | { 39 | // This finds the user stories and bugs that do not have description. 40 | // Azure DevOps does not return the Description field at all when it is empty. 41 | // Therefore, we are checking if the fields exist at all in the API response. 42 | var offendingWorkItems = workItems 43 | .Where(wi => wi["fields"] is JObject fields && 44 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 45 | .Value()) && 46 | ((fields["System.WorkItemType"].Value().ToLower() == "bug" && 47 | // This field might be coming from the old version of Azure DevOps 48 | !fields.ContainsKey("Microsoft.VSTS.TCM.ReproSteps") && 49 | // Looks like the following is the description field for the bugs in the later versions of Azure DevOps 50 | !fields.ContainsKey("System.Description")) || 51 | (fields["System.WorkItemType"].Value().ToLower() == "user story" && 52 | !fields.ContainsKey("System.Description")) 53 | )).ToList(); 54 | 55 | if (!offendingWorkItems.Any()) 56 | { 57 | Sender.Tell(new ActorResponse>(new List(), false)); 58 | } 59 | 60 | var messages = new List(); 61 | var baseUrl = 62 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 63 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 64 | 65 | foreach (var offendingWorkItem in offendingWorkItems) 66 | { 67 | var userDisplayName = offendingWorkItem["fields"]?["System.CreatedBy"]?["displayName"] 68 | ?.Value(); 69 | var userEmail = offendingWorkItem["fields"]?["System.CreatedBy"]?["uniqueName"]?.Value(); 70 | var devOpsGoogleChatUserMap = 71 | _devOpsChatUserMaps.SingleOrDefault(t => 72 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 73 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 74 | var workItemId = offendingWorkItem["id"].Value(); 75 | var workItemUrl = $"{baseUrl}/{workItemId}"; 76 | 77 | var chatDisplayName = devOpsGoogleChatUserMap == null 78 | ? userDisplayName 79 | : $""; 80 | 81 | _logger.LogInformation( 82 | "BOARD: Missing description for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 83 | workItemId, workItemTitle, userEmail, _currentIteration.Name); 84 | 85 | messages.Add( 86 | $"{chatDisplayName}, add a *description* to <{workItemUrl}|{workItemTitle}>."); 87 | } 88 | 89 | Sender.Tell(new ActorResponse>(messages, true)); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/DescriptiveTitlesActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.SettingsModels; 11 | 12 | namespace TarikGuney.ManagerAutomation.Actors 13 | { 14 | /// 15 | /// During the current sprint, this actor finds any work items that do have titles consists of 16 | /// two or fewer numbers of words, which usually is not enough to explain what the work item is about. 17 | /// 18 | public class DescriptiveTitleActor : ReceiveActor 19 | { 20 | private readonly ILogger _logger; 21 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 22 | private readonly List _devOpsChatUserMaps; 23 | private readonly CurrentIterationInfo _currentIteration; 24 | 25 | public DescriptiveTitleActor(ILogger logger, IOptions azureDevOpsSettingsOptions, 26 | IOptions> devOpsChatUserMapOptions, IOptions currentIterationOptions) 27 | { 28 | _logger = logger; 29 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 30 | _devOpsChatUserMaps = devOpsChatUserMapOptions.Value; 31 | _currentIteration = currentIterationOptions.Value; 32 | Receive>(HandleIncomingWorkItems); 33 | } 34 | 35 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 36 | { 37 | var offendingWorkItems = workItems 38 | .Where(wi => wi["fields"] is JObject fields && 39 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"]! 40 | .Value()) && 41 | fields["System.Title"]!.Value().Split(" ").Length <= 2).ToList(); 42 | 43 | if (!offendingWorkItems.Any()) 44 | { 45 | Sender.Tell(new ActorResponse>(new List(), false)); 46 | } 47 | 48 | var messages = new List(); 49 | var baseUrl = 50 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 51 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 52 | 53 | foreach (var offendingWorkItem in offendingWorkItems) 54 | { 55 | var userDisplayName = offendingWorkItem["fields"]!["System.CreatedBy"]!["displayName"] 56 | !.Value(); 57 | var userEmail = offendingWorkItem["fields"]?["System.CreatedBy"]?["uniqueName"]?.Value(); 58 | var devOpsGoogleChatUserMap = 59 | _devOpsChatUserMaps.SingleOrDefault(t => 60 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 61 | 62 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 63 | var workItemId = offendingWorkItem["id"].Value(); 64 | var workItemUrl = $"{baseUrl}/{workItemId}"; 65 | 66 | var chatDisplayName = devOpsGoogleChatUserMap == null 67 | ? userDisplayName 68 | : $""; 69 | 70 | _logger.LogInformation( 71 | "BOARD: Unclear title for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 72 | workItemId, workItemTitle, userEmail, _currentIteration.Name); 73 | 74 | messages.Add( 75 | $"{chatDisplayName}, add a *more descriptive title* to <{workItemUrl}|{workItemTitle}>."); 76 | } 77 | 78 | Sender.Tell(new ActorResponse>(messages, true)); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/EstimateWorkItemsActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.SettingsModels; 11 | 12 | namespace TarikGuney.ManagerAutomation.Actors 13 | { 14 | /// 15 | /// During the current sprint, this actor finds the work items that do not have any estimation 16 | /// or story points. 17 | /// 18 | public class EstimateWorkItemsActor : ReceiveActor 19 | { 20 | private readonly ILogger _logger; 21 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 22 | private readonly List _devOpsChatUserMaps; 23 | private readonly CurrentIterationInfo _currentIteration; 24 | 25 | public EstimateWorkItemsActor(ILogger logger, 26 | IOptions azureDevOpsSettingsOptions, 27 | IOptions> devOpsChatUserMapOptions, IOptions currentIterationOptions) 28 | { 29 | _logger = logger; 30 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 31 | _devOpsChatUserMaps = devOpsChatUserMapOptions.Value; 32 | _currentIteration = currentIterationOptions.Value; 33 | Receive>(HandleIncomingWorkItems); 34 | } 35 | 36 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 37 | { 38 | // Property names that has periods in them won't be parsed by Json.NET as opposed to online JSON Parser tools 39 | // eg. $.value[?(@.fields['Microsoft.VSTS.Scheduling.StoryPoints'] == null && @.fields['System.AssignedTo'] != null)] 40 | // Because of that reason, I had to use enumeration below. 41 | var offendingWorkItems = workItems 42 | .Where(wi => wi["fields"] is JObject fields && 43 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 44 | .Value()) && 45 | !fields.ContainsKey("Microsoft.VSTS.Scheduling.StoryPoints") && 46 | fields.ContainsKey("System.AssignedTo")).ToList(); 47 | 48 | if (!offendingWorkItems.Any()) 49 | { 50 | Sender.Tell(new ActorResponse>(new List(), false)); 51 | } 52 | 53 | var messages = new List(); 54 | var baseUrl = 55 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 56 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 57 | 58 | foreach (var offendingWorkItem in offendingWorkItems) 59 | { 60 | var userDisplayName = offendingWorkItem["fields"]?["System.AssignedTo"]?["displayName"] 61 | ?.Value(); 62 | var userEmail = offendingWorkItem["fields"]?["System.AssignedTo"]?["uniqueName"]?.Value(); 63 | var devOpsGoogleChatUserMap = 64 | _devOpsChatUserMaps.SingleOrDefault(t => 65 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 66 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 67 | var workItemId = offendingWorkItem["id"].Value(); 68 | var workItemUrl = $"{baseUrl}/{workItemId}"; 69 | 70 | var chatDisplayName = devOpsGoogleChatUserMap == null 71 | ? userDisplayName 72 | : $""; 73 | 74 | _logger.LogInformation( 75 | "BOARD: Missing story point for \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 76 | workItemId, workItemTitle, userEmail, _currentIteration.Name); 77 | 78 | messages.Add( 79 | $"{chatDisplayName}, *estimate* the story point of <{workItemUrl}|{workItemTitle}>."); 80 | } 81 | 82 | Sender.Tell(new ActorResponse>(messages, true)); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/GreatPreviousIterationActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Akka.Actor; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using Newtonsoft.Json.Linq; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.SettingsModels; 10 | 11 | namespace TarikGuney.ManagerAutomation.Actors 12 | { 13 | public class GreatPreviousIterationActor : ReceiveActor 14 | { 15 | private readonly ILogger _logger; 16 | private readonly List _devOpsChatUserMaps; 17 | private readonly CurrentIterationInfo _currentIteration; 18 | 19 | public GreatPreviousIterationActor(IOptions> devOpsChatUserMapsOptions, 20 | IOptions currentIterationOptions, ILogger logger) 21 | { 22 | _logger = logger; 23 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 24 | _currentIteration = currentIterationOptions.Value; 25 | 26 | Receive>(HandleIncomingWorkItems); 27 | } 28 | 29 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 30 | { 31 | var workItemsByPersons = workItems 32 | .Where(wi => wi["fields"] is JObject fields && 33 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 34 | .Value()) && 35 | fields.ContainsKey("System.AssignedTo") 36 | ).ToLookup( 37 | wi => wi["fields"]["System.AssignedTo"]["uniqueName"].Value(), t => t); 38 | 39 | if (!workItemsByPersons.Any()) 40 | { 41 | Sender.Tell(new ActorResponse>(new List(), false)); 42 | } 43 | 44 | var messages = new List(); 45 | 46 | foreach (var workItemsByPerson in workItemsByPersons) 47 | { 48 | var anyPendingWorkItems = workItemsByPerson.Any(a => 49 | !a!["fields"]!["System.State"]!.Value() 50 | .Equals("Closed", StringComparison.InvariantCultureIgnoreCase)); 51 | 52 | if (anyPendingWorkItems) 53 | { 54 | continue; 55 | } 56 | 57 | var userDisplayName = workItemsByPerson.First()["fields"]?["System.CreatedBy"]?["displayName"] 58 | ?.Value(); 59 | var userEmail = workItemsByPerson.Key; 60 | var devOpsGoogleChatUserMap = 61 | _devOpsChatUserMaps.SingleOrDefault(t => 62 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 63 | 64 | var chatDisplayName = devOpsGoogleChatUserMap == null 65 | ? userDisplayName 66 | : $""; 67 | 68 | _logger.LogInformation( 69 | "BOARD: Closed everything from the previous sprint by the first day of the current sprint {currentIteration}. Assigned to {userEmail}.", 70 | _currentIteration.Name, userEmail); 71 | 72 | messages.Add( 73 | $"{chatDisplayName}, great work! 👏 You *closed* all of *your previous iteration* work items! 🎉"); 74 | } 75 | 76 | Sender.Tell(new ActorResponse>(messages, true)); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/GreatWorkActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Akka.Actor; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using Newtonsoft.Json.Linq; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.SettingsModels; 10 | 11 | namespace TarikGuney.ManagerAutomation.Actors 12 | { 13 | /// 14 | /// During each sprint, this actor finds the engineers who have completed/closed 15 | /// all of their work items. It then populates a congratulation messages. 16 | /// This is not necessarily a good thing all the time for 17 | /// the engineer might have been underutilized during the sprint. Hence, if this is a 18 | /// repeating message for more than several days, the engineer is mostly likely underutilized 19 | /// or their works are not captured with work items within the project tracking tool. 20 | /// 21 | public class GreatWorkActor : ReceiveActor 22 | { 23 | private readonly EngineeringManagerInfo _engineeringManagerInfo; 24 | private readonly ILogger _logger; 25 | private readonly List _devOpsChatUserMaps; 26 | private readonly CurrentIterationInfo _currentIteration; 27 | 28 | public GreatWorkActor( 29 | IOptions> devOpsChatUserMapsOptions, 30 | IOptions engineeringManagerInfoOptions, 31 | IOptions currentIterationOptions, ILogger logger) 32 | { 33 | _engineeringManagerInfo = engineeringManagerInfoOptions.Value; 34 | _logger = logger; 35 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 36 | _currentIteration = currentIterationOptions.Value; 37 | 38 | Receive>(HandleIncomingWorkItems); 39 | } 40 | 41 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 42 | { 43 | var workItemsByPersons = workItems 44 | .Where(wi => wi["fields"] is JObject fields && 45 | new List() {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 46 | .Value()) && 47 | fields.ContainsKey("System.AssignedTo") && 48 | // Excluding the engineering manager from the congratulation list to prevent self-compliments. 49 | !fields!["System.AssignedTo"]!["uniqueName"]!.Value().Equals( 50 | _engineeringManagerInfo.AzureDevOpsEmail, 51 | StringComparison.InvariantCultureIgnoreCase) 52 | ).ToLookup( 53 | wi => wi["fields"]["System.AssignedTo"]["uniqueName"].Value(), t => t); 54 | 55 | if (!workItemsByPersons.Any()) 56 | { 57 | Sender.Tell(new ActorResponse>(new List(), false)); 58 | } 59 | 60 | var messages = new List(); 61 | 62 | foreach (var workItemsByPerson in workItemsByPersons) 63 | { 64 | // Check if there is any active work item. 65 | if (!workItemsByPerson.All(a => 66 | a!["fields"]!["System.State"]!.Value() 67 | .Equals("Closed", StringComparison.InvariantCultureIgnoreCase))) 68 | { 69 | continue; 70 | } 71 | 72 | // To prevent congratulating people multiple times for the same reasons, we are checking 73 | // if they completed all of their work items recent enough, which is about two days ago to be safe. 74 | if (!workItemsByPerson.Any(a => 75 | { 76 | var closedDate = 77 | DateTime.Parse(a["fields"]["Microsoft.VSTS.Common.ClosedDate"].Value()).ToLocalTime() 78 | .Date; 79 | return closedDate > DateTime.Now.Date.Subtract(TimeSpan.FromDays(2)); 80 | })) 81 | { 82 | continue; 83 | } 84 | 85 | var userDisplayName = workItemsByPerson.First()["fields"]?["System.CreatedBy"]?["displayName"] 86 | ?.Value(); 87 | var userEmail = workItemsByPerson.Key; 88 | var devOpsGoogleChatUserMap = 89 | _devOpsChatUserMaps.SingleOrDefault(t => 90 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 91 | 92 | var chatDisplayName = devOpsGoogleChatUserMap == null 93 | ? userDisplayName 94 | : $""; 95 | 96 | _logger.LogInformation( 97 | "BOARD: Closed everything in the current sprint {currentIteration}. Assigned to {userEmail}.", 98 | _currentIteration.Name, userEmail); 99 | 100 | messages.Add( 101 | $"{chatDisplayName}, great work 👏👏👏! You *closed* all of your work items! 🎉"); 102 | } 103 | 104 | Sender.Tell(new ActorResponse>(messages, false)); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/LongCodeCompleteActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.Helpers; 11 | using TarikGuney.ManagerAutomation.SettingsModels; 12 | 13 | namespace TarikGuney.ManagerAutomation.Actors 14 | { 15 | /// 16 | /// During the sprint and on the first day of the sprint for the last sprint, 17 | /// this actor finds the works items that are not closed/completed. Closed 18 | /// is the items that have "Closed" as the state. 19 | /// 20 | public class LongCodeCompleteActor : ReceiveActor 21 | { 22 | private readonly ILogger _logger; 23 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 24 | private readonly List _devOpsChatUserMaps; 25 | private readonly CurrentIterationInfo _currentIteration; 26 | 27 | public LongCodeCompleteActor(IOptions azureDevOpsSettingsOptions, 28 | IOptions> devOpsChatUserMapsOptions, 29 | IOptions currentIterationOptions, ILogger logger) 30 | { 31 | _logger = logger; 32 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 33 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 34 | _currentIteration = currentIterationOptions.Value; 35 | 36 | Receive>(HandleIncomingWorkItems); 37 | } 38 | 39 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 40 | { 41 | // Property names that has periods in them won't be parsed by Json.NET as opposed to online JSON Parser tools 42 | // eg. $.value[?(@.fields['Microsoft.VSTS.Scheduling.StoryPoints'] == null && @.fields['System.AssignedTo'] != null)] 43 | // Because of that reason, I had to use enumeration below. 44 | var offendingWorkItems = workItems 45 | .Where(wi => wi["fields"] is JObject fields && 46 | new List() {"Bug", "User Story"}.Contains(fields!["System.WorkItemType"]! 47 | .Value()) && 48 | new List {"PR Submitted", "Resolved"}.Contains(fields!["System.State"]! 49 | .Value()) && 50 | DateTime.Parse(fields!["Microsoft.VSTS.Common.StateChangeDate"]!.Value()) 51 | .ToLocalTime() < 52 | DateTime.Now.Date.Subtract(TimeSpan.FromDays(1)) && 53 | fields.ContainsKey("System.AssignedTo")).ToList(); 54 | 55 | if (!offendingWorkItems.Any()) 56 | { 57 | Sender.Tell(new ActorResponse>(new List(), false)); 58 | } 59 | 60 | var messages = new List(); 61 | var baseUrl = 62 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 63 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 64 | 65 | foreach (var offendingWorkItem in offendingWorkItems) 66 | { 67 | var userDisplayName = offendingWorkItem["fields"]?["System.AssignedTo"]?["displayName"] 68 | ?.Value(); 69 | var userEmail = offendingWorkItem["fields"]?["System.AssignedTo"]?["uniqueName"]?.Value(); 70 | var devOpsGoogleChatUserMap = 71 | _devOpsChatUserMaps.SingleOrDefault(t => 72 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 73 | var workItemTitle = offendingWorkItem["fields"]!["System.Title"]!.Value(); 74 | var workItemId = offendingWorkItem["id"].Value(); 75 | var workItemUrl = $"{baseUrl}/{workItemId}"; 76 | var workItemState = offendingWorkItem!["fields"]!["System.State"]!.Value(); 77 | var lastStateChange = 78 | DateTime.Parse(offendingWorkItem["fields"]!["Microsoft.VSTS.Common.StateChangeDate"]! 79 | .Value()).ToLocalTime(); 80 | 81 | var now = DateTime.Now.Date; 82 | var weekendCounts = DateDiffHelper.CalculateWeekendDays(lastStateChange, now); 83 | var idleForTimeSpan = now - lastStateChange.Date - TimeSpan.FromDays(weekendCounts); 84 | 85 | var chatDisplayName = devOpsGoogleChatUserMap == null 86 | ? userDisplayName 87 | : $""; 88 | 89 | _logger.LogInformation( 90 | "BOARD: Pending in incomplete state of {currentState} for {pendingForDays} days. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 91 | workItemState, idleForTimeSpan.TotalDays, workItemId, workItemTitle, userEmail, 92 | _currentIteration.Name); 93 | 94 | // todo Include pr follow up message for PR Submitted state. 95 | messages.Add( 96 | $"{chatDisplayName}, *follow up* on your work of <{workItemUrl}|{workItemTitle}>. " + 97 | $"It is in *{workItemState}* state for *{idleForTimeSpan.TotalDays}* day(s). Don't forget to *have it verified* by a fellow engineer!"); 98 | } 99 | 100 | Sender.Tell(new ActorResponse>(messages, true)); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/OpenWorkItemsActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.SettingsModels; 11 | 12 | namespace TarikGuney.ManagerAutomation.Actors 13 | { 14 | /// 15 | /// On the first of a new sprint, this actor finds the open work items from the last sprint 16 | /// and offers suggestions to the owner of the work item on how it should be dealt with. 17 | /// 18 | public class OpenWorkItemsActor : ReceiveActor 19 | { 20 | private readonly ILogger _logger; 21 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 22 | private readonly List _devOpsChatUserMaps; 23 | private readonly CurrentIterationInfo _currentIteration; 24 | 25 | public OpenWorkItemsActor(IOptions azureDevOpsSettingsOptions, 26 | IOptions> devOpsChatUserMapsOptions, 27 | IOptions currentIterationOptions, ILogger logger) 28 | { 29 | _logger = logger; 30 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 31 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 32 | _currentIteration = currentIterationOptions.Value; 33 | 34 | Receive>(HandleIncomingWorkItems); 35 | } 36 | 37 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 38 | { 39 | // Property names that has periods in them won't be parsed by Json.NET as opposed to online JSON Parser tools 40 | // eg. $.value[?(@.fields['Microsoft.VSTS.Scheduling.StoryPoints'] == null && @.fields['System.AssignedTo'] != null)] 41 | // Because of that reason, I had to use enumeration below. 42 | var offendingWorkItems = workItems 43 | .Where(wi => wi["fields"] is JObject fields && 44 | new List {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 45 | .Value()) && 46 | // Find any open work that is not in closed state. 47 | fields["System.State"].Value().ToLower() != "closed" && 48 | fields.ContainsKey("System.AssignedTo")).ToList(); 49 | 50 | if (!offendingWorkItems.Any()) 51 | { 52 | Sender.Tell(new ActorResponse>(new List(), false)); 53 | } 54 | 55 | var messages = new List(); 56 | 57 | var baseUrl = 58 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 59 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 60 | 61 | foreach (var offendingWorkItem in offendingWorkItems) 62 | { 63 | var currentStatus = offendingWorkItem["fields"]["System.State"].Value(); 64 | var recommendedActionText = currentStatus.ToLower() switch 65 | { 66 | "new" => "Move it to the current sprint.", 67 | "resolved" => "If it is verified, close it. Otherwise, get it verified.", 68 | "pr submitted" => 69 | "Make sure the PR is reviewed, merged, and the work is verified by another engineer.", 70 | _ => 71 | "*What is the status of it?* Can you close it or do you need to create a follow-up work item in the current sprint?" 72 | }; 73 | 74 | var userDisplayName = offendingWorkItem["fields"]?["System.AssignedTo"]?["displayName"] 75 | ?.Value(); 76 | var userEmail = offendingWorkItem["fields"]?["System.AssignedTo"]?["uniqueName"]?.Value(); 77 | var devOpsGoogleChatUserMap = 78 | _devOpsChatUserMaps.SingleOrDefault(t => 79 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 80 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 81 | var workItemId = offendingWorkItem["id"].Value(); 82 | var workItemUrl = $"{baseUrl}/{workItemId}"; 83 | 84 | var chatDisplayName = devOpsGoogleChatUserMap == null 85 | ? userDisplayName 86 | : $""; 87 | 88 | _logger.LogInformation( 89 | "BOARD: Still open in {currentState} state. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 90 | workItemId, workItemTitle, userEmail, _currentIteration.Name); 91 | 92 | messages.Add( 93 | $"{chatDisplayName}, <{workItemUrl}|{workItemTitle}> is in *{currentStatus}* state! {recommendedActionText}"); 94 | } 95 | 96 | Sender.Tell(new ActorResponse>(messages, true)); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/PassedDueWorkItemsActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.Helpers; 11 | using TarikGuney.ManagerAutomation.SettingsModels; 12 | 13 | namespace TarikGuney.ManagerAutomation.Actors 14 | { 15 | /// 16 | /// For the manager's report, this actor finds the work items that passed their due dates. 17 | /// 18 | public class PassedDueWorkItemsActor : ReceiveActor 19 | { 20 | private readonly ILogger _logger; 21 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 22 | private readonly List _devOpsChatUserMaps; 23 | private readonly CurrentIterationInfo _currentIteration; 24 | 25 | public PassedDueWorkItemsActor(IOptions azureDevOpsSettingsOptions, 26 | IOptions> devOpsChatUserMapsOptions, 27 | IOptions currentIterationOptions, ILogger logger) 28 | { 29 | _logger = logger; 30 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 31 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 32 | _currentIteration = currentIterationOptions.Value; 33 | 34 | Receive>(HandleIncomingWorkItems); 35 | } 36 | 37 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 38 | { 39 | var offendingWorkItems = workItems 40 | .Where(wi => wi["fields"] is JObject fields && 41 | new List {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 42 | .Value()) && 43 | // No need to track the closed or new items 44 | !new[] {"new", "closed"}.Contains(fields["System.State"].Value().ToLower() 45 | .Trim()) && 46 | fields.ContainsKey("Microsoft.VSTS.Scheduling.StoryPoints") && 47 | fields.ContainsKey("System.AssignedTo")).ToList(); 48 | 49 | if (!offendingWorkItems.Any()) 50 | { 51 | Sender.Tell(new ActorResponse>(new List(), false)); 52 | } 53 | 54 | var messages = new List(); 55 | var baseUrl = 56 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 57 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 58 | 59 | foreach (var offendingWorkItem in offendingWorkItems) 60 | { 61 | var storyPoints = offendingWorkItem["fields"]["Microsoft.VSTS.Scheduling.StoryPoints"].Value(); 62 | var activatedOn = 63 | DateTime.Parse(offendingWorkItem["fields"]["Microsoft.VSTS.Common.StateChangeDate"] 64 | .Value()).ToLocalTime(); 65 | 66 | // Finding out the weekend days (Saturday and Sunday) in between of activation date and the possible due date. 67 | var pointsToDays = IterationHelper.PointsToDays(storyPoints); 68 | 69 | var weekendDaysCount = 70 | DateDiffHelper.CalculateWeekendDays(activatedOn, activatedOn.Add(pointsToDays)); 71 | 72 | var possibleDueDate = activatedOn.Add(pointsToDays).Add(TimeSpan.FromDays(weekendDaysCount)); 73 | 74 | // If 1 point story is activated on Friday, the due date falls on Saturday. 75 | // Counting Saturday in to the due date, Sunday becomes the new due date, which is still wrong. 76 | // The following switch addresses that problem. 77 | var extraWeekendDays = possibleDueDate.DayOfWeek switch 78 | { 79 | DayOfWeek.Saturday => 2, 80 | DayOfWeek.Sunday => 1, 81 | _ => 0 82 | }; 83 | 84 | var dueDate = possibleDueDate.Add(TimeSpan.FromDays(extraWeekendDays)); 85 | 86 | if (DateTime.Now.Date <= dueDate.Date) 87 | { 88 | continue; 89 | } 90 | 91 | var businessDaysPassed = DateTime.Now.Date - dueDate.Date; 92 | 93 | // todo get the email address of the person. 94 | var userDisplayName = offendingWorkItem["fields"]?["System.AssignedTo"]?["displayName"] 95 | ?.Value(); 96 | var userEmail = offendingWorkItem["fields"]?["System.CreatedBy"]?["uniqueName"]?.Value(); 97 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 98 | var workItemId = offendingWorkItem["id"].Value(); 99 | var workItemUrl = $"{baseUrl}/{workItemId}"; 100 | var currentStatus = offendingWorkItem["fields"]["System.State"].Value(); 101 | 102 | // Logging to the application insights with a different tag. 103 | _logger.LogInformation( 104 | "MANAGER: Passed due date. \"{workItemId}:{workItemTitle}\" is past due for {businessDaysPassed} days in {currentStatus} state. Activated on {activatedOnDate}, was due on {dueOnDate}, and is assigned to {userEmail}, and estimated {storyPoints} points.", 105 | workItemId, workItemTitle, businessDaysPassed.TotalDays, currentStatus, 106 | activatedOn.Date.ToShortDateString(), dueDate.Date.ToShortDateString(), userEmail, storyPoints); 107 | 108 | messages.Add( 109 | $"<{workItemUrl}|{workItemTitle}> is *past due* for *{businessDaysPassed.TotalDays} business day(s)* with *{currentStatus}* state. Activated on *{activatedOn.Date.ToShortDateString()}*, " + 110 | $"was due on *{dueDate.Date.ToShortDateString()}*, " + 111 | $"is assigned to *{userDisplayName}* and estimated *{storyPoints}* points."); 112 | } 113 | 114 | Sender.Tell(new ActorResponse>(messages, true)); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/PendingPullRequestsActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.SettingsModels; 11 | 12 | namespace TarikGuney.ManagerAutomation.Actors 13 | { 14 | public class PendingPullRequestsActor : ReceiveActor 15 | { 16 | private readonly IOptions> _devOpsChatUserMapOptions; 17 | private readonly IOptions _azureDevOpsSettingsOptions; 18 | private readonly ILogger _logger; 19 | 20 | public PendingPullRequestsActor(IOptions> devOpsChatUserMapOptions, 21 | IOptions azureDevOpsSettingsOptions, 22 | ILogger logger 23 | ) 24 | { 25 | _devOpsChatUserMapOptions = devOpsChatUserMapOptions; 26 | _azureDevOpsSettingsOptions = azureDevOpsSettingsOptions; 27 | _logger = logger; 28 | 29 | Receive>(StartAnalysingPullRequests); 30 | } 31 | 32 | private void StartAnalysingPullRequests(IReadOnlyList pullRequestJObjects) 33 | { 34 | if (!pullRequestJObjects.Any()) 35 | { 36 | Sender.Tell(new ActorResponse>(new List(), false)); 37 | } 38 | 39 | var messages = new List(); 40 | 41 | foreach (var pullRequestJObject in pullRequestJObjects) 42 | { 43 | var prCreationDate = pullRequestJObject!["creationDate"]!.Value().ToLocalTime().Date; 44 | 45 | var now = DateTime.Now.Date; 46 | 47 | var pendingForDays = (now - prCreationDate).TotalDays; 48 | 49 | // todo adjust this value based on the feedback 50 | if (pendingForDays < 1) 51 | { 52 | continue; 53 | } 54 | 55 | var prTitle = pullRequestJObject["title"]!.Value(); 56 | var prId = pullRequestJObject["pullRequestId"]!.Value(); 57 | var urlPathEncodedRepoName = 58 | HttpUtility.UrlPathEncode(pullRequestJObject["repository"]["name"].Value()); 59 | var organizationName = _azureDevOpsSettingsOptions.Value.Organization; 60 | var urlPathEncodedProjectName = HttpUtility.UrlPathEncode(_azureDevOpsSettingsOptions.Value.Project); 61 | var prUrl = 62 | $"https://dev.azure.com/{organizationName}/{urlPathEncodedProjectName}/_git/{urlPathEncodedRepoName}/pullrequest/{prId}"; 63 | var prCreatorEmail = pullRequestJObject["createdBy"]!["uniqueName"]!.Value(); 64 | 65 | var devOpsGoogleChatUserMap = 66 | _devOpsChatUserMapOptions.Value.SingleOrDefault(t => 67 | t.AzureDevOpsEmail.Equals(prCreatorEmail, StringComparison.InvariantCultureIgnoreCase)); 68 | 69 | if (devOpsGoogleChatUserMap == null) 70 | { 71 | continue; 72 | } 73 | 74 | var chatDisplayName = $""; 75 | 76 | _logger.LogInformation( 77 | "CODE: Pending pull request \"{pullRequestTitle}:{pullRequestId}\". Created by {createdBy} on {createdDate}.", 78 | prTitle, prId, prCreatorEmail, prCreationDate); 79 | 80 | messages.Add( 81 | $"{chatDisplayName}, *the pull request* <{prUrl}|{prTitle}> is pending for *{pendingForDays} day(s)*. " + 82 | $"Please have it reviewed and merged."); 83 | } 84 | 85 | Sender.Tell(new ActorResponse>(messages, true)); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Actors/StillActiveWorkItemsActor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Web; 5 | using Akka.Actor; 6 | using Microsoft.Extensions.Logging; 7 | using Microsoft.Extensions.Options; 8 | using Newtonsoft.Json.Linq; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.Helpers; 11 | using TarikGuney.ManagerAutomation.SettingsModels; 12 | 13 | namespace TarikGuney.ManagerAutomation.Actors 14 | { 15 | /// 16 | /// At the last of the sprint, this actor identifies the still active work items, 17 | /// and recommends the engineers ways to move it to the next sprint. 18 | /// It calculates the due-dates of the work items, and if the work item was activated late in the sprint 19 | /// and the remaining days were not enough to the finish them, it asks the engineers to move their respective 20 | /// work items directly to the next sprint. 21 | /// 22 | public class StillActiveWorkItemsActor : ReceiveActor 23 | { 24 | private readonly ILogger _logger; 25 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 26 | private readonly List _devOpsChatUserMaps; 27 | private readonly CurrentIterationInfo _currentIteration; 28 | 29 | public StillActiveWorkItemsActor(IOptions azureDevOpsSettingsOptions, 30 | IOptions> devOpsChatUserMapsOptions, 31 | IOptions currentIterationOptions, ILogger logger) 32 | { 33 | _logger = logger; 34 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 35 | _devOpsChatUserMaps = devOpsChatUserMapsOptions.Value; 36 | _currentIteration = currentIterationOptions.Value; 37 | 38 | Receive>(HandleIncomingWorkItems); 39 | } 40 | 41 | private void HandleIncomingWorkItems(IReadOnlyList workItems) 42 | { 43 | // Property names that has periods in them won't be parsed by Json.NET as opposed to online JSON Parser tools 44 | // eg. $.value[?(@.fields['Microsoft.VSTS.Scheduling.StoryPoints'] == null && @.fields['System.AssignedTo'] != null)] 45 | // Because of that reason, I had to use enumeration below. 46 | var offendingWorkItems = workItems 47 | .Where(wi => wi["fields"] is JObject fields && 48 | new List {"Bug", "User Story"}.Contains(fields["System.WorkItemType"] 49 | .Value()) && 50 | fields["System.State"].Value().ToLower() == "active" && 51 | fields.ContainsKey("System.AssignedTo")).ToList(); 52 | 53 | if (!offendingWorkItems.Any()) 54 | { 55 | Sender.Tell(new ActorResponse>(new List(), false)); 56 | } 57 | 58 | var messages = new List(); 59 | var baseUrl = 60 | $"https://dev.azure.com/{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Organization)}/" + 61 | $"{HttpUtility.UrlPathEncode(_azureDevOpsSettings.Project)}/_workitems/edit"; 62 | 63 | foreach (var offendingWorkItem in offendingWorkItems) 64 | { 65 | // Todo Check if there is any user story point assigned. Otherwise, the other reminders will take effect. 66 | 67 | var recommendedActionText = "Make sure the work item is *closed*. " + 68 | $"If you *need more time*, then *create a follow-up* work item, *link it* to the original work, " + 69 | $"*move it* to the appropriate sprint, and *close* the original work item. If you have *not even started working on it*, " + 70 | $"then move it to the appropriate sprint as it is."; 71 | 72 | 73 | // Work items might not have story points, and they have to be sized first. 74 | // If a work item happens to be sized at the end of the sprint, then there is no need to make more assumptions 75 | // about it, and simply suggest the default recommended message. 76 | if (offendingWorkItem["fields"] is JObject fieldsJson && 77 | fieldsJson.ContainsKey("Microsoft.VSTS.Scheduling.StoryPoints")) 78 | { 79 | var storyPoint = offendingWorkItem["fields"]["Microsoft.VSTS.Scheduling.StoryPoints"] 80 | .Value(); 81 | var activationDate = 82 | DateTime.Parse(offendingWorkItem["fields"]["Microsoft.VSTS.Common.ActivatedDate"] 83 | .Value()).ToLocalTime(); 84 | 85 | var dueInDays = IterationHelper.PointsToDays(storyPoint); 86 | 87 | // Local time is assumed for now. Not the best within different time zone cases. 88 | var assumedActivationDate = activationDate.TimeOfDay >= TimeSpan.FromHours(12) 89 | ? activationDate.Date + TimeSpan.FromDays(1) 90 | : activationDate.Date; 91 | var workItemDueDate = assumedActivationDate + dueInDays; 92 | if (workItemDueDate > _currentIteration.FinishDate.Date) 93 | { 94 | recommendedActionText = "Move it to the next iteration."; 95 | } 96 | } 97 | 98 | var userDisplayName = offendingWorkItem["fields"]?["System.AssignedTo"]?["displayName"] 99 | ?.Value(); 100 | var userEmail = offendingWorkItem["fields"]?["System.AssignedTo"]?["uniqueName"]?.Value(); 101 | var devOpsGoogleChatUserMap = 102 | _devOpsChatUserMaps.SingleOrDefault(t => 103 | t.AzureDevOpsEmail.Equals(userEmail, StringComparison.InvariantCultureIgnoreCase)); 104 | var workItemTitle = offendingWorkItem["fields"]?["System.Title"]?.Value(); 105 | var workItemId = offendingWorkItem["id"].Value(); 106 | var workItemUrl = $"{baseUrl}/{workItemId}"; 107 | 108 | var chatDisplayName = devOpsGoogleChatUserMap == null 109 | ? userDisplayName 110 | : $""; 111 | 112 | _logger.LogInformation( 113 | "BOARD: Still in active state. Story \"{workItemId}:{workItemTitle}\". Assigned to {userEmail} in {currentIteration}.", 114 | workItemId, workItemTitle, userEmail, _currentIteration.Name); 115 | 116 | messages.Add( 117 | $"{chatDisplayName}, <{workItemUrl}|{workItemTitle}> is *still active*. {recommendedActionText}."); 118 | } 119 | 120 | Sender.Tell(new ActorResponse>(messages, true)); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/AutoFacModules/ConfigurationModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Net.Http.Headers; 6 | using Autofac; 7 | using Microsoft.Azure.WebJobs; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.Options; 10 | using Newtonsoft.Json.Linq; 11 | using TarikGuney.ManagerAutomation.SettingsModels; 12 | 13 | namespace TarikGuney.ManagerAutomation.AutoFacModules 14 | { 15 | public class ConfigurationModule: Module 16 | { 17 | private readonly ExecutionContext _executionContext; 18 | 19 | public ConfigurationModule(ExecutionContext executionContext) 20 | { 21 | _executionContext = executionContext; 22 | } 23 | 24 | protected override void Load(ContainerBuilder builder) 25 | { 26 | var config = GetConfig(_executionContext); 27 | 28 | var azureDevOpsSettings = config.GetSection("AzureDevOps") 29 | .Get(); 30 | builder 31 | .RegisterInstance( 32 | new OptionsWrapper(azureDevOpsSettings)) 33 | .As>(); 34 | 35 | builder 36 | .RegisterInstance( 37 | new OptionsWrapper(config.GetSection("GoogleChat") 38 | .Get())) 39 | .As>(); 40 | 41 | 42 | builder 43 | .RegisterInstance( 44 | new OptionsWrapper>( 45 | config["AzureDevOpsUsersMapToGoogleChat"].Split(";").Select(userMap => 46 | { 47 | var userMapArray = userMap.Split(":"); 48 | return new DevOpsChatUserMap() 49 | { 50 | AzureDevOpsEmail = userMapArray[0], 51 | GoogleChatUserId = userMapArray[1] 52 | }; 53 | }).ToList())) 54 | .As>>(); 55 | 56 | 57 | builder 58 | .RegisterInstance( 59 | new OptionsWrapper(config.GetSection("EngineeringManagerInfo") 60 | .Get())) 61 | .As>(); 62 | 63 | var authorizedHttpClient = new HttpClient(); 64 | authorizedHttpClient.DefaultRequestHeaders.Authorization = 65 | new AuthenticationHeaderValue("Basic", azureDevOpsSettings.ApiKey); 66 | 67 | builder 68 | .RegisterInstance(authorizedHttpClient) 69 | .As(); 70 | 71 | var currentIterationInfo = GetCurrentIterationSettings(authorizedHttpClient, azureDevOpsSettings); 72 | builder 73 | .RegisterInstance( 74 | new OptionsWrapper(currentIterationInfo)) 75 | .As>(); 76 | } 77 | 78 | private static CurrentIterationInfo GetCurrentIterationSettings(HttpClient azureAuthorizedHttpClient, 79 | AzureDevOpsSettings azureDevOpsSettings) 80 | { 81 | var currentIterationContent = azureAuthorizedHttpClient.GetStringAsync( 82 | $"https://dev.azure.com/{azureDevOpsSettings.Organization}/{azureDevOpsSettings.Project}/{azureDevOpsSettings.Team}/" + 83 | $"_apis/work/teamsettings/iterations?api-version=6.1-preview.1&$timeframe=current").Result; 84 | 85 | var iterationJson = JObject.Parse(currentIterationContent).SelectToken($".value[0]") as JObject; 86 | 87 | var iterationInfo = iterationJson!.ToObject(); 88 | iterationInfo!.FinishDate = DateTime.Parse(iterationJson!["attributes"]!["finishDate"]!.Value()); 89 | iterationInfo!.StartDate = DateTime.Parse(iterationJson!["attributes"]!["startDate"]!.Value()); 90 | 91 | // todo This can be moved to the class itself. Each iteration child class can now set their TimeFrame internally. 92 | iterationInfo.TimeFrame = iterationJson!["attributes"]!["timeFrame"]!.Value().ToLower() switch 93 | { 94 | "current" => IterationTimeFrame.Current, 95 | "past" => IterationTimeFrame.Previous, 96 | "future" => IterationTimeFrame.Next, 97 | _ => IterationTimeFrame.Current 98 | }; 99 | 100 | return iterationInfo; 101 | } 102 | 103 | private static IConfigurationRoot GetConfig(ExecutionContext context) 104 | { 105 | var config = new ConfigurationBuilder() 106 | .SetBasePath(context.FunctionAppDirectory) 107 | .AddJsonFile("local.settings.json", true, reloadOnChange: true) 108 | .AddJsonFile("secrets/appsettings.personal.json", true, reloadOnChange: true) 109 | .AddJsonFile("secrets/appsettings.msi.json", optional: true, reloadOnChange: true) 110 | .AddEnvironmentVariables() 111 | .Build(); 112 | return config; 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/AutoFacModules/CurrentIterationModule.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using TarikGuney.ManagerAutomation.Actors; 3 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 4 | using TarikGuney.ManagerAutomation.Managers; 5 | using TarikGuney.ManagerAutomation.MessageSenders; 6 | 7 | namespace TarikGuney.ManagerAutomation.AutoFacModules 8 | { 9 | public class CurrentIterationModule : Module 10 | { 11 | protected override void Load(ContainerBuilder builder) 12 | { 13 | builder.RegisterType() 14 | .As().SingleInstance(); 15 | builder.RegisterType() 16 | .As().SingleInstance(); 17 | builder.RegisterType() 18 | .As() 19 | .SingleInstance(); 20 | builder.RegisterType().AsSelf(); 21 | builder.RegisterType().AsSelf(); 22 | builder.RegisterType().AsSelf(); 23 | builder.RegisterType().AsSelf(); 24 | builder.RegisterType().AsSelf(); 25 | builder.RegisterType().AsSelf(); 26 | builder.RegisterType().AsSelf(); 27 | builder.RegisterType().AsSelf(); 28 | builder.RegisterType().As(); 29 | builder.RegisterType().AsSelf(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/AutoFacModules/ManagersReportModule.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using TarikGuney.ManagerAutomation.Actors; 3 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 4 | using TarikGuney.ManagerAutomation.Managers; 5 | using TarikGuney.ManagerAutomation.MessageSenders; 6 | 7 | namespace TarikGuney.ManagerAutomation.AutoFacModules 8 | { 9 | public class ManagersReportModule: Module 10 | { 11 | protected override void Load(ContainerBuilder builder) 12 | { 13 | builder.RegisterType() 14 | .As().SingleInstance(); 15 | 16 | builder.RegisterType() 17 | .As().SingleInstance(); 18 | 19 | builder.RegisterType().AsSelf(); 20 | 21 | builder.RegisterType().AsSelf(); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/AutoFacModules/RetrospectiveModule.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using TarikGuney.ManagerAutomation.Actors; 3 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 4 | using TarikGuney.ManagerAutomation.Managers; 5 | using TarikGuney.ManagerAutomation.MessageSenders; 6 | 7 | namespace TarikGuney.ManagerAutomation.AutoFacModules 8 | { 9 | public class RetrospectiveModule: Module 10 | { 11 | protected override void Load(ContainerBuilder builder) 12 | { 13 | builder.RegisterType() 14 | .As().SingleInstance(); 15 | 16 | builder.RegisterType() 17 | .As().SingleInstance(); 18 | 19 | builder.RegisterType() 20 | .As().SingleInstance(); 21 | 22 | builder.RegisterType().AsSelf(); 23 | builder.RegisterType().AsSelf(); 24 | builder.RegisterType().AsSelf(); 25 | builder.RegisterType().AsSelf(); 26 | builder.RegisterType().AsSelf(); 27 | builder.RegisterType().AsSelf(); 28 | builder.RegisterType().AsSelf(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/CommMessages/ActorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.CommMessages 2 | { 3 | /// 4 | /// Encapsulates an immutable response from an actor. It provides an extensibility point for 5 | /// the actor communication. 6 | /// 7 | /// The type of the payload. 8 | public class ActorResponse 9 | { 10 | public T Content { get; } 11 | public bool Success { get; } 12 | 13 | public ActorResponse(T content, bool success) 14 | { 15 | Content = content; 16 | Success = success; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/CommMessages/AnalysisCompleteResponse.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.CommMessages 2 | { 3 | public class AnalysisCompleteResponse 4 | { 5 | 6 | } 7 | } -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/CommMessages/StartAnalysisRequest.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.CommMessages 2 | { 3 | public class StartAnalysisRequest 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/CurrentIterationAutomationFunction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Actor; 3 | using Akka.DI.Core; 4 | using Autofac; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Extensions.Logging; 7 | using TarikGuney.ManagerAutomation.AutoFacModules; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.Managers; 10 | 11 | namespace TarikGuney.ManagerAutomation 12 | { 13 | public static class CurrentIterationAutomationFunction 14 | { 15 | // The function will be executed on Monday through Friday at every 9:30 AM and 3:30 PM 16 | [FunctionName("CurrentIterationAutomation")] 17 | public static void Run([TimerTrigger("0 30 9,15 * * 1-5")] TimerInfo myTimer, 18 | ILogger log, ExecutionContext context) 19 | { 20 | // Sets the settings model defined as static properties in the class. 21 | // No need to send anything on the first day of the sprint since it is the planning day, 22 | // and people most likely won't have much time to keep their work items current. 23 | var configModule = new ConfigurationModule(context); 24 | 25 | var builder = new ContainerBuilder(); 26 | builder.RegisterModule(configModule); 27 | builder.RegisterModule(); 28 | builder.RegisterInstance(log).As(); 29 | var container = builder.Build(); 30 | var actorSystem = ActorSystem.Create("current-iteration-system"); 31 | actorSystem.UseAutofac(container); 32 | var currentIterationManager = actorSystem.ActorOf(actorSystem.DI().Props(), 33 | "current-iteration-manager"); 34 | 35 | var result = 36 | currentIterationManager.Ask(new StartAnalysisRequest(), 37 | TimeSpan.FromMinutes(1)); 38 | result.Wait(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Helpers/DateDiffHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | 4 | namespace TarikGuney.ManagerAutomation.Helpers 5 | { 6 | // todo Convert this into a injectable service. 7 | public static class DateDiffHelper 8 | { 9 | public static int CalculateWeekendDays(DateTime startDate, DateTime endDate) 10 | { 11 | var daysDiff = (int) (endDate.Date - startDate.Date).TotalDays; 12 | var weekendDaysCount = Enumerable.Range(0, daysDiff + 1) 13 | .Select(a => startDate.Add(TimeSpan.FromDays(a))) 14 | .Count(a => a.DayOfWeek == DayOfWeek.Saturday || a.DayOfWeek == DayOfWeek.Sunday); 15 | return weekendDaysCount; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Helpers/IterationHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace TarikGuney.ManagerAutomation.Helpers 4 | { 5 | public static class IterationHelper 6 | { 7 | public static TimeSpan PointsToDays(int storyPoint) 8 | { 9 | return storyPoint switch 10 | { 11 | 1 => TimeSpan.FromDays(1), 12 | 2 => TimeSpan.FromDays(3), 13 | 3 => TimeSpan.FromDays(5), 14 | _ => TimeSpan.FromDays(10) 15 | }; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/IterationWorkItemRetrievers/AzureDevOpsAllPullRequestsRetriever.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Web.Http; 5 | using Microsoft.Extensions.Options; 6 | using Newtonsoft.Json.Linq; 7 | using TarikGuney.ManagerAutomation.SettingsModels; 8 | 9 | namespace TarikGuney.ManagerAutomation.IterationWorkItemRetrievers 10 | { 11 | public class AzureDevOpsAllPullRequestsRetriever : IPullRequestsRetriever 12 | { 13 | private readonly HttpClient _authorizedHttpClient; 14 | private readonly IOptions _azureDevOpsSettingsOptions; 15 | 16 | public AzureDevOpsAllPullRequestsRetriever(HttpClient authorizedHttpClient, 17 | IOptions azureDevOpsSettingsOptions) 18 | { 19 | _authorizedHttpClient = authorizedHttpClient; 20 | _azureDevOpsSettingsOptions = azureDevOpsSettingsOptions; 21 | } 22 | 23 | public IReadOnlyList GetPullRequests() 24 | { 25 | var pullRequestsAPIEndpoint = 26 | $"https://dev.azure.com/{_azureDevOpsSettingsOptions.Value.Organization}/{_azureDevOpsSettingsOptions.Value.Project}/_apis/git/pullrequests?api-version=6.0"; 27 | var resultTask = _authorizedHttpClient.GetAsync(pullRequestsAPIEndpoint); 28 | var prRequestResponse = resultTask.Result; 29 | 30 | if (prRequestResponse.IsSuccessStatusCode) 31 | { 32 | return JObject.Parse(prRequestResponse.Content.ReadAsStringAsync().Result).SelectTokens("$.value[*]") 33 | .Cast().ToList(); 34 | } 35 | // Todo Think about a better exception message; 36 | throw new HttpResponseException(prRequestResponse); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/IterationWorkItemRetrievers/AzureDevOpsIterationWorkItemsRetriever.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using Microsoft.Extensions.Options; 5 | using Newtonsoft.Json.Linq; 6 | using TarikGuney.ManagerAutomation.SettingsModels; 7 | 8 | namespace TarikGuney.ManagerAutomation.IterationWorkItemRetrievers 9 | { 10 | public class AzureDevOpsIterationWorkItemsRetriever : IIterationWorkItemsRetriever 11 | { 12 | private readonly AzureDevOpsSettings _azureDevOpsSettings; 13 | private readonly HttpClient _authorizedHttpClient; 14 | 15 | public AzureDevOpsIterationWorkItemsRetriever(HttpClient authorizedAuthorizedHttpClient, 16 | IOptions azureDevOpsSettingsOptions 17 | ) 18 | { 19 | _azureDevOpsSettings = azureDevOpsSettingsOptions.Value; 20 | _authorizedHttpClient = authorizedAuthorizedHttpClient; 21 | } 22 | 23 | public IReadOnlyList GetWorkItems(IterationTimeFrame iteration) 24 | { 25 | var workItemIds = GetWorkItemIdsByWiql(iteration); 26 | return GetWorkItems(workItemIds); 27 | } 28 | 29 | private IReadOnlyList GetWorkItemIdsByWiql(IterationTimeFrame iterationTimeFrame) 30 | { 31 | var iterationQueryValue = iterationTimeFrame switch 32 | { 33 | IterationTimeFrame.Current => "@CurrentIteration", 34 | IterationTimeFrame.Previous => "@CurrentIteration - 1", 35 | IterationTimeFrame.Next => "@CurrentIteration + 1", 36 | _ => "@CurrentIteration" 37 | }; 38 | 39 | var httpResponse = _authorizedHttpClient.PostAsJsonAsync( 40 | $"https://dev.azure.com/{_azureDevOpsSettings.Organization}/{_azureDevOpsSettings.Project}/{_azureDevOpsSettings.Team}/_apis/wit/wiql?api-version=6.0", 41 | new 42 | { 43 | query = 44 | $"Select [System.Id] From WorkItems Where [System.WorkItemType] IN ('Bug','User Story') AND " + 45 | $"[State] <> 'Removed' AND [System.IterationPath] = {iterationQueryValue}" 46 | }).Result; 47 | 48 | var content = httpResponse.Content.ReadAsStringAsync().Result; 49 | // todo check if the content is null or empty and return appropriate response. 50 | return JObject.Parse(content)!.SelectTokens("$.workItems[*].id")!.Select(a => Extensions.Value(a)) 51 | .ToList(); 52 | } 53 | 54 | private IReadOnlyList GetWorkItems(IEnumerable workItemIds) 55 | { 56 | var result = _authorizedHttpClient.PostAsJsonAsync( 57 | $"https://dev.azure.com/{_azureDevOpsSettings.Organization}/{_azureDevOpsSettings.Project}/_apis/wit/workitemsbatch?api-version=6.1-preview.1", 58 | new WorkItemMessage() {Ids = workItemIds.ToList()} 59 | ).Result; 60 | var content = result.Content.ReadAsStringAsync().Result; 61 | return JObject.Parse(content).SelectTokens("$.value[*]").Cast().ToList(); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/IterationWorkItemRetrievers/IIterationWorkItemsRetriever.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json.Linq; 3 | using TarikGuney.ManagerAutomation.SettingsModels; 4 | 5 | namespace TarikGuney.ManagerAutomation.IterationWorkItemRetrievers 6 | { 7 | public interface IIterationWorkItemsRetriever 8 | { 9 | IReadOnlyList GetWorkItems(IterationTimeFrame iteration); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/IterationWorkItemRetrievers/IPullRequestsRetriever.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Mvc.Formatters; 3 | using Newtonsoft.Json.Linq; 4 | 5 | namespace TarikGuney.ManagerAutomation.IterationWorkItemRetrievers 6 | { 7 | public interface IPullRequestsRetriever 8 | { 9 | IReadOnlyList GetPullRequests(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Managers/CurrentIterationManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Akka.Actor; 6 | using Akka.DI.Core; 7 | using Microsoft.Extensions.Options; 8 | using TarikGuney.ManagerAutomation.Actors; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 11 | using TarikGuney.ManagerAutomation.MessageSenders; 12 | using TarikGuney.ManagerAutomation.SettingsModels; 13 | 14 | namespace TarikGuney.ManagerAutomation.Managers 15 | { 16 | public class CurrentIterationManager : ReceiveActor 17 | { 18 | private readonly ICurrentIterationMessageSender _currentIterationMessageSender; 19 | private readonly IIterationWorkItemsRetriever _workItemsRetriever; 20 | private readonly ILastDayOfCurrentIterationMessageSender _lastDayOfCurrentIterationMessageSender; 21 | private readonly IOptions _currentIterationInfoOptions; 22 | private readonly IPullRequestsRetriever _pullRequestsRetriever; 23 | 24 | public CurrentIterationManager(ICurrentIterationMessageSender currentIterationMessageSender, 25 | IIterationWorkItemsRetriever workItemsRetriever, 26 | ILastDayOfCurrentIterationMessageSender lastDayOfCurrentIterationMessageSender, 27 | IOptions currentIterationInfoOptions, 28 | IPullRequestsRetriever pullRequestsRetriever 29 | ) 30 | { 31 | _currentIterationMessageSender = currentIterationMessageSender; 32 | _workItemsRetriever = workItemsRetriever; 33 | _lastDayOfCurrentIterationMessageSender = lastDayOfCurrentIterationMessageSender; 34 | _currentIterationInfoOptions = currentIterationInfoOptions; 35 | _pullRequestsRetriever = pullRequestsRetriever; 36 | 37 | Receive(StartAnalysis); 38 | } 39 | 40 | private void StartAnalysis(StartAnalysisRequest request) 41 | { 42 | var firstDayOfSprint = _currentIterationInfoOptions.Value.StartDate.Date == DateTime.Now.Date; 43 | // There is another function that runs when it is the first day of the sprint! 44 | if (firstDayOfSprint) 45 | { 46 | Context.Stop(Self); 47 | Sender.Tell(new AnalysisCompleteResponse()); 48 | return; 49 | } 50 | 51 | var lastDayOfSprint = _currentIterationInfoOptions.Value.FinishDate.Date == DateTime.Now.Date; 52 | var currentIterationWorkItems = _workItemsRetriever.GetWorkItems(IterationTimeFrame.Current); 53 | var pendingPullRequests = _pullRequestsRetriever.GetPullRequests(); 54 | 55 | // Creating the subordinate actors. 56 | var estimateWorkItemActor = 57 | Context.ActorOf(Context.DI().Props(), "estimate-work-item-actor"); 58 | var descriptiveTitleActor = 59 | Context.ActorOf(Context.DI().Props(), "descriptive-title-actor"); 60 | var activateWorkItemActor = 61 | Context.ActorOf(Context.DI().Props(), "activate-work-item-actor"); 62 | var descriptionActor = 63 | Context.ActorOf(Context.DI().Props(), "description-actor"); 64 | var longCodeCompleteActor = 65 | Context.ActorOf(Context.DI().Props(), "long-code-complete-actor"); 66 | var greatWorkActor = 67 | Context.ActorOf(Context.DI().Props(), "great-work-actor"); 68 | var stillActiveWorkItemsActor = 69 | Context.ActorOf(Context.DI().Props(), 70 | "still-active-work-items-actor"); 71 | 72 | var pendingPullRequestsActor = 73 | Context.ActorOf(Context.DI().Props(), "pending-pull-requests-actor"); 74 | 75 | // Running the actors. 76 | var tasks = new List(); 77 | 78 | var estimateWorkItemTask = estimateWorkItemActor 79 | .Ask>>(currentIterationWorkItems); 80 | tasks.Add(estimateWorkItemTask); 81 | 82 | var descriptiveTitleTask = descriptiveTitleActor 83 | .Ask>>(currentIterationWorkItems); 84 | tasks.Add(descriptiveTitleTask); 85 | 86 | var activeWorkItemTask = activateWorkItemActor 87 | .Ask>>(currentIterationWorkItems); 88 | tasks.Add(activeWorkItemTask); 89 | 90 | var descriptionTask = descriptionActor 91 | .Ask>>(currentIterationWorkItems); 92 | tasks.Add(descriptionTask); 93 | 94 | var longCodeCompleteTask = longCodeCompleteActor 95 | .Ask>>(currentIterationWorkItems); 96 | tasks.Add(longCodeCompleteTask); 97 | 98 | var greatWorkTask = greatWorkActor 99 | .Ask>>(currentIterationWorkItems); 100 | tasks.Add(greatWorkTask); 101 | 102 | var dummyStillActiveWorkItemsTask = new Task>>( 103 | () => 104 | new ActorResponse>(new List(), false)); 105 | dummyStillActiveWorkItemsTask.Start(); 106 | 107 | var stillActiveWorkItemsTask = lastDayOfSprint 108 | ? stillActiveWorkItemsActor.Ask>>(currentIterationWorkItems) 109 | : dummyStillActiveWorkItemsTask; 110 | tasks.Add(stillActiveWorkItemsTask); 111 | 112 | var longPendingPullRequestsTask = 113 | pendingPullRequestsActor.Ask>>(pendingPullRequests); 114 | tasks.Add(longPendingPullRequestsTask); 115 | 116 | // Waiting for all the of the actors to finish their work and return a response back. 117 | Task.WaitAll(tasks.ToArray()); 118 | 119 | // Collecting the results from each actor. 120 | var messages = new List(); 121 | messages.AddRange(estimateWorkItemTask.Result.Content); 122 | messages.AddRange(descriptiveTitleTask.Result.Content); 123 | messages.AddRange(descriptionTask.Result.Content); 124 | messages.AddRange(activeWorkItemTask.Result.Content); 125 | messages.AddRange(stillActiveWorkItemsTask.Result.Content); 126 | messages.AddRange(longCodeCompleteTask.Result.Content); 127 | messages.AddRange(longPendingPullRequestsTask.Result.Content); 128 | 129 | // Sending "great work" message when there are no other messages makes the greeting a little awkward 130 | // as the greeting asks for completion some work items, but there is none. 131 | // todo Improve this as I still want to send the positive feedback even if there is nothing else. 132 | if (messages.Any()) 133 | { 134 | messages.AddRange(greatWorkTask.Result.Content); 135 | } 136 | 137 | // Sending the messages from each actor to the message senders. Using a different message sender if it 138 | // is the last day of the sprint. 139 | if (lastDayOfSprint) 140 | { 141 | _lastDayOfCurrentIterationMessageSender.SendMessages(messages).Wait(); 142 | } 143 | else 144 | { 145 | _currentIterationMessageSender.SendMessages(messages).Wait(); 146 | } 147 | 148 | Context.Stop(Self); 149 | // This is required for stopping the program. Check out the Ask<> calls in the function classes. 150 | Sender.Tell(new AnalysisCompleteResponse()); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Managers/ProgressReportManager.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Akka.Actor; 3 | using Akka.DI.Core; 4 | using TarikGuney.ManagerAutomation.Actors; 5 | using TarikGuney.ManagerAutomation.CommMessages; 6 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 7 | using TarikGuney.ManagerAutomation.MessageSenders; 8 | using TarikGuney.ManagerAutomation.SettingsModels; 9 | 10 | namespace TarikGuney.ManagerAutomation.Managers 11 | { 12 | public class ProgressReportManager: ReceiveActor 13 | { 14 | private readonly IIterationWorkItemsRetriever _iterationWorkItemsRetriever; 15 | private readonly IManagersReportMessageSender _managersReportMessageSender; 16 | 17 | public ProgressReportManager(IIterationWorkItemsRetriever iterationWorkItemsRetriever, 18 | IManagersReportMessageSender managersReportMessageSender 19 | ) 20 | { 21 | _iterationWorkItemsRetriever = iterationWorkItemsRetriever; 22 | _managersReportMessageSender = managersReportMessageSender; 23 | 24 | Receive(StartAnalysis); 25 | } 26 | 27 | private void StartAnalysis(StartAnalysisRequest request) 28 | { 29 | var passedDueWorkItemsActor = Context.ActorOf(Context.DI().Props(), 30 | "passed-due-work-items-actor"); 31 | 32 | var passedDueWorkItemsTask = 33 | passedDueWorkItemsActor.Ask>>( 34 | _iterationWorkItemsRetriever.GetWorkItems(IterationTimeFrame.Current)); 35 | 36 | var result = passedDueWorkItemsTask.Result; 37 | 38 | _managersReportMessageSender.SendMessages(result.Content).Wait(); 39 | 40 | Context.Stop(Self); 41 | Sender.Tell(new AnalysisCompleteResponse()); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/Managers/RetrospectiveManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Akka.Actor; 6 | using Akka.DI.Core; 7 | using Microsoft.Extensions.Options; 8 | using TarikGuney.ManagerAutomation.Actors; 9 | using TarikGuney.ManagerAutomation.CommMessages; 10 | using TarikGuney.ManagerAutomation.IterationWorkItemRetrievers; 11 | using TarikGuney.ManagerAutomation.MessageSenders; 12 | using TarikGuney.ManagerAutomation.SettingsModels; 13 | 14 | namespace TarikGuney.ManagerAutomation.Managers 15 | { 16 | public class RetrospectiveManager : ReceiveActor 17 | { 18 | private readonly IIterationWorkItemsRetriever _workItemsRetriever; 19 | private readonly IRetrospectiveMessageSender _retrospectiveMessageSender; 20 | private readonly IOptions _currentIterationInfoOptions; 21 | 22 | public RetrospectiveManager(IIterationWorkItemsRetriever workItemsRetriever, 23 | IRetrospectiveMessageSender retrospectiveMessageSender, 24 | IOptions currentIterationInfoOptions) 25 | { 26 | _workItemsRetriever = workItemsRetriever; 27 | _retrospectiveMessageSender = retrospectiveMessageSender; 28 | _currentIterationInfoOptions = currentIterationInfoOptions; 29 | 30 | Receive(HandleStartAnalysisRequest); 31 | } 32 | 33 | private void HandleStartAnalysisRequest(StartAnalysisRequest obj) 34 | { 35 | // This is supposed to run on Mondays bi-weekly at the starting of the new iteration. 36 | if (_currentIterationInfoOptions.Value.StartDate.Date != DateTime.Now.Date) 37 | { 38 | Context.Stop(Self); 39 | Sender.Tell(new AnalysisCompleteResponse()); 40 | return; 41 | } 42 | 43 | var currentIterationWorkItems = 44 | _workItemsRetriever.GetWorkItems(IterationTimeFrame.Previous); 45 | 46 | // Creating the subordinate actors. 47 | var estimateWorkItemActor = 48 | Context.ActorOf(Context.DI().Props(), "estimate-work-item-actor"); 49 | var descriptiveTitleActor = 50 | Context.ActorOf(Context.DI().Props(), "descriptive-title-actor"); 51 | var descriptionActor = 52 | Context.ActorOf(Context.DI().Props(), "description-actor"); 53 | var longCodeCompleteActor = 54 | Context.ActorOf(Context.DI().Props(), "long-code-complete-actor"); 55 | var greatPreviousIterationActor = 56 | Context.ActorOf(Context.DI().Props(), "great-previous-iteration-actor"); 57 | var openWorkItemsActor = 58 | Context.ActorOf(Context.DI().Props(), "open-work-items-actor"); 59 | 60 | // Running the actors. 61 | var tasks = new List(); 62 | 63 | var estimateWorkItemTask = estimateWorkItemActor 64 | .Ask>>(currentIterationWorkItems); 65 | tasks.Add(estimateWorkItemTask); 66 | 67 | var descriptiveTitleTask = descriptiveTitleActor 68 | .Ask>>(currentIterationWorkItems); 69 | tasks.Add(descriptiveTitleTask); 70 | 71 | var descriptionTask = descriptionActor 72 | .Ask>>(currentIterationWorkItems); 73 | tasks.Add(descriptionTask); 74 | 75 | var longCodeCompleteTask = longCodeCompleteActor 76 | .Ask>>(currentIterationWorkItems); 77 | tasks.Add(longCodeCompleteTask); 78 | 79 | var greatPreviousIterationTask = greatPreviousIterationActor 80 | .Ask>>(currentIterationWorkItems); 81 | tasks.Add(greatPreviousIterationTask); 82 | 83 | var openWorkItemsTask = openWorkItemsActor 84 | .Ask>>(currentIterationWorkItems); 85 | tasks.Add(openWorkItemsTask); 86 | 87 | // Waiting for all the of the actors to finish their work and return a response back. 88 | Task.WaitAll(tasks.ToArray()); 89 | 90 | // Collecting the results from each actor. 91 | var messages = new List(); 92 | messages.AddRange(estimateWorkItemTask.Result.Content); 93 | messages.AddRange(descriptiveTitleTask.Result.Content); 94 | messages.AddRange(openWorkItemsTask.Result.Content); 95 | messages.AddRange(longCodeCompleteTask.Result.Content); 96 | 97 | // There is no reason to congratulate individual members if the team closed 98 | // all of the work items! This also simplifies the code a little bit. 99 | if (messages.Any()) 100 | { 101 | messages.AddRange(greatPreviousIterationTask.Result.Content); 102 | } 103 | 104 | // Send the messages 105 | _retrospectiveMessageSender.SendMessages(messages).Wait(); 106 | 107 | // Clearing out and exiting. 108 | Context.Stop(Self); 109 | Sender.Tell(new AnalysisCompleteResponse()); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/ManagersReportAutomationFunction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Actor; 3 | using Akka.DI.Core; 4 | using Autofac; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Extensions.Logging; 7 | using TarikGuney.ManagerAutomation.AutoFacModules; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.Managers; 10 | 11 | namespace TarikGuney.ManagerAutomation 12 | { 13 | public static class ManagersReportAutomationFunction 14 | { 15 | [FunctionName("ManagersReportAutomation")] 16 | public static void Run([TimerTrigger("0 30 9 * * 1-5")] TimerInfo myTimer, 17 | ILogger log, ExecutionContext context) 18 | { 19 | // Sets the settings model defined as static properties in the class. 20 | var configModule = new ConfigurationModule(context); 21 | 22 | var containerBuilder = new ContainerBuilder(); 23 | containerBuilder.RegisterModule(configModule); 24 | containerBuilder.RegisterModule(); 25 | containerBuilder.RegisterInstance(log).As(); 26 | var container = containerBuilder.Build(); 27 | 28 | var actorSystem = ActorSystem.Create("manager-report-actor-system"); 29 | actorSystem.UseAutofac(container); 30 | 31 | var progressReportManager = actorSystem.ActorOf(actorSystem.DI().Props(), 32 | "progress-report-manager"); 33 | 34 | var result = 35 | progressReportManager.Ask(new StartAnalysisRequest(), 36 | TimeSpan.FromMinutes(1)); 37 | 38 | result.Wait(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/CurrentIterationGoogleChatMessageSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Options; 7 | using TarikGuney.ManagerAutomation.SettingsModels; 8 | 9 | namespace TarikGuney.ManagerAutomation.MessageSenders 10 | { 11 | public class CurrentIterationGoogleChatMessageSender : ICurrentIterationMessageSender 12 | { 13 | private readonly IOptions _googleChatSettingsOptions; 14 | 15 | public CurrentIterationGoogleChatMessageSender(IOptions googleChatSettingsOptions) 16 | { 17 | _googleChatSettingsOptions = googleChatSettingsOptions; 18 | } 19 | 20 | public async Task SendMessages(IReadOnlyList messages) 21 | { 22 | var allCompleted = messages == null || !messages.Any(); 23 | 24 | var httpClient = new HttpClient(); 25 | object chatMessage; 26 | 27 | if (allCompleted) 28 | { 29 | chatMessage = new 30 | { 31 | text = "*GREAT WORK* ! Everything is up-to-date. Keep it up!" 32 | }; 33 | } 34 | else 35 | { 36 | var workRequiredGreetings = new[] 37 | { 38 | "Hello there team 👋, please complete the requested actions below *ASAP*", 39 | "Team, please complete the requested actions below *ASAP*", 40 | "It looks like the current sprint board needs more work ☹, please complete the following actions *ASAP*", 41 | "Hey you! Yes, you... 😎 It looks like you need to take care of a couple of things below *ASAP* 👇", 42 | "Hello earthlings 👽, sending you an encrypted message: શક્ય તેટલી વહેલી તકે નીચેની ક્રિયાઓ પૂર્ણ કરો" 43 | }; 44 | 45 | var random = new Random(); 46 | var randomGreeting = workRequiredGreetings[random.Next(0, workRequiredGreetings.Length)]; 47 | 48 | chatMessage = new 49 | { 50 | text = $"{randomGreeting}:\n\n" + string.Join("\n\n", messages) 51 | }; 52 | } 53 | 54 | await httpClient.PostAsJsonAsync(_googleChatSettingsOptions.Value.WebhookUrl, chatMessage); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/ICurrentIterationMessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.MessageSenders 2 | { 3 | public interface ICurrentIterationMessageSender: IMessageSender 4 | { 5 | 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/ILastDayOfCurrentIterationMessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.MessageSenders 2 | { 3 | public interface ILastDayOfCurrentIterationMessageSender : IMessageSender 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/IManagersReportMessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.MessageSenders 2 | { 3 | public interface IManagersReportMessageSender : IMessageSender 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/IMessageSender.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | 4 | namespace TarikGuney.ManagerAutomation.MessageSenders 5 | { 6 | public interface IMessageSender 7 | { 8 | Task SendMessages(IReadOnlyList messages); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/IRetrospectiveMessageSender.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.MessageSenders 2 | { 3 | public interface IRetrospectiveMessageSender: IMessageSender 4 | { 5 | 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/LastDayOfCurrentIterationGoogleChatMessageSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Options; 7 | using TarikGuney.ManagerAutomation.SettingsModels; 8 | 9 | namespace TarikGuney.ManagerAutomation.MessageSenders 10 | { 11 | public class LastDayOfCurrentIterationGoogleChatMessageSender : ILastDayOfCurrentIterationMessageSender 12 | { 13 | private readonly IOptions _googleChatSettingsOptions; 14 | private readonly IOptions _currentIterationInfoOptions; 15 | 16 | public LastDayOfCurrentIterationGoogleChatMessageSender(IOptions googleChatSettingsOptions, 17 | IOptions currentIterationInfoOptions) 18 | { 19 | _googleChatSettingsOptions = googleChatSettingsOptions; 20 | _currentIterationInfoOptions = currentIterationInfoOptions; 21 | } 22 | 23 | public async Task SendMessages(IReadOnlyList messages) 24 | { 25 | var allCompleted = messages == null || !messages.Any(); 26 | 27 | var httpClient = new HttpClient(); 28 | 29 | var greetings = new[] 30 | { 31 | $"Hello there team 👋, this is *the last day* of our current sprint ({_currentIterationInfoOptions.Value.Name})." 32 | }; 33 | 34 | var actionMessage = allCompleted 35 | ? "\n\nAnd, *GREAT WORK* ! 👏🎉👏🎉 *All of the work items are closed* from this sprint! " + 36 | "Have a wonderful weekend and I will see you all next week!" 37 | : "*Unfortunately*, there are some remaining work. Please complete the actions below *before the end of the day*:\n\n"; 38 | 39 | var random = new Random(); 40 | var randomGreeting = greetings[random.Next(0, greetings.Length)]; 41 | 42 | var chatMessage = new 43 | { 44 | text = $"{randomGreeting} {actionMessage}" + string.Join("\n\n", messages) 45 | }; 46 | 47 | await httpClient.PostAsJsonAsync(_googleChatSettingsOptions.Value.WebhookUrl, chatMessage); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/ManagersReportGoogleChatMessageSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Options; 7 | using TarikGuney.ManagerAutomation.SettingsModels; 8 | 9 | namespace TarikGuney.ManagerAutomation.MessageSenders 10 | { 11 | public class ManagersReportGoogleChatMessageSender : IManagersReportMessageSender 12 | { 13 | private readonly IOptions _managerInfoOptions; 14 | 15 | public ManagersReportGoogleChatMessageSender(IOptions managerInfoOptions) 16 | { 17 | _managerInfoOptions = managerInfoOptions; 18 | } 19 | 20 | public async Task SendMessages(IReadOnlyList messages) 21 | { 22 | var allCompleted = messages == null || !messages.Any(); 23 | 24 | var httpClient = new HttpClient(); 25 | var yesterday = DateTime.Now.Subtract(TimeSpan.FromDays(1)).Date.ToShortDateString(); 26 | var greetings = new[] 27 | { 28 | $"Hello . Here is the report for *yesterday* ({yesterday}) progress", 29 | }; 30 | 31 | var random = new Random(); 32 | var randomGreeting = greetings[random.Next(0, greetings.Length)]; 33 | 34 | var finalMessage = allCompleted 35 | ? "The board is looking good and every thing is on track" 36 | : string.Join("\n\n", messages); 37 | 38 | var chatMessage = new 39 | { 40 | text = $"{randomGreeting}:\n\n{finalMessage}" 41 | }; 42 | 43 | await httpClient.PostAsJsonAsync(_managerInfoOptions.Value.ManagerRemindersGoogleWebhookUrl, 44 | chatMessage); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/MessageSenders/RetrospectiveGoogleChatMessageSender.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Options; 6 | using TarikGuney.ManagerAutomation.SettingsModels; 7 | 8 | namespace TarikGuney.ManagerAutomation.MessageSenders 9 | { 10 | public class RetrospectiveGoogleChatMessageSender : IRetrospectiveMessageSender 11 | { 12 | private readonly IOptions _currentIterationOptions; 13 | private readonly IOptions _googleChatSettingsOptions; 14 | 15 | public RetrospectiveGoogleChatMessageSender( 16 | IOptions _currentIterationOptions, 17 | IOptions _googleChatSettingsOptions) 18 | { 19 | this._currentIterationOptions = _currentIterationOptions; 20 | this._googleChatSettingsOptions = _googleChatSettingsOptions; 21 | } 22 | 23 | public async Task SendMessages(IReadOnlyList messages) 24 | { 25 | var allCompleted = messages==null || !messages.Any(); 26 | 27 | var httpClient = new HttpClient(); 28 | 29 | var actionMessage = allCompleted 30 | ? "\n\n*Great work*, ! 👏🎉👏🎉 *All of the work items are closed from the previous sprint*!" 31 | : "Unfortunately, there are some *incomplete work items from the previous sprint.* " + 32 | "Please review and complete them *before the sprint kickoff meeting*"; 33 | 34 | // Not congratulating the individuals if the entire team is already finished everything. 35 | var finalMessage = allCompleted 36 | ? $"Good morning team! 👋 Welcome to the {_currentIterationOptions.Value.Name}! 🎉 {actionMessage}" 37 | : $"Good morning team! 👋 Welcome to the {_currentIterationOptions.Value.Name}! 🎉 {actionMessage}\n\n" + 38 | string.Join("\n\n", messages); 39 | 40 | var chatMessage = new 41 | { 42 | text = finalMessage 43 | }; 44 | 45 | await httpClient.PostAsJsonAsync(_googleChatSettingsOptions.Value.WebhookUrl, chatMessage); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/RetrospectiveAutomationFunction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Akka.Actor; 3 | using Akka.DI.Core; 4 | using Autofac; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Extensions.Logging; 7 | using TarikGuney.ManagerAutomation.AutoFacModules; 8 | using TarikGuney.ManagerAutomation.CommMessages; 9 | using TarikGuney.ManagerAutomation.Managers; 10 | 11 | namespace TarikGuney.ManagerAutomation 12 | { 13 | public static class RetrospectiveAutomationFunction 14 | { 15 | [FunctionName("RetrospectiveAutomation")] 16 | public static void Run([TimerTrigger("0 0 10 * * Mon")] TimerInfo myTimer, 17 | ILogger log, ExecutionContext context) 18 | { 19 | // Sets the settings model defined as static properties in the class. 20 | var configModule = new ConfigurationModule(context); 21 | 22 | var containerBuilder = new ContainerBuilder(); 23 | containerBuilder.RegisterModule(configModule); 24 | containerBuilder.RegisterModule(); 25 | containerBuilder.RegisterInstance(log).As(); 26 | var container = containerBuilder.Build(); 27 | 28 | var actorSystem = ActorSystem.Create("retrospective-automation-actor-system"); 29 | actorSystem.UseAutofac(container); 30 | var retrospectiveManager = 31 | actorSystem.ActorOf(actorSystem.DI().Props(), "retrospective-manager"); 32 | var result = retrospectiveManager.Ask(new StartAnalysisRequest(), 33 | TimeSpan.FromMinutes(1)); 34 | result.Wait(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/AzureDevOpsSettings.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class AzureDevOpsSettings 4 | { 5 | public string ApiKey { get; set; } 6 | public string Organization { get; set; } 7 | public string Project { get; set; } 8 | public string Team { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/CurrentIterationInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class CurrentIterationInfo: IterationInfo 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/DevOpsChatUserMap.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class DevOpsChatUserMap 4 | { 5 | public string AzureDevOpsEmail { get; set; } 6 | public string GoogleChatUserId { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/EngineeringManagerInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class EngineeringManagerInfo: DevOpsChatUserMap 4 | { 5 | public string ManagerRemindersGoogleWebhookUrl { get; set; } 6 | 7 | 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/GoogleChatSettings.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class GoogleChatSettings 4 | { 5 | public string WebhookUrl { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/IterationInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace TarikGuney.ManagerAutomation.SettingsModels 5 | { 6 | public class IterationInfo 7 | { 8 | [JsonProperty("name")] 9 | public string Name { get; set; } 10 | [JsonProperty("id")] 11 | public string Id { get; set; } 12 | [JsonProperty("path")] 13 | public string Path { get; set; } 14 | public DateTime StartDate { get; set; } 15 | public DateTime FinishDate { get; set; } 16 | public IterationTimeFrame TimeFrame { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/IterationTimeFrame.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public enum IterationTimeFrame 4 | { 5 | Previous, 6 | Current, 7 | Next 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/SettingsModels/PreviousIterationInfo.cs: -------------------------------------------------------------------------------- 1 | namespace TarikGuney.ManagerAutomation.SettingsModels 2 | { 3 | public class PreviousIterationInfo: IterationInfo 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/TarikGuney.ManagerAutomation.FunctionApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | V3 5 | TarikGuney.ManagerAutomation 6 | 8 7 | TarikGuney.ManagerAutomation.FunctionApp 8 | TarikGuney.ManagerAutomation.FunctionApp 9 | TarikGuney.ManagerAutomation.FunctionApp 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | PreserveNewest 20 | 21 | 22 | PreserveNewest 23 | Never 24 | 25 | 26 | PreserveNewest 27 | Never 28 | 29 | 30 | PreserveNewest 31 | Never 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/WorkItemMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace TarikGuney.ManagerAutomation 5 | { 6 | // todo Move this to under a Models directory. 7 | public class WorkItemMessage 8 | { 9 | [JsonPropertyName("ids")] 10 | public List Ids { get; set; } 11 | 12 | [JsonPropertyName("$expand")] 13 | public string Expand => "fields"; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.FunctionApp/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "fileLoggingMode": "always", 5 | "applicationInsights": { 6 | "samplingExcludedTypes": "Request", 7 | "samplingSettings": { 8 | "isEnabled": false 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.UnitTests/TarikGuney.ManagerAutomation.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | all 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.UnitTests/UnitTest1.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Xunit; 3 | 4 | namespace TarikGuney.ManagerAutomation.UnitTests 5 | { 6 | public class UnitTest1 7 | { 8 | [Fact] 9 | public void Test1() 10 | { 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/TarikGuney.ManagerAutomation.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TarikGuney.ManagerAutomation.FunctionApp", "TarikGuney.ManagerAutomation.FunctionApp\TarikGuney.ManagerAutomation.FunctionApp.csproj", "{3F23FD09-A46F-406E-82D9-4A2D82876389}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TarikGuney.ManagerAutomation.UnitTests", "TarikGuney.ManagerAutomation.UnitTests\TarikGuney.ManagerAutomation.UnitTests.csproj", "{6BA838E4-22E7-4106-AC4C-A97A0515585D}" 6 | EndProject 7 | Global 8 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 9 | Debug|Any CPU = Debug|Any CPU 10 | Release|Any CPU = Release|Any CPU 11 | EndGlobalSection 12 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 13 | {3F23FD09-A46F-406E-82D9-4A2D82876389}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 14 | {3F23FD09-A46F-406E-82D9-4A2D82876389}.Debug|Any CPU.Build.0 = Debug|Any CPU 15 | {3F23FD09-A46F-406E-82D9-4A2D82876389}.Release|Any CPU.ActiveCfg = Release|Any CPU 16 | {3F23FD09-A46F-406E-82D9-4A2D82876389}.Release|Any CPU.Build.0 = Release|Any CPU 17 | {6BA838E4-22E7-4106-AC4C-A97A0515585D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {6BA838E4-22E7-4106-AC4C-A97A0515585D}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {6BA838E4-22E7-4106-AC4C-A97A0515585D}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {6BA838E4-22E7-4106-AC4C-A97A0515585D}.Release|Any CPU.Build.0 = Release|Any CPU 21 | EndGlobalSection 22 | EndGlobal 23 | -------------------------------------------------------------------------------- /src/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "5.0.100" 4 | } 5 | } --------------------------------------------------------------------------------