diff options
-rw-r--r-- | README | 37 | ||||
-rwxr-xr-x | issues.py | 103 |
2 files changed, 140 insertions, 0 deletions
@@ -0,0 +1,37 @@ +sf2github README +================ + +`sf2github` is a Python program that reads an XML export from a SourceForge project and pushes this data to GitHub via its REST API. + +The script is currently very incomplete and barely tested. If it works for you, great; if not, fix it up and send me a pull request! Currently, only migration of tracker issues is partly implemented, and there's no error handling. + +Also note that the GitHub API is quite slow, taking about 5 seconds per request on my machine and internet connection. Migration of a large project will take a while. + +Issue migration +--------------- + +What works (for me): + +* SF tracker issues become GitHub tracker issues. +* Comments on SF become comments in GitHub. +* Groups and categories on SF both become labels on GitHub. +* Issues with a status that is exactly the text "Closed" or "Deleted" will be closed on GitHub. + +Limitations: + +* Only a single tracker is supported, though this could be easily fixed. +* All issues and comments will be owned by the project's owner on GitHub, but mention the SF username of the original submitter. +* There's some rubbish in the comment text sometimes (Logged In, user_id, Originator) but this is in the SF XML export. + +Usage +----- + +Run the `issues.py` script and it will print instructions. Basically, if your SF XML export is in `foo.xml`, your GitHub username is `john` and your repository is `bar`: + + ./issues.py foo.xml john/bar + +License +------- + +This software is in the public domain. I accept no responsibility for any damage resulting from it. Use at your own risk. + diff --git a/issues.py b/issues.py new file mode 100755 index 0000000..70b88c1 --- /dev/null +++ b/issues.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python + +import sys +import optparse + +parser = optparse.OptionParser(usage='Usage: %prog [options] sfexport.xml githubuser/repo') +opts, args = parser.parse_args() + +try: + xml_file_name, github_repo = args + github_user = github_repo.split('/')[0] +except (ValueError, IndexError): + parser.print_help() + sys.exit(1) + +from BeautifulSoup import BeautifulStoneSoup + +print 'Parsing XML export...' +soup = BeautifulStoneSoup(open(xml_file_name, 'r'), convertEntities=BeautifulStoneSoup.ALL_ENTITIES) + +trackers = soup.document.find('trackers', recursive=False).findAll('tracker', recursive=False) +if len(trackers) > 1: + print 'Multiple trackers not yet supported, sorry' + sys.exit(1) +tracker = trackers[0] + +from urllib import urlencode +from urllib2 import Request, urlopen +from base64 import b64encode +from time import sleep +from getpass import getpass +import re + +github_password = getpass('%s\'s GitHub password: ' % github_user) + +def rest_call(before, after, data_dict=None): + url = 'https://github.com/api/v2/xml/%s/%s/%s' % (before, github_repo, after) + data = urlencode(data_dict or {}) + headers = { + 'Authorization': 'Basic %s' % b64encode('%s:%s' % (github_user, github_password)), + } + request = Request(url, data, headers) + response = urlopen(request) + # GitHub limits API calls to 60 per minute + sleep(1) + return response + +def labelify(string): + return re.sub(r'[^a-z0-9._-]+', '-', string.lower()) + +closed_status_ids = [] +for status in tracker.statuses('status', recursive=False): + status_id = status.id.string + status_name = status.nameTag.string + if status_name in ['Closed', 'Deleted']: + closed_status_ids.append(status_id) + +groups = {} +for group in tracker.groups('group', recursive=False): + groups[group.id.string] = group.group_name.string + +categories = {} +for category in tracker.categories('category', recursive=False): + categories[category.id.string] = category.category_name.string + +for item in tracker.tracker_items('tracker_item', recursive=False): + title = item.summary.string + body = '\n\n'.join([ + 'Converted from [SourceForge issue %s](%s), submitted by %s' % (item.id.string, item.url.string, item.submitter.string), + item.details.string, + ]) + closed = item.status_id.string in closed_status_ids + labels = [] + try: + labels.append(labelify(groups[item.group_id.string])) + except KeyError: + pass + try: + labels.append(labelify(categories[item.category_id.string])) + except KeyError: + pass + + comments = [] + for followup in item.followups('followup', recursive=False): + comments.append('\n\n'.join([ + 'Submitted by %s' % followup.submitter.string, + followup.details.string, + ])) + + print 'Creating: %s [%s] (%d comments)%s' % (title, ','.join(labels), len(comments), ' (closed)' if closed else '') + response = rest_call('issues/open', '', {'title': title, 'body': body}) + issue = BeautifulStoneSoup(response, convertEntities=BeautifulStoneSoup.ALL_ENTITIES) + number = issue.number.string + for label in labels: + print 'Attaching label: %s' % label + rest_call('issues/label/add', '%s/%s' % (label, number)) + for comment in comments: + print 'Creating comment: %s' % comment[:50].replace('\n', ' ') + rest_call('issues/comment', number, {'comment': comment}) + if closed: + print 'Closing...' + rest_call('issues/close', number) + |