blob: cb9bcc7f77b97d465948e38b688c03e5fbc6573f [file] [log] [blame]
#!/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()