Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(132)

Side by Side Diff: src/scripts/tracker_spreadsheet_sync

Issue 1566037: Tracker/Spreadsheet sync: tool to sync tracker issues (Closed)
Patch Set: remove .py suffix from filename Created 10 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | no next file » | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 #!/usr/bin/env python
2
3 # For Spreadsheets:
4 try:
5 from xml.etree import ElementTree
6 except ImportError:
7 from elementtree import ElementTree
8 import gdata.spreadsheet.service
9 import gdata.service
10 import atom.service
11 import gdata.spreadsheet
12 import atom
13
14 # For Issue Tracker:
15 import gdata.projecthosting.client
16 import gdata.projecthosting.data
17 import gdata.gauth
18 import gdata.client
19 import gdata.data
20 import atom.http_core
21 import atom.core
22
23 # For this script:
24 import getpass
25 from optparse import OptionParser
26 import pickle
27
28
29 # Settings
30 credentials_store = 'creds.dat'
31
32 class Merger(object):
33 def __init__(self, ss_key, ss_ws_key, tracker_message, tracker_project,
34 debug):
35 self.ss_key = ss_key
36 self.ss_ws_key = ss_ws_key
37 self.tracker_message = tracker_message
38 self.tracker_project = tracker_project
39 self.debug_enabled = debug
40 self.user_agent = 'adlr-tracker-spreadsheet-merger'
41 self.it_keys = ['id', 'owner', 'status', 'title']
42
43 def debug(self):
44 """Returns true if debug mode is set."""
45 return self.debug_enabled
46
47 def print_feed(self, feed):
48 'Handy for debugging'
49 for i, entry in enumerate(feed.entry):
50 print 'id:', entry.id
51 if isinstance(feed, gdata.spreadsheet.SpreadsheetsCellsFeed):
52 print '%s %s\n' % (entry.title.text, entry.content.text)
53 elif isinstance(feed, gdata.spreadsheet.SpreadsheetsListFeed):
54 print '%s %s %s' % (i, entry.title.text, entry.content.text)
55 # Print this row's value for each column (the custom dictionary is
56 # built using the gsx: elements in the entry.)
57 print 'Contents:'
58 for key in entry.custom:
59 print ' %s: %s' % (key, entry.custom[key].text)
60 print '\n',
61 else:
62 print '%s %s\n' % (i, entry.title.text)
63
64
65 def tracker_login(self):
66 """Logs user into Tracker, using cached credentials if possible.
67 Saves credentials after login."""
68 self.it_client = gdata.projecthosting.client.ProjectHostingClient()
69 self.it_client.source = self.user_agent
70
71 self.load_creds()
72
73 if self.tracker_token and self.tracker_user:
74 print 'Using existing credential for tracker login'
75 self.it_client.auth_token = self.tracker_token
76 else:
77 self.tracker_user = raw_input('Issue Tracker Login:')
78 password = getpass.getpass('Password:')
79 self.it_client.ClientLogin(self.tracker_user, password,
80 source=self.user_agent, service='code',
81 account_type='GOOGLE')
82 self.tracker_token = self.it_client.auth_token
83 self.store_creds()
84
85 def spreadsheet_login(self):
86 """Logs user into Google Spreadsheets, using cached credentials if possible.
87 Saves credentials after login."""
88 self.gd_client = gdata.spreadsheet.service.SpreadsheetsService()
89 self.gd_client.source = self.user_agent
90
91 self.load_creds()
92 if self.docs_token:
93 print 'Using existing credential for docs login'
94 self.gd_client.SetClientLoginToken(self.docs_token)
95 else:
96 self.gd_client.email = raw_input('Google Docs Login:')
97 self.gd_client.password = getpass.getpass('Password:')
98 self.gd_client.ProgrammaticLogin()
99 self.docs_token = self.gd_client.GetClientLoginToken()
100 self.store_creds()
101
102 def fetch_spreadsheet_issues(self):
103 """Fetches all issues from the user-specified spreadsheet. Returns
104 them as an array or dictionaries."""
105 feed = self.gd_client.GetListFeed(self.ss_key, self.ss_ws_key)
106 issues = []
107 for entry in feed.entry:
108 issue = {}
109 for key in entry.custom:
110 issue[key] = entry.custom[key].text
111 issue['__raw_entry'] = entry
112 issues.append(issue)
113 return issues
114
115 def fetch_tracker_issues(self):
116 """Fetches all issues matching the query and returns them as an array
117 of dictionaries."""
118 issues = []
119 got_results = True
120 index = 1
121 while got_results:
122 query = gdata.projecthosting.client.Query(label='Area-Installer',
123 max_results=50,
124 start_index=index)
125 feed = self.it_client.get_issues('chromium-os', query=query)
126 if not feed.entry:
127 got_results = False
128 index = index + len(feed.entry)
129 for issue in feed.entry:
130 issue_dict = {}
131 issue_dict['labels'] = [label.text for label in issue.label]
132 issue_dict['id'] = issue.id.text.split('/')[-1]
133 issue_dict['title'] = issue.title.text
134 issue_dict['status'] = issue.status.text
135 if issue.owner:
136 issue_dict['owner'] = issue.owner.username.text
137 issues.append(issue_dict)
138 return issues
139
140 def store_creds(self):
141 """Stores login credentials to disk."""
142 obj = {}
143 if self.docs_token:
144 obj['docs_token'] = self.docs_token
145 if self.tracker_token:
146 obj['tracker_token'] = self.tracker_token
147 if self.tracker_user:
148 obj['tracker_user'] = self.tracker_user
149 try:
150 f = open(credentials_store, 'w')
151 pickle.dump(obj, f)
152 f.close()
153 except IOError:
154 print 'Unable to store credentials'
155
156 def load_creds(self):
157 """Loads login credentials from disk."""
158 self.docs_token = None
159 self.tracker_token = None
160 self.tracker_user = None
161 try:
162 f = open(credentials_store, 'r')
163 obj = pickle.load(f)
164 f.close()
165 if obj.has_key('docs_token'):
166 self.docs_token = obj['docs_token']
167 if obj.has_key('tracker_token'):
168 self.tracker_token = obj['tracker_token']
169 if obj.has_key('tracker_user'):
170 self.tracker_user = obj['tracker_user']
171 except IOError:
172 print 'Unable to load credentials'
173
174 def browse(self):
175 """Browses Spreadsheets to help the user find the spreadsheet and
176 worksheet keys"""
177 print 'Browsing spreadsheets...'
178
179 if self.ss_key and self.ss_ws_key:
180 print 'You already passed in --ss_key and --ss_ws_key. No need to browse.'
181 return
182
183 print 'Logging in...'
184 self.spreadsheet_login()
185
186 if not self.ss_key:
187 print 'Fetching spreadsheets...'
188 feed = self.gd_client.GetSpreadsheetsFeed()
189 print ''
190 print 'Spreadsheet key - Title'
191 for entry in feed.entry:
192 key = entry.id.text.split('/')[-1]
193 title = entry.title.text
194 print '"%s" - "%s"' % (key, title)
195 print ''
196 print 'Done. Rerun with --ss_key=KEY to browse a list of worksheet keys.'
197 else:
198 print 'Fetching worksheets for spreadsheet', self.ss_key
199 feed = self.gd_client.GetWorksheetsFeed(self.ss_key)
200 for entry in feed.entry:
201 key = entry.id.text.split('/')[-1]
202 title = entry.title.text
203 print ''
204 print 'Worksheet key - Title'
205 print '"%s" - "%s"' % (key, title)
206 print ''
207 print 'Done. You now have keys for --ss_key and --ss_ws_key.'
208
209 def tracker_issue_for_id(self, issues, id_):
210 """Returns the element of issues which has id_ for the key 'id'"""
211 for issue in issues:
212 if issue['id'] == id_:
213 return issue
214 return None
215
216 def spreadsheet_issue_to_tracker_dict(self, ss_issue):
217 """Converts a spreadsheet issue to the dict format that is used to
218 represent a tracker issue."""
219 ret = {}
220 ret['project'] = self.tracker_project
221 ret['title'] = ss_issue['title']
222 ret['summary'] = self.tracker_message
223 ret['owner'] = ss_issue['owner']
224 if ss_issue.get('status') is not None:
225 ret['status'] = ss_issue['status']
226 ret['labels'] = []
227 for (key, value) in ss_issue.items():
228 if key.endswith('-') and (value is not None):
229 ret['labels'].append(key.title() + value)
230 return ret
231
232 def label_from_prefix(self, prefix, corpus):
233 """Given a corpus (array of lable strings), return the first label
234 that begins with the specified prefix."""
235 for label in corpus:
236 if label.startswith(prefix):
237 return label
238 return None
239
240 def update_spreadsheet_issue_to_tracker_dict(self, ss_issue, t_issue):
241 """Updates a given tracker issue with data from the spreadsheet issue."""
242 ret = {}
243 ret['title'] = ss_issue['title']
244 ret['id'] = ss_issue['id']
245 ret['summary'] = self.tracker_message
246 if ss_issue['status'] != t_issue['status']:
247 ret['status'] = ss_issue['status']
248
249 if ss_issue.get('owner'):
250 if (not t_issue.has_key('owner')) or \
251 (ss_issue['owner'] != t_issue['owner']):
252 ret['owner'] = ss_issue['owner']
253 # labels
254 ret['labels'] = []
255 for (key, value) in ss_issue.items():
256 caps_key = key.title()
257 if not caps_key.endswith('-'):
258 continue
259 ss_label = None
260 if value:
261 ss_label = caps_key + value.title()
262 t_label = self.label_from_prefix(caps_key, t_issue['labels'])
263
264 if t_label is None and ss_label is None:
265 # Nothing
266 continue
267
268 if (ss_label is None) or (ss_label != t_label):
269 ret['labels'].append('-' + t_label)
270
271 if (t_label is None) or (t_label != ss_label):
272 ret['labels'].append(ss_label)
273 return ret
274
275 def tracker_issue_has_changed(self, t_issue, ss_issue):
276 """Returns True iff ss_issue indicates changes in t_issue that need to be
277 committed up to the Issue Tracker."""
278 if t_issue is None:
279 return True
280 potential_commit = \
281 self.update_spreadsheet_issue_to_tracker_dict(ss_issue, t_issue)
282
283 if potential_commit.has_key('status') or \
284 potential_commit.has_key('owner') or \
285 (len(potential_commit['labels']) > 0):
286 return True
287 if potential_commit['title'] != t_issue['title']:
288 return True
289 return False
290
291 def spreadsheet_to_tracker_commits(self, ss_issues, t_issues):
292 """Given the current state of all spreadsheet issues and tracker issues,
293 returns a list of all commits that need to go to tracker to get it in
294 line with the spreadsheet."""
295 ret = []
296 for ss_issue in ss_issues:
297 t_issue = self.tracker_issue_for_id(t_issues, ss_issue['id'])
298 commit = {}
299 # TODO see if an update is needed at all
300 if t_issue is None:
301 commit['type'] = 'append'
302 commit['dict'] = self.spreadsheet_issue_to_tracker_dict(ss_issue)
303 commit['__ss_issue'] = ss_issue
304 else:
305 if not self.tracker_issue_has_changed(t_issue, ss_issue):
306 continue
307 commit['type'] = 'update'
308 commit['dict'] = \
309 self.update_spreadsheet_issue_to_tracker_dict(ss_issue, t_issue)
310 ret.append(commit)
311 return ret
312
313 def fetch_issues(self):
314 """Logs into Docs/Tracker, and fetches spreadsheet and tracker issues"""
315 print 'Logging into Docs...'
316 self.spreadsheet_login()
317 print 'Logging into Tracker...'
318 self.tracker_login()
319
320 print 'Fetching tracker issues...'
321 t_issues = self.fetch_tracker_issues()
322 if self.debug():
323 print 'Tracker issues:', t_issues
324 print 'Fetching spreadsheet issues...'
325 ss_issues = self.fetch_spreadsheet_issues()
326 if self.debug():
327 print 'Spreadsheet issues:', ss_issues
328 return (t_issues, ss_issues)
329
330 def spreadsheet_to_tracker(self):
331 """High-level function to manage migrating data from the spreadsheet
332 to Tracker."""
333 (t_issues, ss_issues) = self.fetch_issues()
334 print 'Calculating deltas...'
335 commits = self.spreadsheet_to_tracker_commits(ss_issues, t_issues)
336 if self.debug():
337 print 'got commits:', commits
338 if len(commits) == 0:
339 print 'No deltas. Done.'
340 return
341
342 for commit in commits:
343 dic = commit['dict']
344 labels = dic.get('labels')
345 owner = dic.get('owner')
346 status = dic.get('status')
347
348 if commit['type'] == 'append':
349 print 'Creating new tracker issue...'
350 created = self.it_client.add_issue(self.tracker_project,
351 dic['title'],
352 self.tracker_message,
353 self.tracker_user,
354 labels=labels,
355 owner=owner,
356 status=status)
357 issue_id = created.id.text.split('/')[-1]
358 print 'Created issue with id:', issue_id
359 print 'Write id back to spreadsheet row...'
360 raw_entry = commit['__ss_issue']['__raw_entry']
361 ss_issue = commit['__ss_issue']
362 del ss_issue['__raw_entry']
363 ss_issue.update({'id': issue_id})
364 self.gd_client.UpdateRow(raw_entry, ss_issue)
365 print 'Done.'
366 else:
367 print 'Updating issue with id:', dic['id']
368 self.it_client.update_issue(self.tracker_project,
369 dic['id'],
370 self.tracker_user,
371 comment=self.tracker_message,
372 status=status,
373 owner=owner,
374 labels=labels)
375 print 'Done.'
376
377 def spreadsheet_issue_for_id(self, issues, id_):
378 """Given the array of spreadsheet issues, return the first one that
379 has id_ for the key 'id'."""
380 for issue in issues:
381 if issue['id'] == id_:
382 return issue
383 return None
384
385 def value_for_key_in_labels(self, label_array, prefix):
386 """Given an array of labels and a prefix, return the non-prefix part
387 of the first label that has that prefix. E.g. if label_array is
388 ["Mstone-R7", "Area-Installer"] and prefix is "Area-", returns
389 "Installer"."""
390 for label in label_array:
391 if label.startswith(prefix):
392 return label[len(prefix):]
393 return None
394
395 def tracker_issue_to_spreadsheet_issue(self, t_issue, ss_keys):
396 """Converts a tracker issue to the format used by spreadsheet, given
397 the row headings ss_keys."""
398 new_row = {}
399 for key in ss_keys:
400 if key.endswith('-'):
401 # label
402 new_row[key] = self.value_for_key_in_labels(t_issue['labels'],
403 key.title())
404 # Special cases
405 if key in self.it_keys and key in t_issue:
406 new_row[key] = t_issue[key]
407 return new_row
408
409 def spreadsheet_row_needs_update(self, ss_issue, t_issue):
410 """Returns True iff the spreadsheet issue passed in needs to be updated
411 to match data in the tracker issue."""
412 new_ss_issue = self.tracker_issue_to_spreadsheet_issue(t_issue,
413 ss_issue.keys())
414 for key in new_ss_issue.keys():
415 if not ss_issue.has_key(key):
416 continue
417 if new_ss_issue[key] != ss_issue[key]:
418 return True
419 return False
420
421 def tracker_to_spreadsheet_commits(self, t_issues, ss_issues):
422 """Given the current set of spreadsheet and tracker issues, computes
423 commits needed to go to Spreadsheets to get the spreadsheet in line
424 with what's in Tracker."""
425 ret = []
426 keys = ss_issues[0].keys()
427 for t_issue in t_issues:
428 commit = {}
429 ss_issue = self.spreadsheet_issue_for_id(ss_issues, t_issue['id'])
430 if ss_issue is None:
431 # New issue
432 commit['new_row'] = self.tracker_issue_to_spreadsheet_issue(t_issue,
433 keys)
434 commit['type'] = 'append'
435 elif self.spreadsheet_row_needs_update(ss_issue, t_issue):
436 commit['__raw_entry'] = ss_issue['__raw_entry']
437 del ss_issue['__raw_entry']
438 ss_issue.update(self.tracker_issue_to_spreadsheet_issue(t_issue, keys))
439 commit['dict'] = ss_issue
440 commit['type'] = 'update'
441 else:
442 continue
443 ret.append(commit)
444 return ret
445
446 def tracker_to_spreadsheet(self):
447 """High-level function to migrate data from Tracker to the spreadsheet."""
448 (t_issues, ss_issues) = self.fetch_issues()
449 if len(ss_issues) == 0:
450 raise Exception('Error: must have at least one non-header row in '\
451 'spreadsheet')
452 return
453 ss_keys = ss_issues[0].keys()
454
455 print 'Calculating deltas...'
456 ss_commits = self.tracker_to_spreadsheet_commits(t_issues, ss_issues)
457 if self.debug():
458 print 'commits:', ss_commits
459 if not ss_commits:
460 print 'Nothing to commit.'
461 return
462 print 'Committing...'
463 for commit in ss_commits:
464 if self.debug():
465 print 'Operating on commit:', commit
466 if commit['type'] == 'append':
467 print 'Appending new row...'
468 self.gd_client.InsertRow(commit['new_row'], self.ss_key, self.ss_ws_key)
469 if commit['type'] == 'update':
470 print 'Updating row...'
471 self.gd_client.UpdateRow(commit['__raw_entry'], commit['dict'])
472 print 'Done.'
473
474 def main():
475 class PureEpilogOptionParser(OptionParser):
476 def format_epilog(self, formatter):
477 return self.epilog
478
479 parser = PureEpilogOptionParser()
480 parser.add_option('-a', '--action', dest='action', metavar='ACTION',
481 help='Action to perform')
482 parser.add_option('-d', '--debug', action='store_true', dest='debug',
483 default=False, help='Print debug output.')
484 parser.add_option('-m', '--message', dest='message', metavar='TEXT',
485 help='Log message when updating Tracker issues')
486 parser.add_option('--ss_key', dest='ss_key', metavar='KEY',
487 help='Spreadsheets key (find with browse action)')
488 parser.add_option('--ss_ws_key', dest='ss_ws_key', metavar='KEY',
489 help='Spreadsheets worksheet key (find with browse action)')
490 parser.add_option('--tracker_project', dest='tracker_project',
491 metavar='PROJECT',
492 help='Tracker project (default: chromium-os)',
493 default='chromium-os')
494 parser.epilog = """Actions:
495 browse -- browse spreadsheets to find spreadsheet and worksheet keys.
496 ss_to_t -- for each entry in spreadsheet, apply its values to tracker.
497 If no ID is in the spreadsheet row, a new tracker item is created
498 and the spreadsheet is updated.
499 t_to_ss -- for each tracker entry, apply it or add it to the spreadsheet.
500
501
502 This script can be used to migrate Issue Tracker issues between Issue Tracker
503 and Google Spreadsheets. The spreadsheet should have certain columns in any
504 order: Id, Owner, Title, Status. The spreadsheet may have any label of the
505 form 'Key-'. For those labels that end in '-', this script assumes the cell
506 value and the header form a label that should be applied to the issue. E.g.
507 if the spredsheet has a column named 'Mstone-' and a cell under it called
508 'R8' that corresponds to the label 'Mstone-R8' in Issue Tracker.
509
510 To migrate data, you must choose on each invocation of this script if you
511 wish to migrate data from Issue Tracker to a spreadsheet of vice-versa.
512
513 When migrating from Tracker, all found issues based on the query
514 (which is currently hard-coded to "label=Area-Installer") will be inserted
515 into the spreadsheet (overwritng existing cells if a row with matching ID
516 is found). Custom columns in the spreadsheet won't be overwritten, so if
517 the spreadsheet contains extra columns about issues (e.g. time estimates)
518 they will be preserved.
519
520 When migrating from spreadsheet to Tracker, each row in the spreadsheet
521 is compared to existing tracker issues that match the query
522 (which is currently hard-coded to "label=Area-Installer"). If the
523 spreadsheet row has no Id, a new Issue Tracker issue is created and the new
524 Id is written back to the spreadsheet. If an existing tracker issue exists,
525 it's updated with the data from the spreadsheet if anything has changed.
526
527 Suggested usage:
528 - Create a spreadsheet with columns Id, Owner, Title, Status, and any label
529 prefixes as desired.
530 - Run this script with '-b' to browse your spreadsheet and get the
531 spreadsheet key.
532 - Run this script again with '-b' and the spreadsheet key to get the
533 worksheet key.
534 - Run this script with "-a t_to_ss" or "-a ss_to_t" to migrate data in either
535 direction.
536
537 Known issues:
538 - query is currently hardcoded to label=Area-Installer. That should be
539 a command-line flag.
540 - When creating a new issue on tracker, the owner field isn't set. I (adlr)
541 am not sure why. Workaround: If you rerun this script, tho, it will detect
542 a delta and update the tracker issue with the owner, which seems to succeed.
543 """
544
545 (options, args) = parser.parse_args()
546
547 merger = Merger(options.ss_key, options.ss_ws_key,
548 options.message, options.tracker_project,
549 options.debug)
550 if options.action == 'browse':
551 merger.browse()
552 elif options.action == 'ss_to_t':
553 if not options.message:
554 print 'Error: when updating tracker, -m MESSAGE required.'
555 return
556 merger.spreadsheet_to_tracker()
557 elif options.action == 't_to_ss':
558 merger.tracker_to_spreadsheet()
559 else:
560 raise Exception('Unknown action requested.')
561
562 if __name__ == '__main__':
563 main()
OLDNEW
« no previous file with comments | « no previous file | no next file » | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698