| #!/usr/bin/python2.6 |
| # Copyright (c) 2011 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. |
| |
| """Synchronize issues in Package Status spreadsheet with Issue Tracker.""" |
| |
| import optparse |
| import os |
| import sys |
| import urllib |
| import xml.dom.minidom |
| |
| import gdata.projecthosting.client |
| import gdata.spreadsheet.service |
| |
| sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) |
| import chromite.lib.cros_build_lib as cros_lib |
| import chromite.lib.gdata_lib as gdata_lib |
| import chromite.lib.operation as operation |
| import chromite.lib.upgrade_table as utable |
| |
| # pylint: disable=W0201,R0904 |
| |
| PROJECT_NAME = 'chromium-os' |
| |
| SS_KEY = '0AsXDKtaHikmcdEp1dVN1SG1yRU1xZEw1Yjhka2dCSUE' |
| PKGS_WS_NAME = 'Packages' |
| |
| CROS_ORG = 'chromium.org' |
| CHROMIUMOS_SITE = 'http://www.%s/%s' % (CROS_ORG, PROJECT_NAME) |
| PKG_UPGRADE_PAGE = '%s/gentoo-package-upgrade-process' % CHROMIUMOS_SITE |
| DOCS_SITE = 'https://docs.google.com/a' |
| PKG_STATUS_PAGE = '%s/%s/spreadsheet/ccc?key=%s' % (DOCS_SITE, CROS_ORG, SS_KEY) |
| |
| COL_PACKAGE = gdata_lib.PrepColNameForSS(utable.UpgradeTable.COL_PACKAGE) |
| COL_TEAM = gdata_lib.PrepColNameForSS('Team/Lead') |
| COL_OWNER = gdata_lib.PrepColNameForSS('Owner') |
| COL_TRACKER = gdata_lib.PrepColNameForSS('Tracker') |
| |
| ARCHES = ('amd64', 'arm', 'x86') |
| |
| oper = operation.Operation('sync_package_status') |
| |
| class SyncError(RuntimeError): |
| """Extend RuntimeError for use in this module.""" |
| |
| class IssueComment(object): |
| """Represent a Tracker issue comment.""" |
| |
| __slots__ = ['title', 'text'] |
| |
| def __init__(self, title, text): |
| self.title = title |
| self.text = text |
| |
| def __str__(self): |
| text = '\n '.join(self.text.split('\n')) |
| return '%s:\n %s' % (self.title, text) |
| |
| class Issue(object): |
| """Represents one Tracker Issue.""" |
| |
| SlotDefaults = { |
| 'comments': [], # List of IssueComment objects |
| 'id': 0, # Issue id number (int) |
| 'labels': [], # List of text labels |
| 'owner': None, # Current owner (text, chromium.org account) |
| 'status': None, # Current issue status (text) (e.g. Assigned) |
| 'summary': None,# Issue summary (first comment) |
| 'title': None, # Title text |
| } |
| |
| __slots__ = SlotDefaults.keys() |
| |
| def __init__(self, tracker_issue=None, **kwargs): |
| """Init for one Issue object. |
| |
| |tracker_issue| - Optional, Tracker issue object to use to |
| initialize attributes. |
| |kwargs| - key/value arguments to give initial values to |
| any additional attributes on |self|. |
| """ |
| # Start every attribute with a default value. |
| for slot in self.__slots__: |
| setattr(self, slot, self.SlotDefaults[slot]) |
| |
| # Initialize from real Tracker issue if given. |
| if tracker_issue: |
| self.InitFromTracker(tracker_issue) |
| |
| # Optional overwrite of any attribute using kwargs. |
| for slot in self.__slots__: |
| if slot in kwargs: |
| setattr(self, slot, kwargs[slot]) |
| |
| def __str__(self): |
| """Pretty print of issue.""" |
| lines = ['Issue %d - %s' % (self.id, self.title), |
| 'Status: %s, Owner: %s' % (self.status, self.owner), |
| 'Labels: %s' % ', '.join(self.labels), |
| ] |
| |
| if self.summary: |
| lines.append('Summary: %s' % self.summary) |
| |
| if self.comments: |
| for comment in self.comments: |
| lines.append('%s' % comment) |
| |
| return '\n'.join(lines) |
| |
| def InitFromTracker(self, t_issue): |
| """Initialize |self| from tracker issue |t_issue|""" |
| |
| self.id = int(t_issue.id.text.split('/')[-1]) |
| self.labels = [label.text for label in t_issue.label] |
| if t_issue.owner: |
| self.owner = t_issue.owner.username.text |
| self.status = t_issue.status.text |
| self.summary = t_issue.content.text |
| self.title = t_issue.title.text |
| self.comments = self.GetTrackerIssueComments(self.id) |
| |
| def GetTrackerIssueComments(self, issue_id): |
| """Retrieve comments for |issue_id| from comments URL""" |
| comments = [] |
| |
| feeds = 'http://code.google.com/feeds' |
| url = '%s/issues/p/%s/issues/%d/comments/full' % (feeds, PROJECT_NAME, |
| issue_id) |
| doc = xml.dom.minidom.parse(urllib.urlopen(url)) |
| entries = doc.getElementsByTagName('entry') |
| for entry in entries: |
| title = entry.getElementsByTagName('title')[0].firstChild.nodeValue |
| text = entry.getElementsByTagName('content')[0].firstChild.nodeValue |
| comments.append(IssueComment(title, text)) |
| |
| return comments |
| |
| class TrackerComm(object): |
| """Class to manage communication with Tracker.""" |
| |
| __slots__ = [ |
| 'creds', # gdata_lib.Creds object |
| 'it_client', # Issue Tracker client |
| ] |
| |
| def __init__(self, creds): |
| self.creds = creds |
| |
| self.it_client = gdata.projecthosting.client.ProjectHostingClient() |
| self.it_client.source = 'package_status_upgrade' |
| self.it_client.ClientLogin(creds.user, creds.password, |
| source=self.it_client.source, |
| service='code', |
| account_type='GOOGLE') |
| |
| def GetTrackerIssueById(self, tid): |
| """Get tracker issue given |tid| number. Return Issue object if found.""" |
| |
| query = gdata.projecthosting.client.Query(issue_id=str(tid)) |
| feed = self.it_client.get_issues('chromium-os', query=query) |
| |
| if feed.entry: |
| return Issue(feed.entry[0]) |
| return None |
| |
| def CreateTrackerIssue(self, issue): |
| """Create a new issue in Tracker according to |issue|.""" |
| created = self.it_client.add_issue(project_name=PROJECT_NAME, |
| title=issue.title, |
| content=issue.summary, |
| author=self.creds.user, |
| status=issue.status, |
| owner=issue.owner, |
| labels=issue.labels) |
| issue.id = int(created.id.text.split('/')[-1]) |
| return issue.id |
| |
| def AppendTrackerIssueById(self, issue_id, comment): |
| """Append |comment| to issue |issue_id| in Tracker""" |
| self.it_client.update_issue(project_name=PROJECT_NAME, |
| issue_id=issue_id, |
| author=self.creds.user, |
| comment=comment) |
| return issue_id |
| |
| # TODO(mtennant): Remove this class; Use refactored version in gdata_lib. |
| class SpreadsheetComm(object): |
| """Class to manage communication with one Google Spreadsheet worksheet.""" |
| |
| __slots__ = [ |
| 'columns', # List of column names |
| 'creds', # gdata_lib.Creds object |
| 'gd_client', # Google Data client |
| 'ss_key', # Spreadsheet key |
| 'ws_key', # Worksheet key |
| ] |
| |
| def __init__(self, creds, ss_key, ws_name): |
| self.creds = creds |
| self._LoginWithUserPassword(creds.user, creds.password) |
| |
| self.ss_key = ss_key |
| self.ws_key = self._GetWorksheetKey(ss_key, ws_name) |
| |
| self.columns = self._GetColumns() |
| |
| def _LoginWithUserPassword(self, user, password): |
| """Set up and connect the Google Doc client using email/password.""" |
| gd_client = gdata_lib.RetrySpreadsheetsService() |
| |
| gd_client.source = 'Sync Package Status' |
| gd_client.email = user |
| gd_client.password = password |
| gd_client.ProgrammaticLogin() |
| self.gd_client = gd_client |
| |
| def _GetWorksheetKey(self, ss_key, ws_name): |
| """Get the worksheet key with name |ws_name| in spreadsheet |ss_key|.""" |
| feed = self.gd_client.GetWorksheetsFeed(ss_key) |
| # The worksheet key is the last component in the URL (after last '/') |
| for entry in feed.entry: |
| if ws_name == entry.title.text: |
| return entry.id.text.split('/')[-1] |
| |
| oper.Die('Unable to find worksheet "%s" in spreadsheet "%s"' % |
| (ws_name, ss_key)) |
| |
| def _GetColumns(self): |
| """Return list of column names in worksheet.""" |
| columns = [] |
| |
| query = gdata.spreadsheet.service.CellQuery() |
| query['max-row'] = '1' |
| feed = self.gd_client.GetCellsFeed(self.ss_key, self.ws_key, query=query) |
| for entry in feed.entry: |
| columns.append(entry.content.text) |
| |
| return columns |
| |
| def GetColumnIndex(self, colName): |
| """Get the column index (starting at 1) for column |colName|""" |
| for ix, col in enumerate(self.columns): |
| if colName == col: |
| # Spreadsheet column indices start at 1. |
| return ix + 1 |
| |
| return None |
| |
| def GetAllRowsAsDicts(self): |
| """Get every row in spreadsheet as dicts of key/value pairs.""" |
| feed = self.gd_client.GetListFeed(self.ss_key, self.ws_key) |
| rows = [] |
| for entry in feed.entry: |
| row = {} |
| for key, val in entry.custom.items(): |
| row[key] = gdata_lib.ScrubValFromSS(val.text) |
| |
| rows.append(row) |
| |
| return rows |
| |
| def ReplaceCellValue(self, rowIx, colIx, val): |
| """Replace cell value at |rowIx| and |colIx| with |val|""" |
| self.gd_client.UpdateCell(rowIx, colIx, val, self.ss_key, self.ws_key) |
| |
| def ClearCellValue(self, rowIx, colIx): |
| """Clear cell value at |rowIx| and |colIx|""" |
| self.ReplaceCellValue(rowIx, colIx, None) |
| |
| class Syncer(object): |
| """Class to manage synchronizing between spreadsheet and Tracker.""" |
| |
| # Map spreadsheet team names to Tracker team values. |
| VALID_TEAMS = {'build': 'BuildRelease', |
| 'kernel': 'Kernel', |
| 'security': 'Security', |
| 'system': 'Systems', |
| 'ui': 'UI', |
| } |
| UPGRADE_STATES = set([utable.UpgradeTable.STATE_NEEDS_UPGRADE, |
| utable.UpgradeTable.STATE_NEEDS_UPGRADE_AND_PATCHED, |
| utable.UpgradeTable.STATE_NEEDS_UPGRADE_AND_DUPLICATED, |
| ]) |
| |
| __slots__ = [ |
| 'owners', # Set of owners to select (None means no filter) |
| 'pretend', # If True, make no real changes |
| 'scomm', # SpreadsheetComm |
| 'tcomm', # TrackerComm |
| 'teams', # Set of teams to select (None means no filter) |
| 'tracker_col_ix', # Index of Tracker column in spreadsheet |
| 'verbose', # Verbose boolean |
| ] |
| |
| def __init__(self, tcomm, scomm, pretend=False, verbose=False): |
| self.tcomm = tcomm |
| self.scomm = scomm |
| |
| self.tracker_col_ix = scomm.GetColumnIndex('Tracker') |
| |
| self.teams = None |
| self.owners = None |
| |
| self.pretend = pretend |
| self.verbose = verbose |
| |
| def _ReduceTeamName(self, team): |
| """Translate |team| from spreadsheet/commandline name to short name. |
| |
| For example: build/bdavirro --> build, build --> build |
| """ |
| if team: |
| return team.lower().split('/')[0] |
| return None |
| |
| def SetTeamFilter(self, teamarg): |
| """Set team filter using colon-separated team names in |teamarg| |
| |
| Resulting filter in self.teams is set of "reduced" team names. |
| Examples: |
| 'build:system:ui' --> set(['build', 'system', 'ui']) |
| 'Build:system:UI' --> set(['build', 'system', 'ui']) |
| |
| If an invalid team name is given oper.Die is called with explanation. |
| """ |
| if teamarg: |
| teamlist = [] |
| for team in teamarg.split(':'): |
| t = self._ReduceTeamName(team) |
| if t in self.VALID_TEAMS: |
| teamlist.append(t) |
| else: |
| oper.Die('Invalid team name "%s". Choose from: %s' % |
| (team, ','.join(self.VALID_TEAMS.keys()))) |
| self.teams = set(teamlist) |
| else: |
| self.teams = None |
| |
| def _ReduceOwnerName(self, owner): |
| """Translate |owner| from spreadsheet/commandline name to short name. |
| |
| For example: joe@chromium.org -> joe, joe --> joe |
| """ |
| if owner: |
| return owner.lower().split('@')[0] |
| return None |
| |
| def SetOwnerFilter(self, ownerarg): |
| """Set owner filter using colon-separated owner names in |ownerarg|""" |
| if ownerarg: |
| self.owners = set([self._ReduceOwnerName(o) for o in ownerarg.split(':')]) |
| else: |
| self.owners = None |
| |
| def _RowPassesFilters(self, row): |
| """Return true if |row| passes any team/owner filters""" |
| if self.teams: |
| team = self._ReduceTeamName(row[COL_TEAM]) |
| if team not in self.teams: |
| return False |
| |
| if self.owners: |
| owner = self._ReduceOwnerName(row[COL_OWNER]) |
| if owner not in self.owners: |
| return False |
| |
| return True |
| |
| def Sync(self): |
| """Do synchronization between Spreadsheet and Tracker.""" |
| |
| errors = [] |
| |
| # Go over each row in Spreadsheet. |
| rows = self.scomm.GetAllRowsAsDicts() |
| for rowIx, row in enumerate(rows): |
| # Spreadsheet row index starts at 1, and we don't count |
| # the header row. So add 2. |
| rowIx += 2 |
| if not self._RowPassesFilters(row): |
| oper.Info('\nSkipping row %d, pkg: %r (team=%s, owner=%s) ...' % |
| (rowIx, row[COL_PACKAGE], row[COL_TEAM], row[COL_OWNER])) |
| continue |
| |
| oper.Info('\nProcessing row %d, pkg: %r (team=%s, owner=%s) ...' % |
| (rowIx, row[COL_PACKAGE], row[COL_TEAM], row[COL_OWNER])) |
| |
| try: |
| new_issue = self._GenIssueForRow(row) |
| old_issue_id = self._GetRowTrackerId(row) |
| |
| if new_issue and not old_issue_id: |
| self._CreateRowIssue(rowIx, row, new_issue) |
| elif not new_issue and old_issue_id: |
| self._ClearRowIssue(rowIx, row) |
| else: |
| # Nothing to do for this package. |
| reason = 'already has issue' if old_issue_id else 'no upgrade needed' |
| oper.Notice('Nothing to do for row %d, package %r: %s.' % |
| (rowIx, row[COL_PACKAGE], reason)) |
| except SyncError: |
| errors.append('Error processing row %d, pkg: %r. See above.' % |
| (rowIx, row[COL_PACKAGE])) |
| |
| if errors: |
| raise SyncError('\n'.join(errors)) |
| |
| def _GetRowValue(self, row, colName, arch=None): |
| """Get value from |row| at |colName|, adjusted for |arch|""" |
| if arch: |
| colName = utable.UpgradeTable.GetColumnName(colName, arch=arch) |
| colName = gdata_lib.PrepColNameForSS(colName) |
| return row[colName] |
| |
| def _GenIssueForRow(self, row): |
| """Generate an Issue object for |row| if applicable""" |
| # Row needs an issue if it "needs upgrade" on any platform. |
| statuses = {} |
| needs_issue = False |
| for arch in ARCHES: |
| state = self._GetRowValue(row, utable.UpgradeTable.COL_STATE, arch) |
| statuses[arch] = state |
| if state in self.UPGRADE_STATES: |
| needs_issue = True |
| |
| if not needs_issue: |
| return None |
| |
| pkg = row[COL_PACKAGE] |
| team = self._ReduceTeamName(row[COL_TEAM]) |
| if not team: |
| oper.Error('Unable to create Issue for package "%s" because no ' |
| 'team value is specified.' % pkg) |
| raise SyncError() |
| |
| team_label = self.VALID_TEAMS[team] |
| labels = ['Type-Task', |
| 'Area-LinuxUserSpace', |
| 'Pri-2', |
| 'Team-%s' % team_label, |
| ] |
| |
| owner = self._ReduceOwnerName(row[COL_OWNER]) |
| status = 'Untriaged' |
| if owner: |
| owner = owner + '@chromium.org' |
| status = 'Available' |
| else: |
| owner = None # Rather than '' |
| |
| title = '%s package needs upgrade from upstream Portage' % pkg |
| |
| lines = ['The %s package can be upgraded from upstream Portage' % pkg, |
| '', |
| 'At this moment the status on each arch is as follows:', |
| ] |
| |
| for arch in sorted(statuses): |
| arch_status = statuses[arch] |
| if arch_status: |
| # Get all versions for this arch right now. |
| curr_ver_col = utable.UpgradeTable.COL_CURRENT_VER |
| curr_ver = self._GetRowValue(row, curr_ver_col, arch) |
| stable_upst_ver_col = utable.UpgradeTable.COL_STABLE_UPSTREAM_VER |
| stable_upst_ver = self._GetRowValue(row, stable_upst_ver_col, arch) |
| latest_upst_ver_col = utable.UpgradeTable.COL_LATEST_UPSTREAM_VER |
| latest_upst_ver = self._GetRowValue(row, latest_upst_ver_col, arch) |
| |
| arch_vers = ['Current version: %s' % curr_ver, |
| 'Stable upstream version: %s' % stable_upst_ver, |
| 'Latest upstream version: %s' % latest_upst_ver] |
| lines.append(' On %s: %s' % (arch, arch_status)) |
| lines.append(' %s' % ', '.join(arch_vers)) |
| else: |
| lines.append(' On %s: not used' % arch) |
| |
| lines.append('') |
| lines.append('Check the latest status for this package, including ' |
| 'which upstream versions are available, at:\n %s' % |
| PKG_STATUS_PAGE) |
| lines.append('For help upgrading see: %s' % PKG_UPGRADE_PAGE) |
| |
| summary = '\n'.join(lines) |
| |
| issue = Issue(title=title, |
| summary=summary, |
| status=status, |
| owner=owner, |
| labels=labels, |
| ) |
| return issue |
| |
| def _GetRowTrackerId(self, row): |
| """Get the tracker issue id in |row| if it exists, return None otherwise.""" |
| tracker_val = row[COL_TRACKER] |
| if tracker_val: |
| return int(tracker_val) |
| |
| return None |
| |
| def _CreateRowIssue(self, rowIx, row, issue): |
| """Create a Tracker issue for |issue|, insert into |row| at |rowIx|""" |
| |
| pkg = row[COL_PACKAGE] |
| if not self.pretend: |
| oper.Info('Creating Tracker issue for package %s with details:\n%s' % |
| (pkg, issue)) |
| issue_id = self.tcomm.CreateTrackerIssue(issue) |
| oper.Info('Inserting new Tracker issue %d for package %s' % |
| (issue_id, pkg)) |
| ss_issue_val = self._GenSSLinkToIssue(issue_id) |
| self.scomm.ReplaceCellValue(rowIx, self.tracker_col_ix, ss_issue_val) |
| |
| oper.Notice('Created Tracker issue %d for row %d, package %r' % |
| (issue_id, rowIx, pkg)) |
| else: |
| oper.Notice('Would create and insert issue for row %d, package %r' % |
| (rowIx, pkg)) |
| oper.Info('Issue would be as follows:\n%s' % issue) |
| |
| def _GenSSLinkToIssue(self, issue_id): |
| """Create the spreadsheet hyperlink format for |issue_id|""" |
| return '=hyperlink("crosbug.com/%d";"%d")' % (issue_id, issue_id) |
| |
| def _ClearRowIssue(self, rowIx, row): |
| """Clear the Tracker cell for row at |rowIx|""" |
| |
| pkg = row[COL_PACKAGE] |
| if not self.pretend: |
| oper.Notice('Clearing Tracker issue for package %s' % pkg) |
| self.scomm.ClearCellValue(rowIx, self.tracker_col_ix) |
| else: |
| oper.Notice('Would clear Tracker issue from row %d, package %r' % |
| (rowIx, pkg)) |
| |
| |
| def _CreateOptParser(): |
| """Create the optparser.parser object for command-line args.""" |
| usage = 'Usage: %prog [options]' |
| epilog = ('\n' |
| 'Use this script to synchronize tracker issues between the ' |
| 'package status spreadsheet and the chromium-os Tracker.\n' |
| 'It uses the "Tracker" column of the package spreadsheet. ' |
| 'If a package needs an upgrade and has no tracker issue\n' |
| 'in that column then a tracker issue is created. If it ' |
| 'does not need an upgrade then that column is cleared.\n' |
| '\n' |
| 'Credentials must be specified using --cred-file or ' |
| '--email. The former has a default value which you can\n' |
| 'rely on if valid, the latter will prompt for your password. ' |
| 'If you specify --email you will be given a chance to save\n' |
| 'your email/password out as a credentials file for next time.\n' |
| '\n' |
| 'Uses spreadsheet key %s (worksheet "%s").\n' |
| '\n' |
| 'Use the --team and --owner options to operate only on ' |
| 'packages assigned to particular owners or teams.\n' |
| '\n' % |
| (SS_KEY, PKGS_WS_NAME) |
| ) |
| |
| class MyOptParser(optparse.OptionParser): |
| """Override default epilog formatter, which strips newlines.""" |
| def format_epilog(self, formatter): |
| return self.epilog |
| |
| teamhelp = '[%s]' % ', '.join(Syncer.VALID_TEAMS.keys()) |
| |
| parser = MyOptParser(usage=usage, epilog=epilog) |
| parser.add_option('--cred-file', dest='cred_file', type='string', |
| action='store', default=gdata_lib.CRED_FILE, |
| help='Path to gdata credentials file [default: "%default"]') |
| parser.add_option('--email', dest='email', type='string', |
| action='store', default=None, |
| help="Email for Google Doc/Tracker user") |
| parser.add_option('--pretend', dest='pretend', action='store_true', |
| default=False, |
| help='Do not make any actual changes.') |
| parser.add_option('--team', dest='team', type='string', action='store', |
| default=None, |
| help='Filter by team; colon-separated %s' % teamhelp) |
| parser.add_option('--owner', dest='owner', type='string', action='store', |
| default=None, |
| help='Filter by package owner;' |
| ' colon-separated chromium.org accounts') |
| parser.add_option('--verbose', dest='verbose', action='store_true', |
| default=False, |
| help='Enable verbose output (for debugging)') |
| |
| return parser |
| |
| def main(): |
| """Main function.""" |
| parser = _CreateOptParser() |
| (options, _args) = parser.parse_args() |
| |
| oper.verbose = options.verbose |
| |
| if not options.email and not os.path.exists(options.cred_file): |
| options.email = os.environ['USER'] |
| oper.Notice('Assuming your chromium email is %s@chromium.org.' |
| ' Override with --email.' % options.email) |
| |
| creds = gdata_lib.Creds(cred_file=options.cred_file, user=options.email) |
| tcomm = TrackerComm(creds) |
| scomm = SpreadsheetComm(creds, SS_KEY, PKGS_WS_NAME) |
| |
| syncer = Syncer(tcomm, scomm, |
| pretend=options.pretend, verbose=options.verbose) |
| |
| if options.team: |
| syncer.SetTeamFilter(options.team) |
| if options.owner: |
| syncer.SetOwnerFilter(options.owner) |
| |
| try: |
| syncer.Sync() |
| except SyncError as ex: |
| oper.Die(str(ex)) |
| |
| # If --email, which is only effective when run interactively (because the |
| # password must be entered), give the option of saving to a creds file for |
| # next time. |
| if options.email and options.cred_file: |
| prompt = ('Do you want to save credentials for next time to %r?' % |
| options.cred_file) |
| if 'yes' == cros_lib.YesNoPrompt(default='no', prompt=prompt): |
| creds.StoreCreds(options.cred_file) |
| oper.Notice('Be sure to save the creds file to the same location' |
| ' outside your chroot so it will also be used with' |
| ' future chroots.') |
| |
| if __name__ == '__main__': |
| main() |