[
  {
    "path": ".github/ISSUE_TEMPLATE/catalog-change-request.yml",
    "content": "name: Business process catalog change request\ndescription: Use this issue type to register that you would like to request a change to the business process catalog. This can include adding a new row, updating an existing row, or removal of a row.\ntitle: \"[CATALOG]: \"\nlabels: [\"catalog\", \"triage\"]\nassignees:\n  - rachel-profitt\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for contributing to the [Dynamics 365 business process catalog](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/)! Registering your request here is the first step in contributing. Your request will be reviewed and approved. Not all request may be approved. Approved requests will be added to the business process catalog. Find the catalog and templates at [https://github.com/microsoft/dynamics365patternspractices/templates/business-processes](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/business-processes). If you want to contribute with architectural patterns for Dynamics 365 implementations, use the templates at [https://github.com/microsoft/dynamics365patternspractices/templates/architecture](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/architecture). We do not recommend starting work on a new article for a new business process catalog row until the catalog request is approved. You can use this request to add new business processes or new patterns and practices to the catalog. Use one issue request for each new row you want to add or update. \n  - type: input\n    id: contact\n    attributes:\n      label: Contact details\n      description: How can we get in touch with you?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: dropdown\n    id: organization-type\n    attributes:\n      label: \"Organization type\"\n      description: Which type of organization do you work for?\n      options:\n        - \"Microsoft MVP\"\n        - \"Partner / ISV / Independant consultant\"\n        - \"Customer organization\"\n        - \"Microsoft employee\"\n    validations:\n      required: true\n  - type: dropdown\n    id: endtoend\n    attributes:\n      label: \"End-to-end business process\"\n      description: Which end-to-end business process is the article related to?\n      options:\n        - \"Acquire to dispose\"\n        - \"Administer to operate\"\n        - \"Case to resolution\"\n        - \"Concept to market\"\n        - \"Design to retire\"\n        - \"Forecast to plan\"\n        - \"Hire to retire\"\n        - \"Inventory to deliver\"\n        - \"Order to cash\"\n        - \"Plan to produce\"\n        - \"Procure to pay\"\n        - \"Project to profit\"\n        - \"Prospect to quote\"\n        - \"Record to report\"\n        - \"Service to cash\"\n    validations:\n      required: true\n  - type: input\n    id: area-name\n    attributes:\n      label: \"Which business process area is this article related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create and manage sales\"\n    validations:\n      required: true\n  - type: input\n    id: process-name\n    attributes:\n      label: \"Which business process is this article related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create a sales order\"\n    validations:\n      required: true\n  - type: input\n    id: pattern-name\n    attributes:\n      label: \"Which pattern or practice is this request related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog. If this is a new pattern or practice, enter the suggested name for the pattern or practice.\" \n      value: \"Create a sales order in the retail point of sale\"\n    validations:\n      required: false\n  - type: textarea\n    id: comments\n    attributes:\n      label: \"Please describe the suggested change. If this is an update to an existing row in the catalog, please be sure to indicate in the comments.\"\n      description: \"Write your comments\"\n      value: \"Comments go here\"\n    validations:\n      required: true\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information, see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/), or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n  - type: markdown\n    attributes:\n      value: \"## Thanks!\"\n  - type: markdown\n    attributes:\n      value: Thank you for contributing to the [business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/). \n   \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: Submit content requests\n    url: https://github.com/microsoft/dynamics365patternspractices/issues/new/choose\n    about: Choose the right template for your feedback from the list above\n  - name: Discussions\n    url: https://github.com/microsoft/dynamics365patternspractices/discussions\n    about: Please ask and answer questions here.\n  - name: Email the Microsoft Dynamics 365 Guidance team\n    url: mailto:bizprocessguide@microsoft.com\n    about: For additional support, please email the team.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/content-request.yml",
    "content": "name: Content request\ndescription: Request a new feature or article for the Dynamics 365 guidance hub\ntitle: \"[CONTENT REQUEST]: \"\nlabels: [\"feature\", \"triage\"]\nassignees:\n  - edupont04\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for submitting this content request. We triage this feedback regularly and use it to plan for future documentation improvements. Not all content requests are implemented, but we'll prioritize based on current work priorities and the tooling available.\n  - type: input\n    id: contact\n    attributes:\n      label: Contact Details\n      description: How can we get in touch with you if we need more info?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the type of article or feature on the Microsoft Learn website that you would like to see in the Dynamics 365 guidance documentation\n      description: Also, tell us what you expected to see?\n      placeholder: Tell us what you want to see!\n      value: \"A great new feature!\"\n    validations:\n      required: true  \n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/error-report.yml",
    "content": "name: Error Report\ndescription: File a an error report for Dynamics 365 guidance content\ntitle: \"[ERROR]: \"\nlabels: [\"bug\", \"triage\"]\nassignees:\n  - rachel-profitt\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the time to submit this error report!\n  - type: input\n    id: contact\n    attributes:\n      label: Contact Details\n      description: How can we get in touch with you if we need more info?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the error you see in the documentation, or the correction you suggest\n      description: Also, tell us what you expected to see?\n      placeholder: Tell us what you want to see!\n      value: \"Something is wrong in the content!\"\n    validations:\n      required: true\n  - type: input\n    id: siteURL\n    attributes:\n      label: Link to the article where the error is\n      description: Please copy and paste the link to the article or place on Microsoft Learn where the error or correction is needed.\n    validations:\n      required: true  \n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/new-business-process-area-draft.yml",
    "content": "name: New business process area\ndescription: Use this issue type to indicate that you are beginning work on a new business process area article. The business process area should already be listed in the business process catalog.\ntitle: \"[AREA]: \"\nlabels: [\"area\", \"triage\"]\nassignees:\n  - rachel-profitt\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for taking the to contribute to the [Dynamics 365 business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/). Registering your work here is the first step in contributing. Learn more [here](https://learn.microsoft.com/en-us/dynamics365/get-started/contribute#register-your-work). \n  - type: input\n    id: contact\n    attributes:\n      label: Contact details\n      description: How can we get in touch with you?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: dropdown\n    id: organization-type\n    attributes:\n      label: \"Organization type\"\n      description: Which type of organization do you work for?\n      options:\n        - \"Partner / ISV / Independant consultant\"\n        - \"Customer organization\"\n        - \"Microsoft MVP\"\n        - \"Microsoft employee\"\n    validations:\n      required: true\n  - type: dropdown\n    id: endtoend\n    attributes:\n      label: \"End-to-end business process\"\n      description: Which end-to-end business process is the article related to?\n      options:\n        - \"Acquire to dispose\"\n        - \"Administer to operate\"\n        - \"Case to resolution\"\n        - \"Concept to market\"\n        - \"Design to retire\"\n        - \"Forecast to plan\"\n        - \"Hire to retire\"\n        - \"Inventory to deliver\"\n        - \"Order to cash\"\n        - \"Plan to produce\"\n        - \"Procure to pay\"\n        - \"Project to profit\"\n        - \"Prospect to quote\"\n        - \"Record to report\"\n        - \"Service to cash\"\n    validations:\n      required: true\n  - type: input\n    id: area-name\n    attributes:\n      label: \"Which business process area is this article related to?\" \n      description: \"Make sure to use the same name that is listed in the business process catalog.\" \n      value: \"Create and manage sales\"\n    validations:\n      required: true\n  - type: textarea\n    id: comments\n    attributes:\n      label: \"Enter any additional comments or information you want us to know.\"\n      description: \"Write the comments\"\n      value: \"Comments go here\"\n    validations:\n      required: false\n  - type: input\n    id: expected-date\n    attributes:\n      label: \"Specify the date you expect the article to be completed and ready for review.\" \n      description: \"Please include the month, date, and year in the format mm/dd/yyyy\"\n      value: \"01/24/2024\"\n    validations:\n      required: true\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n  - type: markdown\n    attributes:\n      value: \"## Thanks!\"\n  - type: markdown\n    attributes:\n      value: Thank you for contributing to the [business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/). \n   \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/new-business-process-draft.yml",
    "content": "name: New business process\ndescription: Use this issue type to indicate that you are beginning work on a new business process article. The business process should already exist in the business process catalog.\ntitle: \"[BUSINESS PROCESS]: \"\nlabels: [\"business process\", \"triage\"]\nassignees:\n  - rachel-profitt\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for contributing to the business process guidance! Registering your work here is the first step in contributing. Learn more [here](https://learn.microsoft.com/en-us/dynamics365/get-started/contribute#register-your-work). \n  - type: input\n    id: contact\n    attributes:\n      label: Contact details\n      description: How can we get in touch with you?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: dropdown\n    id: organization-type\n    attributes:\n      label: \"Organization type\"\n      description: Which type of organization do you work for?\n      options:\n        - \"Parnter / ISV / Independant consultant\"\n        - \"Customer organization\"\n        - \"Microsoft MVP\"\n        - \"Microsoft employee\"\n    validations:\n      required: true\n  - type: dropdown\n    id: endtoend\n    attributes:\n      label: \"End-to-end business process\"\n      description: Which end-to-end business process is the article related to?\n      options:\n        - \"Acquire to dispose\"\n        - \"Administer to operate\"\n        - \"Case to resolution\"\n        - \"Concept to market\"\n        - \"Design to retire\"\n        - \"Forecast to plan\"\n        - \"Hire to retire\"\n        - \"Inventory to deliver\"\n        - \"Order to cash\"\n        - \"Plan to produce\"\n        - \"Procure to pay\"\n        - \"Project to profit\"\n        - \"Prospect to quote\"\n        - \"Record to report\"\n        - \"Service to cash\"\n    validations:\n      required: true\n  - type: input\n    id: area-name\n    attributes:\n      label: \"Which business process area is this article related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create and manage sales\"\n    validations:\n      required: true\n  - type: input\n    id: process-name\n    attributes:\n      label: \"Enter the name of the business process you are starting work on\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create a sales order\"\n    validations:\n      required: true\n  - type: textarea\n    id: comments\n    attributes:\n      label: \"Enter any additional comments or information you want us to know.\"\n      description: \"Write the comments\"\n      value: \"Comments go here\"\n    validations:\n      required: false\n  - type: input\n    id: expected-date\n    attributes:\n      label: \"Specify the date you expect the article to be completed and ready for review.\" \n      description: \"Please include the month, date, and year in the format mm/dd/yyyy\"\n      value: \"01/24/2024\"\n    validations:\n      required: true\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n  - type: markdown\n    attributes:\n      value: \"## Thanks!\"\n  - type: markdown\n    attributes:\n      value: Thank you for contributing to the [business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/overview)! \n   \n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/new-pattern-draft.yml",
    "content": "name: New pattern or practice\ndescription: Use this issue type to register that you have started work on a new pattern or practice article for Dynamics 365 implementations.\ntitle: \"[PATTERN]: \"\nlabels: [\"pattern\", \"triage\"]\nassignees:\n  - rachel-profitt\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for contributing to the [Dynamics 365 business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/)! Registering your work here is the first step in contributing. Your new article should already be listed in the business process catalog. Find the catalog and templates at [https://github.com/microsoft/dynamics365patternspractices/templates/business-processes](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/business-processes). If you want to contribute with architectural patterns for Dynamics 365 implementations, use the templates at [https://github.com/microsoft/dynamics365patternspractices/templates/architecture](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/architecture).\n  - type: input\n    id: contact\n    attributes:\n      label: Contact details\n      description: How can we get in touch with you?\n      placeholder: myemail@example.com\n    validations:\n      required: true\n  - type: dropdown\n    id: organization-type\n    attributes:\n      label: \"Organization type\"\n      description: Which type of organization do you work for?\n      options:\n        - \"Microsoft MVP\"\n        - \"Partner / ISV / Independant consultant\"\n        - \"Customer organization\"\n        - \"Microsoft employee\"\n    validations:\n      required: true\n  - type: dropdown\n    id: endtoend\n    attributes:\n      label: \"End-to-end business process\"\n      description: Which end-to-end business process is the article related to?\n      options:\n        - \"Acquire to dispose\"\n        - \"Administer to operate\"\n        - \"Case to resolution\"\n        - \"Concept to market\"\n        - \"Design to retire\"\n        - \"Forecast to plan\"\n        - \"Hire to retire\"\n        - \"Inventory to deliver\"\n        - \"Order to cash\"\n        - \"Plan to produce\"\n        - \"Procure to pay\"\n        - \"Project to profit\"\n        - \"Prospect to quote\"\n        - \"Record to report\"\n        - \"Service to cash\"\n    validations:\n      required: true\n  - type: input\n    id: area-name\n    attributes:\n      label: \"Which business process area is this article related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create and manage sales\"\n    validations:\n      required: true\n  - type: input\n    id: process-name\n    attributes:\n      label: \"Which business process is this article related to?\" \n      description: \"Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create a sales order\"\n    validations:\n      required: true\n  - type: input\n    id: pattern-name\n    attributes:\n      label: \"Which pattern or practice is this request related to?\" \n      description: \"Enter the name of the pattern or practice you are starting work on. Make sure you use the same name that is listed in the business process catalog.\" \n      value: \"Create a sales order in the retail point of sale\"\n    validations:\n      required: true\n  - type: textarea\n    id: comments\n    attributes:\n      label: \"Enter any additional comments or information you want us to know.\"\n      description: \"Write your comments\"\n      value: \"Comments go here\"\n    validations:\n      required: false\n  - type: input\n    id: expected-date\n    attributes:\n      label: \"Specify the date you expect the article to be completed and ready for review.\" \n      description: \"Please include the month, date, and year in the format mm/dd/yyyy\"\n      value: \"01/24/2024\"\n    validations:\n      required: true\n  - type: checkboxes\n    id: terms\n    attributes:\n      label: Code of Conduct\n      description: By submitting this issue, you agree to follow the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information, see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/), or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n      options:\n        - label: I agree to follow this project's Code of Conduct\n          required: true\n  - type: markdown\n    attributes:\n      value: \"## Thanks!\"\n  - type: markdown\n    attributes:\n      value: Thank you for contributing to the [business process guidance](https://learn.microsoft.com/en-us/dynamics365/guidance/). \n   \n"
  },
  {
    "path": ".github/workflows/auto-assign.yml",
    "content": "name: Automatic Issue Assignment\non:\n  issues:\n    types: [opened]\njobs:\n  assign:\n    runs-on: ubuntu-latest\n    steps:\n    - name: Assign issue based on business process\n      uses: actions/github-script@v5\n      with:\n        script: |\n          const issueBody = context.payload.issue.body;\n          const processMap = {\n            //'Acquire to dispose': 'Harshad',\n            //'Administer to operate': 'Harsh',\n            //'Case to resolution': 'Vinoth',\n            //'Concept to market': 'Jinal',\n            //'Design to retire': 'Alejandra',\n            'Forecast to plan': 'riblack-microsoft',\n            //'Hire to retire': 'Priyanka',\n            //'Inventory to deliver': 'Nicole',\n            //'Order to cash': 'Nikhil',\n            //'Plan to produce': 'Phillip',\n            'Procure to pay': 'AdiVijayashankar',\n            //'Project to profit': 'Lalitha',\n            //'Prospect to quote': 'Kody',\n            'Record to report': 'kgiardini',\n            'Service to cash': 'Dean-Hardy'\n          };\n          const match = /End-to-End Business Process.*\\n.*\\[(.*)\\]/i.exec(issueBody);\n          const selectedProcess = match ? match[1].trim() : null;\n          const assignee = processMap[selectedProcess];\n          if (assignee) {\n            github.issues.addAssignees({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: context.payload.issue.number,\n              assignees: [assignee]\n            });\n            }\n\n"
  },
  {
    "path": ".gitignore",
    "content": "## Ignore Visual Studio temporary files, build results, and\n## files generated by popular Visual Studio add-ons.\n##\n## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore\n\n# User-specific files\n*.rsuser\n*.suo\n*.user\n*.userosscache\n*.sln.docstates\n\n# User-specific files (MonoDevelop/Xamarin Studio)\n*.userprefs\n\n# Mono auto generated files\nmono_crash.*\n\n# Build results\n[Dd]ebug/\n[Dd]ebugPublic/\n[Rr]elease/\n[Rr]eleases/\nx64/\nx86/\n[Aa][Rr][Mm]/\n[Aa][Rr][Mm]64/\nbld/\n[Bb]in/\n[Oo]bj/\n[Ll]og/\n[Ll]ogs/\n\n# Visual Studio 2015/2017 cache/options directory\n.vs/\n# Uncomment if you have tasks that create the project's static files in wwwroot\n#wwwroot/\n\n# Visual Studio 2017 auto generated files\nGenerated\\ Files/\n\n# MSTest test Results\n[Tt]est[Rr]esult*/\n[Bb]uild[Ll]og.*\n\n# NUnit\n*.VisualState.xml\nTestResult.xml\nnunit-*.xml\n\n# Build Results of an ATL Project\n[Dd]ebugPS/\n[Rr]eleasePS/\ndlldata.c\n\n# Benchmark Results\nBenchmarkDotNet.Artifacts/\n\n# .NET Core\nproject.lock.json\nproject.fragment.lock.json\nartifacts/\n\n# StyleCop\nStyleCopReport.xml\n\n# Files built by Visual Studio\n*_i.c\n*_p.c\n*_h.h\n*.ilk\n*.meta\n*.obj\n*.iobj\n*.pch\n*.pdb\n*.ipdb\n*.pgc\n*.pgd\n*.rsp\n*.sbr\n*.tlb\n*.tli\n*.tlh\n*.tmp\n*.tmp_proj\n*_wpftmp.csproj\n*.log\n*.vspscc\n*.vssscc\n.builds\n*.pidb\n*.svclog\n*.scc\n\n# Chutzpah Test files\n_Chutzpah*\n\n# Visual C++ cache files\nipch/\n*.aps\n*.ncb\n*.opendb\n*.opensdf\n*.sdf\n*.cachefile\n*.VC.db\n*.VC.VC.opendb\n\n# Visual Studio profiler\n*.psess\n*.vsp\n*.vspx\n*.sap\n\n# Visual Studio Trace Files\n*.e2e\n\n# TFS 2012 Local Workspace\n$tf/\n\n# Guidance Automation Toolkit\n*.gpState\n\n# ReSharper is a .NET coding add-in\n_ReSharper*/\n*.[Rr]e[Ss]harper\n*.DotSettings.user\n\n# TeamCity is a build add-in\n_TeamCity*\n\n# DotCover is a Code Coverage Tool\n*.dotCover\n\n# AxoCover is a Code Coverage Tool\n.axoCover/*\n!.axoCover/settings.json\n\n# Visual Studio code coverage results\n*.coverage\n*.coveragexml\n\n# NCrunch\n_NCrunch_*\n.*crunch*.local.xml\nnCrunchTemp_*\n\n# MightyMoose\n*.mm.*\nAutoTest.Net/\n\n# Web workbench (sass)\n.sass-cache/\n\n# Installshield output folder\n[Ee]xpress/\n\n# DocProject is a documentation generator add-in\nDocProject/buildhelp/\nDocProject/Help/*.HxT\nDocProject/Help/*.HxC\nDocProject/Help/*.hhc\nDocProject/Help/*.hhk\nDocProject/Help/*.hhp\nDocProject/Help/Html2\nDocProject/Help/html\n\n# Click-Once directory\npublish/\n\n# Publish Web Output\n*.[Pp]ublish.xml\n*.azurePubxml\n# Note: Comment the next line if you want to checkin your web deploy settings,\n# but database connection strings (with potential passwords) will be unencrypted\n*.pubxml\n*.publishproj\n\n# Microsoft Azure Web App publish settings. Comment the next line if you want to\n# checkin your Azure Web App publish settings, but sensitive information contained\n# in these scripts will be unencrypted\nPublishScripts/\n\n# NuGet Packages\n*.nupkg\n# NuGet Symbol Packages\n*.snupkg\n# The packages folder can be ignored because of Package Restore\n**/[Pp]ackages/*\n# except build/, which is used as an MSBuild target.\n!**/[Pp]ackages/build/\n# Uncomment if necessary however generally it will be regenerated when needed\n#!**/[Pp]ackages/repositories.config\n# NuGet v3's project.json files produces more ignorable files\n*.nuget.props\n*.nuget.targets\n\n# Microsoft Azure Build Output\ncsx/\n*.build.csdef\n\n# Microsoft Azure Emulator\necf/\nrcf/\n\n# Windows Store app package directories and files\nAppPackages/\nBundleArtifacts/\nPackage.StoreAssociation.xml\n_pkginfo.txt\n*.appx\n*.appxbundle\n*.appxupload\n\n# Visual Studio cache files\n# files ending in .cache can be ignored\n*.[Cc]ache\n# but keep track of directories ending in .cache\n!?*.[Cc]ache/\n\n# Others\nClientBin/\n~$*\n*~\n*.dbmdl\n*.dbproj.schemaview\n*.jfm\n*.pfx\n*.publishsettings\norleans.codegen.cs\n\n# Including strong name files can present a security risk\n# (https://github.com/github/gitignore/pull/2483#issue-259490424)\n#*.snk\n\n# Since there are multiple workflows, uncomment next line to ignore bower_components\n# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)\n#bower_components/\n\n# RIA/Silverlight projects\nGenerated_Code/\n\n# Backup & report files from converting an old project file\n# to a newer Visual Studio version. Backup files are not needed,\n# because we have git ;-)\n_UpgradeReport_Files/\nBackup*/\nUpgradeLog*.XML\nUpgradeLog*.htm\nServiceFabricBackup/\n*.rptproj.bak\n\n# SQL Server files\n*.mdf\n*.ldf\n*.ndf\n\n# Business Intelligence projects\n*.rdl.data\n*.bim.layout\n*.bim_*.settings\n*.rptproj.rsuser\n*- [Bb]ackup.rdl\n*- [Bb]ackup ([0-9]).rdl\n*- [Bb]ackup ([0-9][0-9]).rdl\n\n# Microsoft Fakes\nFakesAssemblies/\n\n# GhostDoc plugin setting file\n*.GhostDoc.xml\n\n# Node.js Tools for Visual Studio\n.ntvs_analysis.dat\nnode_modules/\n\n# Visual Studio 6 build log\n*.plg\n\n# Visual Studio 6 workspace options file\n*.opt\n\n# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)\n*.vbw\n\n# Visual Studio LightSwitch build output\n**/*.HTMLClient/GeneratedArtifacts\n**/*.DesktopClient/GeneratedArtifacts\n**/*.DesktopClient/ModelManifest.xml\n**/*.Server/GeneratedArtifacts\n**/*.Server/ModelManifest.xml\n_Pvt_Extensions\n\n# Paket dependency manager\n.paket/paket.exe\npaket-files/\n\n# FAKE - F# Make\n.fake/\n\n# CodeRush personal settings\n.cr/personal\n\n# Python Tools for Visual Studio (PTVS)\n__pycache__/\n*.pyc\n\n# Cake - Uncomment if you are using it\n# tools/**\n# !tools/packages.config\n\n# Tabs Studio\n*.tss\n\n# Telerik's JustMock configuration file\n*.jmconfig\n\n# BizTalk build output\n*.btp.cs\n*.btm.cs\n*.odx.cs\n*.xsd.cs\n\n# OpenCover UI analysis results\nOpenCover/\n\n# Azure Stream Analytics local run output\nASALocalRun/\n\n# MSBuild Binary and Structured Log\n*.binlog\n\n# NVidia Nsight GPU debugger configuration file\n*.nvuser\n\n# MFractors (Xamarin productivity tool) working folder\n.mfractor/\n\n# Local History for Visual Studio\n.localhistory/\n\n# BeatPulse healthcheck temp database\nhealthchecksdb\n\n# Backup folder for Package Reference Convert tool in Visual Studio 2017\nMigrationBackup/\n\n# Ionide (cross platform F# VS Code tools) working folder\n.ionide/\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "/templates/business-processes/ @rachel-profitt\n/templates/architecture/ @edupont04"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Microsoft Open Source Code of Conduct\n\nThis project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).\n\nResources:\n\n- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)\n- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)\n- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nThis project welcomes contributions and suggestions. Most contributions require you to agree to a\nContributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us\nthe rights to use your contribution. For details, visit [https://cla.opensource.microsoft.com](https://cla.opensource.microsoft.com).\n\nWhen you submit a pull request, a CLA bot will automatically determine whether you need to provide\na CLA and decorate the PR appropriately (such as a status check or comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.\n\n- If you want to contribute with business process content, use the templates at [https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/business-processes](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/business-processes)  \n\n  Submit your contribution at [https://aka.ms/D365SubmitPnP](https://aka.ms/D365SubmitPnP)\n\n- If you want to contribute with architectural guidance, use the templates at [https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/architecture](https://github.com/microsoft/dynamics365patternspractices/tree/main/templates/architecture)  \n\n  Submit your contribution as a pull request here in this repo. Learn more about getting started in the [Get started](#get-started) section  \n\n- If you want to contribute with purely conceptual content, use the templates at [https://github.com/MicrosoftDocs/dynamics365-docs-templates](https://github.com/MicrosoftDocs/dynamics365-docs-templates)  \n\nLearn more at [https://learn.microsoft.com/dynamics365/get-started/contribute](https://learn.microsoft.com/dynamics365/get-started/contribute).  \n\n## Get started\n\n1. Fork this repo\n\n   To submit guidance content for Dynamics 365, you cannot work directly in the repo, so the first thing you need to do is create a fork of the repo under your GitHub account. A fork basically is copy of this repo that lets you work freely on the content without affecting the original *dynamics365patternspractices* repo. For more information, see [Fork a Repo](https://help.github.com/articles/fork-a-repo/).\n\n2. Install GitHub Desktop (optional) and clone your forked repo.\n\n    GitHub Desktop makes is easy to work and collaborate with repos locally from your own desktop. For more information, see [GitHub Desktop](https://desktop.github.com/).  \n\n3. Choose the relevant template, and copy it to a relevant location on your device.  \n\n  Optionally, use Visual Studio Code and the Microsoft Learn Authoring Pack to author your Markdown files. If you want to submit Word documents with business process content, follow the guidance at [https://learn.microsoft.com/dynamics365/get-started/contribute](https://learn.microsoft.com/dynamics365/get-started/contribute). \n\n## Trademarks\n\nThis project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).\nUse of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.\nAny use of third-party trademarks or logos are subject to those third-party's policies.\n"
  },
  {
    "path": "LICENSE",
    "content": "Attribution 4.0 International\n\n=======================================================================\n\nCreative Commons Corporation (\"Creative Commons\") is not a law firm and\ndoes not provide legal services or legal advice. Distribution of\nCreative Commons public licenses does not create a lawyer-client or\nother relationship. Creative Commons makes its licenses and related\ninformation available on an \"as-is\" basis. Creative Commons gives no\nwarranties regarding its licenses, any material licensed under their\nterms and conditions, or any related information. Creative Commons\ndisclaims all liability for damages resulting from their use to the\nfullest extent possible.\n\nUsing Creative Commons Public Licenses\n\nCreative Commons public licenses provide a standard set of terms and\nconditions that creators and other rights holders may use to share\noriginal works of authorship and other material subject to copyright\nand certain other rights specified in the public license below. The\nfollowing considerations are for informational purposes only, are not\nexhaustive, and do not form part of our licenses.\n\n     Considerations for licensors: Our public licenses are\n     intended for use by those authorized to give the public\n     permission to use material in ways otherwise restricted by\n     copyright and certain other rights. Our licenses are\n     irrevocable. Licensors should read and understand the terms\n     and conditions of the license they choose before applying it.\n     Licensors should also secure all rights necessary before\n     applying our licenses so that the public can reuse the\n     material as expected. Licensors should clearly mark any\n     material not subject to the license. This includes other CC-\n     licensed material, or material used under an exception or\n     limitation to copyright. More considerations for licensors:\n\twiki.creativecommons.org/Considerations_for_licensors\n\n     Considerations for the public: By using one of our public\n     licenses, a licensor grants the public permission to use the\n     licensed material under specified terms and conditions. If\n     the licensor's permission is not necessary for any reason--for\n     example, because of any applicable exception or limitation to\n     copyright--then that use is not regulated by the license. Our\n     licenses grant only permissions under copyright and certain\n     other rights that a licensor has authority to grant. Use of\n     the licensed material may still be restricted for other\n     reasons, including because others have copyright or other\n     rights in the material. A licensor may make special requests,\n     such as asking that all changes be marked or described.\n     Although not required by our licenses, you are encouraged to\n     respect those requests where reasonable. More_considerations\n     for the public: \n\twiki.creativecommons.org/Considerations_for_licensees\n\n=======================================================================\n\nCreative Commons Attribution 4.0 International Public License\n\nBy exercising the Licensed Rights (defined below), You accept and agree\nto be bound by the terms and conditions of this Creative Commons\nAttribution 4.0 International Public License (\"Public License\"). To the\nextent this Public License may be interpreted as a contract, You are\ngranted the Licensed Rights in consideration of Your acceptance of\nthese terms and conditions, and the Licensor grants You such rights in\nconsideration of benefits the Licensor receives from making the\nLicensed Material available under these terms and conditions.\n\n\nSection 1 -- Definitions.\n\n  a. Adapted Material means material subject to Copyright and Similar\n     Rights that is derived from or based upon the Licensed Material\n     and in which the Licensed Material is translated, altered,\n     arranged, transformed, or otherwise modified in a manner requiring\n     permission under the Copyright and Similar Rights held by the\n     Licensor. For purposes of this Public License, where the Licensed\n     Material is a musical work, performance, or sound recording,\n     Adapted Material is always produced where the Licensed Material is\n     synched in timed relation with a moving image.\n\n  b. Adapter's License means the license You apply to Your Copyright\n     and Similar Rights in Your contributions to Adapted Material in\n     accordance with the terms and conditions of this Public License.\n\n  c. Copyright and Similar Rights means copyright and/or similar rights\n     closely related to copyright including, without limitation,\n     performance, broadcast, sound recording, and Sui Generis Database\n     Rights, without regard to how the rights are labeled or\n     categorized. For purposes of this Public License, the rights\n     specified in Section 2(b)(1)-(2) are not Copyright and Similar\n     Rights.\n\n  d. Effective Technological Measures means those measures that, in the\n     absence of proper authority, may not be circumvented under laws\n     fulfilling obligations under Article 11 of the WIPO Copyright\n     Treaty adopted on December 20, 1996, and/or similar international\n     agreements.\n\n  e. Exceptions and Limitations means fair use, fair dealing, and/or\n     any other exception or limitation to Copyright and Similar Rights\n     that applies to Your use of the Licensed Material.\n\n  f. Licensed Material means the artistic or literary work, database,\n     or other material to which the Licensor applied this Public\n     License.\n\n  g. Licensed Rights means the rights granted to You subject to the\n     terms and conditions of this Public License, which are limited to\n     all Copyright and Similar Rights that apply to Your use of the\n     Licensed Material and that the Licensor has authority to license.\n\n  h. Licensor means the individual(s) or entity(ies) granting rights\n     under this Public License.\n\n  i. Share means to provide material to the public by any means or\n     process that requires permission under the Licensed Rights, such\n     as reproduction, public display, public performance, distribution,\n     dissemination, communication, or importation, and to make material\n     available to the public including in ways that members of the\n     public may access the material from a place and at a time\n     individually chosen by them.\n\n  j. Sui Generis Database Rights means rights other than copyright\n     resulting from Directive 96/9/EC of the European Parliament and of\n     the Council of 11 March 1996 on the legal protection of databases,\n     as amended and/or succeeded, as well as other essentially\n     equivalent rights anywhere in the world.\n\n  k. You means the individual or entity exercising the Licensed Rights\n     under this Public License. Your has a corresponding meaning.\n\n\nSection 2 -- Scope.\n\n  a. License grant.\n\n       1. Subject to the terms and conditions of this Public License,\n          the Licensor hereby grants You a worldwide, royalty-free,\n          non-sublicensable, non-exclusive, irrevocable license to\n          exercise the Licensed Rights in the Licensed Material to:\n\n            a. reproduce and Share the Licensed Material, in whole or\n               in part; and\n\n            b. produce, reproduce, and Share Adapted Material.\n\n       2. Exceptions and Limitations. For the avoidance of doubt, where\n          Exceptions and Limitations apply to Your use, this Public\n          License does not apply, and You do not need to comply with\n          its terms and conditions.\n\n       3. Term. The term of this Public License is specified in Section\n          6(a).\n\n       4. Media and formats; technical modifications allowed. The\n          Licensor authorizes You to exercise the Licensed Rights in\n          all media and formats whether now known or hereafter created,\n          and to make technical modifications necessary to do so. The\n          Licensor waives and/or agrees not to assert any right or\n          authority to forbid You from making technical modifications\n          necessary to exercise the Licensed Rights, including\n          technical modifications necessary to circumvent Effective\n          Technological Measures. For purposes of this Public License,\n          simply making modifications authorized by this Section 2(a)\n          (4) never produces Adapted Material.\n\n       5. Downstream recipients.\n\n            a. Offer from the Licensor -- Licensed Material. Every\n               recipient of the Licensed Material automatically\n               receives an offer from the Licensor to exercise the\n               Licensed Rights under the terms and conditions of this\n               Public License.\n\n            b. No downstream restrictions. You may not offer or impose\n               any additional or different terms or conditions on, or\n               apply any Effective Technological Measures to, the\n               Licensed Material if doing so restricts exercise of the\n               Licensed Rights by any recipient of the Licensed\n               Material.\n\n       6. No endorsement. Nothing in this Public License constitutes or\n          may be construed as permission to assert or imply that You\n          are, or that Your use of the Licensed Material is, connected\n          with, or sponsored, endorsed, or granted official status by,\n          the Licensor or others designated to receive attribution as\n          provided in Section 3(a)(1)(A)(i).\n\n  b. Other rights.\n\n       1. Moral rights, such as the right of integrity, are not\n          licensed under this Public License, nor are publicity,\n          privacy, and/or other similar personality rights; however, to\n          the extent possible, the Licensor waives and/or agrees not to\n          assert any such rights held by the Licensor to the limited\n          extent necessary to allow You to exercise the Licensed\n          Rights, but not otherwise.\n\n       2. Patent and trademark rights are not licensed under this\n          Public License.\n\n       3. To the extent possible, the Licensor waives any right to\n          collect royalties from You for the exercise of the Licensed\n          Rights, whether directly or through a collecting society\n          under any voluntary or waivable statutory or compulsory\n          licensing scheme. In all other cases the Licensor expressly\n          reserves any right to collect such royalties.\n\n\nSection 3 -- License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the\nfollowing conditions.\n\n  a. Attribution.\n\n       1. If You Share the Licensed Material (including in modified\n          form), You must:\n\n            a. retain the following if it is supplied by the Licensor\n               with the Licensed Material:\n\n                 i. identification of the creator(s) of the Licensed\n                    Material and any others designated to receive\n                    attribution, in any reasonable manner requested by\n                    the Licensor (including by pseudonym if\n                    designated);\n\n                ii. a copyright notice;\n\n               iii. a notice that refers to this Public License;\n\n                iv. a notice that refers to the disclaimer of\n                    warranties;\n\n                 v. a URI or hyperlink to the Licensed Material to the\n                    extent reasonably practicable;\n\n            b. indicate if You modified the Licensed Material and\n               retain an indication of any previous modifications; and\n\n            c. indicate the Licensed Material is licensed under this\n               Public License, and include the text of, or the URI or\n               hyperlink to, this Public License.\n\n       2. You may satisfy the conditions in Section 3(a)(1) in any\n          reasonable manner based on the medium, means, and context in\n          which You Share the Licensed Material. For example, it may be\n          reasonable to satisfy the conditions by providing a URI or\n          hyperlink to a resource that includes the required\n          information.\n\n       3. If requested by the Licensor, You must remove any of the\n          information required by Section 3(a)(1)(A) to the extent\n          reasonably practicable.\n\n       4. If You Share Adapted Material You produce, the Adapter's\n          License You apply must not prevent recipients of the Adapted\n          Material from complying with this Public License.\n\n\nSection 4 -- Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that\napply to Your use of the Licensed Material:\n\n  a. for the avoidance of doubt, Section 2(a)(1) grants You the right\n     to extract, reuse, reproduce, and Share all or a substantial\n     portion of the contents of the database;\n\n  b. if You include all or a substantial portion of the database\n     contents in a database in which You have Sui Generis Database\n     Rights, then the database in which You have Sui Generis Database\n     Rights (but not its individual contents) is Adapted Material; and\n\n  c. You must comply with the conditions in Section 3(a) if You Share\n     all or a substantial portion of the contents of the database.\n\nFor the avoidance of doubt, this Section 4 supplements and does not\nreplace Your obligations under this Public License where the Licensed\nRights include other Copyright and Similar Rights.\n\n\nSection 5 -- Disclaimer of Warranties and Limitation of Liability.\n\n  a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE\n     EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS\n     AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF\n     ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,\n     IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,\n     WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR\n     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,\n     ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT\n     KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT\n     ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.\n\n  b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE\n     TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,\n     NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,\n     INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,\n     COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR\n     USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN\n     ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR\n     DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR\n     IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.\n\n  c. The disclaimer of warranties and limitation of liability provided\n     above shall be interpreted in a manner that, to the extent\n     possible, most closely approximates an absolute disclaimer and\n     waiver of all liability.\n\n\nSection 6 -- Term and Termination.\n\n  a. This Public License applies for the term of the Copyright and\n     Similar Rights licensed here. However, if You fail to comply with\n     this Public License, then Your rights under this Public License\n     terminate automatically.\n\n  b. Where Your right to use the Licensed Material has terminated under\n     Section 6(a), it reinstates:\n\n       1. automatically as of the date the violation is cured, provided\n          it is cured within 30 days of Your discovery of the\n          violation; or\n\n       2. upon express reinstatement by the Licensor.\n\n     For the avoidance of doubt, this Section 6(b) does not affect any\n     right the Licensor may have to seek remedies for Your violations\n     of this Public License.\n\n  c. For the avoidance of doubt, the Licensor may also offer the\n     Licensed Material under separate terms or conditions or stop\n     distributing the Licensed Material at any time; however, doing so\n     will not terminate this Public License.\n\n  d. Sections 1, 5, 6, 7, and 8 survive termination of this Public\n     License.\n\n\nSection 7 -- Other Terms and Conditions.\n\n  a. The Licensor shall not be bound by any additional or different\n     terms or conditions communicated by You unless expressly agreed.\n\n  b. Any arrangements, understandings, or agreements regarding the\n     Licensed Material not stated herein are separate from and\n     independent of the terms and conditions of this Public License.\n\n\nSection 8 -- Interpretation.\n\n  a. For the avoidance of doubt, this Public License does not, and\n     shall not be interpreted to, reduce, limit, restrict, or impose\n     conditions on any use of the Licensed Material that could lawfully\n     be made without permission under this Public License.\n\n  b. To the extent possible, if any provision of this Public License is\n     deemed unenforceable, it shall be automatically reformed to the\n     minimum extent necessary to make it enforceable. If the provision\n     cannot be reformed, it shall be severed from this Public License\n     without affecting the enforceability of the remaining terms and\n     conditions.\n\n  c. No term or condition of this Public License will be waived and no\n     failure to comply consented to unless expressly agreed to by the\n     Licensor.\n\n  d. Nothing in this Public License constitutes or may be interpreted\n     as a limitation upon, or waiver of, any privileges and immunities\n     that apply to the Licensor or You, including from the legal\n     processes of any jurisdiction or authority.\n\n\n=======================================================================\n\nCreative Commons is not a party to its public\nlicenses. Notwithstanding, Creative Commons may elect to apply one of\nits public licenses to material it publishes and in those instances\nwill be considered the “Licensor.” The text of the Creative Commons\npublic licenses is dedicated to the public domain under the CC0 Public\nDomain Dedication. Except for the limited purpose of indicating that\nmaterial is shared under a Creative Commons public license or as\notherwise permitted by the Creative Commons policies published at\ncreativecommons.org/policies, Creative Commons does not authorize the\nuse of the trademark \"Creative Commons\" or any other trademark or logo\nof Creative Commons without its prior written consent including,\nwithout limitation, in connection with any unauthorized modifications\nto any of its public licenses or any other arrangements,\nunderstandings, or agreements concerning use of licensed material. For\nthe avoidance of doubt, this paragraph does not form part of the\npublic licenses.\n\nCreative Commons may be contacted at creativecommons.org.\n"
  },
  {
    "path": "LICENSE-ASSETS",
    "content": "Creative Commons Attribution-ShareAlike 4.0 International Public License\n\nBy exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License (\"Public License\"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.\n\nSection 1 – Definitions.\n\na. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.\nb. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.\nc. BY-SA Compatible License means a license listed at creativecommons.org/compatiblelicenses, approved by Creative Commons as essentially the equivalent of this Public License.\nd. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.\ne. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.\nf. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.\ng. License Elements means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.\nh. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.\ni. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.\nj. Licensor means the individual(s) or entity(ies) granting rights under this Public License.\nk. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.\nl. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.\nm. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.\n\nSection 2 – Scope.\n\na. License grant.\n  1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:\n    A. reproduce and Share the Licensed Material, in whole or in part; and\n    B. produce, reproduce, and Share Adapted Material.\n  2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.\n  3. Term. The term of this Public License is specified in Section 6(a).\n  4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.\n  5. Downstream recipients.\n    A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.\n    B. Additional offer from the Licensor – Adapted Material. Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapter’s License You apply.\n    C. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.\n  6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).\n\nb. Other rights.\n\n  1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.\n  2. Patent and trademark rights are not licensed under this Public License.\n  3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.\n\nSection 3 – License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the following conditions.\n\na. Attribution.\n\n  1. If You Share the Licensed Material (including in modified form), You must:\n\n    A. retain the following if it is supplied by the Licensor with the Licensed Material:\n      i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);\n      ii. a copyright notice;\n      iii. a notice that refers to this Public License;\n      iv. a notice that refers to the disclaimer of warranties;\n      v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;\n    B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and\n    C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.\n  2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.\n  3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.\n\nb. ShareAlike.\nIn addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.\n\n  1. The Adapter’s License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.\n  2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.\n  3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.\n\nSection 4 – Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:\n\na. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;\nb. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and\nc. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.\nFor the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.\n\nSection 5 – Disclaimer of Warranties and Limitation of Liability.\n\na. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.\nb. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.\nc. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.\n\nSection 6 – Term and Termination.\n\na. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.\nb. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:\n\n   1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or\n   2. upon express reinstatement by the Licensor.\n For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.\nc. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.\nd. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.\n\nSection 7 – Other Terms and Conditions.\n\na. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.\nb. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.\n\nSection 8 – Interpretation.\n\na. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.\nb. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.\nc. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.\nd. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority."
  },
  {
    "path": "README.md",
    "content": "# Dynamics 365 Patterns and Practices\n\nWelcome to the repository for patterns, practices, business process guides, and other types of guidance content for Microsoft Dynamics 365! This repo provides a way for you to actively contribute to the Dynamics 365 guidance content, and we welcome your contributions. Register your plans for a contribution as an [Issue](https://github.com/microsoft/dynamics365patternspractices/issues/new/choose), and submit the contribution as a [pull request](https://github.com/microsoft/dynamics365patternspractices/pulls). Learn more at [Contribute to Microsoft's content for Dynamics 365](https://learn.microsoft.com/en-us/dynamics365/get-started/contribute#dynamics-365-guidance-content).  \n\nThe *main* branch is the default branch with approved templates and other artifacts.\n\nIf you have any questions, you can submit feedback as an Issue or a pull request.\n\n## Microsoft Open Source Code of Conduct\n\nThis project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).\nFor more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.\n\n## Trademarks\n\nThis project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft trademarks or logos is subject to and must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).\nUse of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.\nAny use of third-party trademarks or logos are subject to those third-party's policies.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security\n\nMicrosoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).\n\nIf you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below.\n\n## Reporting Security Issues\n\n**Please do not report security vulnerabilities through public GitHub issues.**\n\nInstead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report).\n\nIf you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com).  If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey).\n\nYou should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). \n\nPlease include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:\n\n* Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)\n* Full paths of source file(s) related to the manifestation of the issue\n* The location of the affected source code (tag/branch/commit or direct URL)\n* Any special configuration required to reproduce the issue\n* Step-by-step instructions to reproduce the issue\n* Proof-of-concept or exploit code (if possible)\n* Impact of the issue, including how an attacker might exploit the issue\n\nThis information will help us triage your report more quickly.\n\nIf you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs.\n\n## Preferred Languages\n\nWe prefer all communications to be in English.\n\n## Policy\n\nMicrosoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd).\n"
  },
  {
    "path": "SUPPORT.md",
    "content": "# Support\r\n\r\n## How to file issues and get help  \r\n\r\nThis project uses GitHub Issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue.\r\n\r\nFor help and questions about using this project, please reach out to us on Yammer, if you're external to Microsoft, or in Teams if you're internal to Microsoft.\r\n\r\n## Microsoft Support Policy  \r\n\r\nSupport for this project is limited to the resources listed above.\r\n"
  },
  {
    "path": "architectures/readme.md",
    "content": "# Download reference architectures\n\nThis folder contains downloadable reference architectures for solutions with Microsoft Dynamics 365 apps. Get an overview of the available architectures in the [Microsoft Dynamics 365 guidance hub](https://learn.microsoft.com/dynamics365/guidance/reference-architectures/).\n\n## Fetch a download\n\nTo download an architecture, choose the file in the explorer, and then choose the **Download raw file** icon.  \n\n## Contribute\n\nFetch the appropriate Markdown templates from the [guidance-templates](https://github.com/MicrosoftDocs/dynamics365-docs-templates/tree/main/guidance-templates) folder in the [dynamics365-docs-templates](https://github.com/MicrosoftDocs/dynamics365-docs-templates/) GitHub repo.  \n\nLearn more at [Contribute to Microsoft content for Dynamics 365](https://learn.microsoft.com/dynamics365/get-started/contribute#architectures).  \n"
  },
  {
    "path": "business-process-catalog/README.md",
    "content": "# Microsoft's business process catalog\nThe term *business process* covers a wide range of structured, often sequenced, activities or tasks to achieve a predetermined organizational goal. The term can also refer to the cumulative effects of all steps progressing toward a business goal. In our articles, we illustrate this sequence of steps in flowcharts. Dynamics 365 is a suite of applications that are designed to help organization meet the organizational goals aligned to a variety of business processes focused on specific industries. Learn more at [About business processes](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about).\n\n## Download the catalog\n\nDownload the latest version of the catalog from [https://aka.ms/BusinessProcessCatalog](https://aka.ms/BusinessProcessCatalog). We update the catalog at least four times each year. Learn more at [Introduction to the business process catalog](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about).\n\nLearn how to use the catalog in Azure DevOps at [Use the business process catalog as a template in Azure DevOps Services](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-import-catalog-devops).\n"
  },
  {
    "path": "graphics/README.md",
    "content": "---\ndate: 11/27/2025\n---\n# Dynamics 365 Patterns and Practices graphics\n\nThis folder contains source files for the diagrams in the [business process guide](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about) in the Microsoft Dynamics 365 guidance hub. Microsoft uploads new versions of the files with each update of the business process catalog. We always recommend that you download the latest version each time you start a new project. \n\nThe graphics are created in Visio based on the data in the associated Excel workbook.  Each end-to-end process includes one Visio file. The graphics inside each file are listed by [catalog ID](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about#catalog-ids). We recommend that you download the graphics to help accelerate the deployment of your fit-gap anaylsis process and to understand how the process works by default with Dynamics 365 applications out of the box. Keep in mind that not all variations of business processes may be documented in a given diagram. You can easily customize the graphics for your business requirements by modifying the arrows, adding steps, or removing steps as required. \n"
  },
  {
    "path": "sample-solutions/README.md",
    "content": "# Sample solutions for implementation projects with Dynamics 365 apps\n\nThis folder contains sample solutions that you can choose to use in an implementation project. More details coming soon about how to upload sample solutions.  \n\n## Currently in this folder\n\nThe folder currently contains the following sample solutions:\n\n|Name of subfolder  |Description  |\n|---------|---------|\n|**businessprocesscatalog-mavim**    | Contains files that you can import into Power Automate so that you can import the business process catalog in Mavim. Learn more at [Import the business process catalog in Mavim using a Power Automate flow](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-import-catalog-mavim).     |\n"
  },
  {
    "path": "submit-architecture/placeholder.md",
    "content": "---\ntitle: Placeholder only\ndescription: Don't use this file because it doesn't do anything.\nauthor: edupont04\nms.author: edupont\nms.topic: article\nms.reviewer: raprofit\nms.date: 06/16/2023\n---\n\n# Placeholder for contributions for Dynamics 365 guidance content\n\nI'm just a placeholder. You can submit a pull request with contributions to the Microsoft Dynamics 365 architecture and patterns content to this folder. Learn more at [Contribute to Microsoft's content for Dynamics 365](https://learn.microsoft.com/en-us/dynamics365/get-started/contribute#dynamics-365-guidance-content).  \n"
  },
  {
    "path": "submit-business-processes/placeholder.md",
    "content": "---\ntitle: Placeholder only\ndescription: Don't use this file because it doesn't do anything.\nauthor: edupont04\nms.author: edupont\nms.topic: article\nms.reviewer: raprofit\nms.date: 06/16/2023\n---\n\n# Placeholder for contributions for Dynamics 365 guidance content\n\nI'm just a placeholder. You can submit a pull request with contributions to the Microsoft Dynamics 365 business process content to this folder. Learn more at [Contribute to Microsoft's content for Dynamics 365](https://learn.microsoft.com/en-us/dynamics365/get-started/contribute#dynamics-365-guidance-content).  \n"
  },
  {
    "path": "templates/Azure-DevOps-templates/1_ADO_Creation_Script (Preview).py",
    "content": "import pandas as pd\r\nimport requests\r\nimport base64\r\nimport json\r\nimport time\r\nimport urllib.parse\r\nimport datetime\r\nimport os\r\nimport sys\r\n\r\n# === USER CONFIGURATION ===\r\n# Fill in these variables with your Azure DevOps details and file paths.\r\nADO_ORG_URL = \"https://dev.azure.com/<YOUR_ORGANIZATION>\"       # e.g. \"https://dev.azure.com/Contoso\"\r\nADO_PROJECT = \"<YOUR_PROJECT_NAME>\"                              # e.g. \"Business process catalog\"\r\nPROCESS_NAME = \"<YOUR_PROCESS_NAME>\"                             # e.g. \"Business process catalog\"\r\nPAT = \"<YOUR_PERSONAL_ACCESS_TOKEN>\"                             # Azure DevOps PAT with full access\r\nEXCEL_FILE = \"ADO template guideline (Preview).xlsx\"     # Path to the Excel template file\r\nLOG_FILE = \"1_ADO_Creation_Script_Log.txt\"\r\n\r\n# === AUTHENTICATION SETUP ===\r\n# Prepare HTTP headers for Azure DevOps REST API calls.\r\nauthorization = str.encode(':' + PAT)\r\nb64_auth = base64.b64encode(authorization).decode()\r\nheaders = {\r\n    \"Content-Type\": \"application/json\",\r\n    \"Authorization\": f\"Basic {b64_auth}\"\r\n}\r\n\r\n# Resolve relative path to the script directory and check existence\r\nbase_dir = os.path.dirname(os.path.abspath(__file__))\r\nexcel_path = EXCEL_FILE if os.path.isabs(EXCEL_FILE) else os.path.join(base_dir, EXCEL_FILE)\r\nif not os.path.exists(excel_path):\r\n    print(f\"Error: Excel file not found: {excel_path}\")\r\n    print(f\"Script directory: {base_dir}\")\r\n    print(\"Files in script directory:\")\r\n    for f in sorted(os.listdir(base_dir)):\r\n        print(\"  \", f)\r\n    sys.exit(1)\r\n\r\ndef log_api_call(url, payload, response):\r\n    \"\"\"\r\n    Logs details of an API call to the log file.\r\n    Args:\r\n        url (str): The API endpoint URL.\r\n        payload (dict): The request payload.\r\n        response (requests.Response): The HTTP response object.\r\n    \"\"\"\r\n    with open(LOG_FILE, \"a\", encoding=\"utf-8\") as f:\r\n        f.write(f\"\\n[{datetime.datetime.now()}]\\n\")\r\n        f.write(f\"URL: {url}\\n\")\r\n        f.write(f\"Payload: {json.dumps(payload, indent=2)}\\n\")\r\n        f.write(f\"Response [{response.status_code}]: {response.text}\\n\")\r\n        f.write(\"-\" * 60 + \"\\n\")\r\n\r\ndef log(msg):\r\n    \"\"\"\r\n    Writes a message to the log file.\r\n    Args:\r\n        msg (str): The message to log.\r\n    \"\"\"\r\n    with open(LOG_FILE, \"a\", encoding=\"utf-8\") as f:\r\n        f.write(msg + \"\\n\")\r\n\r\ndef url_encode(name):\r\n    \"\"\"\r\n    URL-encodes a string for safe use in API endpoints.\r\n    Args:\r\n        name (str): The string to encode.\r\n    Returns:\r\n        str: The URL-encoded string.\r\n    \"\"\"\r\n    return urllib.parse.quote(name)\r\n\r\ndef get_agile_process_id():\r\n    \"\"\"\r\n    Retrieves the process type ID for the built-in Agile process.\r\n    Returns:\r\n        str: The Agile process type ID.\r\n    Raises:\r\n        Exception: If Agile process is not found.\r\n    \"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes?api-version=7.1-preview.2\"\r\n    resp = requests.get(url, headers=headers)\r\n    resp.raise_for_status()\r\n    for proc in resp.json().get(\"value\", []):\r\n        if proc[\"name\"].lower() == \"agile\":\r\n            return proc[\"typeId\"]\r\n    raise Exception(\"Agile process not found.\")\r\n\r\ndef get_process_id_by_name(process_name):\r\n    \"\"\"\r\n    Gets the process type ID for a given process name.\r\n    Args:\r\n        process_name (str): The name of the process.\r\n    Returns:\r\n        str or None: The process type ID, or None if not found.\r\n    \"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes?api-version=7.1-preview.2\"\r\n    resp = requests.get(url, headers=headers)\r\n    resp.raise_for_status()\r\n    for proc in resp.json().get(\"value\", []):\r\n        if proc[\"name\"].strip().lower() == process_name.strip().lower():\r\n            return proc[\"typeId\"]\r\n    return None\r\n\r\ndef create_process(process_name):\r\n    \"\"\"\r\n    Creates a new custom process based on Agile if it does not exist.\r\n    Args:\r\n        process_name (str): The name of the process to create.\r\n    Returns:\r\n        str: The process type ID.\r\n    \"\"\"\r\n    agile_id = get_agile_process_id()\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes?api-version=7.1-preview.2\"\r\n    payload = {\r\n        \"name\": process_name,\r\n        \"description\": f\"Custom process based on Agile: {process_name}\",\r\n        \"parentProcessTypeId\": agile_id\r\n    }\r\n    resp = requests.post(url, headers=headers, json=payload)\r\n    log_api_call(url, payload, resp)\r\n    if resp.status_code in [200, 201]:\r\n        print(f\"Created process: {process_name}\")\r\n        return resp.json().get(\"typeId\")\r\n    elif resp.status_code == 409:\r\n        print(f\"Process already exists: {process_name}\")\r\n        return get_process_id_by_name(process_name)\r\n    else:\r\n        print(f\"Failed to create process: {resp.status_code} - {resp.text}\")\r\n        raise Exception(\"Process creation failed.\")\r\n\r\ndef get_project_id_by_name(project_name):\r\n    \"\"\"\r\n    Gets the project ID for a given project name.\r\n    Args:\r\n        project_name (str): The name of the project.\r\n    Returns:\r\n        str or None: The project ID, or None if not found.\r\n    \"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/projects?api-version=7.1-preview.4\"\r\n    resp = requests.get(url, headers=headers)\r\n    resp.raise_for_status()\r\n    for proj in resp.json().get(\"value\", []):\r\n        if proj[\"name\"].strip().lower() == project_name.strip().lower():\r\n            return proj[\"id\"]\r\n    return None\r\n\r\ndef create_project(project_name, process_id):\r\n    \"\"\"\r\n    Creates a new Azure DevOps project using the specified process.\r\n    Args:\r\n        project_name (str): The name of the project.\r\n        process_id (str): The process type ID to use.\r\n    Returns:\r\n        str or None: The project ID, or None if creation is asynchronous.\r\n    \"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/projects?api-version=7.1-preview.4\"\r\n    payload = {\r\n        \"name\": project_name,\r\n        \"description\": f\"Project for process {process_id}\",\r\n        \"capabilities\": {\r\n            \"versioncontrol\": {\"sourceControlType\": \"Git\"},\r\n            \"processTemplate\": {\"templateTypeId\": process_id}\r\n        }\r\n    }\r\n    resp = requests.post(url, headers=headers, json=payload)\r\n    log_api_call(url, payload, resp)\r\n    if resp.status_code in [202]:  # Project creation is async\r\n        print(f\"Project creation started: {project_name}\")\r\n        return None\r\n    elif resp.status_code == 409:\r\n        print(f\"Project already exists: {project_name}\")\r\n        return get_project_id_by_name(project_name)\r\n    else:\r\n        print(f\"Failed to create project: {resp.status_code} - {resp.text}\")\r\n        raise Exception(\"Project creation failed.\")\r\n\r\ndef build_reference_name(wit_name):\r\n    \"\"\"\r\n    Builds a reference name for a work item type by removing spaces and special characters.\r\n    Args:\r\n        wit_name (str): The work item type name.\r\n    Returns:\r\n        str: The reference name.\r\n    \"\"\"\r\n    safe_process = PROCESS_NAME.replace(\" \", \"\")  # Remove spaces from process name\r\n    safe_name = wit_name.replace(\" \", \"\").replace(\"-\", \"\").replace(\"_\", \"\")\r\n    return f\"{safe_process}.{safe_name}\"\r\n\r\ndef safe_json_value(val, default=\"\"):\r\n    \"\"\"\r\n    Safely converts a value to string, handling NaN and None.\r\n    Args:\r\n        val: The value to convert.\r\n        default: The default value if val is NaN or None.\r\n    Returns:\r\n        str: The safe string value.\r\n    \"\"\"\r\n    if pd.isna(val) or val is None:\r\n        return default\r\n    if isinstance(val, float) and (val != val):\r\n        return default\r\n    return str(val)\r\n# Clear the log file\r\nif os.path.exists(LOG_FILE):\r\n    os.remove(LOG_FILE)\r\n\r\n# === PROCESS AND PROJECT CREATION ===\r\n# Ensure the process and project exist before proceeding.\r\nprocess_id = get_process_id_by_name(PROCESS_NAME)\r\nif not process_id:\r\n    process_id = create_process(PROCESS_NAME)\r\n\r\nproject_id = get_project_id_by_name(ADO_PROJECT)\r\nif not project_id:\r\n    create_project(ADO_PROJECT, process_id)\r\n    print(\"Waiting 30 seconds for project creation to complete...\")\r\n    time.sleep(30)\r\nelse:\r\n    print(f\"Project already exists: {ADO_PROJECT}\")\r\n\r\nADO_PROCESS_ID = process_id\r\n\r\n# === WORK ITEM TYPES CREATION/UPDATE ===\r\nprint(\"Starting work item type creation...\")\r\ndf = pd.read_excel(excel_path, sheet_name=\"Work item types\")\r\ndf.columns = df.columns.str.strip()\r\ndf = df.drop_duplicates(subset=[\"Work item type\"])\r\nfor col, default in [(\"Description\", \"\"), (\"Color\", \"0078D4\"), (\"Icon\", \"icon_gear\")]:\r\n    if col in df.columns:\r\n        df[col] = df[col].fillna(default)\r\n\r\n# Fetch all existing work item types in one call to avoid per-item 500 errors\r\nwit_list_url = f\"{ADO_ORG_URL}/_apis/work/processes/{ADO_PROCESS_ID}/workitemtypes?api-version=7.1-preview.2\"\r\nwit_list_response = requests.get(wit_list_url, headers=headers)\r\nlog_api_call(wit_list_url, {}, wit_list_response)\r\nexisting_wits = {}\r\nif wit_list_response.status_code == 200:\r\n    for wit in wit_list_response.json().get(\"value\", []):\r\n        existing_wits[wit[\"referenceName\"]] = wit\r\n        # Also index by the short name (last segment) for matching spreadsheet ref names\r\n        short_name = wit[\"referenceName\"].rsplit(\".\", 1)[-1] if \".\" in wit[\"referenceName\"] else wit[\"referenceName\"]\r\n        existing_wits[short_name] = wit\r\n\r\nfor idx, row in df.iterrows():\r\n    wit_name = row[\"Work item type\"]\r\n    description = row[\"Help text\"] if \"Help text\" in row and pd.notna(row[\"Help text\"]) else \"\"\r\n    inherit_from = row.get(\"Inherit from\", None)\r\n    color = row[\"Color\"]\r\n    icon = row[\"Icon\"] if pd.notna(row[\"Icon\"]) and str(row[\"Icon\"]).strip() != \"\" else \"icon_test_case\"\r\n    custom_flag = str(row.get(\"Custom work item type\", \"\")).strip().lower()\r\n    ref_name = row.get(\"Reference name\", build_reference_name(wit_name)).strip()\r\n\r\n    # Check if work item type already exists using the pre-fetched list\r\n    exists = ref_name in existing_wits\r\n\r\n    # Skip standard work item types (no action needed)\r\n    if custom_flag == \"no\":\r\n        print(f\"Skipped: {wit_name} (Standard work item, no changes)\")\r\n        continue\r\n\r\n    # Disable work item types marked as disabled\r\n    if custom_flag == \"disabled\":\r\n        if exists:\r\n            full_ref = existing_wits[ref_name][\"referenceName\"]\r\n            already_disabled = existing_wits[ref_name].get(\"isDisabled\", False)\r\n            if already_disabled:\r\n                print(f\"Already disabled: {wit_name}\")\r\n            else:\r\n                disable_url = f\"{ADO_ORG_URL}/_apis/work/processes/{ADO_PROCESS_ID}/workitemtypes/{full_ref}?api-version=7.1-preview.2\"\r\n                disable_payload = {\"isDisabled\": True}\r\n                response = requests.patch(disable_url, json=disable_payload, headers=headers)\r\n                log_api_call(disable_url, disable_payload, response)\r\n                if response.status_code in [200, 204]:\r\n                    print(f\"Disabled: {wit_name}\")\r\n                else:\r\n                    print(f\"ERROR disabling {wit_name}: {response.status_code} - {response.text}\")\r\n        else:\r\n            print(f\"Skipped: {wit_name} (marked disabled but not found in process)\")\r\n        continue\r\n\r\n    # Create or update custom work item type\r\n    if custom_flag == \"yes\":\r\n        payload = {\r\n            \"name\": wit_name,\r\n            \"description\": description,\r\n            \"referenceName\": ref_name,\r\n            \"color\": color,\r\n            \"icon\": icon\r\n        }\r\n        if pd.notna(inherit_from) and str(inherit_from).strip() != \"\":\r\n            payload[\"inherits\"] = inherit_from\r\n\r\n        if exists:\r\n            # Use the full reference name from ADO for the update URL\r\n            full_ref = existing_wits[ref_name][\"referenceName\"]\r\n            update_url = f\"{ADO_ORG_URL}/_apis/work/processes/{ADO_PROCESS_ID}/workitemtypes/{full_ref}?api-version=7.1-preview.2\"\r\n            response = requests.patch(update_url, json=payload, headers=headers)\r\n            log_api_call(update_url, payload, response)\r\n            print(f\"Updated: {wit_name}\")\r\n        else:\r\n            create_url = f\"{ADO_ORG_URL}/_apis/work/processes/{ADO_PROCESS_ID}/workitemtypes?api-version=7.1-preview.2\"\r\n            response = requests.post(create_url, json=payload, headers=headers)\r\n            log_api_call(create_url, payload, response)\r\n            print(f\"Created: {wit_name}\")\r\n\r\nprint(\"Work item type creation complete.\")\r\n\r\n# === FIELD AND PICKLIST CREATION/UPDATE ===\r\nprint(\"Starting Azure DevOps field creation script...\")\r\nprint(f\"Reading spreadsheet: {EXCEL_FILE}\")\r\n\r\n# Load fields from Excel\r\ndf_fields = pd.read_excel(excel_path, sheet_name=\"Fields\")\r\ndf_fields.columns = df_fields.columns.str.strip()\r\ndf_fields = df_fields.drop_duplicates(subset=[\"Reference name\"])\r\ndf_fields = df_fields.fillna(\"\")\r\n\r\nprint(f\"Loaded {len(df_fields)} fields from spreadsheet.\")\r\n\r\n# Load picklists from Excel\r\npicklist_df = pd.read_excel(excel_path, sheet_name=\"Picklists\")\r\npicklist_df.columns = picklist_df.columns.str.strip()\r\npicklist_dict = {}\r\nfor col in picklist_df.columns:\r\n    values = [safe_json_value(v) for v in picklist_df[col].dropna().tolist()]\r\n    values = [v for v in values if v != \"\"]\r\n    if values:\r\n        picklist_dict[col] = values\r\n\r\nprint(f\"Loaded {len(picklist_dict)} picklists from Picklists tab.\")\r\n\r\n# Get existing organization fields\r\nfields_url = f\"{ADO_ORG_URL}/_apis/wit/fields?api-version=7.1-preview.2\"\r\nfields_response = requests.get(fields_url, headers=headers)\r\nlog_api_call(fields_url, {}, fields_response)\r\nif fields_response.status_code == 200:\r\n    try:\r\n        fields_json = fields_response.json()\r\n        existing_fields = {field[\"referenceName\"]: field for field in fields_json.get(\"value\", [])}\r\n        existing_fields_by_name = {field[\"name\"]: field for field in fields_json.get(\"value\", [])}\r\n    except Exception as e:\r\n        log(f\"Error decoding fields JSON: {e}\")\r\n        existing_fields = {}\r\n        existing_fields_by_name = {}\r\nelse:\r\n    log(f\"Fields endpoint returned status {fields_response.status_code}: {fields_response.text}\")\r\n    existing_fields = {}\r\n    existing_fields_by_name = {}\r\n\r\n# Get existing picklists\r\nlists_url = f\"{ADO_ORG_URL}/_apis/work/processes/lists?api-version=7.1\"\r\npicklist_ids = {}\r\n\r\nexisting_lists_resp = requests.get(lists_url, headers=headers)\r\nlog_api_call(lists_url, {}, existing_lists_resp)\r\nexisting_lists = {}\r\nif existing_lists_resp.status_code == 200:\r\n    try:\r\n        lists_json = existing_lists_resp.json()\r\n        for lst in lists_json.get(\"value\", []):\r\n            existing_lists[lst[\"name\"]] = lst\r\n    except Exception as e:\r\n        log(f\"Error decoding lists JSON: {e}\")\r\n\r\n# Create or update picklists\r\nfor label, values in picklist_dict.items():\r\n    payload = {\r\n        \"name\": label,\r\n        \"type\": \"String\",\r\n        \"items\": values\r\n    }\r\n    if label in existing_lists:\r\n        picklist_id = existing_lists[label][\"id\"]\r\n        update_url = f\"{ADO_ORG_URL}/_apis/work/processes/lists/{picklist_id}?api-version=7.1\"\r\n        print(f\"Updating picklist: {label} with values: {values}\")\r\n        log(f\"Updating picklist: {label} with values: {values}\")\r\n        response = requests.put(update_url, json=payload, headers=headers)\r\n        log_api_call(update_url, payload, response)\r\n        if response.status_code in [200, 201]:\r\n            picklist_ids[label] = picklist_id\r\n            log(f\"  Picklist updated with id: {picklist_id}\")\r\n        else:\r\n            print(f\"  Failed to update picklist: {label}\")\r\n            log(f\"  Failed to update picklist: {label}\")\r\n            log(f\"  Response: {response.text}\")\r\n    else:\r\n        print(f\"Creating picklist: {label} with values: {values}\")\r\n        log(f\"Creating picklist: {label} with values: {values}\")\r\n        response = requests.post(lists_url, json=payload, headers=headers)\r\n        log_api_call(lists_url, payload, response)\r\n        if response.status_code in [200, 201]:\r\n            picklist_id = response.json().get(\"id\")\r\n            picklist_ids[label] = picklist_id\r\n            log(f\"  Picklist created with id: {picklist_id}\")\r\n        else:\r\n            print(f\"  Failed to create picklist: {label}\")\r\n            log(f\"  Failed to create picklist: {label}\")\r\n            log(f\"  Response: {response.text}\")\r\n\r\n# Create or update fields\r\nfields_url_create = f\"{ADO_ORG_URL}/_apis/wit/fields?api-version=7.1\"\r\nfor idx, row in df_fields.iterrows():\r\n    field_name = safe_json_value(row.get(\"Field name\"))       # Unique name in ADO (suffixed with MS BPC)\r\n    reference_name = safe_json_value(row.get(\"Reference name\")) # Unique reference name\r\n    field_label = safe_json_value(row.get(\"Label\"))            # User-friendly display label\r\n    field_type = safe_json_value(row.get(\"Field type\"))\r\n    custom_flag = str(safe_json_value(row.get(\"Custom field\"))).strip().lower()\r\n    description = safe_json_value(row.get(\"Description\"))\r\n\r\n    # Skip standard (OOB) fields\r\n    if custom_flag == \"no\":\r\n        print(f\"Processing field: {field_label} (name: {field_name}, ref: {reference_name}) type: {field_type} Standard OOB field-skipping\")\r\n        log(f\"Processing field: {field_label} (name: {field_name}, ref: {reference_name}) type: {field_type} Standard OOB field-skipping\")\r\n        continue\r\n\r\n    # Handle picklist fields — match picklist by Label (matches Picklists tab column headers)\r\n    if field_type in [\"PicklistString\", \"PicklistInteger\"]:\r\n        picklist_id = picklist_ids.get(field_label)\r\n        if not picklist_id:\r\n            print(f\"  No picklist id found for label '{field_label}', skipping field creation.\")\r\n            log(f\"  No picklist id found for label '{field_label}', skipping field creation.\")\r\n            continue\r\n        field_payload = {\r\n            \"name\": field_name,\r\n            \"referenceName\": reference_name,\r\n            \"type\": \"String\",\r\n            \"isPicklist\": True,\r\n            \"picklistId\": picklist_id,\r\n            \"description\": description\r\n        }\r\n    else:\r\n        field_payload = {\r\n            \"name\": field_name,\r\n            \"type\": field_type,\r\n            \"referenceName\": reference_name,\r\n            \"description\": description\r\n        }\r\n\r\n    # Check for existing fields by both reference name and name\r\n    ref_match = existing_fields.get(reference_name)\r\n    name_match = existing_fields_by_name.get(field_name)\r\n    ref_exists = ref_match is not None\r\n    name_exists = name_match is not None\r\n\r\n    if ref_exists and name_exists:\r\n        # Both match — verify they point to the same field, then update\r\n        if ref_match[\"referenceName\"] == name_match[\"referenceName\"]:\r\n            print(f\"  Field '{reference_name}' (name: {field_name}) already exists. Updating...\")\r\n            log(f\"Field '{reference_name}' (name: {field_name}) already exists. Updating...\")\r\n            log(\"Payload: \" + json.dumps(field_payload, indent=2, allow_nan=False))\r\n            update_field_url = f\"{ADO_ORG_URL}/_apis/wit/fields/{url_encode(reference_name)}?api-version=7.1\"\r\n            response = requests.patch(update_field_url, json=field_payload, headers=headers)\r\n            log_api_call(update_field_url, field_payload, response)\r\n            print(f\"  Field update response: {response}\")\r\n        else:\r\n            # Reference name and name each exist but belong to different fields — conflict\r\n            error_msg = (f\"ERROR: Conflict for field '{field_label}' — reference name '{reference_name}' matches \"\r\n                         f\"existing field '{ref_match['name']}', but name '{field_name}' matches a different \"\r\n                         f\"existing field with ref '{name_match['referenceName']}'. Please review and correct.\")\r\n            print(f\"  {error_msg}\")\r\n            log(error_msg)\r\n    elif ref_exists and not name_exists:\r\n        # Only reference name matches — partial conflict\r\n        error_msg = (f\"ERROR: Partial match for field '{field_label}' — reference name '{reference_name}' already \"\r\n                     f\"exists with name '{ref_match['name']}', but the expected name '{field_name}' was not found. \"\r\n                     f\"Please review and correct the spreadsheet.\")\r\n        print(f\"  {error_msg}\")\r\n        log(error_msg)\r\n    elif not ref_exists and name_exists:\r\n        # Only name matches — partial conflict\r\n        error_msg = (f\"ERROR: Partial match for field '{field_label}' — name '{field_name}' already exists with \"\r\n                     f\"reference name '{name_match['referenceName']}', but the expected reference name \"\r\n                     f\"'{reference_name}' was not found. Please review and correct the spreadsheet.\")\r\n        print(f\"  {error_msg}\")\r\n        log(error_msg)\r\n    else:\r\n        # Neither exists — create new field\r\n        print(f\"Creating field: {field_label} (name: {field_name}, ref: {reference_name})\")\r\n        log(f\"Creating field: {field_label} (name: {field_name}, ref: {reference_name})\")\r\n        log(\"Payload: \" + json.dumps(field_payload, indent=2, allow_nan=False))\r\n        response = requests.post(fields_url_create, json=field_payload, headers=headers)\r\n        log_api_call(fields_url_create, field_payload, response)\r\n        print(f\"  Field creation response: {response}\")\r\n\r\nprint(\"Script finished. See log file for details:\")\r\nprint(f\"  {LOG_FILE}\")\r\nlog(\"Done!\")"
  },
  {
    "path": "templates/Azure-DevOps-templates/2_ADO_Page_Layout_Script_Threaded (Preview).py",
    "content": "import requests\r\nimport pandas as pd\r\nimport json\r\nfrom typing import Optional\r\nimport os\r\nimport urllib.parse\r\nimport sys\r\nimport base64\r\nimport time\r\nimport threading\r\nfrom concurrent.futures import ThreadPoolExecutor, as_completed\r\n\r\n# === CONFIGURATION ===\r\nADO_ORG_URL = \"https://dev.azure.com/<YOUR_ORGANIZATION>\"       # e.g. \"https://dev.azure.com/Contoso\"\r\nADO_PROJECT = \"<YOUR_PROJECT_NAME>\"                              # e.g. \"Business process catalog\"\r\nPROCESS_NAME = \"<YOUR_PROCESS_NAME>\"                             # e.g. \"Business process catalog\"\r\nPAT = \"<YOUR_PERSONAL_ACCESS_TOKEN>\"                             # Azure DevOps PAT with full access\r\nEXCEL_FILE = \"ADO template guideline (Preview).xlsx\"     # Path to the Excel template file\r\nLOG_FILE = \"2_ADO_Page_Layout_Script_Threaded_Log.txt\"\r\nSYSTEM_WORK_ITEM_TYPE = \"Microsoft.VSTS.WorkItemTypes\"\r\n\r\n# === THREADING CONFIGURATION ===\r\n# Maximum number of parallel threads for processing work item types\r\n# Recommended: 5-10 to balance speed vs Azure DevOps API rate limits\r\nMAX_WORKERS = 8\r\n\r\n# === AUTHENTICATION SETUP ===\r\nauthorization = str.encode(':' + PAT)\r\nb64_auth = base64.b64encode(authorization).decode()\r\nheaders = {\r\n    \"Content-Type\": \"application/json\",\r\n    \"Authorization\": f\"Basic {b64_auth}\"\r\n}\r\n\r\n# === THREAD-SAFE LOGGING ===\r\nlog_lock = threading.Lock()\r\n\r\ndef log(msg: str):\r\n    \"\"\"Thread-safe logging to file and console.\"\"\"\r\n    with log_lock:\r\n        with open(LOG_FILE, \"a\", encoding=\"utf-8\") as f:\r\n            f.write(msg + \"\\n\")\r\n        print(msg)\r\n\r\n# === THREAD-SAFE CACHES ===\r\nlayout_cache_lock = threading.Lock()\r\nlayout_cache: dict[str, Optional[dict]] = {}\r\nlocked_layout_wits: set[str] = set()\r\n\r\n# === Retry logic for requests ===\r\ndef make_request_with_retry(method, url, max_retries=3, retry_delay=2, **kwargs):\r\n    \"\"\"\r\n    Makes an HTTP request with retry logic for transient errors.\r\n    \r\n    Args:\r\n        method: HTTP method ('GET', 'POST', 'PATCH', 'PUT', 'DELETE')\r\n        url: Request URL\r\n        max_retries: Maximum retry attempts\r\n        retry_delay: Initial delay in seconds (exponential backoff)\r\n        **kwargs: Additional arguments to pass to requests (headers, json, etc.)\r\n    \r\n    Returns:\r\n        Response object\r\n    \"\"\"\r\n    resp = None\r\n    for attempt in range(max_retries):\r\n        try:\r\n            if method.upper() == 'GET':\r\n                resp = requests.get(url, **kwargs)\r\n            elif method.upper() == 'POST':\r\n                resp = requests.post(url, **kwargs)\r\n            elif method.upper() == 'PATCH':\r\n                resp = requests.patch(url, **kwargs)\r\n            elif method.upper() == 'PUT':\r\n                resp = requests.put(url, **kwargs)\r\n            elif method.upper() == 'DELETE':\r\n                resp = requests.delete(url, **kwargs)\r\n            else:\r\n                raise ValueError(f\"Unsupported HTTP method: {method}\")\r\n            \r\n            # Handle rate limiting and service unavailability\r\n            if resp.status_code in (429, 503, 504):\r\n                if attempt < max_retries - 1:\r\n                    wait_time = retry_delay * (2 ** attempt)\r\n                    log(f\" Service unavailable (status {resp.status_code}). Retrying in {wait_time} seconds... (Attempt {attempt + 1}/{max_retries})\")\r\n                    time.sleep(wait_time)\r\n                    continue\r\n            \r\n            return resp\r\n            \r\n        except requests.exceptions.RequestException as e:\r\n            if attempt < max_retries - 1:\r\n                wait_time = retry_delay * (2 ** attempt)\r\n                log(f\" Request error: {e}. Retrying in {wait_time} seconds... (Attempt {attempt + 1}/{max_retries})\")\r\n                time.sleep(wait_time)\r\n            else:\r\n                raise\r\n    \r\n    return resp\r\n\r\n# === Process ID Lookup ===\r\ndef get_process_id_by_name(process_name):\r\n    \"\"\"\r\n    Gets the process type ID for a given process name.\r\n    Args:\r\n        process_name (str): The name of the process.\r\n    Returns:\r\n        str: The process type ID.\r\n    Raises:\r\n        Exception: If process is not found.\r\n    \"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes?api-version=7.1-preview.2\"\r\n    resp = requests.get(url, headers=headers)\r\n    resp.raise_for_status()\r\n    for proc in resp.json().get(\"value\", []):\r\n        if proc[\"name\"].strip().lower() == process_name.strip().lower():\r\n            log(f\"Found process '{process_name}' with ID: {proc['typeId']}\")\r\n            return proc[\"typeId\"]\r\n    raise Exception(f\"Process '{process_name}' not found. Please create it first using ADO_Creation_Script.py\")\r\n\r\ndef build_reference_name(wit_name):\r\n    \"\"\"\r\n    Builds a reference name for a work item type by removing spaces and special characters.\r\n    Args:\r\n        wit_name (str): The work item type name.\r\n    Returns:\r\n        str: The reference name.\r\n    \"\"\"\r\n    safe_process = PROCESS_NAME.replace(\" \", \"\")  # Remove spaces from process name\r\n    safe_name = wit_name.replace(\" \", \"\").replace(\"-\", \"\").replace(\"_\", \"\")\r\n    return f\"{safe_process}.{safe_name}\"\r\n\r\ndef is_system_work_item_type(wit_ref_name: str) -> bool:\r\n    \"\"\"\r\n    Determines if a work item type is an OOTB system type (locked layout).\r\n    Args:\r\n        wit_ref_name (str): The reference name of the work item type.\r\n    Returns:\r\n        bool: True if it's a system work item type, False otherwise.\r\n    \"\"\"\r\n    return wit_ref_name.startswith(\"Microsoft.VSTS.WorkItemTypes.\")\r\n\r\n# === Utility helpers ===\r\ndef safe_json_value(val, default=\"\"):\r\n    if pd.isna(val) or val is None:\r\n        return default\r\n    if isinstance(val, float) and (val != val):  # NaN\r\n        return default\r\n    return str(val)\r\n\r\ndef parse_required(val):\r\n    v = safe_json_value(val).strip().lower()\r\n    if v == \"yes\":\r\n        return True\r\n    if v == \"conditional\":\r\n        return False\r\n    return False\r\n\r\ndef parse_default_value(val):\r\n    v = safe_json_value(val)\r\n    if v.strip().lower() == \"none\":\r\n        return \"\"\r\n    return v\r\n\r\n# === Azure DevOps API helpers (Thread-Safe) ===\r\nLOCKED_LAYOUT_MARKER = \"FormLayoutInfoNotAvailableException\"\r\n\r\ndef invalidate_layout_cache(wit_ref_name: str) -> None:\r\n    \"\"\"Thread-safe cache invalidation.\"\"\"\r\n    with layout_cache_lock:\r\n        layout_cache.pop(wit_ref_name, None)\r\n\r\ndef get_layout(wit_ref_name: str, process_id: str, force_refresh: bool = False) -> Optional[dict]:\r\n    \"\"\"Get the layout for a work item type with retry logic (thread-safe).\"\"\"\r\n    with layout_cache_lock:\r\n        if not force_refresh:\r\n            if wit_ref_name in locked_layout_wits:\r\n                return None\r\n            cached = layout_cache.get(wit_ref_name)\r\n            if cached is not None:\r\n                return cached\r\n\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workItemTypes/{wit_ref_name}/layout?api-version=7.1-preview.1\"\r\n\r\n    resp = make_request_with_retry('GET', url, headers=headers)\r\n\r\n    if resp.status_code in (400, 403) and LOCKED_LAYOUT_MARKER in resp.text:\r\n        log(f\" Layout for '{wit_ref_name}' is locked (likely OOTB). Skipping layout changes.\")\r\n        with layout_cache_lock:\r\n            locked_layout_wits.add(wit_ref_name)\r\n            layout_cache.pop(wit_ref_name, None)\r\n        return None\r\n\r\n    resp.raise_for_status()\r\n    result = resp.json()\r\n    with layout_cache_lock:\r\n        layout_cache[wit_ref_name] = result\r\n    return result\r\n\r\ndef get_section_id(layout: Optional[dict], page_id: str, section_label: Optional[str] = None) -> Optional[str]:\r\n    \"\"\"\r\n    Given a layout and a page_id, return the section id for the given section label\r\n    (e.g., \"Section 1\"). If not found, return the first section id on the page.\r\n    \"\"\"\r\n    if not layout:\r\n        return None\r\n    excel_to_ado_section = {\r\n        \"left\": \"section1\",\r\n        \"middle\": \"section2\",\r\n        \"right\": \"section3\"\r\n    }\r\n    section_label_clean = section_label.strip().lower() if section_label else None\r\n    mapped_label = excel_to_ado_section.get(section_label_clean, section_label_clean) if section_label_clean else None\r\n    for page in layout.get(\"pages\", []):\r\n        if page.get(\"id\") == page_id:\r\n            for section in page.get(\"sections\", []):\r\n                api_label = section.get(\"id\", \"\").strip().lower()\r\n                if mapped_label and api_label == mapped_label:\r\n                    return section.get(\"id\")\r\n            if page.get(\"sections\"):\r\n                return page[\"sections\"][0][\"id\"]\r\n    return None\r\n\r\ndef find_page_by_label(layout: Optional[dict], page_label: str) -> Optional[dict]:\r\n    if not layout:\r\n        return None\r\n    for page in layout.get(\"pages\", []):\r\n        if page.get(\"label\") == page_label:\r\n            return page\r\n    return None\r\n\r\ndef ensure_group_on_page(layout: Optional[dict], page_id: str, group_label: str) -> tuple[Optional[str], Optional[str]]:\r\n    \"\"\"\r\n    Check if a group with the given label exists anywhere on the page (in any section).\r\n    Returns (group_id, section_id) if found, otherwise (None, None).\r\n    \"\"\"\r\n    if not layout:\r\n        return None, None\r\n    for page in layout.get(\"pages\", []):\r\n        if page.get(\"id\") == page_id:\r\n            for section in page.get(\"sections\", []):\r\n                for group in section.get(\"groups\", []):\r\n                    if group.get(\"label\") == group_label:\r\n                        return group.get(\"id\"), section.get(\"id\")\r\n    return None, None\r\n\r\ndef ensure_group_in_section(layout: Optional[dict], page_id: str, section_id: str, group_label: str) -> Optional[str]:\r\n    \"\"\"\r\n    Return group_id if a group with the given label exists in the specific section; otherwise None.\r\n    \"\"\"\r\n    if not layout:\r\n        return None\r\n    for page in layout.get(\"pages\", []):\r\n        if page.get(\"id\") == page_id:\r\n            for section in page.get(\"sections\", []):\r\n                if section.get(\"id\") == section_id:\r\n                    for group in section.get(\"groups\", []):\r\n                        if group.get(\"label\") == group_label:\r\n                            return group.get(\"id\")\r\n    return None\r\n\r\ndef add_page_if_missing(wit_ref_name: str, process_id: str, page_label: str, order: int) -> Optional[str]:\r\n    log(f\"[add_page_if_missing] Checking for page '{page_label}' on '{wit_ref_name}'\")\r\n    layout = get_layout(wit_ref_name, process_id)\r\n    if layout is None:\r\n        return None\r\n    existing = find_page_by_label(layout, page_label)\r\n    if existing:\r\n        log(f\" Page '{page_label}' already exists on '{wit_ref_name}' (id: {existing.get('id')})\")\r\n        return existing.get(\"id\")\r\n\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workItemTypes/{wit_ref_name}/layout/pages?api-version=7.1-preview.1\"\r\n    payload = {\"label\": page_label, \"order\": order, \"visible\": True, \"inherited\": True}\r\n    log(f\" Creating page '{page_label}' on '{wit_ref_name}' with payload: {json.dumps(payload)}\")\r\n    \r\n    resp = make_request_with_retry('POST', url, headers=headers, json=payload)\r\n    \r\n    if resp.status_code in [200, 201]:\r\n        invalidate_layout_cache(wit_ref_name)\r\n        pid = resp.json().get(\"id\")\r\n        log(f\" Added page '{page_label}' to '{wit_ref_name}' (id: {pid})\")\r\n        return pid\r\n    elif resp.status_code == 409:\r\n        layout = get_layout(wit_ref_name, process_id, force_refresh=True)\r\n        if layout is None:\r\n            return None\r\n        existing = find_page_by_label(layout, page_label)\r\n        if existing:\r\n            return existing.get(\"id\")\r\n    log(f\" ERROR: Failed to add page '{page_label}': {resp.status_code} - {resp.text}\")\r\n    return None\r\n\r\ndef add_group_if_missing(wit_ref_name: str, process_id: str, page_id: str, section_id: str, group_label: str) -> Optional[str]:\r\n    \"\"\"\r\n    Ensures a group exists on the page. First checks the entire page for the group.\r\n    If found in a different section, logs a warning and returns that group_id.\r\n    If not found anywhere, creates it in the target section.\r\n    \"\"\"\r\n    log(f\"[add_group_if_missing] Checking for group '{group_label}' on page '{page_id}' for '{wit_ref_name}'\")\r\n    layout = get_layout(wit_ref_name, process_id)\r\n    if layout is None:\r\n        return None\r\n    \r\n    group_id, found_section_id = ensure_group_on_page(layout, page_id, group_label)\r\n    if group_id:\r\n        if found_section_id == section_id:\r\n            log(f\" Group '{group_label}' already exists in target section '{section_id}' on page '{page_id}' (id: {group_id})\")\r\n        else:\r\n            log(f\" WARNING: Group '{group_label}' already exists in section '{found_section_id}' instead of target section '{section_id}' on page '{page_id}' (id: {group_id}). Using existing group.\")\r\n        return group_id\r\n\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workItemTypes/{wit_ref_name}/layout/pages/{page_id}/sections/{section_id}/groups?api-version=7.1-preview.1\"\r\n    payload = {\"label\": group_label, \"visible\": True, \"inherited\": True}\r\n    log(f\" Creating group '{group_label}' in section '{section_id}' on page '{page_id}' with payload: {json.dumps(payload)}\")\r\n    \r\n    resp = make_request_with_retry('POST', url, headers=headers, json=payload)\r\n    \r\n    if resp.status_code in [200, 201]:\r\n        invalidate_layout_cache(wit_ref_name)\r\n        layout = get_layout(wit_ref_name, process_id, force_refresh=True)\r\n        if layout is None:\r\n            return None\r\n        group_id = ensure_group_in_section(layout, page_id, section_id, group_label)\r\n        if group_id:\r\n            log(f\" Added group '{group_label}' to section '{section_id}' on page '{page_id}' (id: {group_id})\")\r\n            return group_id\r\n    elif resp.status_code == 409:\r\n        layout = get_layout(wit_ref_name, process_id, force_refresh=True)\r\n        if layout is None:\r\n            return None\r\n        group_id, found_section_id = ensure_group_on_page(layout, page_id, group_label)\r\n        if group_id:\r\n            if found_section_id != section_id:\r\n                log(f\" WARNING: Group '{group_label}' was created in section '{found_section_id}' instead of target section '{section_id}' (409 conflict)\")\r\n            return group_id\r\n    log(f\" ERROR: Failed to add group '{group_label}': {resp.status_code} - {resp.text}\")\r\n    return None\r\n\r\ndef get_control_type(field_type: Optional[str], field_ref_name: str, picklist_name: Optional[str]) -> str:\r\n    ft = (field_type or \"\").strip().lower()\r\n    ref = (field_ref_name or \"\").strip().lower()\r\n    pick = (picklist_name or \"\").strip()\r\n\r\n    if ref in [\"system.areaid\", \"system.area\", \"system.areapath\", \"system.iterationid\", \"system.iteration\", \"system.iterationpath\"]:\r\n        return \"WorkItemClassificationControl\"\r\n    if ref in [\"system.assignedto\", \"system.createdby\", \"system.changedby\", \"system.authorizedas\", \"system.owner\", \"system.requestedby\"]:\r\n        return \"IdentityControl\"\r\n    if ref in [\"system.createddate\", \"system.changeddate\", \"system.resolveddate\", \"system.closeddate\"] or ft == \"datetime\":\r\n        return \"DateTimeControl\"\r\n    if ft == \"boolean\":\r\n        return \"BooleanControl\"\r\n    if ft == \"html\":\r\n        return \"HtmlFieldControl\"\r\n    if pick:\r\n        if ft == \"integer\":\r\n            return \"PickListIntegerControl\"\r\n        return \"PickListStringControl\"\r\n    if ft == \"identity\":\r\n        return \"IdentityControl\"\r\n    return \"Field\"\r\n\r\ndef add_control_if_missing(wit_ref_name: str, process_id: str, page_id: str, section_id: str, group_id: str,\r\n                           field_ref_name: str, label: str, order: int,\r\n                           field_type: Optional[str] = None, picklist_name: Optional[str] = None):\r\n    log(f\"[add_control_if_missing] Checking for control '{label}' ({field_ref_name}) \"\r\n        f\"in group '{group_id}' on page '{page_id}' for '{wit_ref_name}'\")\r\n\r\n    if not section_id:\r\n        log(f\" ERROR: section_id is empty for page '{page_id}'. Cannot add control.\")\r\n        return\r\n\r\n    layout = get_layout(wit_ref_name, process_id)\r\n    if layout is None:\r\n        log(f\" Skipping control '{label}' on '{wit_ref_name}' because layout is unavailable\")\r\n        return\r\n    \r\n    found_group = False\r\n    for page in layout.get(\"pages\", []):\r\n        if page.get(\"id\") == page_id:\r\n            for section in page.get(\"sections\", []):\r\n                if section.get(\"id\") == section_id:\r\n                    for group in section.get(\"groups\", []):\r\n                        if group.get(\"id\") == group_id:\r\n                            found_group = True\r\n                            for control in group.get(\"controls\", []):\r\n                                if control.get(\"id\") == field_ref_name:\r\n                                    log(f\" Control '{label}' ({field_ref_name}) already exists in group '{group_id}' on page '{page_id}'\")\r\n                                    return\r\n                            break\r\n    if not found_group:\r\n        log(f\" ERROR: group_id '{group_id}' not found in section '{section_id}' on page '{page_id}'.\")\r\n        return\r\n\r\n    control_type = get_control_type(field_type, field_ref_name, picklist_name)\r\n    payload = {\r\n        \"id\": field_ref_name,\r\n        \"label\": label,\r\n        \"order\": order,\r\n        \"controlType\": control_type,\r\n        \"visible\": True,\r\n        \"inherited\": True\r\n    }\r\n\r\n    enc_wit = urllib.parse.quote(wit_ref_name, safe='')\r\n    enc_group = urllib.parse.quote(group_id, safe='')\r\n\r\n    url = (f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workItemTypes/{enc_wit}\"\r\n           f\"/layout/groups/{enc_group}/controls\"\r\n           f\"?api-version=7.1-preview.1\")\r\n\r\n    log(f\" Creating control '{label}' ({field_ref_name}) in group '{group_id}' on page '{page_id}' \"\r\n        f\"in section '{section_id}'\")\r\n\r\n    resp = make_request_with_retry('POST', url, headers=headers, json=payload)\r\n    \r\n    if resp.status_code in [200, 201]:\r\n        invalidate_layout_cache(wit_ref_name)\r\n        log(f\" Added control '{label}' ({field_ref_name}) to group '{group_id}' on page '{page_id}' \"\r\n            f\"in section '{section_id}' for '{wit_ref_name}'\")\r\n    elif resp.status_code == 409:\r\n        log(f\" Control '{label}' ({field_ref_name}) already exists in group '{group_id}' on page '{page_id}' \"\r\n            f\"in section '{section_id}' for '{wit_ref_name}' (409 conflict)\")\r\n    else:\r\n        body = resp.text\r\n        log(f\" ERROR: Failed to add control '{label}': {resp.status_code} - {body}\")\r\n\r\n\r\ndef process_work_item_type(wit_row, process_id: str, field_labels: list, reference_names: dict, \r\n                           field_name_map: dict, field_types: dict, picklist_names: dict, required_flags: dict, \r\n                           default_values: dict, field_layout_map: dict, existing_fields: dict) -> dict:\r\n    \"\"\"\r\n    Process a single work item type - adds fields and updates layout.\r\n    This function is designed to run in a separate thread.\r\n    \r\n    Returns:\r\n        dict with 'wit_name', 'status', 'fields_added', 'errors'\r\n    \"\"\"\r\n    result = {\r\n        'wit_name': '',\r\n        'status': 'success',\r\n        'fields_added': 0,\r\n        'errors': []\r\n    }\r\n    \r\n    try:\r\n        custom_type_flag = safe_json_value(wit_row.get(\"Custom work item type\")).strip().lower()\r\n        wit_name_raw = safe_json_value(wit_row.get(\"Work item type\")).strip()\r\n        wit_ref_name_excel = safe_json_value(wit_row.get(\"Reference name\")).strip()\r\n        \r\n        result['wit_name'] = wit_name_raw\r\n        \r\n        # Build reference name using consistent logic\r\n        if custom_type_flag == \"yes\":\r\n            wit_ref_name = build_reference_name(wit_name_raw)\r\n        else:\r\n            wit_ref_name = wit_ref_name_excel if wit_ref_name_excel else f\"{SYSTEM_WORK_ITEM_TYPE}.{wit_name_raw}\"\r\n\r\n        log(f\"[Thread] Processing work item type: {wit_name_raw} (Reference: {wit_ref_name})\")\r\n\r\n        # Check if this is a system work item type (OOTB with locked layout)\r\n        is_system_wit = is_system_work_item_type(wit_ref_name)\r\n        if is_system_wit:\r\n            log(f\" Work item type '{wit_ref_name}' is a system (OOTB) type with locked layout. Fields will be added but layout updates will be skipped.\")\r\n            with layout_cache_lock:\r\n                locked_layout_wits.add(wit_ref_name)\r\n\r\n        # Get existing fields on the WIT\r\n        encoded_wit_name = urllib.parse.quote(wit_name_raw, safe='')\r\n        wit_fields_url = f\"{ADO_ORG_URL}/{ADO_PROJECT}/_apis/wit/workitemtypes/{encoded_wit_name}/fields?api-version=7.0\"\r\n        wit_fields_resp = requests.get(wit_fields_url, headers=headers)\r\n        wit_existing_fields = set()\r\n        if wit_fields_resp.status_code == 200:\r\n            wit_fields_json = wit_fields_resp.json()\r\n            wit_existing_fields = set(f[\"referenceName\"] for f in wit_fields_json.get(\"value\", []))\r\n        elif wit_fields_resp.status_code == 404:\r\n            log(f\" No fields found for work item type '{wit_ref_name}' in project '{ADO_PROJECT}' (404). Continuing with additions.\")\r\n        else:\r\n            log(f\" Failed to get fields for work item type '{wit_ref_name}': {wit_fields_resp.status_code}\")\r\n            result['status'] = 'error'\r\n            result['errors'].append(f\"Failed to get fields: {wit_fields_resp.status_code}\")\r\n            return result\r\n\r\n        wit_field_flags = {label: safe_json_value(wit_row.get(label)).strip().upper() for label in field_labels}\r\n        if all(flag != \"X\" for flag in wit_field_flags.values()):\r\n            log(f\" Skipping '{wit_name_raw}' — no fields flagged for addition.\")\r\n            result['status'] = 'skipped'\r\n            return result\r\n\r\n        # Loop through fields to add (using Label to match WIT sheet columns)\r\n        for field_label in field_labels:\r\n            if wit_field_flags.get(field_label) != \"X\":\r\n                continue\r\n            field_layout_info = field_layout_map.get(field_label)\r\n            if not field_layout_info:\r\n                log(f\" WARNING: No layout metadata for field '{field_label}'. Skipping layout update.\")\r\n                continue\r\n\r\n            ref_name = reference_names.get(field_label)\r\n            field_name = field_name_map.get(field_label)  # MS BPC-suffixed name\r\n            field_type = safe_json_value(field_types.get(field_label)).strip()\r\n            picklist_name = safe_json_value(picklist_names.get(field_label)) if picklist_names else \"\"\r\n            required = parse_required(required_flags.get(field_label)) if required_flags else False\r\n            default_value = parse_default_value(default_values.get(field_label)) if default_values else \"\"\r\n\r\n            # Ensure field exists at org level\r\n            if ref_name not in existing_fields:\r\n                log(f\" ERROR: Field '{field_label}' (name: {field_name}, ref: {ref_name}) does not exist at organization level. Cannot add to WIT '{wit_ref_name}'.\")\r\n                result['errors'].append(f\"Field '{field_label}' not at org level\")\r\n                continue\r\n\r\n            # Add field to WIT if not present\r\n            if ref_name in wit_existing_fields:\r\n                log(f\" Field '{field_label}' (name: {field_name}, ref: {ref_name}) already exists on WIT '{wit_ref_name}'. Skipping field addition.\")\r\n            else:\r\n                payload = {\r\n                    \"referenceName\": ref_name,\r\n                    \"required\": required,\r\n                    \"visible\": True\r\n                }\r\n                if field_type.lower() == \"identity\":\r\n                    payload[\"allowGroups\"] = True\r\n                if default_value:\r\n                    payload[\"defaultValue\"] = default_value\r\n\r\n                add_field_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workItemTypes/{wit_ref_name}/fields?api-version=7.0\"\r\n                log(f\" Adding field '{field_label}' (name: {field_name}, ref: {ref_name}) to WIT '{wit_ref_name}'\")\r\n                add_resp = make_request_with_retry('POST', add_field_url, headers=headers, json=payload)\r\n                if add_resp.status_code in [200, 201]:\r\n                    log(f\" Successfully added field '{field_label}' (name: {field_name}, ref: {ref_name}) to '{wit_ref_name}'.\")\r\n                    wit_existing_fields.add(ref_name)\r\n                    result['fields_added'] += 1\r\n                else:\r\n                    log(f\" ERROR: Failed to add field '{field_label}' (name: {field_name}, ref: {ref_name}) to '{wit_ref_name}': {add_resp.status_code} - {add_resp.text}\")\r\n                    result['errors'].append(f\"Failed to add field '{field_label}'\")\r\n                    continue\r\n\r\n            # === Layout update section ===\r\n            with layout_cache_lock:\r\n                if wit_ref_name in locked_layout_wits:\r\n                    log(f\" Skipping layout updates for '{wit_ref_name}' because its layout is locked.\")\r\n                    continue\r\n            \r\n            if field_type.lower() == \"html\":\r\n                log(f\" SKIPPED: HTML field '{field_label}' ({ref_name}) cannot be added to the form layout via API.\")\r\n                continue\r\n            \r\n            page_name = safe_json_value(field_layout_info[\"Page name\"])\r\n            group_sequence = int(field_layout_info[\"Group sequence\"]) if not pd.isna(field_layout_info[\"Group sequence\"]) else 1\r\n            group_name = safe_json_value(field_layout_info[\"Group name\"])\r\n            field_sequence = int(field_layout_info[\"Field sequence\"]) if not pd.isna(field_layout_info[\"Field sequence\"]) else 1\r\n            section_label_raw = safe_json_value(field_layout_info[\"Group location\"])\r\n\r\n            # 1) Ensure page exists\r\n            layout = get_layout(wit_ref_name, process_id)\r\n            if layout is None:\r\n                with layout_cache_lock:\r\n                    locked_layout_wits.add(wit_ref_name)\r\n                continue\r\n            page = find_page_by_label(layout, page_name)\r\n            page_id = page.get(\"id\") if page else None\r\n            if not page_id:\r\n                page_id = add_page_if_missing(wit_ref_name, process_id, page_name, group_sequence)\r\n            if not page_id:\r\n                log(f\" ERROR: Could not resolve or create page '{page_name}' for WIT '{wit_ref_name}'.\")\r\n                result['errors'].append(f\"Could not create page '{page_name}'\")\r\n                continue\r\n\r\n            # 2) Resolve section id\r\n            layout = get_layout(wit_ref_name, process_id)\r\n            if layout is None:\r\n                with layout_cache_lock:\r\n                    locked_layout_wits.add(wit_ref_name)\r\n                continue\r\n            section_id = get_section_id(layout, page_id, section_label=section_label_raw)\r\n            if not section_id:\r\n                log(f\" ERROR: Could not resolve section for label '{section_label_raw}' on page '{page_name}'.\")\r\n                result['errors'].append(f\"Could not resolve section '{section_label_raw}'\")\r\n                continue\r\n           \r\n            # 3) Check if group exists\r\n            existing_group = ensure_group_on_page(layout, page_id, group_name)\r\n            if existing_group:\r\n                group_id, actual_section_id = existing_group\r\n                if actual_section_id and actual_section_id != section_id:\r\n                    log(f\" WARNING: Group '{group_name}' exists in section '{actual_section_id}' instead of target section '{section_id}'.\")\r\n                    section_id = actual_section_id\r\n                elif not actual_section_id:\r\n                    group_id = add_group_if_missing(wit_ref_name, process_id, page_id, section_id, group_name)\r\n                    if not group_id:\r\n                        log(f\" ERROR: Could not create group '{group_name}'.\")\r\n                        result['errors'].append(f\"Could not create group '{group_name}'\")\r\n                        continue\r\n            else:\r\n                group_id = add_group_if_missing(wit_ref_name, process_id, page_id, section_id, group_name)\r\n                if not group_id:\r\n                    log(f\" ERROR: Could not create group '{group_name}'.\")\r\n                    result['errors'].append(f\"Could not create group '{group_name}'\")\r\n                    continue\r\n\r\n            # 4) Add control to the group — use Label for form display\r\n            add_control_if_missing(\r\n                wit_ref_name, process_id, page_id, section_id, group_id,\r\n                ref_name, field_label, field_sequence,\r\n                field_type=field_type, picklist_name=picklist_name\r\n            )\r\n\r\n        log(f\"[Thread] Finished processing WIT '{wit_name_raw}'\")\r\n        \r\n    except Exception as e:\r\n        result['status'] = 'error'\r\n        result['errors'].append(str(e))\r\n        log(f\"[Thread] ERROR processing WIT: {e}\")\r\n    \r\n    return result\r\n\r\n\r\n# Resolve relative path to the script directory and check existence\r\nbase_dir = os.path.dirname(os.path.abspath(__file__))\r\nexcel_path = EXCEL_FILE if os.path.isabs(EXCEL_FILE) else os.path.join(base_dir, EXCEL_FILE)\r\nif not os.path.exists(excel_path):\r\n    print(f\"Error: Excel file not found: {excel_path}\")\r\n    print(f\"Script directory: {base_dir}\")\r\n    print(\"Files in script directory:\")\r\n    for f in sorted(os.listdir(base_dir)):\r\n        print(\"  \", f)\r\n    sys.exit(1)\r\n\r\n\r\n# === Main flow ===\r\ndef main():\r\n    start_time = time.time()\r\n    \r\n    log(\"=\" * 60)\r\n    log(\"Starting MULTITHREADED Azure DevOps script\")\r\n    log(f\"Max parallel workers: {MAX_WORKERS}\")\r\n    log(\"=\" * 60)\r\n    \r\n    log(f\"Looking up process: {PROCESS_NAME}\")\r\n    process_id = get_process_id_by_name(PROCESS_NAME)\r\n    log(f\"Found process ID: {process_id}\")\r\n    \r\n    log(f\"Reading spreadsheet: {EXCEL_FILE}\")\r\n\r\n    # Read Excel sheets\r\n    wit_df = pd.read_excel(excel_path, sheet_name=\"Work item types\")\r\n    wit_df.columns = wit_df.columns.str.strip()\r\n    df = pd.read_excel(excel_path, sheet_name=\"Fields\")\r\n    df.columns = df.columns.str.strip()\r\n\r\n    # Sort Fields sheet\r\n    sort_columns = []\r\n    if \"Page name\" in df.columns:\r\n        sort_columns.append(\"Page name\")\r\n    if \"Group location\" in df.columns:\r\n        sort_columns.append(\"Group location\")\r\n    if \"Group sequence\" in df.columns:\r\n        sort_columns.append(\"Group sequence\")\r\n    if \"Field sequence\" in df.columns:\r\n        sort_columns.append(\"Field sequence\")\r\n\r\n    if sort_columns:\r\n        df = df.sort_values(sort_columns)\r\n        log(f\"Sorted Fields sheet by: {', '.join(sort_columns)}\")\r\n\r\n    # Get existing organization fields\r\n    fields_url = f\"{ADO_ORG_URL}/_apis/wit/fields?api-version=7.0\"\r\n    fields_response = requests.get(fields_url, headers=headers)\r\n    if fields_response.status_code == 200:\r\n        fields_json = fields_response.json()\r\n        existing_fields = {field[\"referenceName\"]: field for field in fields_json.get(\"value\", [])}\r\n        log(f\"Retrieved {len(existing_fields)} organization fields.\")\r\n    else:\r\n        log(f\"Failed to get organization fields: {fields_response.status_code} - {fields_response.text}\")\r\n        existing_fields = {}\r\n\r\n    # Get all picklists\r\n    picklists_url = f\"{ADO_ORG_URL}/_apis/work/processes/lists?api-version=7.0\"\r\n    picklists_response = requests.get(picklists_url, headers=headers)\r\n    if picklists_response.status_code == 200:\r\n        log(f\"Retrieved {len(picklists_response.json().get('value', []))} picklists.\")\r\n    else:\r\n        log(f\"Failed to get picklists: {picklists_response.status_code} - {picklists_response.text}\")\r\n\r\n    # Build lookups from Fields sheet, keyed by Label (matches WIT sheet column headers)\r\n    field_labels = df[\"Label\"].tolist()\r\n    reference_names = df.set_index(\"Label\")[\"Reference name\"].to_dict()\r\n    field_name_map = df.set_index(\"Label\")[\"Field name\"].to_dict()  # Label -> Field name (MS BPC-suffixed)\r\n    field_types = df.set_index(\"Label\")[\"Field type\"].to_dict()\r\n    picklist_names = df.set_index(\"Label\")[\"Picklist name\"].to_dict() if \"Picklist name\" in df.columns else {}\r\n    required_flags = df.set_index(\"Label\")[\"Required\"].to_dict() if \"Required\" in df.columns else {}\r\n    default_values = df.set_index(\"Label\")[\"Default value\"].to_dict() if \"Default value\" in df.columns else {}\r\n\r\n    if df[\"Label\"].duplicated().any():\r\n        dupes = df[df[\"Label\"].duplicated() == True][\"Label\"].unique()\r\n        dupes_str = \", \".join(str(name) for name in dupes)\r\n        log(f\" WARNING: Duplicate layout rows found for labels: {dupes_str}. Using the first occurrence of each.\")\r\n    layout_rows = df.drop_duplicates(subset=\"Label\", keep=\"first\")\r\n    field_layout_map = layout_rows.set_index(\"Label\").to_dict(orient=\"index\")\r\n\r\n    # Clear caches\r\n    with layout_cache_lock:\r\n        layout_cache.clear()\r\n        locked_layout_wits.clear()\r\n\r\n    # Prepare work item types for parallel processing\r\n    wit_rows = [row for _, row in wit_df.iterrows()]\r\n    total_wits = len(wit_rows)\r\n    log(f\"Processing {total_wits} work item types with {MAX_WORKERS} parallel workers...\")\r\n\r\n    # Process work item types in parallel\r\n    results = []\r\n    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:\r\n        # Submit all tasks\r\n        future_to_wit = {\r\n            executor.submit(\r\n                process_work_item_type,\r\n                wit_row, process_id, field_labels, reference_names,\r\n                field_name_map, field_types, picklist_names, required_flags,\r\n                default_values, field_layout_map, existing_fields\r\n            ): wit_row for wit_row in wit_rows\r\n        }\r\n        \r\n        # Collect results as they complete\r\n        completed = 0\r\n        for future in as_completed(future_to_wit):\r\n            completed += 1\r\n            try:\r\n                result = future.result()\r\n                results.append(result)\r\n                log(f\"Progress: {completed}/{total_wits} work item types processed\")\r\n            except Exception as e:\r\n                log(f\"ERROR: Thread raised exception: {e}\")\r\n                results.append({'wit_name': 'Unknown', 'status': 'error', 'errors': [str(e)]})\r\n\r\n    # Summary\r\n    elapsed_time = time.time() - start_time\r\n    log(\"=\" * 60)\r\n    log(\"SUMMARY\")\r\n    log(\"=\" * 60)\r\n    \r\n    successful = sum(1 for r in results if r['status'] == 'success')\r\n    skipped = sum(1 for r in results if r['status'] == 'skipped')\r\n    failed = sum(1 for r in results if r['status'] == 'error')\r\n    total_fields_added = sum(r.get('fields_added', 0) for r in results)\r\n    \r\n    log(f\"Total work item types: {total_wits}\")\r\n    log(f\"  Successful: {successful}\")\r\n    log(f\"  Skipped: {skipped}\")\r\n    log(f\"  Failed: {failed}\")\r\n    log(f\"Total fields added: {total_fields_added}\")\r\n    log(f\"Elapsed time: {elapsed_time:.2f} seconds ({elapsed_time/60:.2f} minutes)\")\r\n    log(\"=\" * 60)\r\n    \r\n    if failed > 0:\r\n        log(\"Failed work item types:\")\r\n        for r in results:\r\n            if r['status'] == 'error':\r\n                log(f\"  - {r['wit_name']}: {', '.join(r['errors'])}\")\r\n    \r\n    log(\"Script finished. See log file for details:\")\r\n    log(f\"  {LOG_FILE}\")\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    # Ensure log file is empty at start\r\n    if os.path.exists(LOG_FILE):\r\n        os.remove(LOG_FILE)\r\n    main()\r\n"
  },
  {
    "path": "templates/Azure-DevOps-templates/3_ADO_Teams_Areas_Script (Preview).py",
    "content": "import os\r\nimport sys\r\nimport base64\r\nimport urllib.parse\r\nfrom collections import defaultdict\r\nimport pandas as pd\r\nimport requests\r\nfrom requests.auth import HTTPBasicAuth\r\n\r\n# === CONFIGURATION ===\r\nADO_ORG_URL = \"https://dev.azure.com/<YOUR_ORGANIZATION>\"  # e.g. \"https://dev.azure.com/Contoso\"\r\nADO_PROJECT = \"<YOUR_PROJECT_NAME>\"                         # e.g. \"Business process catalog\"\r\nPAT = \"<YOUR_PERSONAL_ACCESS_TOKEN>\"                        # Azure DevOps PAT with full access\r\nEXCEL_FILE = \"ADO template guideline (Preview).xlsx\" # Path to the Excel template file\r\nLOG_FILE = \"3_ADO_Teams_Areas_Log.txt\"\r\nSprints_TEAM_NAME = \"Sprints\"\r\n\r\nHEADERS_JSON = {\"Content-Type\": \"application/json\"}\r\n\r\ndef log(msg: str):\r\n    with open(LOG_FILE, \"a\", encoding=\"utf-8\") as f:\r\n        f.write(msg + \"\\n\")\r\n    print(msg)\r\n\r\ndef resolve_excel_path() -> str:\r\n    base_dir = os.path.dirname(os.path.abspath(__file__))\r\n    return EXCEL_FILE if os.path.isabs(EXCEL_FILE) else os.path.join(base_dir, EXCEL_FILE)\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 1: Create Teams (logic from Script 4)\r\n# ---------------------------------------------------------------------------\r\n\r\ndef get_project_id() -> str:\r\n    proj_url = f\"{ADO_ORG_URL}/_apis/projects/{ADO_PROJECT}?api-version=7.1\"\r\n    resp = requests.get(proj_url, headers=HEADERS_JSON, auth=HTTPBasicAuth('', PAT))\r\n    if resp.status_code != 200:\r\n        log(f\"ERROR: Could not fetch project ID for '{ADO_PROJECT}': {resp.status_code} - {resp.text}\")\r\n        sys.exit(1)\r\n    project_id = resp.json().get(\"id\")\r\n    if not project_id:\r\n        log(f\"ERROR: Project ID not found in response for '{ADO_PROJECT}'\")\r\n        sys.exit(1)\r\n    return project_id\r\n\r\ndef create_teams_from_excel(excel_path: str, project_id: str) -> list[str]:\r\n    try:\r\n        df = pd.read_excel(excel_path, sheet_name=\"Area paths\")\r\n    except Exception as e:\r\n        log(f\"ERROR: Unable to read 'Area paths' sheet for team extraction: {e}\")\r\n        sys.exit(1)\r\n\r\n    df = df.iloc[:, 1:6]\r\n    df.columns = [\"L1\", \"L2\", \"L3\", \"L4\", \"Teams\"]\r\n\r\n    if \"Teams\" not in df.columns:\r\n        log(\"ERROR: 'Teams' column not found in Area paths sheet\")\r\n        sys.exit(1)\r\n\r\n    team_names = [str(x).strip() for x in df[\"Teams\"].dropna().unique() if str(x).strip()]\r\n    if Sprints_TEAM_NAME not in team_names:\r\n        team_names.append(Sprints_TEAM_NAME)\r\n\r\n    created_or_existing = []\r\n    for team_name in team_names:\r\n        log(f\"Processing team: {team_name}\")\r\n        url = f\"{ADO_ORG_URL}/_apis/projects/{project_id}/teams?api-version=7.1\"\r\n        payload = {\"name\": team_name}\r\n        resp = requests.post(url, headers=HEADERS_JSON, auth=HTTPBasicAuth('', PAT), json=payload)\r\n        if resp.status_code in [200, 201]:\r\n            log(f\"  ✔ Successfully created team '{team_name}'.\")\r\n            created_or_existing.append(team_name)\r\n        elif resp.status_code == 409:\r\n            log(f\"  ✔ Team '{team_name}' already exists (409 conflict). Skipping create.\")\r\n            created_or_existing.append(team_name)\r\n        else:\r\n            log(f\"  ✖ ERROR: Failed to create team '{team_name}': {resp.status_code} - {resp.text}\")\r\n    return created_or_existing\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 2: Create Area Paths and Assign Teams (logic from Script 5)\r\n# ---------------------------------------------------------------------------\r\n\r\nBASE_URL = f\"https://dev.azure.com/{ADO_ORG_URL.split('/')[-1]}/{ADO_PROJECT}/_apis/wit/classificationnodes/Areas\"\r\nAUTH_HEADER = {\r\n    \"Authorization\": \"Basic \" + base64.b64encode(f':{PAT}'.encode()).decode(),\r\n    \"Content-Type\": \"application/json\",\r\n}\r\n\r\ndef create_area(path_list: list[str]):\r\n    parent = \"/\".join(path_list[:-1])\r\n    new_node = path_list[-1]\r\n    if parent == \"\":\r\n        url = f\"{BASE_URL}?api-version=7.1\"\r\n    else:\r\n        url = f\"{BASE_URL}/{parent}?api-version=7.1\"\r\n    body = {\"name\": new_node}\r\n    r = requests.post(url, headers=AUTH_HEADER, json=body)\r\n    if r.status_code in [200, 201]:\r\n        log(f\"  ✔ Created or exists: {parent}/{new_node}\")\r\n    elif r.status_code == 409:\r\n        log(f\"  ✔ Already exists: {parent}/{new_node}\")\r\n    else:\r\n        log(f\"  ✖ Error creating area '{parent}/{new_node}': {r.status_code} {r.text}\")\r\n\r\ndef _format_area_path(relative_path: str) -> str:\r\n    relative = relative_path.replace(\"/\", \"\\\\\")\r\n    return f\"{ADO_PROJECT}\\\\{relative}\" if relative else ADO_PROJECT\r\n\r\ndef _build_team_payload(default_path: str, all_paths: set[str]) -> dict:\r\n    default_formatted = _format_area_path(default_path)\r\n    values = [{\"value\": default_formatted, \"includeChildren\": False}]\r\n    for path in sorted(all_paths):\r\n        if path == default_path:\r\n            continue\r\n        values.append({\"value\": _format_area_path(path), \"includeChildren\": False})\r\n    return {\"defaultValue\": default_formatted, \"values\": values}\r\n\r\ndef set_team_area(team_name: str, default_path: str, paths: set[str]) -> None:\r\n    encoded_team = urllib.parse.quote(team_name)\r\n    org = ADO_ORG_URL.split(\"/\")[-1]\r\n    url = f\"https://dev.azure.com/{org}/{ADO_PROJECT}/{encoded_team}/_apis/work/teamsettings/teamfieldvalues?api-version=7.1\"\r\n    payload = _build_team_payload(default_path, paths)\r\n    response = requests.patch(url, json=payload, headers={\"Content-Type\": \"application/json\"}, auth=HTTPBasicAuth(\"\", PAT))\r\n    if response.status_code in [200, 204]:\r\n        log(f\"  ✔ Assigned Team '{team_name}' → {payload['defaultValue']} with {len(payload['values'])} entries\")\r\n    else:\r\n        log(f\"  ✖ FAILED to assign team '{team_name}': {response.status_code} - {response.text}\")\r\n\r\ndef create_areas_and_assign_teams_from_excel(excel_path: str, allowed_teams: set[str]):\r\n    try:\r\n        df = pd.read_excel(excel_path, sheet_name=\"Area paths\")\r\n    except Exception as e:\r\n        log(f\"ERROR: Unable to read 'Area paths' sheet: {e}\")\r\n        return\r\n\r\n    # Expect columns B..F => L1, L2, L3, L4, Teams\r\n    df = df.iloc[:, 1:6]\r\n    df.columns = [\"L1\", \"L2\", \"L3\", \"L4\", \"Teams\"]\r\n\r\n    current_L1 = None\r\n    current_L2 = None\r\n    current_L3 = None\r\n\r\n    team_assignments: dict[str, dict[str, set[str]]] = defaultdict(lambda: {\"default\": None, \"paths\": set()})\r\n\r\n    def track_team(team_name: str | None, path_list: list[str], default_value: str | None = None):\r\n        if not team_name or not path_list:\r\n            return\r\n        team_name = str(team_name).strip()\r\n        if team_name == \"\":\r\n            return\r\n        if team_name not in allowed_teams and team_name != Sprints_TEAM_NAME:\r\n            log(f\"  • Skipping team assignment for unknown team '{team_name}'\")\r\n            return\r\n        entry = team_assignments[team_name]\r\n        if entry[\"default\"] is None:\r\n            entry[\"default\"] = default_value if default_value else path_list[0]\r\n        entry[\"paths\"].add(\"/\".join(path_list))\r\n\r\n    # Create areas and gather team-area relations\r\n    for _, row in df.iterrows():\r\n        L1, L2, L3, L4 = row[\"L1\"], row[\"L2\"], row[\"L3\"], row[\"L4\"]\r\n        team = row[\"Teams\"] if pd.notna(row[\"Teams\"]) else None\r\n\r\n        if pd.notna(L1):\r\n            current_L1 = str(L1).strip()\r\n            path = [current_L1]\r\n            create_area(path)\r\n            track_team(team, path, current_L1)\r\n            current_L2 = None\r\n            current_L3 = None\r\n            continue\r\n\r\n        if pd.notna(L2) and current_L1:\r\n            current_L2 = str(L2).strip()\r\n            path = [current_L1, current_L2]\r\n            create_area(path)\r\n            track_team(team, path, current_L2)\r\n            current_L3 = None\r\n            continue\r\n\r\n        if pd.notna(L3) and current_L2:\r\n            current_L3 = str(L3).strip()\r\n            path = [current_L1, current_L2, current_L3]\r\n            create_area(path)\r\n            track_team(team, path, current_L3)\r\n            continue\r\n\r\n        if pd.notna(L4) and current_L3:\r\n            child_L4 = str(L4).strip()\r\n            path = [current_L1, current_L2, current_L3, child_L4]\r\n            create_area(path)\r\n            track_team(team, path, child_L4)\r\n            continue\r\n\r\n    # Assign teams to area paths\r\n    for team_name, data in team_assignments.items():\r\n        default_path = data[\"default\"]\r\n        paths = data[\"paths\"]\r\n        if not default_path or not paths:\r\n            continue\r\n        if default_path not in paths:\r\n            paths.add(default_path)\r\n        set_team_area(team_name, default_path, paths)\r\n\r\n    # Ensure Sprints team gets access to every area path\r\n    if Sprints_TEAM_NAME in allowed_teams:\r\n        all_paths = set()\r\n        for entry in team_assignments.values():\r\n            all_paths.update(entry[\"paths\"])\r\n        if all_paths:\r\n            default_for_sprints = next(iter(sorted(all_paths)))\r\n            set_team_area(Sprints_TEAM_NAME, default_for_sprints, all_paths)\r\n        else:\r\n            log(\"WARNING: No area paths collected; unable to assign Sprints team\")\r\n\r\n# ---------------------------------------------------------------------------\r\n# Orchestration\r\n# ---------------------------------------------------------------------------\r\n\r\ndef main():\r\n    # Reset log\r\n    if os.path.exists(LOG_FILE):\r\n        os.remove(LOG_FILE)\r\n\r\n    excel_path = resolve_excel_path()\r\n    if not os.path.exists(excel_path):\r\n        log(f\"Error: Excel file not found: {excel_path}\")\r\n        sys.exit(1)\r\n\r\n    # 1) Create Teams from Area Paths sheet\r\n    project_id = get_project_id()\r\n    teams = create_teams_from_excel(excel_path, project_id)\r\n    allowed_teams = set(teams)\r\n\r\n    # 2) Create Areas and Assign Teams\r\n    create_areas_and_assign_teams_from_excel(excel_path, allowed_teams)\r\n\r\nif __name__ == \"__main__\":\r\n    main()\r\n"
  },
  {
    "path": "templates/Azure-DevOps-templates/4_ADO_Backlog_Config_Script (Preview).py",
    "content": "\"\"\"\r\nScript 4: ADO Backlog Configuration (Private Preview)\r\n\r\nConfigures backlog levels, WIT-to-backlog mappings, iteration paths,\r\nand team settings for the Microsoft Business Process Catalog ADO template.\r\n\r\nRun AFTER Script 1 (creation), Script 2 (page layout), and Script 3 (teams/areas).\r\n\r\nReads from these Excel sheets:\r\n  - \"Backlogs\"          → Backlog level definitions (name, type, color, default WIT, rename from)\r\n  - \"Work item types\"   → WIT-to-backlog mapping (Backlog name column)\r\n  - \"Iteration paths\"   → Hierarchical iteration path definitions\r\n  - \"Teams\"             → Team settings (bug behavior, include sub areas, backlog iteration)\r\n  - \"Area paths\"        → Area path team assignments (for include sub areas update)\r\n\"\"\"\r\n\r\nimport os\r\nimport sys\r\nimport base64\r\nimport json\r\nimport time\r\nimport urllib.parse\r\nfrom collections import defaultdict\r\nimport pandas as pd\r\nimport requests\r\nfrom requests.auth import HTTPBasicAuth\r\n\r\n# === USER CONFIGURATION ===\r\nADO_ORG_URL = \"https://dev.azure.com/<YOUR_ORGANIZATION>\"       # e.g. \"https://dev.azure.com/Contoso\"\r\nADO_PROJECT = \"<YOUR_PROJECT_NAME>\"                              # e.g. \"Business process catalog\"\r\nPROCESS_NAME = \"<YOUR_PROCESS_NAME>\"                             # e.g. \"Business process catalog\"\r\nPAT = \"<YOUR_PERSONAL_ACCESS_TOKEN>\"                             # Azure DevOps PAT with full access\r\nEXCEL_FILE = \"ADO template guideline (Preview).xlsx\"     # Path to the Excel template file\r\nLOG_FILE = \"4_ADO_Backlog_Config_Log.txt\"\r\n\r\n# === AUTHENTICATION SETUP ===\r\nauthorization = str.encode(':' + PAT)\r\nb64_auth = base64.b64encode(authorization).decode()\r\nHEADERS = {\r\n    \"Content-Type\": \"application/json\",\r\n    \"Authorization\": f\"Basic {b64_auth}\"\r\n}\r\n\r\n# ---------------------------------------------------------------------------\r\n# Utilities\r\n# ---------------------------------------------------------------------------\r\n\r\ndef log(msg: str):\r\n    with open(LOG_FILE, \"a\", encoding=\"utf-8\") as f:\r\n        f.write(msg + \"\\n\")\r\n    print(msg)\r\n\r\n\r\ndef resolve_excel_path() -> str:\r\n    base_dir = os.path.dirname(os.path.abspath(__file__))\r\n    return EXCEL_FILE if os.path.isabs(EXCEL_FILE) else os.path.join(base_dir, EXCEL_FILE)\r\n\r\n\r\ndef make_request_with_retry(method: str, url: str, max_retries: int = 3, **kwargs):\r\n    \"\"\"Make an HTTP request with retry logic for transient errors (429, 503).\"\"\"\r\n    for attempt in range(max_retries):\r\n        resp = requests.request(method, url, **kwargs)\r\n        if resp.status_code == 429:\r\n            retry_after = int(resp.headers.get(\"Retry-After\", 5))\r\n            log(f\"  Rate limited (429). Retrying in {retry_after}s... (Attempt {attempt+1}/{max_retries})\")\r\n            time.sleep(retry_after)\r\n            continue\r\n        if resp.status_code == 503:\r\n            wait = 2 ** attempt\r\n            log(f\"  Service unavailable (503). Retrying in {wait}s... (Attempt {attempt+1}/{max_retries})\")\r\n            time.sleep(wait)\r\n            continue\r\n        return resp\r\n    return resp\r\n\r\n\r\ndef get_process_id() -> str:\r\n    \"\"\"Retrieve the process ID by name.\"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes?api-version=7.1-preview.2\"\r\n    resp = make_request_with_retry(\"GET\", url, headers=HEADERS)\r\n    if resp.status_code != 200:\r\n        log(f\"ERROR: Failed to list processes: {resp.status_code} - {resp.text}\")\r\n        sys.exit(1)\r\n    for proc in resp.json().get(\"value\", []):\r\n        if proc[\"name\"] == PROCESS_NAME:\r\n            return proc[\"typeId\"]\r\n    log(f\"ERROR: Process '{PROCESS_NAME}' not found.\")\r\n    sys.exit(1)\r\n\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 1: Configure Backlog Levels (Behaviors)\r\n# ---------------------------------------------------------------------------\r\n\r\n# Map from spreadsheet \"Backlog type\" to the parent behavior ref name for new portfolio backlogs\r\nPORTFOLIO_PARENT_BEHAVIOR = \"System.PortfolioBacklogBehavior\"\r\n\r\n# Map from spreadsheet \"Rename from\" values to known behavior display names and ref names\r\nKNOWN_BEHAVIOR_RENAMES = {\r\n    \"Epic\": \"Epics\",       # ADO default name is \"Epics\" (plural)\r\n    \"Feature\": \"Features\", # ADO default name is \"Features\" (plural)\r\n}\r\n\r\n# Known behavior reference names for fallback lookup when old name was already changed\r\nKNOWN_BEHAVIOR_REFS = {\r\n    \"Epic\": \"Microsoft.VSTS.Agile.EpicBacklogBehavior\",\r\n    \"Feature\": \"Microsoft.VSTS.Agile.FeatureBacklogBehavior\",\r\n}\r\n\r\n\r\ndef get_existing_behaviors(process_id: str) -> list[dict]:\r\n    \"\"\"Fetch all behaviors for the process.\"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/behaviors?api-version=7.1-preview.2\"\r\n    resp = make_request_with_retry(\"GET\", url, headers=HEADERS)\r\n    if resp.status_code != 200:\r\n        log(f\"ERROR: Failed to get behaviors: {resp.status_code} - {resp.text}\")\r\n        return []\r\n    return resp.json().get(\"value\", [])\r\n\r\n\r\ndef configure_backlog_levels(process_id: str, backlogs_df: pd.DataFrame) -> dict:\r\n    \"\"\"\r\n    Configure backlog levels by renaming existing behaviors and creating new ones.\r\n    Returns a dict mapping backlog_name -> behavior_refName for use in WIT assignment.\r\n    \"\"\"\r\n    log(\"\\n\" + \"=\" * 60)\r\n    log(\"PART 1: Configure Backlog Levels (Behaviors)\")\r\n    log(\"=\" * 60)\r\n\r\n    existing = get_existing_behaviors(process_id)\r\n    behavior_by_name = {b[\"name\"]: b for b in existing}\r\n    behavior_by_ref = {b[\"referenceName\"]: b for b in existing}\r\n\r\n    log(f\"Found {len(existing)} existing behaviors: {[b['name'] for b in existing]}\")\r\n\r\n    backlog_to_behavior_ref = {}  # backlog_name -> behavior referenceName\r\n\r\n    for _, row in backlogs_df.iterrows():\r\n        backlog_name = str(row[\"Backlog name\"]).strip()\r\n        backlog_type = str(row[\"Backlog type\"]).strip()\r\n        color = str(row[\"Color\"]).strip().lstrip(\"#\") if pd.notna(row[\"Color\"]) else None\r\n        rename_from = str(row[\"Rename from\"]).strip() if pd.notna(row[\"Rename from\"]) else None\r\n\r\n        log(f\"\\nProcessing backlog level: '{backlog_name}' (type: {backlog_type}, rename_from: {rename_from})\")\r\n\r\n        # Check if a behavior with the target name already exists (idempotency)\r\n        if backlog_name in behavior_by_name:\r\n            ref = behavior_by_name[backlog_name][\"referenceName\"]\r\n            log(f\"  Behavior '{backlog_name}' already exists (ref: {ref}). Skipping.\")\r\n            backlog_to_behavior_ref[backlog_name] = ref\r\n            continue\r\n\r\n        # RENAME: Find the existing behavior by its old name and rename it\r\n        if rename_from and rename_from not in (\"(new)\", \"(rename)\"):\r\n            # Try exact match first, then the known plural form\r\n            old_display = KNOWN_BEHAVIOR_RENAMES.get(rename_from, rename_from)\r\n            source_behavior = behavior_by_name.get(old_display)\r\n            if not source_behavior:\r\n                # Try exact rename_from value\r\n                source_behavior = behavior_by_name.get(rename_from)\r\n            # Also try lookup by known referenceName (in case behavior was previously renamed)\r\n            if not source_behavior:\r\n                known_ref = KNOWN_BEHAVIOR_REFS.get(rename_from)\r\n                if known_ref:\r\n                    source_behavior = behavior_by_ref.get(known_ref)\r\n                    if source_behavior:\r\n                        old_display = source_behavior[\"name\"]  # Use its current name for logging\r\n            if source_behavior:\r\n                ref = source_behavior[\"referenceName\"]\r\n                payload = {\"name\": backlog_name}\r\n                if color:\r\n                    payload[\"color\"] = color.lstrip(\"#\")\r\n                url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/behaviors/{ref}?api-version=7.1-preview.2\"\r\n                resp = make_request_with_retry(\"PUT\", url, headers=HEADERS, json=payload)\r\n                if resp.status_code in [200, 204]:\r\n                    log(f\"  Renamed behavior '{old_display}' → '{backlog_name}' (ref: {ref})\")\r\n                    backlog_to_behavior_ref[backlog_name] = ref\r\n                    # Update local cache\r\n                    behavior_by_name[backlog_name] = source_behavior\r\n                    behavior_by_name[backlog_name][\"name\"] = backlog_name\r\n                    if old_display in behavior_by_name and old_display != backlog_name:\r\n                        del behavior_by_name[old_display]\r\n                else:\r\n                    log(f\"  ERROR renaming '{old_display}' → '{backlog_name}': {resp.status_code} - {resp.text}\")\r\n                continue\r\n            else:\r\n                log(f\"  WARNING: Could not find behavior named '{old_display}' or '{rename_from}' to rename. Will try to create instead.\")\r\n\r\n        # RENAME by type: For \"(rename)\" entries, match by backlog type\r\n        if rename_from == \"(rename)\":\r\n            # Match by the backlog type category\r\n            type_to_ref = {\r\n                \"Requirements backlog\": \"System.RequirementBacklogBehavior\",\r\n                \"Iteration backlog\": \"System.TaskBacklogBehavior\",\r\n            }\r\n            target_ref = type_to_ref.get(backlog_type)\r\n            if target_ref and target_ref in behavior_by_ref:\r\n                source_behavior = behavior_by_ref[target_ref]\r\n                current_name = source_behavior[\"name\"]\r\n                if current_name == backlog_name:\r\n                    log(f\"  Behavior already named '{backlog_name}' (ref: {target_ref}). Skipping.\")\r\n                    backlog_to_behavior_ref[backlog_name] = target_ref\r\n                    continue\r\n                payload = {\"name\": backlog_name}\r\n                if color:\r\n                    payload[\"color\"] = color.lstrip(\"#\")\r\n                url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/behaviors/{target_ref}?api-version=7.1-preview.2\"\r\n                resp = make_request_with_retry(\"PUT\", url, headers=HEADERS, json=payload)\r\n                if resp.status_code in [200, 204]:\r\n                    log(f\"  Renamed behavior '{current_name}' → '{backlog_name}' (ref: {target_ref})\")\r\n                    backlog_to_behavior_ref[backlog_name] = target_ref\r\n                else:\r\n                    log(f\"  ERROR renaming '{current_name}' → '{backlog_name}': {resp.status_code} - {resp.text}\")\r\n                continue\r\n            else:\r\n                log(f\"  WARNING: Could not find behavior for type '{backlog_type}' to rename.\")\r\n\r\n        # CREATE NEW: For \"(new)\" entries, create a new portfolio behavior\r\n        if rename_from == \"(new)\" or rename_from is None:\r\n            payload = {\r\n                \"name\": backlog_name,\r\n                \"inherits\": PORTFOLIO_PARENT_BEHAVIOR,\r\n            }\r\n            if color:\r\n                payload[\"color\"] = color.lstrip(\"#\")\r\n            url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/behaviors?api-version=7.1-preview.2\"\r\n            resp = make_request_with_retry(\"POST\", url, headers=HEADERS, json=payload)\r\n            if resp.status_code in [200, 201]:\r\n                new_ref = resp.json().get(\"referenceName\", \"unknown\")\r\n                log(f\"  Created new behavior '{backlog_name}' (ref: {new_ref})\")\r\n                backlog_to_behavior_ref[backlog_name] = new_ref\r\n                # Update local cache\r\n                behavior_by_name[backlog_name] = resp.json()\r\n                behavior_by_ref[new_ref] = resp.json()\r\n            elif resp.status_code == 409:\r\n                log(f\"  Behavior '{backlog_name}' already exists (409). Fetching ref name...\")\r\n                refreshed = get_existing_behaviors(process_id)\r\n                for b in refreshed:\r\n                    if b[\"name\"] == backlog_name:\r\n                        backlog_to_behavior_ref[backlog_name] = b[\"referenceName\"]\r\n                        break\r\n            else:\r\n                log(f\"  ERROR creating behavior '{backlog_name}': {resp.status_code} - {resp.text}\")\r\n            continue\r\n\r\n    log(f\"\\nBacklog level configuration complete. Mappings: {json.dumps(backlog_to_behavior_ref, indent=2)}\")\r\n    return backlog_to_behavior_ref\r\n\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 2: Assign WITs to Backlog Levels\r\n# ---------------------------------------------------------------------------\r\n\r\ndef get_all_wit_refs(process_id: str) -> dict:\r\n    \"\"\"Fetch all WIT reference names from the process. Returns dict: short_name -> full_ref_name.\"\"\"\r\n    url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypes?api-version=7.1-preview.2\"\r\n    resp = make_request_with_retry(\"GET\", url, headers=HEADERS)\r\n    if resp.status_code != 200:\r\n        log(f\"ERROR: Failed to list WITs: {resp.status_code}\")\r\n        return {}\r\n    result = {}\r\n    for wit in resp.json().get(\"value\", []):\r\n        ref = wit[\"referenceName\"]\r\n        name = wit[\"name\"]\r\n        result[name] = ref\r\n        # Also index by lowercase for case-insensitive matching\r\n        result[name.lower()] = ref\r\n    return result\r\n\r\n\r\ndef assign_wits_to_backlogs(process_id: str, wit_df: pd.DataFrame,\r\n                             backlog_to_behavior_ref: dict):\r\n    \"\"\"Assign each WIT to its backlog level behavior based on the spreadsheet.\"\"\"\r\n    log(\"\\n\" + \"=\" * 60)\r\n    log(\"PART 2: Assign Work Item Types to Backlog Levels\")\r\n    log(\"=\" * 60)\r\n\r\n    wit_refs = get_all_wit_refs(process_id)\r\n    log(f\"Found {len(wit_refs)} work item types in process.\")\r\n\r\n    # Build the default WIT lookup from Backlogs sheet (passed via backlog_to_behavior_ref context)\r\n    # We need the backlogs_df for default WIT info — it's passed indirectly via the global scope\r\n    \r\n    assigned_count = 0\r\n    skipped_count = 0\r\n    error_count = 0\r\n\r\n    for _, row in wit_df.iterrows():\r\n        wit_name = str(row[\"Work item type\"]).strip()\r\n        backlog_name = str(row.get(\"Backlog name\", \"\")).strip()\r\n        custom_flag = str(row.get(\"Custom work item type\", \"\")).strip().lower()\r\n\r\n        # Skip WITs with no backlog or \"No associated backlog\"\r\n        if not backlog_name or backlog_name == \"No associated backlog\" or pd.isna(row.get(\"Backlog name\")):\r\n            continue\r\n\r\n        # Skip disabled WITs\r\n        if custom_flag == \"disabled\":\r\n            log(f\"  Skipping '{wit_name}' — disabled WIT.\")\r\n            skipped_count += 1\r\n            continue\r\n\r\n        # Find the behavior ref for this backlog\r\n        behavior_ref = backlog_to_behavior_ref.get(backlog_name)\r\n        if not behavior_ref:\r\n            log(f\"  WARNING: No behavior found for backlog '{backlog_name}' (WIT: {wit_name}). Skipping.\")\r\n            skipped_count += 1\r\n            continue\r\n\r\n        # Find the WIT ref name (try exact, then case-insensitive)\r\n        wit_ref = wit_refs.get(wit_name) or wit_refs.get(wit_name.lower())\r\n        if not wit_ref:\r\n            log(f\"  WARNING: WIT '{wit_name}' not found in process. Skipping.\")\r\n            skipped_count += 1\r\n            continue\r\n\r\n        # Check current behavior assignment\r\n        check_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{wit_ref}/behaviors?api-version=7.1-preview.1\"\r\n        check_resp = make_request_with_retry(\"GET\", check_url, headers=HEADERS)\r\n        current_behaviors = []\r\n        if check_resp.status_code == 200:\r\n            current_behaviors = check_resp.json().get(\"value\", [])\r\n\r\n        already_assigned = False\r\n        for cb in current_behaviors:\r\n            if cb.get(\"behavior\", {}).get(\"id\") == behavior_ref:\r\n                already_assigned = True\r\n                break\r\n\r\n        if already_assigned:\r\n            log(f\"  '{wit_name}' already assigned to '{backlog_name}'. Skipping.\")\r\n            skipped_count += 1\r\n            continue\r\n\r\n        # Remove existing behavior assignments before adding new one\r\n        for cb in current_behaviors:\r\n            old_ref = cb.get(\"behavior\", {}).get(\"id\")\r\n            if old_ref:\r\n                del_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{wit_ref}/behaviors/{old_ref}?api-version=7.1-preview.1\"\r\n                del_resp = make_request_with_retry(\"DELETE\", del_url, headers=HEADERS)\r\n                if del_resp.status_code in [200, 204]:\r\n                    log(f\"  Removed old behavior '{old_ref}' from '{wit_name}'.\")\r\n                else:\r\n                    log(f\"  WARNING: Could not remove old behavior '{old_ref}' from '{wit_name}': {del_resp.status_code}\")\r\n\r\n        # Assign to new behavior\r\n        payload = {\r\n            \"behavior\": {\"id\": behavior_ref},\r\n            \"isDefault\": False\r\n        }\r\n        add_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{wit_ref}/behaviors?api-version=7.1-preview.1\"\r\n        resp = make_request_with_retry(\"POST\", add_url, headers=HEADERS, json=payload)\r\n        if resp.status_code in [200, 201]:\r\n            log(f\"  Assigned '{wit_name}' → '{backlog_name}' (behavior: {behavior_ref})\")\r\n            assigned_count += 1\r\n        elif resp.status_code == 409:\r\n            log(f\"  '{wit_name}' already assigned (409). Skipping.\")\r\n            skipped_count += 1\r\n        else:\r\n            log(f\"  ERROR assigning '{wit_name}' → '{backlog_name}': {resp.status_code} - {resp.text}\")\r\n            error_count += 1\r\n\r\n    log(f\"\\nWIT assignment complete. Assigned: {assigned_count}, Skipped: {skipped_count}, Errors: {error_count}\")\r\n\r\n    # Now set default WITs for each backlog level\r\n    log(\"\\nSetting default work item types for backlog levels...\")\r\n    set_default_wits(process_id, backlog_to_behavior_ref, wit_refs)\r\n\r\n\r\ndef set_default_wits(process_id: str, backlog_to_behavior_ref: dict, wit_refs: dict):\r\n    \"\"\"Set the default WIT for each backlog level using the Backlogs sheet.\"\"\"\r\n    # Re-read the Backlogs sheet for default WIT info\r\n    excel_path = resolve_excel_path()\r\n    backlogs_df = pd.read_excel(excel_path, sheet_name=\"Backlogs\")\r\n    backlogs_df.columns = backlogs_df.columns.str.strip()\r\n\r\n    for _, row in backlogs_df.iterrows():\r\n        backlog_name = str(row[\"Backlog name\"]).strip()\r\n        default_wit_name = str(row[\"Default work item type\"]).strip()\r\n        behavior_ref = backlog_to_behavior_ref.get(backlog_name)\r\n\r\n        if not behavior_ref:\r\n            continue\r\n\r\n        default_wit_ref = wit_refs.get(default_wit_name) or wit_refs.get(default_wit_name.lower())\r\n        if not default_wit_ref:\r\n            log(f\"  WARNING: Default WIT '{default_wit_name}' for backlog '{backlog_name}' not found in process.\")\r\n            continue\r\n\r\n        # Check if this WIT is already assigned and set isDefault\r\n        check_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{default_wit_ref}/behaviors?api-version=7.1-preview.1\"\r\n        check_resp = make_request_with_retry(\"GET\", check_url, headers=HEADERS)\r\n        if check_resp.status_code != 200:\r\n            log(f\"  WARNING: Could not check behaviors for '{default_wit_name}': {check_resp.status_code}\")\r\n            continue\r\n\r\n        current = check_resp.json().get(\"value\", [])\r\n        already_default = False\r\n        for cb in current:\r\n            if cb.get(\"behavior\", {}).get(\"id\") == behavior_ref and cb.get(\"isDefault\"):\r\n                already_default = True\r\n                break\r\n\r\n        if already_default:\r\n            log(f\"  '{default_wit_name}' is already the default for '{backlog_name}'. Skipping.\")\r\n            continue\r\n\r\n        # Remove and re-add with isDefault=True\r\n        for cb in current:\r\n            if cb.get(\"behavior\", {}).get(\"id\") == behavior_ref:\r\n                del_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{default_wit_ref}/behaviors/{behavior_ref}?api-version=7.1-preview.1\"\r\n                make_request_with_retry(\"DELETE\", del_url, headers=HEADERS)\r\n                break\r\n\r\n        payload = {\r\n            \"behavior\": {\"id\": behavior_ref},\r\n            \"isDefault\": True\r\n        }\r\n        add_url = f\"{ADO_ORG_URL}/_apis/work/processes/{process_id}/workitemtypesbehaviors/{default_wit_ref}/behaviors?api-version=7.1-preview.1\"\r\n        resp = make_request_with_retry(\"POST\", add_url, headers=HEADERS, json=payload)\r\n        if resp.status_code in [200, 201]:\r\n            log(f\"  Set '{default_wit_name}' as default for '{backlog_name}'\")\r\n        else:\r\n            log(f\"  ERROR setting default '{default_wit_name}' for '{backlog_name}': {resp.status_code} - {resp.text}\")\r\n\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 3: Create Iteration Paths\r\n# ---------------------------------------------------------------------------\r\n\r\ndef create_iteration_paths(iterations_df: pd.DataFrame) -> dict:\r\n    \"\"\"\r\n    Create hierarchical iteration paths from the Iteration paths sheet.\r\n    Returns a dict of iteration_path -> identifier (GUID) for team assignment.\r\n    \"\"\"\r\n    log(\"\\n\" + \"=\" * 60)\r\n    log(\"PART 3: Create Iteration Paths\")\r\n    log(\"=\" * 60)\r\n\r\n    encoded_project = urllib.parse.quote(ADO_PROJECT)\r\n    base_url = f\"{ADO_ORG_URL}/{encoded_project}/_apis/wit/classificationnodes/Iterations\"\r\n\r\n    # Parse hierarchical structure (same pattern as area paths in Script 3)\r\n    levels = [c for c in iterations_df.columns if c.startswith(\"Level\")]\r\n    if not levels:\r\n        log(\"WARNING: No 'Level' columns found in Iteration paths sheet. Skipping.\")\r\n        return {}\r\n\r\n    log(f\"Found {len(levels)} levels in Iteration paths sheet: {levels}\")\r\n\r\n    current_parents = {}  # level_index -> current parent name\r\n    created_paths = []\r\n\r\n    for _, row in iterations_df.iterrows():\r\n        for i, level_col in enumerate(levels):\r\n            val = row[level_col]\r\n            if pd.isna(val):\r\n                continue\r\n            node_name = str(val).strip()\r\n            if not node_name:\r\n                continue\r\n\r\n            # Build the path components\r\n            path_parts = []\r\n            for j in range(i):\r\n                parent = current_parents.get(j)\r\n                if parent:\r\n                    path_parts.append(parent)\r\n            path_parts.append(node_name)\r\n\r\n            # Update current parent tracking\r\n            current_parents[i] = node_name\r\n            # Clear children levels\r\n            for j in range(i + 1, len(levels)):\r\n                current_parents.pop(j, None)\r\n\r\n            # Create the iteration node\r\n            parent_path = \"/\".join(path_parts[:-1])\r\n            if parent_path:\r\n                url = f\"{base_url}/{urllib.parse.quote(parent_path, safe='/')}?api-version=7.1\"\r\n            else:\r\n                url = f\"{base_url}?api-version=7.1\"\r\n\r\n            payload = {\"name\": node_name}\r\n            resp = make_request_with_retry(\"POST\", url, headers=HEADERS, json=payload)\r\n            full_path = \"/\".join(path_parts)\r\n\r\n            if resp.status_code in [200, 201]:\r\n                log(f\"  Created iteration: {full_path}\")\r\n                created_paths.append(full_path)\r\n            elif resp.status_code == 409:\r\n                log(f\"  Already exists: {full_path}\")\r\n                created_paths.append(full_path)\r\n            else:\r\n                log(f\"  ERROR creating iteration '{full_path}': {resp.status_code} - {resp.text}\")\r\n\r\n    # Fetch all iteration nodes with GUIDs for team assignment\r\n    log(\"\\nFetching iteration node identifiers...\")\r\n    iteration_map = {}\r\n    fetch_url = f\"{base_url}?$depth=10&api-version=7.1\"\r\n    resp = make_request_with_retry(\"GET\", fetch_url, headers=HEADERS)\r\n    if resp.status_code == 200:\r\n        _collect_iteration_ids(resp.json(), \"\", iteration_map)\r\n        log(f\"  Collected {len(iteration_map)} iteration node identifiers.\")\r\n    else:\r\n        log(f\"  WARNING: Could not fetch iteration tree: {resp.status_code}\")\r\n\r\n    log(f\"\\nIteration path creation complete. Created/verified: {len(created_paths)}\")\r\n    return iteration_map\r\n\r\n\r\ndef _collect_iteration_ids(node: dict, parent_path: str, result: dict):\r\n    \"\"\"Recursively collect iteration node paths and their identifiers.\"\"\"\r\n    name = node.get(\"name\", \"\")\r\n    current_path = f\"{parent_path}/{name}\" if parent_path else name\r\n    identifier = node.get(\"identifier\")\r\n    if identifier:\r\n        result[current_path] = identifier\r\n        # Also store by name only for simple lookups\r\n        result[name] = identifier\r\n    for child in node.get(\"children\", []):\r\n        _collect_iteration_ids(child, current_path, result)\r\n\r\n\r\n# ---------------------------------------------------------------------------\r\n# Part 4: Configure Team Settings\r\n# ---------------------------------------------------------------------------\r\n\r\ndef configure_team_settings(teams_df: pd.DataFrame, iteration_map: dict):\r\n    \"\"\"\r\n    Configure team settings: bug behavior, backlog iteration, iterations, and include sub areas.\r\n    \"\"\"\r\n    log(\"\\n\" + \"=\" * 60)\r\n    log(\"PART 4: Configure Team Settings\")\r\n    log(\"=\" * 60)\r\n\r\n    encoded_project = urllib.parse.quote(ADO_PROJECT)\r\n\r\n    # Get the root iteration identifier (project root)\r\n    root_iteration_id = iteration_map.get(ADO_PROJECT)\r\n\r\n    # Collect all iteration node IDs (non-root) for assigning all iterations to teams\r\n    all_iteration_ids = []\r\n    for path, guid in iteration_map.items():\r\n        if path != ADO_PROJECT and guid != root_iteration_id:\r\n            all_iteration_ids.append({\"id\": guid, \"path\": path})\r\n\r\n    success_count = 0\r\n    error_count = 0\r\n\r\n    for _, row in teams_df.iterrows():\r\n        team_name = str(row[\"Teams\"]).strip()\r\n        bug_behavior = str(row.get(\"Bug behavior\", \"asRequirements\")).strip()\r\n        include_sub_areas = str(row.get(\"Include sub areas\", \"Yes\")).strip().lower() in (\"yes\", \"true\", \"1\")\r\n        backlog_iteration_value = str(row.get(\"Backlog iteration\", \"@currentIteration\")).strip()\r\n\r\n        encoded_team = urllib.parse.quote(team_name)\r\n        log(f\"\\nConfiguring team: '{team_name}'\")\r\n\r\n        # --- 4a: Update team settings (bug behavior, backlog iteration) ---\r\n        settings_url = f\"{ADO_ORG_URL}/{encoded_project}/{encoded_team}/_apis/work/teamsettings?api-version=7.1\"\r\n\r\n        # First GET current settings to check if team exists\r\n        get_resp = make_request_with_retry(\"GET\", settings_url, headers=HEADERS)\r\n        if get_resp.status_code == 404:\r\n            log(f\"  Team '{team_name}' not found (404). Skipping.\")\r\n            error_count += 1\r\n            continue\r\n        elif get_resp.status_code != 200:\r\n            log(f\"  ERROR getting settings for '{team_name}': {get_resp.status_code} - {get_resp.text}\")\r\n            error_count += 1\r\n            continue\r\n\r\n        current_settings = get_resp.json()\r\n\r\n        # Build PATCH payload for team settings\r\n        settings_payload = {}\r\n\r\n        # Bug behavior\r\n        current_bug = current_settings.get(\"bugsBehavior\", \"\")\r\n        if current_bug != bug_behavior:\r\n            settings_payload[\"bugsBehavior\"] = bug_behavior\r\n\r\n        # Backlog iteration — set to root iteration (all iterations visible)\r\n        if backlog_iteration_value.lower() == \"@currentiteration\":\r\n            # Use the root iteration node as the backlog iteration\r\n            if root_iteration_id:\r\n                current_backlog_iter = current_settings.get(\"backlogIteration\", {}).get(\"id\", \"\")\r\n                if current_backlog_iter != root_iteration_id:\r\n                    settings_payload[\"backlogIteration\"] = root_iteration_id\r\n            # Set default iteration macro\r\n            current_macro = current_settings.get(\"defaultIterationMacro\", \"\")\r\n            if current_macro != \"@CurrentIteration\":\r\n                settings_payload[\"defaultIterationMacro\"] = \"@CurrentIteration\"\r\n        else:\r\n            # Use a specific iteration path\r\n            iter_id = iteration_map.get(backlog_iteration_value)\r\n            if iter_id:\r\n                settings_payload[\"backlogIteration\"] = iter_id\r\n\r\n        if settings_payload:\r\n            patch_resp = make_request_with_retry(\"PATCH\", settings_url, headers=HEADERS, json=settings_payload)\r\n            if patch_resp.status_code in [200, 204]:\r\n                log(f\"  Updated team settings: {list(settings_payload.keys())}\")\r\n            else:\r\n                log(f\"  ERROR updating settings: {patch_resp.status_code} - {patch_resp.text}\")\r\n                error_count += 1\r\n        else:\r\n            log(f\"  Team settings already configured correctly.\")\r\n\r\n        # --- 4b: Add iterations to team ---\r\n        iterations_url = f\"{ADO_ORG_URL}/{encoded_project}/{encoded_team}/_apis/work/teamsettings/iterations?api-version=7.1\"\r\n\r\n        # Get current team iterations\r\n        iter_resp = make_request_with_retry(\"GET\", iterations_url, headers=HEADERS)\r\n        current_iter_ids = set()\r\n        if iter_resp.status_code == 200:\r\n            for it in iter_resp.json().get(\"value\", []):\r\n                current_iter_ids.add(it.get(\"id\", \"\"))\r\n\r\n        added = 0\r\n        for iter_info in all_iteration_ids:\r\n            if iter_info[\"id\"] in current_iter_ids:\r\n                continue\r\n            add_payload = {\"id\": iter_info[\"id\"]}\r\n            add_resp = make_request_with_retry(\"POST\", iterations_url, headers=HEADERS, json=add_payload)\r\n            if add_resp.status_code in [200, 201]:\r\n                added += 1\r\n            elif add_resp.status_code == 409:\r\n                pass  # Already exists\r\n            else:\r\n                log(f\"  WARNING: Could not add iteration '{iter_info['path']}' to team: {add_resp.status_code}\")\r\n\r\n        if added > 0:\r\n            log(f\"  Added {added} iterations to team.\")\r\n        else:\r\n            log(f\"  All iterations already assigned.\")\r\n\r\n        # --- 4c: Update area paths to include sub areas ---\r\n        if include_sub_areas:\r\n            _update_team_area_include_children(encoded_project, encoded_team, team_name)\r\n\r\n        success_count += 1\r\n\r\n    log(f\"\\nTeam settings configuration complete. Success: {success_count}, Errors: {error_count}\")\r\n\r\n\r\ndef _update_team_area_include_children(encoded_project: str, encoded_team: str, team_name: str):\r\n    \"\"\"Update team area paths to include children.\"\"\"\r\n    areas_url = f\"{ADO_ORG_URL}/{encoded_project}/{encoded_team}/_apis/work/teamsettings/teamfieldvalues?api-version=7.1\"\r\n    resp = make_request_with_retry(\"GET\", areas_url, headers=HEADERS)\r\n    if resp.status_code != 200:\r\n        log(f\"  WARNING: Could not get area settings for '{team_name}': {resp.status_code}\")\r\n        return\r\n\r\n    current = resp.json()\r\n    default_value = current.get(\"defaultValue\", \"\")\r\n    values = current.get(\"values\", [])\r\n\r\n    # Check if any area needs includeChildren updated\r\n    needs_update = False\r\n    updated_values = []\r\n    for v in values:\r\n        new_v = {\"value\": v[\"value\"], \"includeChildren\": True}\r\n        if not v.get(\"includeChildren\", False):\r\n            needs_update = True\r\n        updated_values.append(new_v)\r\n\r\n    if not needs_update:\r\n        log(f\"  Area paths already include children.\")\r\n        return\r\n\r\n    patch_payload = {\r\n        \"defaultValue\": default_value,\r\n        \"values\": updated_values\r\n    }\r\n    patch_resp = make_request_with_retry(\"PATCH\", areas_url, headers=HEADERS, json=patch_payload)\r\n    if patch_resp.status_code in [200, 204]:\r\n        log(f\"  Updated area paths to include children.\")\r\n    else:\r\n        log(f\"  WARNING: Could not update area children: {patch_resp.status_code} - {patch_resp.text}\")\r\n\r\n\r\n# ---------------------------------------------------------------------------\r\n# Orchestration\r\n# ---------------------------------------------------------------------------\r\n\r\ndef main():\r\n    # Reset log\r\n    if os.path.exists(LOG_FILE):\r\n        os.remove(LOG_FILE)\r\n\r\n    start_time = time.time()\r\n    log(f\"ADO Backlog Configuration Script started at {time.strftime('%Y-%m-%d %H:%M:%S')}\")\r\n    log(f\"Organization: {ADO_ORG_URL}\")\r\n    log(f\"Project: {ADO_PROJECT}\")\r\n    log(f\"Process: {PROCESS_NAME}\")\r\n\r\n    excel_path = resolve_excel_path()\r\n    if not os.path.exists(excel_path):\r\n        log(f\"ERROR: Excel file not found: {excel_path}\")\r\n        sys.exit(1)\r\n\r\n    # Get process ID\r\n    process_id = get_process_id()\r\n    log(f\"Process ID: {process_id}\")\r\n\r\n    # Read spreadsheet sheets\r\n    try:\r\n        backlogs_df = pd.read_excel(excel_path, sheet_name=\"Backlogs\")\r\n        backlogs_df.columns = backlogs_df.columns.str.strip()\r\n        log(f\"Read {len(backlogs_df)} rows from 'Backlogs' sheet.\")\r\n    except Exception as e:\r\n        log(f\"ERROR: Could not read 'Backlogs' sheet: {e}\")\r\n        sys.exit(1)\r\n\r\n    try:\r\n        wit_df = pd.read_excel(excel_path, sheet_name=\"Work item types\")\r\n        wit_df.columns = wit_df.columns.str.strip()\r\n        log(f\"Read {len(wit_df)} rows from 'Work item types' sheet.\")\r\n    except Exception as e:\r\n        log(f\"ERROR: Could not read 'Work item types' sheet: {e}\")\r\n        sys.exit(1)\r\n\r\n    try:\r\n        iterations_df = pd.read_excel(excel_path, sheet_name=\"Iteration paths\")\r\n        iterations_df.columns = iterations_df.columns.str.strip()\r\n        log(f\"Read {len(iterations_df)} rows from 'Iteration paths' sheet.\")\r\n    except Exception as e:\r\n        log(f\"WARNING: Could not read 'Iteration paths' sheet: {e}. Skipping iteration creation.\")\r\n        iterations_df = None\r\n\r\n    try:\r\n        teams_df = pd.read_excel(excel_path, sheet_name=\"Teams\")\r\n        teams_df.columns = teams_df.columns.str.strip()\r\n        log(f\"Read {len(teams_df)} rows from 'Teams' sheet.\")\r\n    except Exception as e:\r\n        log(f\"WARNING: Could not read 'Teams' sheet: {e}. Skipping team settings.\")\r\n        teams_df = None\r\n\r\n    # Part 1: Configure backlog levels\r\n    backlog_to_behavior_ref = configure_backlog_levels(process_id, backlogs_df)\r\n\r\n    # Part 2: Assign WITs to backlog levels\r\n    assign_wits_to_backlogs(process_id, wit_df, backlog_to_behavior_ref)\r\n\r\n    # Part 3: Create iteration paths\r\n    iteration_map = {}\r\n    if iterations_df is not None and not iterations_df.empty:\r\n        iteration_map = create_iteration_paths(iterations_df)\r\n    else:\r\n        log(\"\\nSkipping iteration path creation (no data).\")\r\n        # Still fetch existing iterations for team settings\r\n        encoded_project = urllib.parse.quote(ADO_PROJECT)\r\n        fetch_url = f\"{ADO_ORG_URL}/{encoded_project}/_apis/wit/classificationnodes/Iterations?$depth=10&api-version=7.1\"\r\n        resp = make_request_with_retry(\"GET\", fetch_url, headers=HEADERS)\r\n        if resp.status_code == 200:\r\n            _collect_iteration_ids(resp.json(), \"\", iteration_map)\r\n\r\n    # Part 4: Configure team settings\r\n    if teams_df is not None and not teams_df.empty:\r\n        configure_team_settings(teams_df, iteration_map)\r\n    else:\r\n        log(\"\\nSkipping team settings configuration (no data).\")\r\n\r\n    # Summary\r\n    elapsed = time.time() - start_time\r\n    log(\"\\n\" + \"=\" * 60)\r\n    log(\"SUMMARY\")\r\n    log(\"=\" * 60)\r\n    log(f\"Elapsed time: {elapsed:.1f} seconds ({elapsed/60:.2f} minutes)\")\r\n    log(f\"Script finished. See log file for details:\")\r\n    log(f\"  {LOG_FILE}\")\r\n    log(\"=\" * 60)\r\n\r\n\r\nif __name__ == \"__main__\":\r\n    main()\r\n"
  },
  {
    "path": "templates/Azure-DevOps-templates/README.md",
    "content": "# Azure DevOps template for the Microsoft business process catalog (Preview)\nThis folder contains four python scripts and one Excel template that define an Azure DevOps process, project, work item types, fields, area paths, and much more. To learn how to use the python scripts, visit the Dynamics 365 Guidance Hub. Each script has a set of instructions and steps that must be performed manually before running the next script. Below are the high-level steps to follow.\n\n1. [Automate Azure DevOps project, process, work item types, fields, and picklists from Excel with Python](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-configure-azure-devops-project-processes)\n2. [Automate Azure DevOps page layout creation with Python](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-configure-azure-devops-page-layout)\n3. [Automate the creation of Azure DevOps teams and area paths with Python scripts](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-configure-azure-devops-teams-area-paths)\n4. [Azure DevOps backlog configuration for the Microsoft Business Process Catalog](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-configure-azure-devops-backlog)\n\nTo find guidance for troubleshooting common issues with the Python scripts, see [Troubleshooting the Azure DevOps Python Scripts (Preview)](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-configure-azure-devops-troubleshooting).\n\nOnce you have configured and setup your Azure DevOps project, you can import the business process catalog into your Azure DevOps. Download the latest version of the business process catalog by visiting [https://aka.ms/businessprocesscatalog](https://aka.ms/businessprocesscatalog). To learn more about how to import the catalog, see [Import the catalog into Azure DevOps](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-import-catalog-devops).\n"
  },
  {
    "path": "templates/business-processes/README.md",
    "content": "If you have landed on this page looking for the business process catalog, you can find the catalog on the Microsoft Download Center by navigating to [https://aka.ms/businessprocesscatalog](https://aka.ms/businessprocesscatalog)\n"
  },
  {
    "path": "templates/business-processes/import-business-processes-ADO.md",
    "content": "---\ndate: 11/14/2023\nauthor: rachel-profitt\n---\n\n# Import the business process catalog into Azure DevOps\n\nThis article is replaced by an article in the [Dynamics 365 guidance hub](https://learn.microsoft.com/en-us/dynamics365/guidance/). Learn more at [Import the business process catalog into Azure DevOps](https://learn.microsoft.com/en-us/dynamics365/guidance/business-processes/about-import-catalog-devops).  \n"
  },
  {
    "path": "templates/reference-architectures.md",
    "content": "# Reference architectures and design patterns\n\nWe welcome contributions of architectural guidance, including solution ideas and design patterns. If you have a best practice or reference implementation, submit your proposal either to [the Azure team](https://learn.microsoft.com/contribute/architecture-center/aac-contribute) or to us in [Dynamics 365](https://learn.microsoft.com/dynamics365/get-started/contribute#dynamics-365-guidance-content).  \n\nFetch the appropriate Markdown templates from the [guidance-templates](https://github.com/MicrosoftDocs/dynamics365-docs-templates/tree/main/guidance-templates) folder in the [dynamics365-docs-templates](https://github.com/MicrosoftDocs/dynamics365-docs-templates/) GitHub repo.  \n\nLearn more at [Contribute to Microsoft content for Dynamics 365](https://learn.microsoft.com/dynamics365/get-started/contribute#dynamics-365-guidance-content).  \n"
  },
  {
    "path": "workshops/README.md",
    "content": "\n\n# Workshops templates\nThe business process catalog includes comprehensive set of workshop templates designed to streamline collaboration and decision-making across key business areas. These templates are structured to enhance engagement and ensure precise alignment with business goals. Below, you’ll find the core structure of the workshops, a detailed list of processes that include workshops in this release, and an overview of the three distinct workshop types: Storyboard, Storyline Design Review, and Deep-Dive Design.\n\n## Workshop template structure\nEach workshop template includes:\n-\tA clear agenda to guide participants through the session’s objectives.\n-\tPre-defined tools and resources tailored to facilitate effective collaboration.\n-\tComprehensive instructions to ensure consistency and alignment with best practices.\n\nThe templates are designed to be flexible and scalable, accommodating the unique needs of sellers, technical sellers, solution architects, and business analysts or functional consultants.\n\n## Processes featuring workshops \nThe following end-to-end processes include Word document templates for the business process catalog\n- Acquire to dispose\n-\tDesign to retire\n-\tForecast to plan\n-\tInventory to deliver\n-\tHire to retire\n- Order to cash\n- Prospect to quote (Public Preview)\n- Source to pay\n\n## Workshop types\nThree workshop types are included for each end-to-end process. Each workshop template is tailored to specific stages of the business process design and refinement.\n-\tStoryboard design workshops\n\nThese workshops are highly visual and focus on mapping out the entire business process. Participants collaborate to create a high-level overview of the business processes, scenarios, and objectives, identifying key milestones and potential bottlenecks. The objective is to establish a shared vision and roadmap. These workshops are recommended to be run early in the pre-sales stage of an engagement to help the team get a better understanding of the customers business needs and create a demo plan. \n\nEach storyboard design workshop includes the following components:\n  -\tA storyboard graphic in the Visio file that is available for the end-to-end business process in the GitHub repository. The Visio files can be downloaded at https://aka.ms/businessprocessflow.  \n  -\tA Word document template for the workshop. The Word document template files can be downloaded at https://aka.ms/businessprocessworkshops. \n  -\tWork items in the Azure DevOps template. This includes one Workshop type work item for the overall workshop including the details of the workshop from the template, and Tasks that are children work items under the parent Workshop work item. \n\n-\tStoryline design review workshops\n\nIn these sessions, the primary scenario is demonstrated to the customer in Dynamics 365. Teams delve into the specifics of the business process in Dynamics 365. The goal is to review and refine the storyline behind the process and conduct a fit-to-standard analysis, ensuring all steps are aligned with strategic objectives. Feedback loops are built into the session to address gaps or inconsistencies. \n\nEach Storyline design review workshop includes the following components:\n  -\tA Word document template for the workshop. The Word document template files can be downloaded at https://aka.ms/businessprocessworkshops. \n  -\tWork items in the Azure DevOps template. This includes one Workshop type work item for the overall workshop including the details of the workshop from the template, and Tasks that are children work items under the parent Workshop work item. \n\n-\tDeep-dive design workshops\n\nThese workshops are designed for thorough exploration of intricate process details. The focus is on addressing complex challenges, testing assumptions, and finalizing designs. They are particularly useful for processes requiring cross-functional alignment and technical input. These workshops are intended to be run by the implementation team in the Implement phase of a project. Each level two business process area in the catalog includes at least one Deep-dive design workshop. However, some business process areas may include multiple workshops. \n\n  -\tA Word document template for the workshop. The Word document template files can be downloaded at https://aka.ms/businessprocessworkshops.\n  -\tWork items in the Azure DevOps template. This includes one Workshop type work item for the overall workshop including the details of the workshop from the template. These workshops do not include detailed tasks. However, the catalog includes Configuration deliverables which make up much of the work functional consultants would need to do in order for the process to work according to the customers business requirements. \n\n## Using the workshop templates in Azure DevOps\n\nWhile there is not one specific way to use the templates and work items provided in the business process catalog, the following list of tips and tricks can be used to help ensure good management and governance of the process:\n-\tDocument each business requirement using Requirement type work items. \n- Document Risks, Issues, Actions, and Decisions (RAID) log items using the related work item types. \n- Create Task type work items to track specific tasks that need to be completed or followed up on by the project team. \n- Work items should be linked to the lowest possible level of the business process catalog, typically level four scenarios. However, if a business requirement is more general, it may be appropriate to link it to a higher-level business process or area.\n\n"
  }
]