├── .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`nOriginal 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`nOriginal 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`nWork 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 |
--------------------------------------------------------------------------------