Skip to content

Instantly share code, notes, and snippets.

@graemerocher
Last active March 26, 2024 00:09
Show Gist options
  • Star 28 You must be signed in to star a gist
  • Fork 8 You must be signed in to fork a gist
  • Save graemerocher/ee99ddef8d0e201f0615 to your computer and use it in GitHub Desktop.
Save graemerocher/ee99ddef8d0e201f0615 to your computer and use it in GitHub Desktop.
JIRA to Github Issues Migration Script
@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