blob: dd04d27b5f2a530e37b426ba092d68331c9585aa [file] [log] [blame]
#!/usr/bin/env python
# Copyright (c) 2010 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# For Spreadsheets:
try:
from xml.etree import ElementTree
except ImportError:
from elementtree import ElementTree
import gdata.spreadsheet.service
import gdata.service
import atom.service
import gdata.spreadsheet
import atom
# For Issue Tracker:
import gdata.projecthosting.client
import gdata.projecthosting.data
import gdata.gauth
import gdata.client
import gdata.data
import atom.http_core
import atom.core
# For this script:
import getpass
from optparse import OptionParser
import pickle
from sets import Set
# Settings
credentials_store = 'creds.dat'
class Merger(object):
def __init__(self, ss_key, ss_ws_key, tracker_message, tracker_project,
debug, pretend):
self.ss_key = ss_key
self.ss_ws_key = ss_ws_key
self.tracker_message = tracker_message
self.tracker_project = tracker_project
self.debug_enabled = debug
self.pretend = pretend
self.user_agent = 'adlr-tracker-spreadsheet-merger'
self.it_keys = ['id', 'owner', 'status', 'title']
def debug(self, message):
"""Prints message if debug mode is set."""
if self.debug_enabled:
print message
def print_feed(self, feed):
'Handy for debugging'
for i, entry in enumerate(feed.entry):
print 'id:', entry.id
if isinstance(feed, gdata.spreadsheet.SpreadsheetsCellsFeed):
print '%s %s\n' % (entry.title.text, entry.content.text)
elif isinstance(feed, gdata.spreadsheet.SpreadsheetsListFeed):
print '%s %s %s' % (i, entry.title.text, entry.content.text)
# Print this row's value for each column (the custom dictionary is
# built using the gsx: elements in the entry.)
print 'Contents:'
for key in entry.custom:
print ' %s: %s' % (key, entry.custom[key].text)
print '\n',
else:
print '%s %s\n' % (i, entry.title.text)
def tracker_login(self):
"""Logs user into Tracker, using cached credentials if possible.
Saves credentials after login."""
self.it_client = gdata.projecthosting.client.ProjectHostingClient()
self.it_client.source = self.user_agent
self.load_creds()
if self.tracker_token and self.tracker_user:
print 'Using existing credential for tracker login'
self.it_client.auth_token = self.tracker_token
else:
self.tracker_user = raw_input('Issue Tracker Login:')
password = getpass.getpass('Password:')
self.it_client.ClientLogin(self.tracker_user, password,
source=self.user_agent, service='code',
account_type='GOOGLE')
self.tracker_token = self.it_client.auth_token
self.store_creds()
def spreadsheet_login(self):
"""Logs user into Google Spreadsheets, using cached credentials if possible.
Saves credentials after login."""
self.gd_client = gdata.spreadsheet.service.SpreadsheetsService()
self.gd_client.source = self.user_agent
self.load_creds()
if self.docs_token:
print 'Using existing credential for docs login'
self.gd_client.SetClientLoginToken(self.docs_token)
else:
self.gd_client.email = raw_input('Google Docs Login:')
self.gd_client.password = getpass.getpass('Password:')
self.gd_client.ProgrammaticLogin()
self.docs_token = self.gd_client.GetClientLoginToken()
self.store_creds()
def fetch_spreadsheet_issues(self):
"""Fetches all issues from the user-specified spreadsheet. Returns
them as an array or dictionaries."""
feed = self.gd_client.GetListFeed(self.ss_key, self.ss_ws_key)
issues = []
for entry in feed.entry:
issue = {}
for key in entry.custom:
issue[key] = entry.custom[key].text
issue['__raw_entry'] = entry
issues.append(issue)
return issues
def ids_for_spreadsheet_issues(self, ss_issues):
"""Returns a Set of strings, each string an id from ss_issues"""
ret = Set()
for ss_issue in ss_issues:
ret.add(ss_issue['id'])
return ret
def tracker_issues_for_query_feed(self, feed):
"""Converts a feed object from a query to a list of tracker issue
dictionaries."""
issues = []
for issue in feed.entry:
issue_dict = {}
issue_dict['labels'] = [label.text for label in issue.label]
issue_dict['id'] = issue.id.text.split('/')[-1]
issue_dict['title'] = issue.title.text
issue_dict['status'] = issue.status.text
if issue.owner:
issue_dict['owner'] = issue.owner.username.text
issues.append(issue_dict)
return issues
def fetch_tracker_issues(self, ss_issues):
"""Fetches all relevant issues from traacker and returns them as an array
of dictionaries. Relevance is:
- has an ID that's in ss_issues, OR
- (is Area=Installer AND status is open).
Open status is one of: Unconfirmed, Untriaged, Available, Assigned,
Started, Upstream"""
issues = []
got_results = True
index = 1
while got_results:
query = gdata.projecthosting.client.Query(label='Area-Installer',
max_results=50,
start_index=index)
feed = self.it_client.get_issues('chromium-os', query=query)
if not feed.entry:
got_results = False
index = index + len(feed.entry)
issues.extend(self.tracker_issues_for_query_feed(feed))
# Now, remove issues that are open or in ss_issues.
ss_ids = self.ids_for_spreadsheet_issues(ss_issues)
open_statuses = ['Unconfirmed', 'Untriaged', 'Available', 'Assigned',
'Started', 'Upstream']
new_issues = []
for issue in issues:
if issue['status'] in open_statuses or issue['id'] in ss_ids:
new_issues.append(issue)
# Remove id from ss_ids, if it's there
ss_ids.discard(issue['id'])
issues = new_issues
# Now, for each ss_id that didn't turn up in the query, explicitly add it
for id_ in ss_ids:
query = gdata.projecthosting.client.Query(issue_id=id_,
max_results=50,
start_index=index)
feed = self.it_client.get_issues('chromium-os', query=query)
if not feed.entry:
print 'No result for id', id_
continue
issues.extend(self.tracker_issues_for_query_feed(feed))
return issues
def store_creds(self):
"""Stores login credentials to disk."""
obj = {}
if self.docs_token:
obj['docs_token'] = self.docs_token
if self.tracker_token:
obj['tracker_token'] = self.tracker_token
if self.tracker_user:
obj['tracker_user'] = self.tracker_user
try:
f = open(credentials_store, 'w')
pickle.dump(obj, f)
f.close()
except IOError:
print 'Unable to store credentials'
def load_creds(self):
"""Loads login credentials from disk."""
self.docs_token = None
self.tracker_token = None
self.tracker_user = None
try:
f = open(credentials_store, 'r')
obj = pickle.load(f)
f.close()
if obj.has_key('docs_token'):
self.docs_token = obj['docs_token']
if obj.has_key('tracker_token'):
self.tracker_token = obj['tracker_token']
if obj.has_key('tracker_user'):
self.tracker_user = obj['tracker_user']
except IOError:
print 'Unable to load credentials'
def browse(self):
"""Browses Spreadsheets to help the user find the spreadsheet and
worksheet keys"""
print 'Browsing spreadsheets...'
if self.ss_key and self.ss_ws_key:
print 'You already passed in --ss_key and --ss_ws_key. No need to browse.'
return
print 'Logging in...'
self.spreadsheet_login()
if not self.ss_key:
print 'Fetching spreadsheets...'
feed = self.gd_client.GetSpreadsheetsFeed()
print ''
print 'Spreadsheet key - Title'
for entry in feed.entry:
key = entry.id.text.split('/')[-1]
title = entry.title.text
print '"%s" - "%s"' % (key, title)
print ''
print 'Done. Rerun with --ss_key=KEY to browse a list of worksheet keys.'
else:
print 'Fetching worksheets for spreadsheet', self.ss_key
feed = self.gd_client.GetWorksheetsFeed(self.ss_key)
for entry in feed.entry:
key = entry.id.text.split('/')[-1]
title = entry.title.text
print ''
print 'Worksheet key - Title'
print '"%s" - "%s"' % (key, title)
print ''
print 'Done. You now have keys for --ss_key and --ss_ws_key.'
def tracker_issue_for_id(self, issues, id_):
"""Returns the element of issues which has id_ for the key 'id'"""
for issue in issues:
if issue['id'] == id_:
return issue
return None
def spreadsheet_issue_to_tracker_dict(self, ss_issue):
"""Converts a spreadsheet issue to the dict format that is used to
represent a tracker issue."""
ret = {}
ret['project'] = self.tracker_project
ret['title'] = ss_issue['title']
ret['summary'] = self.tracker_message
ret['owner'] = ss_issue['owner']
if ss_issue.get('status') is not None:
ret['status'] = ss_issue['status']
ret['labels'] = []
for (key, value) in ss_issue.items():
if key.endswith('-') and (value is not None):
ret['labels'].append(key.title() + value)
return ret
def label_from_prefix(self, prefix, corpus):
"""Given a corpus (array of lable strings), return the first label
that begins with the specified prefix."""
for label in corpus:
if label.startswith(prefix):
return label
return None
def update_spreadsheet_issue_to_tracker_dict(self, ss_issue, t_issue):
"""Updates a given tracker issue with data from the spreadsheet issue."""
ret = {}
ret['title'] = ss_issue['title']
ret['id'] = ss_issue['id']
ret['summary'] = self.tracker_message
if ss_issue['status'] != t_issue['status']:
ret['status'] = ss_issue['status']
if ss_issue.get('owner'):
if (not t_issue.has_key('owner')) or \
(ss_issue['owner'] != t_issue['owner']):
ret['owner'] = ss_issue['owner']
# labels
ret['labels'] = []
for (key, value) in ss_issue.items():
caps_key = key.title()
if not caps_key.endswith('-'):
continue
ss_label = None
if value:
ss_label = caps_key + value.title()
t_label = self.label_from_prefix(caps_key, t_issue['labels'])
if t_label is None and ss_label is None:
# Nothing
continue
if (t_label is not None) and \
((ss_label is None) or (ss_label != t_label)):
ret['labels'].append('-' + t_label)
if (ss_label is not None) and \
((t_label is None) or (t_label != ss_label)):
ret['labels'].append(ss_label)
return ret
def tracker_issue_has_changed(self, t_issue, ss_issue):
"""Returns True iff ss_issue indicates changes in t_issue that need to be
committed up to the Issue Tracker."""
if t_issue is None:
return True
potential_commit = \
self.update_spreadsheet_issue_to_tracker_dict(ss_issue, t_issue)
if potential_commit.has_key('status') or \
potential_commit.has_key('owner') or \
(len(potential_commit['labels']) > 0):
return True
if potential_commit['title'] != t_issue['title']:
return True
return False
def spreadsheet_to_tracker_commits(self, ss_issues, t_issues):
"""Given the current state of all spreadsheet issues and tracker issues,
returns a list of all commits that need to go to tracker to get it in
line with the spreadsheet."""
ret = []
for ss_issue in ss_issues:
t_issue = self.tracker_issue_for_id(t_issues, ss_issue['id'])
commit = {}
# TODO see if an update is needed at all
if t_issue is None:
commit['type'] = 'append'
commit['dict'] = self.spreadsheet_issue_to_tracker_dict(ss_issue)
commit['__ss_issue'] = ss_issue
else:
if not self.tracker_issue_has_changed(t_issue, ss_issue):
continue
commit['type'] = 'update'
commit['dict'] = \
self.update_spreadsheet_issue_to_tracker_dict(ss_issue, t_issue)
ret.append(commit)
return ret
def fetch_issues(self):
"""Logs into Docs/Tracker, and fetches spreadsheet and tracker issues"""
print 'Logging into Docs...'
self.spreadsheet_login()
print 'Logging into Tracker...'
self.tracker_login()
print 'Fetching spreadsheet issues...'
ss_issues = self.fetch_spreadsheet_issues()
self.debug('Spreadsheet issues: %s' % ss_issues)
print 'Fetching tracker issues...'
t_issues = self.fetch_tracker_issues(ss_issues)
self.debug('Tracker issues: %s' % t_issues)
return (t_issues, ss_issues)
def spreadsheet_to_tracker(self):
"""High-level function to manage migrating data from the spreadsheet
to Tracker."""
(t_issues, ss_issues) = self.fetch_issues()
print 'Calculating deltas...'
commits = self.spreadsheet_to_tracker_commits(ss_issues, t_issues)
self.debug('got commits: %s' % commits)
if not commits:
print 'No deltas. Done.'
return
for commit in commits:
dic = commit['dict']
labels = dic.get('labels')
owner = dic.get('owner')
status = dic.get('status')
if commit['type'] == 'append':
print 'Creating new tracker issue...'
if self.pretend:
print '(Skipping because --pretend is set)'
continue
created = self.it_client.add_issue(self.tracker_project,
dic['title'],
self.tracker_message,
self.tracker_user,
labels=labels,
owner=owner,
status=status)
issue_id = created.id.text.split('/')[-1]
print 'Created issue with id:', issue_id
print 'Write id back to spreadsheet row...'
raw_entry = commit['__ss_issue']['__raw_entry']
ss_issue = commit['__ss_issue']
del ss_issue['__raw_entry']
ss_issue.update({'id': issue_id})
self.gd_client.UpdateRow(raw_entry, ss_issue)
print 'Done.'
else:
print 'Updating issue with id:', dic['id']
if self.pretend:
print '(Skipping because --pretend is set)'
continue
self.it_client.update_issue(self.tracker_project,
dic['id'],
self.tracker_user,
comment=self.tracker_message,
status=status,
owner=owner,
labels=labels)
print 'Done.'
def spreadsheet_issue_for_id(self, issues, id_):
"""Given the array of spreadsheet issues, return the first one that
has id_ for the key 'id'."""
for issue in issues:
if issue['id'] == id_:
return issue
return None
def value_for_key_in_labels(self, label_array, prefix):
"""Given an array of labels and a prefix, return the non-prefix part
of the first label that has that prefix. E.g. if label_array is
["Mstone-R7", "Area-Installer"] and prefix is "Area-", returns
"Installer"."""
for label in label_array:
if label.startswith(prefix):
return label[len(prefix):]
return None
def tracker_issue_to_spreadsheet_issue(self, t_issue, ss_keys):
"""Converts a tracker issue to the format used by spreadsheet, given
the row headings ss_keys."""
new_row = {}
for key in ss_keys:
if key.endswith('-'):
# label
new_row[key] = self.value_for_key_in_labels(t_issue['labels'],
key.title())
# Special cases
if key in self.it_keys and key in t_issue:
new_row[key] = t_issue[key]
return new_row
def spreadsheet_row_needs_update(self, ss_issue, t_issue):
"""Returns True iff the spreadsheet issue passed in needs to be updated
to match data in the tracker issue."""
new_ss_issue = self.tracker_issue_to_spreadsheet_issue(t_issue,
ss_issue.keys())
for key in new_ss_issue.keys():
if not ss_issue.has_key(key):
continue
if new_ss_issue[key] != ss_issue[key]:
return True
return False
def tracker_to_spreadsheet_commits(self, t_issues, ss_issues):
"""Given the current set of spreadsheet and tracker issues, computes
commits needed to go to Spreadsheets to get the spreadsheet in line
with what's in Tracker."""
ret = []
keys = ss_issues[0].keys()
for t_issue in t_issues:
commit = {}
ss_issue = self.spreadsheet_issue_for_id(ss_issues, t_issue['id'])
if ss_issue is None:
# New issue
commit['new_row'] = self.tracker_issue_to_spreadsheet_issue(t_issue,
keys)
commit['type'] = 'append'
elif self.spreadsheet_row_needs_update(ss_issue, t_issue):
commit['__raw_entry'] = ss_issue['__raw_entry']
del ss_issue['__raw_entry']
ss_issue.update(self.tracker_issue_to_spreadsheet_issue(t_issue, keys))
commit['dict'] = ss_issue
commit['type'] = 'update'
else:
continue
ret.append(commit)
return ret
def tracker_to_spreadsheet(self):
"""High-level function to migrate data from Tracker to the spreadsheet."""
(t_issues, ss_issues) = self.fetch_issues()
if len(ss_issues) == 0:
raise Exception('Error: must have at least one non-header row in '\
'spreadsheet')
return
ss_keys = ss_issues[0].keys()
print 'Calculating deltas...'
ss_commits = self.tracker_to_spreadsheet_commits(t_issues, ss_issues)
self.debug('commits: %s' % ss_commits)
if not ss_commits:
print 'Nothing to commit.'
return
print 'Committing...'
for commit in ss_commits:
self.debug('Operating on commit: %s' % commit)
if commit['type'] == 'append':
print 'Appending new row...'
if not self.pretend:
self.gd_client.InsertRow(commit['new_row'],
self.ss_key, self.ss_ws_key)
else:
print '(Skipped because --pretend set)'
if commit['type'] == 'update':
print 'Updating row...'
if not self.pretend:
self.gd_client.UpdateRow(commit['__raw_entry'], commit['dict'])
else:
print '(Skipped because --pretend set)'
print 'Done.'
def main():
class PureEpilogOptionParser(OptionParser):
def format_epilog(self, formatter):
return self.epilog
parser = PureEpilogOptionParser()
parser.add_option('-a', '--action', dest='action', metavar='ACTION',
help='Action to perform')
parser.add_option('-d', '--debug', action='store_true', dest='debug',
default=False, help='Print debug output.')
parser.add_option('-m', '--message', dest='message', metavar='TEXT',
help='Log message when updating Tracker issues')
parser.add_option('-p', '--pretend', action='store_true', dest='pretend',
default=False, help="Don't commit anything.")
parser.add_option('--ss_key', dest='ss_key', metavar='KEY',
help='Spreadsheets key (find with browse action)')
parser.add_option('--ss_ws_key', dest='ss_ws_key', metavar='KEY',
help='Spreadsheets worksheet key (find with browse action)')
parser.add_option('--tracker_project', dest='tracker_project',
metavar='PROJECT',
help='Tracker project (default: chromium-os)',
default='chromium-os')
parser.epilog = """Actions:
browse -- browse spreadsheets to find spreadsheet and worksheet keys.
ss_to_t -- for each entry in spreadsheet, apply its values to tracker.
If no ID is in the spreadsheet row, a new tracker item is created
and the spreadsheet is updated.
t_to_ss -- for each tracker entry, apply it or add it to the spreadsheet.
This script can be used to migrate Issue Tracker issues between Issue Tracker
and Google Spreadsheets. The spreadsheet should have certain columns in any
order: Id, Owner, Title, Status. The spreadsheet may have any label of the
form 'Key-'. For those labels that end in '-', this script assumes the cell
value and the header form a label that should be applied to the issue. E.g.
if the spredsheet has a column named 'Mstone-' and a cell under it called
'R8' that corresponds to the label 'Mstone-R8' in Issue Tracker.
To migrate data, you must choose on each invocation of this script if you
wish to migrate data from Issue Tracker to a spreadsheet of vice-versa.
When migrating from Tracker, all found issues based on the query
(which is currently hard-coded to "label=Area-Installer") will be inserted
into the spreadsheet (overwritng existing cells if a row with matching ID
is found). Custom columns in the spreadsheet won't be overwritten, so if
the spreadsheet contains extra columns about issues (e.g. time estimates)
they will be preserved.
When migrating from spreadsheet to Tracker, each row in the spreadsheet
is compared to existing tracker issues that match the query
(which is currently hard-coded to "label=Area-Installer"). If the
spreadsheet row has no Id, a new Issue Tracker issue is created and the new
Id is written back to the spreadsheet. If an existing tracker issue exists,
it's updated with the data from the spreadsheet if anything has changed.
Suggested usage:
- Create a spreadsheet with columns Id, Owner, Title, Status, and any label
prefixes as desired.
- Run this script with '-b' to browse your spreadsheet and get the
spreadsheet key.
- Run this script again with '-b' and the spreadsheet key to get the
worksheet key.
- Run this script with "-a t_to_ss" or "-a ss_to_t" to migrate data in either
direction.
Known issues:
- query is currently hardcoded to label=Area-Installer. That should be
a command-line flag.
- When creating a new issue on tracker, the owner field isn't set. I (adlr)
am not sure why. Workaround: If you rerun this script, tho, it will detect
a delta and update the tracker issue with the owner, which seems to succeed.
"""
(options, args) = parser.parse_args()
merger = Merger(options.ss_key, options.ss_ws_key,
options.message, options.tracker_project,
options.debug, options.pretend)
if options.action == 'browse':
merger.browse()
elif options.action == 'ss_to_t':
if not options.message:
print 'Error: when updating tracker, -m MESSAGE required.'
return
merger.spreadsheet_to_tracker()
elif options.action == 't_to_ss':
merger.tracker_to_spreadsheet()
else:
raise Exception('Unknown action requested.')
if __name__ == '__main__':
main()