Index: tracker_spreadsheet_sync |
diff --git a/tracker_spreadsheet_sync b/tracker_spreadsheet_sync |
deleted file mode 100755 |
index dd04d27b5f2a530e37b426ba092d68331c9585aa..0000000000000000000000000000000000000000 |
--- a/tracker_spreadsheet_sync |
+++ /dev/null |
@@ -1,622 +0,0 @@ |
-#!/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() |