summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThomas ten Cate <ttencate@gmail.com>2011-03-13 17:42:48 +0100
committerThomas ten Cate <ttencate@gmail.com>2011-03-13 17:42:48 +0100
commitc75202c39f65b047ade93ca1a8316c8c18f23fa5 (patch)
tree0076a5fa4d57a1122c27d300ae181acc766917cc
Initial import.
-rw-r--r--README37
-rwxr-xr-xissues.py103
2 files changed, 140 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..28998a6
--- /dev/null
+++ b/README
@@ -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)
+