Last active
November 19, 2024 15:23
-
-
Save graemerocher/ee99ddef8d0e201f0615 to your computer and use it in GitHub Desktop.
JIRA to Github Issues Migration Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Grab(group='com.github.groovy-wslite', module='groovy-wslite', version='1.1.0') | |
@Grab(group='joda-time', module='joda-time', version='2.7') | |
import wslite.rest.* | |
import org.joda.time.* | |
import org.joda.time.format.* | |
import groovy.xml.* | |
import groovy.json.* | |
import static java.lang.System.* | |
import groovy.transform.* | |
def xml = new XmlSlurper() | |
// The path of the JIRA XML export | |
entities = xml.parse(new File("data/entities.xml")) | |
// You should set your Github API token to the GH_TOKEN environment variable | |
githubToken = getenv('GH_TOKEN') | |
// configure these variables to modify JIRA source and Github target project | |
projectToMigrate = 'GRAILS' | |
repoSlug= 'grails/grails-core' | |
// if your milestone names use a prefix modify it here | |
milestonePrefix = "grails-" | |
jiraDateFormat ='yyyy-MM-dd HH:mm:ss.S' | |
dateFormatter = ISODateTimeFormat.dateTime() | |
// Whether to migrate only closed/resolved issues or to also migrate open issues | |
onlyClosed = true | |
// Configure how JIRA usernames map to Github usernames | |
jiraToGibhubAuthorMappings = [ | |
graemerocher: 'graemerocher', | |
pledbrook:'pledbrook', | |
brownj:'jeffbrown', | |
burtbeckwith:'burtbeckwith', | |
wangjammer7:'marcpalmer', | |
wangjammer5:'marcpalmer', | |
ldaley:'alkemist', | |
lhotari:'lhotari', | |
fletcherr:'robfletcher', | |
pred:'smaldini' | |
] | |
def projects = entities.Project.collect { | |
new Project(key: it.@originalkey, id: it.@id) | |
} | |
urlFragment = "https://api.github.com/repos/$repoSlug" | |
Project project = projects.find { it.key == projectToMigrate } | |
if(!githubToken) { | |
println "No GH_TOKEN environment variable set" | |
exit 1 | |
} | |
hasHitRateLimit = { response -> | |
response.headers['X-RateLimit-Remaining'] && response.headers['X-RateLimit-Remaining'].toInteger() == 0 | |
} | |
waitOnRateLimit = { response -> | |
long sleepTime = response.headers['X-RateLimit-Reset'].toLong() * 1000 | |
long currentTime = currentTimeMillis() | |
while(currentTime < sleepTime) { | |
println "Rate Limit Reached! Sleeping until ${new Date(sleepTime)}. Please wait...." | |
sleep( sleepTime - currentTime ) | |
currentTime = currentTimeMillis() | |
} | |
println "Resuming..." | |
} | |
if(project) { | |
def projectId = project.id | |
def versions = entities.Version.findAll { | |
it.@project.text() == project.id | |
}.collect { | |
new Version(it.@id.text(), | |
project, | |
it.@name.text(), | |
it.@description.text(), | |
Boolean.valueOf(it.@released.text()), | |
it.@releasedate.text() ) | |
}.collectEntries { | |
[(it.id): it] | |
} | |
def statuses = entities.Status.collectEntries { status -> | |
def name = status.@name.text() | |
[ (status.@id.text()) : | |
new Status(name: status.@name.text()) | |
] | |
} | |
def components = entities.Component.findAll { | |
!it.@name.text().startsWith("Grails-") | |
}.collectEntries { component -> | |
[ (component.@id.text()): component.@name.text() ] | |
} | |
def priorities = entities.Priority.collectEntries { priority -> | |
[ (priority.@id.text()): priority.@name.text() ] | |
} | |
def resolutions = entities.Resolution.collectEntries { resolution -> | |
[ (resolution.@id.text()): resolution.@name.text() ] | |
} | |
def issueTypes = entities.IssueType.collectEntries { issueType -> | |
[ (issueType.@id.text()): issueType.@name.text() ] | |
} | |
println "Statuses: ${statuses.values()*.name}" | |
println "Priorities: ${priorities.values()}" | |
println "Resolutions: ${resolutions.values()}" | |
println "Issue Types: ${issueTypes.values()}" | |
// First read existing Milestone data | |
def milestones = [:] | |
def milestoneData = new RESTClient("$urlFragment/milestones?state=all") | |
.get(headers:[Authorization: "token $githubToken"]) | |
.json | |
int page = 1 | |
while(milestoneData) { | |
for(m in milestoneData) { | |
milestones[m.title] = m.number | |
} | |
page++ | |
milestoneData = new RESTClient("$urlFragment/milestones?state=all&page=$page") | |
.get(headers:[Authorization: "token $githubToken"]) | |
.json | |
} | |
for(version in versions.values()) { | |
def milestoneTitle = "${milestonePrefix}${version.name}".toString() | |
def existingNumber = milestones[milestoneTitle] | |
// if the milestone already exists just populate it | |
if(existingNumber) { | |
version.milestoneId = existingNumber | |
} | |
else { | |
// otherwise create a new milestone for the version | |
println "Creating Milestone: $version" | |
def client = new RESTClient("$urlFragment/milestones") | |
try { | |
def response = client.post(headers:[Authorization: "token $githubToken"]) { | |
json title: milestoneTitle, | |
description: version.description, | |
state: version.released ? 'closed' : 'open', | |
due_on: dateFormatter.print( new DateTime(version.releaseDate ?: new Date()) ) | |
} | |
version.milestoneId = response.json.number.toInteger() | |
if(response.statusCode == 200 || response.statusCode == 201) { | |
println "Milestone Created $version" | |
} | |
else { | |
println "Error occurred: ${response.statusCode}" | |
println response.json.toString() | |
} | |
} | |
catch(RESTClientException e) { | |
println "Error occurred Creating Milestone: ${e.response.statusCode}" | |
println e.response.contentAsString | |
if ( hasHitRateLimit(response) ) { | |
waitOnRateLimit(response) | |
try { | |
def response = client.post(headers:[Authorization: "token $githubToken"]) { | |
json title: milestoneTitle, | |
description: version.description, | |
state: version.released ? 'closed' : 'open', | |
due_on: dateFormatter.print( new DateTime(version.releaseDate ?: new Date()) ) | |
} | |
version.milestoneId = response.json.number.toInteger() | |
} | |
catch(RESTClientException e2) { | |
// no further attempts | |
println "Error occurred Creating Milestone: ${e2.response.statusCode}" | |
println e2.response.contentAsString | |
} | |
} | |
} | |
} | |
} | |
def nodeAssociations = entities.NodeAssociation | |
def issues = entities.Issue.findAll { | |
it.@project.text() == project.id | |
}.collect { | |
// to obtain fix version and milestone version | |
// <NodeAssociation sourceNodeId="31735" sourceNodeEntity="Issue" sinkNodeId="10995" sinkNodeEntity="Version" associationType="IssueFixVersion"/> | |
def dateCreated | |
if( it.@created ) { | |
try { | |
dateCreated = new Date().parse(jiraDateFormat, it.@created.text()) | |
} catch(e) { | |
// ignore | |
} | |
} | |
// create base issue data | |
def issue = new Issue( | |
id: it.@id.text(), | |
jiraKey: it.@key.text(), | |
reporter: it.@reporter.text(), | |
assignee: it.@assignee.text(), | |
project: project, | |
summary: it.@summary.text(), | |
environment: it.@environment.text(), | |
description: it.description.text(), | |
priority: priorities[it.@priority.text()], | |
type: issueTypes[it.@type.text()], | |
status: statuses[it.@status.text()], | |
resolution: resolutions[it.@resolution.text()], | |
created: dateCreated | |
) | |
def votes = it.@votes.text() | |
if(votes) { | |
issue.popular = votes.toInteger() > 9 | |
} | |
def versionId = nodeAssociations.find { | |
it.@sourceNodeId.text() == issue.id && it.@sourceNodeEntity.text() == 'Issue' && it.@associationType.text() == "IssueVersion" | |
}?.@sinkNodeId?.text() | |
def fixVersionId = nodeAssociations.find { | |
it.@sourceNodeId.text() == issue.id && it.@sourceNodeEntity.text() == 'Issue' && it.@associationType.text() == "IssueFixVersion" | |
}?.@sinkNodeId?.text() | |
issue.version = versions[versionId] | |
issue.fixVersion = versions[fixVersionId] | |
// parse issue comments | |
issue.comments = entities.Action.findAll { | |
(it.@issue.text() == issue.id) && (it.@type == "comment") | |
}.collect { | |
def commentCreated | |
try { | |
commentCreated = new Date().parse(jiraDateFormat, it.@created.text()) | |
} catch(e) { | |
commentCreated = new Date() | |
} | |
new Comment(id: it.@id.text(), | |
author: it.@author.text(), | |
body: it.@body.text() ?: it.body.text(), | |
created: commentCreated) | |
}.sort { | |
it.created | |
} | |
println "Created Issue Object for Issue: ${issue.jiraKey}" | |
if( onlyClosed && !issue.status.closed && !issue.popular) { | |
// we're only migrating historically closed issues and issues with significant votes | |
return issue | |
} | |
println "Publishing Issue: ${issue.jiraKey}" | |
try { | |
def searchClient = new RESTClient("https://api.github.com/search/issues?q=repo:${repoSlug}+${issue.jiraKey}") | |
def searchResults = searchClient.get(headers:[Authorization: "token $githubToken"]).json | |
def issueExists = 0 < searchResults.total_count ?: 0 | |
if(issueExists) { | |
if( searchResults.items[0].title.contains(issue.jiraKey) ) { | |
println "Issue ${issue.jiraKey} already exists, skipping..." | |
return issue | |
} | |
} | |
} | |
catch(RESTClientException e) { | |
// probably hit the rate limit | |
println "Error occurred searching for existing issue: ${e.response.statusCode}" | |
println e.response.contentAsString | |
if ( hasHitRateLimit(e.response) ) { | |
waitOnRateLimit(e.response) | |
} | |
} | |
def client = new RESTClient("$urlFragment/import/issues") | |
def labels = [] | |
def comments = [] | |
def assignee = jiraToGibhubAuthorMappings[issue.assignee] | |
if(issue.resolution) { | |
labels << issue.resolution | |
} | |
if(issue.type) { | |
labels << issue.type | |
} | |
if(issue.priority) { | |
labels << issue.priority | |
} | |
if(issue.comments) { | |
for(comment in issue.comments) { | |
if(comment.body.trim()) { | |
comments << [ | |
created_at: dateFormatter.print( new DateTime( comment.created ) ), | |
body: """$comment.author said: | |
$comment.body""" | |
] | |
} | |
} | |
} | |
def issueJson = [ | |
title: "${issue.jiraKey}: ${issue.summary}", | |
body: """ | |
Original Reporter: ${issue.reporter} | |
Environment: ${issue.environment ?: 'Not Specified'} | |
Version: ${issue.version?.name ?: 'Not Specified'} | |
Migrated From: http://jira.grails.org/browse/${issue.jiraKey} | |
${issue.description}""", | |
created_at: dateFormatter.print( new DateTime( issue.created ) ), | |
closed: issue.resolution ? true : false, | |
labels: labels | |
] | |
if(assignee) { | |
issueJson.assignee = assignee | |
} | |
if(issue.fixVersion) { | |
issueJson.milestone = issue.fixVersion.milestoneId | |
} | |
try { | |
def response = client.post(headers:[Authorization: "token $githubToken", | |
Accept: "application/vnd.github.golden-comet-preview+json"]) { | |
json( | |
issue: issueJson, | |
comments:comments | |
) | |
} | |
println "Issue Created. API Limit: ${response.headers['X-RateLimit-Remaining']}" | |
} | |
catch(RESTClientException e) { | |
println "Error occurred: ${e.response.statusCode}" | |
println e.response.contentAsString | |
if ( hasHitRateLimit(e.response) ) { | |
waitOnRateLimit(e.response) | |
try { | |
client.post(headers:[Authorization: "token $githubToken", | |
Accept: "application/vnd.github.golden-comet-preview+json"]) { | |
json( | |
issue: issueJson, | |
comments:comments | |
) | |
} | |
println "Issue Created." | |
} | |
catch(RESTClientException e2 ) { | |
println "Error occurred: ${e2.response.statusCode}" | |
println e2.response.contentAsString | |
} | |
} | |
} | |
return issue | |
} | |
println "Issue Migration Complete." | |
} | |
else { | |
println "Project not found" | |
exit 1 | |
} | |
// Model Classes | |
@ToString | |
class Project { | |
String key | |
String id | |
} | |
@ToString | |
class Version { | |
String id | |
Project project | |
String name | |
String description | |
boolean released | |
Date releaseDate | |
int milestoneId | |
Version(String id, Project project, String name, String description, boolean released = false, String releaseDate = null) { | |
this.id = id | |
this.project = project | |
this.name = name | |
this.description = description | |
this.released = released | |
if(releaseDate) { | |
this.releaseDate = new Date().parse('yyyy-MM-dd HH:mm:ss.S', releaseDate) | |
} | |
} | |
} | |
@ToString | |
class Issue { | |
String id | |
String jiraKey | |
String reporter | |
String assignee | |
Project project | |
String summary | |
String environment | |
String description | |
String priority | |
String type | |
Status status | |
String resolution | |
Date created | |
Version version | |
Version fixVersion | |
boolean popular | |
Collection<String> components = [] | |
Collection<Comment> comments = [] | |
} | |
@ToString | |
class Comment { | |
String id | |
String author | |
Date created | |
String body | |
} | |
@ToString | |
class Status { | |
String name | |
boolean isClosed() { | |
name == "Closed" || name == "Resolved" | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment