├── .github └── workflows │ └── migrate-work-items.yml ├── .gitignore ├── LICENSE ├── README.md └── ado_workitems_to_github_issues.ps1 /.github/workflows/migrate-work-items.yml: -------------------------------------------------------------------------------- 1 | name: Migrate Work Items 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | ado-org: 7 | description: 'ado-org' 8 | required: true 9 | default: 'jjohanning0798' 10 | ado-project: 11 | description: 'ado-project' 12 | required: true 13 | default: 'PartsUnlimited' 14 | ado_area_path: 15 | description: 'ADO area path to migrate - uses the UNDER operator' 16 | required: true 17 | default: 'migrate' 18 | ado_migrate_closed_workitems: 19 | description: 'Migrate closed work items' 20 | required: true 21 | type: boolean 22 | default: 'true' 23 | ado_production_run: 24 | description: tag migrated work items with migrated-to-github and add discussion comment 25 | required: true 26 | type: boolean 27 | default: 'false' 28 | gh-org: 29 | description: 'gh-org' 30 | required: true 31 | default: 'joshjohanning-org' 32 | gh-repo: 33 | description: 'gh-org' 34 | required: true 35 | default: 'migrate-ado-workitems' 36 | gh_update_assigned_to: 37 | description: 'Update Assigned To' 38 | required: true 39 | type: boolean 40 | default: 'true' 41 | gh_assigned_to_user_suffix: 42 | description: 'EMU suffix' 43 | required: true 44 | default: '_corp' 45 | gh_add_ado_comments: 46 | description: 'Add ADO Comments' 47 | required: true 48 | type: boolean 49 | default: 'true' 50 | 51 | jobs: 52 | migrate: 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - uses: actions/checkout@v4 57 | 58 | - name: get az and az devops version 59 | run: az --version 60 | 61 | - name: get gh version 62 | run: gh --version 63 | 64 | # doesn't work with the (unofficial) issue migration API? 65 | # - uses: actions/create-github-app-token@v1 66 | # id: app-token 67 | # with: 68 | # app-id: 179484 # work-item-migrator 69 | # private-key: ${{ secrets.PRIVATE_KEY }} 70 | # owner: ${{ github.repository_owner }} 71 | 72 | - name: run migration 73 | shell: bash 74 | run: | 75 | ado_migrate_closed_workitems_param="" 76 | ado_production_run_param="" 77 | gh_update_assigned_to_param="" 78 | gh_add_ado_comments_param="" 79 | 80 | if [ "${{ github.event.inputs.ado_migrate_closed_workitems }}" == "true" ]; then 81 | ado_migrate_closed_workitems_param="--ado_migrate_closed_workitems" 82 | fi 83 | 84 | if [ "${{ github.event.inputs.ado_production_run }}" == "true" ]; then 85 | ado_production_run_param="--ado_production_run" 86 | fi 87 | 88 | if [ "${{ github.event.inputs.gh_update_assigned_to }}" == "true" ]; then 89 | gh_update_assigned_to_param="--gh_update_assigned_to" 90 | fi 91 | 92 | if [ "${{ github.event.inputs.gh_add_ado_comments }}" == "true" ]; then 93 | gh_add_ado_comments_param="--gh_add_ado_comments" 94 | fi 95 | 96 | pwsh ./ado_workitems_to_github_issues.ps1 -ado_pat "${{ secrets.ADO_PAT }}" -ado_org "${{ github.event.inputs.ado-org }}" -ado_project "${{ github.event.inputs.ado-project }}" -ado_area_path "${{ github.event.inputs.ado_area_path }}" $ado_migrate_closed_workitems_param $ado_production_run_param -gh_pat "${{ secrets.GH_PAT }}" -gh_org "${{ github.event.inputs.gh-org }}" -gh_repo "${{ github.event.inputs.gh-repo }}" $gh_update_assigned_to_param -gh_assigned_to_user_suffix "${{ github.event.inputs.gh_assigned_to_user_suffix }}" $gh_add_ado_comments_param 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | temp_issue_body.txt 2 | temp_comment_body.txt 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Josh Johanning 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ado_workitems_to_github_issues 2 | 3 | PowerShell script to migrate Azure DevOps work items to GitHub Issues 4 | 5 | ### Prerequisites 6 | 7 | 1. Install az devops and github cli where this is running (ie: action or locally; GitHub-hosted runners already have) 8 | 2. In GitHub, [create a label](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels) for EACH work item type that is being migrated (as lower case) 9 | - ie: "user story", "bug", "task", "feature" 10 | 3. Define under what area path you want to migrate 11 | - You can modify the WIQL if you want to use a different way to migrate work items, such as `[TAG] = "migrate"` 12 | 13 | ### Things it migrates 14 | 15 | 1. Title 16 | 2. Description (or for a bug, repro steps and/or system info) 17 | 3. State (if the work item is done / closed, it will be closed in GitHub) 18 | 4. It will try to assign the work item to the correct user in GitHub - based on ADO email before the `@` 19 | - This uses the `-gh_update_assigned_to` and `-gh_assigned_to_user_suffix` options 20 | - Users have to be added to GitHub org 21 | 5. Migrate acceptance criteria as part of issue body (if present) 22 | 6. Adds in the following as a comment to the issue: 23 | - Original work item url 24 | - Basic details in a collapsed markdown table 25 | - Entire work item as JSON in a collapsed section 26 | 7. Creates tag "copied-to-github" and a comment on the ADO work item with `-$ado_production_run"`. The tag prevents duplicate copying. 27 | 28 | ### To Do 29 | 1. Provide user mapping option 30 | 31 | ### Things it won't ever migrate 32 | 1. Created date/update dates 33 | 34 | ### Example 35 | 36 | - [Screenshot](https://user-images.githubusercontent.com/19912012/157745772-69f5cf75-5407-491e-a754-d94b188378ff.png) 37 | - [Migrated GitHub Issue](https://github.com/joshjohanning-org/migrate-ado-workitems/issues/296) 38 | 39 | ## Instructions for Running in Actions 40 | 41 | The recommendation is to use a GitHub App to run the migration - a GitHub app has higher rate limits than using a user PAT. 42 | 43 | 1. Create a (classic) GitHub personal access token with at least the following scopes: 44 | + `repo` (all scopes here) 45 | + `read:org` 46 | 2. Create the following action secrets: 47 | + `ADO_PAT`: Azure DevOps PAT with appropriate permissions to read work items 48 | + `GH_PAT`: The value of the PAT created in step #1 49 | 3. Use the [action](.github/workflows/migrate-work-items.yml) 50 | 4. Update any defaults in the [action](.github/workflows/migrate-work-items.yml) (ie: Azure DevOps organization and project, GitHub organization, repo, and any other defaults you want changed) 51 | 5. Ensure the action exists in the repo's default branch 52 | 6. Run the workflow 53 | 54 | ## Instructions for Running Locally 55 | 56 | Using the GitHub app might be better so you don't reach a limit on your GitHub account on creating new issues 😀 57 | 58 | ```pwsh 59 | ./ado_workitems_to_github_issues.ps1 ` 60 | -ado_pat "abc" ` 61 | -ado_org "jjohanning0798" ` 62 | -ado_project "PartsUnlimited" ` 63 | -ado_area_path "PartsUnlimited\migrate" ` 64 | -ado_migrate_closed_workitems ` 65 | -ado_production_run # IMPORTANT - USING THIS WILL UPDATE THE WORK ITEM IN ADO!` 66 | -gh_pat "ghp_xxx" ` 67 | -gh_org "joshjohanning-org" ` 68 | -gh_repo "migrate-ado-workitems" ` 69 | -gh_update_assigned_to ` 70 | -gh_assigned_to_user_suffix "" ` 71 | -gh_add_ado_comments 72 | ``` 73 | 74 | ## Script Options 75 | 76 | | Parameter | Required | Default | Description | 77 | |---------------------------------|----------|----------|---------------------------------------------------------------------------------------------------------------------------------------------| 78 | | `-ado_pat` | Yes | | Azure DevOps Personal Access Token (PAT) with appropriate permissions to read work items (and update, with `-ado_production_run`) | 79 | | `-ado_org` | Yes | | Azure DevOps organization to migrate from | 80 | | `-ado_project` | Yes | | Azure DevOps project to migrate from | 81 | | `-ado_area_path` | Yes | | Azure DevOps area path to migrate from - uses the `UNDER` operator | 82 | | `-ado_migrate_closed_workitems` | No | `$false` | Switch to migrate closed/resoled/done/removed work items | 83 | | `-ado_production_run` | No | `$false` | Switch to add `copied-to-github` tag and comment on ADO work item | 84 | | `-gh_pat` | Yes | | GitHub Personal Access Token (PAT) with appropriate permissions to read/write issues | 85 | | `-gh_org` | Yes | | GitHub organization to migrate work items to | 86 | | `-gh_repo` | Yes | | GitHub repo to migrate work items to | 87 | | `-gh_update_assigned_to` | No | `$false` | Switch to update the GitHub issue's assignee based on the username portion of an email address (before the @ sign) | 88 | | `-gh_assigned_to_user_suffix` | No | `""` | Used in conjunction with `-gh_update_assigned_to`, used to suffix the username, e.g. if using GitHub Enterprise Managed User (EMU) instance | 89 | | `-gh_add_ado_comments` | No | `$false` | Switch to add ADO comments as a section with the migrated work item | 90 | 91 | **Note**: With `-gh_update_assigned_to`, you/your users will receive a lot of emails from GitHub when the user is assigned to the issue 92 | -------------------------------------------------------------------------------- /ado_workitems_to_github_issues.ps1: -------------------------------------------------------------------------------- 1 | ############################################################## 2 | # Migrate Azure DevOps work items to GitHub Issues 3 | ############################################################## 4 | 5 | # Prerequisites: 6 | # 1. Install az devops and github cli 7 | # 2. create a label for EACH work item type that is being migrated (as lower case) 8 | # - ie: "user story", "bug", "task", "feature" 9 | # 3. define under what area path you want to migrate 10 | # - You can modify the WIQL if you want to use a different way to migrate work items, such as [TAG] = "migrate" 11 | 12 | # How to run: 13 | # ./ado_workitems_to_github_issues.ps1 -ado_pat "xxx" -ado_org "jjohanning0798" -ado_project "PartsUnlimited" -ado_area_path "PartsUnlimited\migrate" -gh_pat "xxx" -gh_org "joshjohanning-org" -gh_repo "migrate-ado-workitems" -gh_assigned_to_user_suffix "_corp" 14 | 15 | # Optional switches to add - if you add this parameter, this means you want it set to TRUE (for false, simply do not provide) 16 | # -ado_migrate_closed_workitems 17 | # -ado_production_run 18 | # -gh_update_assigned_to 19 | # -gh_add_ado_comments 20 | 21 | # 22 | # Things it migrates: 23 | # 1. Title 24 | # 2. Description (or repro steps + system info for a bug) 25 | # 3. State (if the work item is done / closed, it will be closed in GitHub) 26 | # 4. It will try to assign the work item to the correct user in GitHub - based on ADO email (-gh_update_assigned_to and -gh_assigned_to_user_suffix options) - they of course have to be in GitHub already 27 | # 5. Migrate acceptance criteria as part of issue body (if present) 28 | # 6. Adds in the following as a comment to the issue: 29 | # a. Original work item url 30 | # b. Basic details in a collapsed markdown table 31 | # c. Entire work item as JSON in a collapsed section 32 | # 7. Creates tag "copied-to-github" and a comment on the ADO work item with `-$ado_production_run` . The tag prevents duplicate copying. 33 | # 34 | 35 | # 36 | # Things it won't ever migrate: 37 | # 1. Created date/update dates 38 | # 39 | 40 | [CmdletBinding()] 41 | param ( 42 | [string]$ado_pat, # Azure DevOps PAT 43 | [string]$ado_org, # Azure devops org without the URL, eg: "MyAzureDevOpsOrg" 44 | [string]$ado_project, # Team project name that contains the work items, eg: "TailWindTraders" 45 | [string]$ado_area_path, # Area path in Azure DevOps to migrate; uses the 'UNDER' operator) 46 | [switch]$ado_migrate_closed_workitems, # migrate work items with the state of done, closed, resolved, and removed 47 | [switch]$ado_production_run, # tag migrated work items with 'migrated-to-github' and add discussion comment 48 | [string]$gh_pat, # GitHub PAT 49 | [string]$gh_org, # GitHub organization to create the issues in 50 | [string]$gh_repo, # GitHub repository to create the issues in 51 | [switch]$gh_update_assigned_to, # try to update the assigned to field in GitHub 52 | [string]$gh_assigned_to_user_suffix = "", # the emu suffix, ie: "_corp" 53 | [switch]$gh_add_ado_comments # try to get ado comments 54 | ) 55 | 56 | # Set the auth token for az commands 57 | $env:AZURE_DEVOPS_EXT_PAT = $ado_pat; 58 | # Set the auth token for gh commands 59 | $env:GH_TOKEN = $gh_pat; 60 | 61 | az devops configure --defaults organization="https://dev.azure.com/$ado_org" project="$ado_project" 62 | 63 | # add the wiql to not migrate closed work items 64 | if (!$ado_migrate_closed_workitems) { 65 | $closed_wiql = "[State] <> 'Done' and [State] <> 'Closed' and [State] <> 'Resolved' and [State] <> 'Removed' and" 66 | } 67 | 68 | $wiql = "select [ID], [Title], [System.Tags] from workitems where $closed_wiql [System.AreaPath] UNDER '$ado_area_path' and not [System.Tags] Contains 'copied-to-github' order by [ID]"; 69 | 70 | $query=az boards query --wiql $wiql | ConvertFrom-Json 71 | 72 | $count = 0; 73 | 74 | ForEach($workitem in $query) { 75 | $original_workitem_json_beginning="`n`n
Original Work Item JSON

" + "`n`n" + '```json' 76 | $original_workitem_json_end="`n" + '```' + "`n

" 77 | 78 | $workitemId = $workitem.id; 79 | 80 | $details_json = az boards work-item show --id $workitem.id --output json 81 | $details = $details_json | ConvertFrom-Json 82 | 83 | # double quotes in the title must be escaped with \ to be passed to gh cli 84 | # workaround for https://github.com/cli/cli/issues/3425 and https://stackoverflow.com/questions/6714165/powershell-stripping-double-quotes-from-command-line-arguments 85 | $title = $details.fields.{System.Title} -replace "`"","`\`"" 86 | 87 | Write-Host "Copying work item $workitemId to $gh_org/$gh_repo on github"; 88 | 89 | $description="" 90 | 91 | # bug doesn't have Description field - add repro steps and/or system info 92 | if ($details.fields.{System.WorkItemType} -eq "Bug") { 93 | if(![string]::IsNullOrEmpty($details.fields.{Microsoft.VSTS.TCM.ReproSteps})) { 94 | # Fix line # reference in "Repository:" URL. 95 | $reproSteps = ($details.fields.{Microsoft.VSTS.TCM.ReproSteps}).Replace('/tree/', '/blob/').Replace('?&path=', '').Replace('&line=', '#L'); 96 | $description += "## Repro Steps`n`n" + $reproSteps + "`n`n"; 97 | } 98 | if(![string]::IsNullOrEmpty($details.fields.{Microsoft.VSTS.TCM.SystemInfo})) { 99 | $description+="## System Info`n`n" + $details.fields.{Microsoft.VSTS.TCM.SystemInfo} + "`n`n" 100 | } 101 | } else { 102 | $description+=$details.fields.{System.Description} 103 | # add in acceptance criteria if it has it 104 | if(![string]::IsNullOrEmpty($details.fields.{Microsoft.VSTS.Common.AcceptanceCriteria})) { 105 | $description+="`n`n## Acceptance Criteria`n`n" + $details.fields.{Microsoft.VSTS.Common.AcceptanceCriteria} 106 | } 107 | } 108 | 109 | $gh_comment="[Original Work Item URL](https://dev.azure.com/$ado_org/$ado_project/_workitems/edit/$($workitem.id))" 110 | 111 | # use empty string if there is no user is assigned 112 | if ( $null -ne $details.fields.{System.AssignedTo}.displayName ) 113 | { 114 | $ado_assigned_to_display_name = $details.fields.{System.AssignedTo}.displayName 115 | $ado_assigned_to_unique_name = $details.fields.{System.AssignedTo}.uniqueName 116 | } 117 | else { 118 | $ado_assigned_to_display_name = "" 119 | $ado_assigned_to_unique_name = "" 120 | } 121 | 122 | # create the details table 123 | $gh_comment+="`n`n
Original Work Item Details

" + "`n`n" 124 | $gh_comment+= "| Created date | Created by | Changed date | Changed By | Assigned To | State | Type | Area Path | Iteration Path|`n|---|---|---|---|---|---|---|---|---|`n" 125 | $gh_comment+="| $($details.fields.{System.CreatedDate}) | $($details.fields.{System.CreatedBy}.displayName) | $($details.fields.{System.ChangedDate}) | $($details.fields.{System.ChangedBy}.displayName) | $ado_assigned_to_display_name | $($details.fields.{System.State}) | $($details.fields.{System.WorkItemType}) | $($details.fields.{System.AreaPath}) | $($details.fields.{System.IterationPath}) |`n`n" 126 | $gh_comment+="`n" + "`n

" 127 | 128 | # prepare the comment 129 | 130 | # getting comments if enabled 131 | if($gh_add_ado_comments -eq $true) { 132 | $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" 133 | $base64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(":$ado_pat")) 134 | $headers.Add("Authorization", "Basic $base64") 135 | $response = Invoke-RestMethod "https://dev.azure.com/$ado_org/$ado_project/_apis/wit/workItems/$($workitem.id)/comments?api-version=7.1-preview.3" -Method 'GET' -Headers $headers 136 | 137 | if($response.count -gt 0) { 138 | $gh_comment+="`n`n
Work Item Comments ($($response.count))

" + "`n`n" 139 | ForEach($comment in $response.comments) { 140 | $gh_comment+= "| Created date | Created by | JSON URL |`n|---|---|---|`n" 141 | $gh_comment+="| $($comment.createdDate) | $($comment.createdBy.displayName) | [URL]($($comment.url)) |`n`n" 142 | $gh_comment+="**Comment text**: $($comment.text)`n`n-----------`n`n" 143 | } 144 | $gh_comment+="`n" + "`n

" 145 | } 146 | } 147 | 148 | # setting the label on the issue to be the work item type 149 | $work_item_type = $details.fields.{System.WorkItemType}.ToLower() 150 | 151 | $issueHeaders = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" 152 | $issueHeaders.Add("Authorization", "token $gh_pat") 153 | $issueHeaders.Add("Accept", "application/vnd.github.golden-comet-preview+json") 154 | $issueHeaders.Add("Content-Type", "application/json") 155 | 156 | Write-Host "Migrating https://dev.azure.com/$ado_org/$ado_project/_workitems/edit/$($workitem.id)" 157 | 158 | if ([string]::IsNullOrEmpty($description)){ 159 | # Can't have an empty body on this API, so add work item url 160 | $description = "[Original Work Item URL](https://dev.azure.com/$ado_org/$ado_project/_workitems/edit/$($workitem.id))" 161 | } 162 | 163 | $params = @{ 164 | issue = @{ 165 | title = "$title" 166 | body = "$description" 167 | } 168 | comments = @(@{ 169 | body = "$gh_comment" 170 | }) 171 | } | ConvertTo-Json 172 | 173 | Write-Host " Issue creation request: $params"; 174 | $issueMigrateResponse = Invoke-RestMethod https://api.github.com/repos/$gh_org/$gh_repo/import/issues -Method 'POST' -Body $params -Headers $issueHeaders 175 | 176 | Write-Host " Issue creation response: $issueMigrateResponse"; 177 | $issue_url = "" 178 | while($true) { 179 | Write-Host "Sleeping for 1 seconds..." 180 | Start-Sleep -Seconds 1 181 | $issueCreationResponse = Invoke-RestMethod $issueMigrateResponse.url -Method 'GET' -Headers $issueHeaders -StatusCodeVariable 'statusCode' 182 | 183 | Write-Host " Issue creation response: $issueCreationResponse"; 184 | Write-Host " Status code: $statusCode"; 185 | if ($statusCode -eq 404) { 186 | continue 187 | } 188 | 189 | if ($statusCode -ne 200) { 190 | throw "Issue creation failed with status code $statusCode" 191 | } 192 | 193 | if ($issueCreationResponse.status -eq "imported") { 194 | $issue_url = $issueCreationResponse.issue_url 195 | break 196 | } elseif ($issueCreationResponse.status -eq "failed") { 197 | throw "Issue creation failed with message $issueCreationResponse" 198 | } 199 | } 200 | 201 | if (![string]::IsNullOrEmpty($issue_url.Trim())) { 202 | Write-Host " Issue created: $issue_url"; 203 | $count++; 204 | } 205 | else { 206 | throw "Issue creation failed."; 207 | } 208 | 209 | # update assigned to in GitHub if the option is set - tries to use ado email to map to github username 210 | if ($gh_update_assigned_to -eq $true -and $ado_assigned_to_unique_name -ne "") { 211 | $gh_assignee=$ado_assigned_to_unique_name.Split("@")[0] 212 | $gh_assignee=$gh_assignee.Replace(".", "-") + $gh_assigned_to_user_suffix 213 | write-host " trying to assign to: $gh_assignee" 214 | $assigned=gh issue edit $issue_url --add-assignee "$gh_assignee" 215 | } 216 | 217 | # Add the tag "copied-to-github" plus a comment to the work item 218 | if ($ado_production_run) { 219 | $workitemTags = $workitem.fields.'System.Tags'; 220 | $discussion = "This work item was copied to github as issue $issue_url"; 221 | az boards work-item update --id "$workitemId" --fields "System.Tags=copied-to-github; $workitemTags" --discussion "$discussion" | Out-Null; 222 | } 223 | 224 | # close out the issue if it's closed on the Azure Devops side 225 | $ado_closure_states = "Done","Closed","Resolved","Removed" 226 | if ($ado_closure_states.Contains($details.fields.{System.State})) { 227 | gh issue close $issue_url 228 | } 229 | 230 | } 231 | Write-Host "Total items copied: $count" 232 | --------------------------------------------------------------------------------