blob: d5f10be70a508762b19770c8026b6024c1eeb4c3 [file] [log] [blame]
# 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."""
from __future__ import print_function
import optparse
import os
import sys
from chromite.lib import cros_build_lib
from chromite.lib import gdata_lib
from chromite.lib import operation
from chromite.lib import upgrade_table as utable
from chromite.scripts import upload_package_status as ups
TRACKER_PROJECT_NAME = 'chromium'
PKGS_WS_NAME = 'Packages'
CR_ORG = 'chromium.org'
CHROMIUMOS_SITE = 'http://www.%s/chromium-os' % CR_ORG
PKG_UPGRADE_PAGE = '%s/gentoo-package-upgrade-process' % CHROMIUMOS_SITE
DOCS_SITE = 'https://docs.google.com/a'
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')
DISABLING_TRACKER_VALUES = set(['n/a', 'disable', 'disabled'])
oper = operation.Operation('sync_package_status')
def _GetPkgSpreadsheetURL(ss_key):
return '%s/%s/spreadsheet/ccc?key=%s' % (DOCS_SITE, CR_ORG, ss_key)
class SyncError(RuntimeError):
"""Extend RuntimeError for use in this module."""
class PackageBlacklisted(RuntimeError):
"""Raised when package has disabled automatic tracker syncing."""
class Syncer(object):
"""Class to manage synchronizing between spreadsheet and Tracker."""
# Map spreadsheet team names to Tracker team labels.
VALID_TEAMS = {'build': 'Build',
'kernel': 'Cr-OS-Kernel',
'security': 'Security',
'system': 'Cr-OS-Systems',
'ui': 'Cr-UI'}
UPGRADE_STATES = set(
[utable.UpgradeTable.STATE_NEEDS_UPGRADE,
utable.UpgradeTable.STATE_NEEDS_UPGRADE_AND_PATCHED,
utable.UpgradeTable.STATE_NEEDS_UPGRADE_AND_DUPLICATED])
__slots__ = (
'default_owner', # Default owner to use when creating issues
'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 = None
self.teams = None
self.owners = None
self.default_owner = 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 SetDefaultOwner(self, default_owner):
"""Use |default_owner| as isue owner when none set in spreadsheet."""
if default_owner and default_owner == 'me':
self.default_owner = os.environ['USER']
else:
self.default_owner = default_owner
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."""
self.tracker_col_ix = self.scomm.GetColumnIndex(COL_TRACKER)
if None is self.tracker_col_ix:
raise SyncError('Unable to find "Tracker" column in spreadsheet')
errors = []
# Go over each row in Spreadsheet. Row index starts at 2
# because spreadsheet rows start at 1 and we don't want the header row.
rows = self.scomm.GetRows()
for rowIx, row in enumerate(rows, start=self.scomm.ROW_NUMBER_OFFSET):
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 PackageBlacklisted:
oper.Notice('Tracker sync disabled for row %d, package %r: skipped.' %
(rowIx, row[COL_PACKAGE]))
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()
labels = ['Type-Bug',
'OS-Chrome',
'Cr-OS-Packages',
'Pri-2',
self.VALID_TEAMS[team]]
owner = self._ReduceOwnerName(row[COL_OWNER])
status = 'Untriaged'
if owner:
owner = owner + '@chromium.org'
status = 'Available'
elif self.default_owner:
owner = self.default_owner + '@chromium.org.'
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' %
_GetPkgSpreadsheetURL(self.scomm.ss_key))
lines.append('For help upgrading see: %s' % PKG_UPGRADE_PAGE)
summary = '\n'.join(lines)
issue = gdata_lib.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.
Raises:
PackageBlacklisted if package has Tracker column value to disable syncing.
"""
tracker_val = row[COL_TRACKER]
if tracker_val:
try:
return int(tracker_val)
except ValueError:
# See if the unexpected value is one that disables tracker syncing.
if tracker_val.replace(' ', '').lower() in DISABLING_TRACKER_VALUES:
raise PackageBlacklisted()
raise
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))
# Before actually creating the Tracker issue, confirm that writing
# to this spreadsheet row is going to work.
try:
self.scomm.ClearCellValue(rowIx, self.tracker_col_ix)
except gdata_lib.SpreadsheetError as ex:
oper.Error('Unable to write to row %d, package %r. Aborting issue'
' creation. Error was:\n%s' % (rowIx, pkg, ex))
raise SyncError
try:
issue_id = self.tcomm.CreateTrackerIssue(issue)
except gdata_lib.TrackerInvalidUserError as ex:
oper.Warning('%s. Ignoring owner field for issue %d, package %r.' %
(ex, rowIx, pkg))
issue.summary += ('\n\nNote that the row for this package in'
' the spreadsheet at go/crospkgs has an "owner"\n'
'value that is not a valid Tracker user: "%s".' %
issue.owner)
issue.owner = None
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)
# This really should not fail since write access was checked before.
try:
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))
except gdata_lib.SpreadsheetError as ex:
oper.Error('Failed to write link to new issue %d into'
' row %d, package %r:\n%s' %
(issue_id, rowIx, pkg, ex))
oper.Error('This means that the spreadsheet will have no record of'
' this Tracker Issue and will create one again next time'
' unless the spreadsheet is edited by hand!')
raise SyncError
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("crbug.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:
try:
self.scomm.ClearCellValue(rowIx, self.tracker_col_ix)
oper.Notice('Cleared Tracker issue from row %d, package %r' %
(rowIx, pkg))
except gdata_lib.SpreadsheetError as ex:
oper.Error('Error while clearing Tracker issue for'
' row %d, package %r:\n%s' % (rowIx, pkg, ex))
raise SyncError
else:
oper.Notice('Would clear Tracker issue from row %d, package %r' %
(rowIx, pkg))
def PrepareCreds(cred_file, token_file, email):
"""Return a Creds object from given credentials.
If |email| is given, the Creds object will contain that |email|
and a password entered at a prompt.
Otherwise, if |token_file| is given then the Creds object will have
the auth_token from that file.
Otherwise, if |cred_file| is given then the Creds object will have
the email/password from that file.
"""
creds = gdata_lib.Creds()
if email:
creds.SetCreds(email)
elif token_file and os.path.exists(token_file):
creds.LoadAuthToken(token_file)
elif cred_file and os.path.exists(cred_file):
creds.LoadCreds(cred_file)
return creds
def _CreateOptParser():
"""Create the optparser.parser object for command-line args."""
usage = 'Usage: %prog [options]'
epilog = ("""
Use this script to synchronize tracker issues between the package status
spreadsheet and the chromium-os Tracker. It uses the "Tracker" column of the
package spreadsheet. If a package needs an upgrade and has no tracker issue in
that column then a tracker issue is created. If it does not need an upgrade
then that column is cleared.
Credentials must be specified using --auth-token-file, --cred-file or --email.
The first two have default values which you can rely on if valid, the latter
will prompt for your password. If you specify --email you will be given a
chance to save your email/password out as a credentials file for next time.
Uses spreadsheet key %(ss_key)s, worksheet "%(ws_name)s".
(if --test-spreadsheet is set then spreadsheet
%(test_ss_key)s is used).
Use the --team and --owner options to operate only on packages assigned to
particular owners or teams. Generally, running without a team or owner filter
is not intended, so use --team=all and/or --owner=all.
Issues will be assigned to the owner of the package in the spreadsheet, if
available. If not, the owner defaults to value given to --default-owner.
The --owner and --default-owner options accept "me" as an argument, which is
only useful if your username matches your chromium account name.
""" %
{'ss_key': ups.REAL_SS_KEY, 'ws_name': PKGS_WS_NAME,
'test_ss_key': ups.TEST_SS_KEY})
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('--auth-token-file', dest='token_file', type='string',
action='store', default=gdata_lib.TOKEN_FILE,
help='File for reading/writing Docs auth token.'
' [default: "%default"]')
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('--default-owner', dest='default_owner',
type='string', action='store', default=None,
help='Specify issue owner to use when package has no owner')
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('--test-spreadsheet', dest='test_ss',
action='store_true', default=False,
help='Sync to the testing spreadsheet (implies --pretend).')
parser.add_option('--verbose', dest='verbose', action='store_true',
default=False,
help='Enable verbose output (for debugging)')
return parser
def _CheckOptions(options):
"""Vet the options."""
me = os.environ['USER']
if not options.email and not os.path.exists(options.cred_file):
options.email = me
oper.Notice('Assuming your chromium email is %s@chromium.org.'
' Override with --email.' % options.email)
if not options.team and not options.owner:
oper.Notice('Without --owner or --team filters this will run for all'
' packages in the spreadsheet (same as --team=all).')
if not cros_build_lib.BooleanPrompt(
'Are you sure you want to run for all packages?', False):
sys.exit(0)
if options.team and options.team == 'all':
options.team = None
if options.owner and options.owner == 'all':
options.owner = None
if options.owner and options.owner == 'me':
options.owner = me
oper.Notice('Using %r for owner filter (from $USER envvar)' % options.owner)
if options.test_ss and not options.pretend:
oper.Notice('Running in --pretend mode because of --test-spreadsheet')
options.pretend = True
def main(argv):
"""Main function."""
parser = _CreateOptParser()
(options, _args) = parser.parse_args(argv)
oper.verbose = options.verbose
_CheckOptions(options)
ss_key = ups.TEST_SS_KEY if options.test_ss else ups.REAL_SS_KEY
# Prepare credentials for Docs and Tracker access.
creds = PrepareCreds(options.cred_file, options.token_file, options.email)
scomm = gdata_lib.SpreadsheetComm()
scomm.Connect(creds, ss_key, PKGS_WS_NAME, source='Sync Package Status')
tcomm = gdata_lib.TrackerComm()
tcomm.Connect(creds, TRACKER_PROJECT_NAME, source='Sync Package Status')
oper.Notice('Syncing between Tracker and spreadsheet %s' % ss_key)
syncer = Syncer(tcomm, scomm,
pretend=options.pretend, verbose=options.verbose)
if options.team:
syncer.SetTeamFilter(options.team)
if options.owner:
syncer.SetOwnerFilter(options.owner)
if options.default_owner:
syncer.SetDefaultOwner(options.default_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 cros_build_lib.BooleanPrompt(prompt, False):
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.')