2014-10-08 14:31:55 +02:00
|
|
|
#!/usr/bin/python
|
2016-08-05 07:41:30 +02:00
|
|
|
# Copyright (c) 2014-2016 SUSE LLC
|
2014-10-08 14:31:55 +02:00
|
|
|
#
|
|
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
|
|
# in the Software without restriction, including without limitation the rights
|
|
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
#
|
|
|
|
# The above copyright notice and this permission notice shall be included in
|
|
|
|
# all copies or substantial portions of the Software.
|
|
|
|
#
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
|
# SOFTWARE.
|
|
|
|
|
|
|
|
from pprint import pprint
|
|
|
|
import os, sys, re
|
|
|
|
import logging
|
|
|
|
from optparse import OptionParser
|
|
|
|
import cmdln
|
2015-03-31 11:33:39 +02:00
|
|
|
from collections import namedtuple
|
2017-02-15 09:47:59 -06:00
|
|
|
from collections import OrderedDict
|
2017-02-10 15:41:45 -06:00
|
|
|
from osclib.comments import CommentAPI
|
2017-07-18 17:06:37 -05:00
|
|
|
from osclib.conf import Config
|
2018-03-05 17:21:15 -06:00
|
|
|
from osclib.core import group_members
|
2015-05-28 13:06:10 +02:00
|
|
|
from osclib.memoize import memoize
|
2017-07-18 17:06:37 -05:00
|
|
|
from osclib.stagingapi import StagingAPI
|
2015-05-22 14:46:29 +02:00
|
|
|
import signal
|
|
|
|
import datetime
|
2017-06-23 15:29:31 +02:00
|
|
|
import yaml
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
try:
|
|
|
|
from xml.etree import cElementTree as ET
|
|
|
|
except ImportError:
|
|
|
|
import cElementTree as ET
|
|
|
|
|
2017-07-18 17:06:37 -05:00
|
|
|
from osc import conf
|
2014-10-08 14:31:55 +02:00
|
|
|
import osc.core
|
|
|
|
import urllib2
|
|
|
|
|
|
|
|
class ReviewBot(object):
|
|
|
|
"""
|
|
|
|
A generic obs request reviewer
|
|
|
|
Inherit from this class and implement check functions for each action type:
|
|
|
|
|
|
|
|
def check_action_<type>(self, req, action):
|
|
|
|
return (None|True|False)
|
|
|
|
"""
|
|
|
|
|
2015-05-13 13:27:44 +02:00
|
|
|
DEFAULT_REVIEW_MESSAGES = { 'accepted' : 'ok', 'declined': 'review failed' }
|
2016-08-05 07:41:30 +02:00
|
|
|
REVIEW_CHOICES = ('normal', 'no', 'accept', 'accept-onpass', 'fallback-onfail', 'fallback-always')
|
2015-05-13 13:27:44 +02:00
|
|
|
|
2017-02-10 15:41:45 -06:00
|
|
|
COMMENT_MARKER_REGEX = re.compile(r'<!-- (?P<bot>[^ ]+) state=(?P<state>[^ ]+)(?: result=(?P<result>[^ ]+))? -->')
|
|
|
|
|
2017-01-13 15:36:34 +01:00
|
|
|
# map of default config entries
|
|
|
|
config_defaults = {
|
2017-01-31 17:28:23 +01:00
|
|
|
# list of tuples (prefix, apiurl, submitrequestprefix)
|
2017-01-13 15:36:34 +01:00
|
|
|
# set this if the obs instance maps another instance into it's
|
|
|
|
# namespace
|
|
|
|
'project_namespace_api_map' : [
|
2017-01-31 17:28:23 +01:00
|
|
|
('openSUSE.org:', 'https://api.opensuse.org', 'obsrq'),
|
2017-01-13 15:36:34 +01:00
|
|
|
],
|
|
|
|
}
|
|
|
|
|
2015-03-27 15:01:45 +01:00
|
|
|
def __init__(self, apiurl = None, dryrun = False, logger = None, user = None, group = None):
|
2014-10-08 14:31:55 +02:00
|
|
|
self.apiurl = apiurl
|
2017-01-17 20:57:06 -06:00
|
|
|
self.ibs = apiurl.startswith('https://api.suse.de')
|
2014-10-08 14:31:55 +02:00
|
|
|
self.dryrun = dryrun
|
|
|
|
self.logger = logger
|
|
|
|
self.review_user = user
|
2015-03-27 15:01:45 +01:00
|
|
|
self.review_group = group
|
2014-10-08 14:31:55 +02:00
|
|
|
self.requests = []
|
2015-05-13 13:27:44 +02:00
|
|
|
self.review_messages = ReviewBot.DEFAULT_REVIEW_MESSAGES
|
2016-03-08 16:43:54 +01:00
|
|
|
self._review_mode = 'normal'
|
|
|
|
self.fallback_user = None
|
|
|
|
self.fallback_group = None
|
2017-02-10 15:41:45 -06:00
|
|
|
self.comment_api = CommentAPI(self.apiurl)
|
2017-02-15 07:55:35 -06:00
|
|
|
self.bot_name = self.__class__.__name__
|
2017-06-20 16:29:19 -05:00
|
|
|
self.only_one_action = False
|
2017-06-20 16:25:46 -05:00
|
|
|
self.request_default_return = None
|
2017-06-20 16:32:16 -05:00
|
|
|
self.comment_handler = False
|
2018-03-05 17:21:15 -06:00
|
|
|
self.override_allow = True
|
|
|
|
self.override_group_key = '{}-override-group'.format(self.bot_name.lower())
|
2016-03-08 16:43:54 +01:00
|
|
|
|
2016-12-23 13:59:17 +01:00
|
|
|
self.load_config()
|
|
|
|
|
|
|
|
def _load_config(self, handle = None):
|
2017-01-13 15:36:34 +01:00
|
|
|
d = self.__class__.config_defaults
|
2016-12-23 13:59:17 +01:00
|
|
|
y = yaml.safe_load(handle) if handle is not None else {}
|
|
|
|
return namedtuple('BotConfig', sorted(d.keys()))(*[ y.get(p, d[p]) for p in sorted(d.keys()) ])
|
|
|
|
|
|
|
|
def load_config(self, filename = None):
|
|
|
|
if filename:
|
2017-06-23 15:29:31 +02:00
|
|
|
with open(filename, 'r') as fh:
|
|
|
|
self.config = self._load_config(fh)
|
2016-12-23 13:59:17 +01:00
|
|
|
else:
|
|
|
|
self.config = self._load_config()
|
|
|
|
|
2017-07-18 17:06:37 -05:00
|
|
|
def staging_api(self, project):
|
|
|
|
if project not in self.staging_apis:
|
|
|
|
config = Config(project)
|
|
|
|
self.staging_apis[project] = StagingAPI(self.apiurl, project)
|
|
|
|
|
|
|
|
config.apply_remote(self.staging_apis[project])
|
|
|
|
self.staging_config[project] = conf.config[project].copy()
|
|
|
|
|
|
|
|
return self.staging_apis[project]
|
|
|
|
|
2016-03-08 16:43:54 +01:00
|
|
|
@property
|
|
|
|
def review_mode(self):
|
|
|
|
return self._review_mode
|
|
|
|
|
|
|
|
@review_mode.setter
|
|
|
|
def review_mode(self, value):
|
2016-03-17 16:15:31 +01:00
|
|
|
if value not in self.REVIEW_CHOICES:
|
2016-03-10 13:49:39 +01:00
|
|
|
raise Exception("invalid review option: %s"%value)
|
2016-03-08 16:43:54 +01:00
|
|
|
self._review_mode = value
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
def set_request_ids(self, ids):
|
|
|
|
for rqid in ids:
|
2017-06-22 23:06:40 -05:00
|
|
|
u = osc.core.makeurl(self.apiurl, [ 'request', rqid ], { 'withfullhistory' : '1' })
|
2014-10-08 14:31:55 +02:00
|
|
|
r = osc.core.http_GET(u)
|
|
|
|
root = ET.parse(r).getroot()
|
|
|
|
req = osc.core.Request()
|
|
|
|
req.read(root)
|
|
|
|
self.requests.append(req)
|
|
|
|
|
2016-07-28 15:26:14 +02:00
|
|
|
# function called before requests are reviewed
|
|
|
|
def prepare_review(self):
|
|
|
|
pass
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
def check_requests(self):
|
2017-07-18 17:06:37 -05:00
|
|
|
self.staging_apis = {}
|
|
|
|
self.staging_config = {}
|
2016-03-08 16:43:54 +01:00
|
|
|
|
2016-07-28 15:26:14 +02:00
|
|
|
# give implementations a chance to do something before single requests
|
|
|
|
self.prepare_review()
|
2014-10-08 14:31:55 +02:00
|
|
|
for req in self.requests:
|
2016-06-28 22:30:27 +02:00
|
|
|
self.logger.info("checking %s"%req.reqid)
|
2016-12-29 00:34:56 -06:00
|
|
|
self.request = req
|
2018-03-05 17:21:15 -06:00
|
|
|
|
|
|
|
override = self.request_override_check(req)
|
|
|
|
if override is not None:
|
|
|
|
good = override
|
|
|
|
else:
|
|
|
|
good = self.check_one_request(req)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2016-03-08 16:43:54 +01:00
|
|
|
if self.review_mode == 'no':
|
|
|
|
good = None
|
|
|
|
elif self.review_mode == 'accept':
|
|
|
|
good = True
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
if good is None:
|
2015-05-22 11:07:33 +02:00
|
|
|
self.logger.info("%s ignored"%req.reqid)
|
2014-10-08 14:31:55 +02:00
|
|
|
elif good:
|
|
|
|
self._set_review(req, 'accepted')
|
2016-08-05 07:41:30 +02:00
|
|
|
elif self.review_mode != 'accept-onpass':
|
2016-06-07 14:41:03 +02:00
|
|
|
self._set_review(req, 'declined')
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2018-03-05 17:21:15 -06:00
|
|
|
@memoize(session=True)
|
|
|
|
def request_override_check_users(self, project):
|
|
|
|
"""Determine users allowed to override review in a comment command."""
|
|
|
|
self.staging_api(project)
|
|
|
|
config = self.staging_config[project]
|
|
|
|
|
|
|
|
users = group_members(self.apiurl, config['staging-group'])
|
|
|
|
|
|
|
|
if self.override_group_key:
|
|
|
|
override_group = config.get(self.override_group_key)
|
|
|
|
if override_group:
|
|
|
|
users += group_members(self.apiurl, override_group)
|
|
|
|
|
|
|
|
return users
|
|
|
|
|
|
|
|
def request_override_check(self, request):
|
|
|
|
"""Check for a comment command requesting review override."""
|
|
|
|
if not self.override_allow:
|
|
|
|
return None
|
|
|
|
|
|
|
|
comments = self.comment_api.get_comments(request_id=request.reqid)
|
|
|
|
users = self.request_override_check_users(request.actions[0].tgt_project)
|
|
|
|
for args, who in self.comment_api.command_find(
|
|
|
|
comments, self.review_user, 'override', users):
|
|
|
|
message = 'overridden by {}'.format(who)
|
|
|
|
override = args[1] or None
|
|
|
|
if override == 'accept':
|
|
|
|
self.review_messages['accepted'] = message
|
|
|
|
return True
|
|
|
|
|
|
|
|
if override == 'decline':
|
|
|
|
self.review_messages['declined'] = message
|
|
|
|
return False
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
def _set_review(self, req, state):
|
2015-03-27 15:01:45 +01:00
|
|
|
doit = self.can_accept_review(req.reqid)
|
2015-02-20 13:10:26 +01:00
|
|
|
if doit is None:
|
2017-10-17 09:10:17 +02:00
|
|
|
self.logger.info("can't change state, %s does not have the reviewer"%(req.reqid))
|
2015-03-27 15:01:45 +01:00
|
|
|
|
2016-06-07 14:41:03 +02:00
|
|
|
newstate = state
|
|
|
|
|
|
|
|
by_user = self.fallback_user
|
|
|
|
by_group = self.fallback_group
|
|
|
|
|
2017-01-05 17:19:54 +01:00
|
|
|
msg = self.review_messages[state] if state in self.review_messages else state
|
|
|
|
self.logger.info("%s %s: %s"%(req.reqid, state, msg))
|
|
|
|
|
2016-06-07 14:41:03 +02:00
|
|
|
if state == 'declined':
|
|
|
|
if self.review_mode == 'fallback-onfail':
|
|
|
|
self.logger.info("%s needs fallback reviewer"%req.reqid)
|
2017-01-05 17:19:54 +01:00
|
|
|
self.add_review(req, by_group=by_group, by_user=by_user, msg="Automated review failed. Needs fallback reviewer.")
|
2016-06-07 14:41:03 +02:00
|
|
|
newstate = 'accepted'
|
|
|
|
elif self.review_mode == 'fallback-always':
|
2017-01-05 17:19:54 +01:00
|
|
|
self.add_review(req, by_group=by_group, by_user=by_user, msg='Adding fallback reviewer')
|
2016-06-07 14:41:03 +02:00
|
|
|
|
2015-02-20 13:10:26 +01:00
|
|
|
if doit == True:
|
2014-10-08 14:31:55 +02:00
|
|
|
self.logger.debug("setting %s to %s"%(req.reqid, state))
|
|
|
|
if not self.dryrun:
|
|
|
|
osc.core.change_review_state(apiurl = self.apiurl,
|
2016-06-07 14:41:03 +02:00
|
|
|
reqid = req.reqid, newstate = newstate,
|
2015-03-27 15:01:45 +01:00
|
|
|
by_group=self.review_group,
|
2014-10-08 14:31:55 +02:00
|
|
|
by_user=self.review_user, message=msg)
|
|
|
|
else:
|
2015-02-20 13:10:26 +01:00
|
|
|
self.logger.debug("%s review not changed"%(req.reqid))
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2017-08-15 22:18:53 -05:00
|
|
|
# allow_duplicate=True should only be used if it makes sense to force a
|
|
|
|
# re-review in a scenario where the bot adding the review will rerun.
|
|
|
|
# Normally a declined review will automatically be reopened along with the
|
|
|
|
# request and any other bot reviews already added will not be touched unless
|
|
|
|
# the issuing bot is rerun which does not fit normal workflow.
|
|
|
|
def add_review(self, req, by_group=None, by_user=None, by_project=None, by_package=None,
|
|
|
|
msg=None, allow_duplicate=False):
|
2014-10-08 14:31:55 +02:00
|
|
|
query = {
|
|
|
|
'cmd': 'addreview'
|
|
|
|
}
|
|
|
|
if by_group:
|
|
|
|
query['by_group'] = by_group
|
|
|
|
elif by_user:
|
|
|
|
query['by_user'] = by_user
|
|
|
|
elif by_project:
|
|
|
|
query['by_project'] = by_project
|
|
|
|
if by_package:
|
|
|
|
query['by_package'] = by_package
|
|
|
|
else:
|
|
|
|
raise osc.oscerr.WrongArgs("missing by_*")
|
|
|
|
|
2017-08-15 22:18:53 -05:00
|
|
|
for r in req.reviews:
|
|
|
|
if (r.by_group == by_group and
|
|
|
|
r.by_project == by_project and
|
|
|
|
r.by_package == by_package and
|
|
|
|
r.by_user == by_user and
|
|
|
|
# Only duplicate when allow_duplicate and state != new.
|
|
|
|
(not allow_duplicate or r.state == 'new')):
|
|
|
|
del query['cmd']
|
2017-08-29 22:18:16 -05:00
|
|
|
self.logger.debug('skipped adding duplicate review for {}'.format(
|
2017-08-15 22:18:53 -05:00
|
|
|
'/'.join(query.values())))
|
|
|
|
return
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
u = osc.core.makeurl(self.apiurl, ['request', req.reqid], query)
|
|
|
|
if self.dryrun:
|
|
|
|
self.logger.info('POST %s' % u)
|
2017-08-11 16:29:18 -05:00
|
|
|
return
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2017-08-11 16:29:18 -05:00
|
|
|
r = osc.core.http_POST(u, data=msg)
|
2014-10-08 14:31:55 +02:00
|
|
|
code = ET.parse(r).getroot().attrib['code']
|
|
|
|
if code != 'ok':
|
2017-08-11 16:29:18 -05:00
|
|
|
raise Exception('non-ok return code: {}'.format(code))
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
def check_one_request(self, req):
|
|
|
|
"""
|
|
|
|
check all actions in one request.
|
|
|
|
|
|
|
|
calls helper functions for each action type
|
|
|
|
|
|
|
|
return None if nothing to do, True to accept, False to reject
|
|
|
|
"""
|
2017-06-20 16:29:19 -05:00
|
|
|
|
2017-06-20 16:31:25 -05:00
|
|
|
# Copy original values to revert changes made to them.
|
|
|
|
self.review_messages = self.DEFAULT_REVIEW_MESSAGES.copy()
|
|
|
|
|
2017-06-20 16:29:19 -05:00
|
|
|
if self.only_one_action and len(req.actions) != 1:
|
|
|
|
self.review_messages['declined'] = 'Only one action per request'
|
|
|
|
return False
|
|
|
|
|
2017-06-20 16:32:16 -05:00
|
|
|
if self.comment_handler is not False:
|
|
|
|
self.comment_handler_add()
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
overall = None
|
|
|
|
for a in req.actions:
|
|
|
|
fn = 'check_action_%s'%a.type
|
|
|
|
if not hasattr(self, fn):
|
2016-06-01 13:43:46 +02:00
|
|
|
fn = 'check_action__default'
|
|
|
|
func = getattr(self, fn)
|
|
|
|
ret = func(req, a)
|
2014-10-08 14:31:55 +02:00
|
|
|
if ret == False or overall is None and ret is not None:
|
|
|
|
overall = ret
|
|
|
|
return overall
|
|
|
|
|
2017-04-28 12:23:08 +02:00
|
|
|
@staticmethod
|
|
|
|
def _is_patchinfo(pkgname):
|
|
|
|
return pkgname == 'patchinfo' or pkgname.startswith('patchinfo.')
|
|
|
|
|
2015-03-19 16:42:18 +01:00
|
|
|
def check_action_maintenance_incident(self, req, a):
|
2015-05-13 15:55:00 +02:00
|
|
|
dst_package = a.src_package
|
2016-08-05 15:16:39 +02:00
|
|
|
# Ignoring patchinfo package for checking
|
2017-04-28 12:23:08 +02:00
|
|
|
if self._is_patchinfo(a.src_package):
|
2017-10-17 09:10:17 +02:00
|
|
|
self.logger.info("package is patchinfo, ignoring")
|
|
|
|
return None
|
2015-05-13 15:55:00 +02:00
|
|
|
# dirty obs crap
|
2015-05-26 10:20:43 +02:00
|
|
|
if a.tgt_releaseproject is not None:
|
|
|
|
ugly_suffix = '.'+a.tgt_releaseproject.replace(':', '_')
|
|
|
|
if dst_package.endswith(ugly_suffix):
|
|
|
|
dst_package = dst_package[:-len(ugly_suffix)]
|
2015-05-13 15:55:00 +02:00
|
|
|
return self.check_source_submission(a.src_project, a.src_package, a.src_rev, a.tgt_releaseproject, dst_package)
|
2015-03-19 16:42:18 +01:00
|
|
|
|
|
|
|
def check_action_maintenance_release(self, req, a):
|
|
|
|
pkgname = a.src_package
|
2017-04-28 12:23:08 +02:00
|
|
|
if self._is_patchinfo(pkgname):
|
2015-03-19 16:42:18 +01:00
|
|
|
return None
|
|
|
|
linkpkg = self._get_linktarget_self(a.src_project, pkgname)
|
|
|
|
if linkpkg is not None:
|
|
|
|
pkgname = linkpkg
|
|
|
|
# packages in maintenance have links to the target. Use that
|
|
|
|
# to find the real package name
|
|
|
|
(linkprj, linkpkg) = self._get_linktarget(a.src_project, pkgname)
|
|
|
|
if linkpkg is None or linkprj is None or linkprj != a.tgt_project:
|
|
|
|
self.logger.error("%s/%s is not a link to %s"%(a.src_project, pkgname, a.tgt_project))
|
|
|
|
return False
|
|
|
|
else:
|
|
|
|
pkgname = linkpkg
|
2015-03-31 11:33:39 +02:00
|
|
|
return self.check_source_submission(a.src_project, a.src_package, None, a.tgt_project, pkgname)
|
2015-03-19 16:42:18 +01:00
|
|
|
|
|
|
|
def check_action_submit(self, req, a):
|
2015-03-31 11:33:39 +02:00
|
|
|
return self.check_source_submission(a.src_project, a.src_package, a.src_rev, a.tgt_project, a.tgt_package)
|
2015-03-19 16:42:18 +01:00
|
|
|
|
2016-06-01 13:43:46 +02:00
|
|
|
def check_action__default(self, req, a):
|
2018-03-05 11:38:16 -06:00
|
|
|
message = 'unhandled request type {}'.format(a.type)
|
|
|
|
self.logger.error(message)
|
|
|
|
self.review_messages['accepted'] += ': ' + message
|
2017-06-20 16:25:46 -05:00
|
|
|
return self.request_default_return
|
2016-06-01 13:43:46 +02:00
|
|
|
|
2015-03-19 16:42:18 +01:00
|
|
|
def check_source_submission(self, src_project, src_package, src_rev, target_project, target_package):
|
|
|
|
""" default implemention does nothing """
|
|
|
|
self.logger.info("%s/%s@%s -> %s/%s"%(src_project, src_package, src_rev, target_project, target_package))
|
|
|
|
return None
|
|
|
|
|
2015-05-28 13:06:10 +02:00
|
|
|
@staticmethod
|
|
|
|
@memoize(session=True)
|
|
|
|
def _get_sourceinfo(apiurl, project, package, rev=None):
|
2014-10-08 14:31:55 +02:00
|
|
|
query = { 'view': 'info' }
|
2015-03-31 11:33:39 +02:00
|
|
|
if rev is not None:
|
2014-10-08 14:31:55 +02:00
|
|
|
query['rev'] = rev
|
2015-05-28 13:06:10 +02:00
|
|
|
url = osc.core.makeurl(apiurl, ('source', project, package), query=query)
|
2014-10-08 14:31:55 +02:00
|
|
|
try:
|
2015-05-28 13:06:10 +02:00
|
|
|
return ET.parse(osc.core.http_GET(url)).getroot()
|
2015-07-07 14:15:34 +08:00
|
|
|
except (urllib2.HTTPError, urllib2.URLError):
|
2014-10-08 14:31:55 +02:00
|
|
|
return None
|
|
|
|
|
2015-05-28 13:06:10 +02:00
|
|
|
def get_originproject(self, project, package, rev=None):
|
|
|
|
root = ReviewBot._get_sourceinfo(self.apiurl, project, package, rev)
|
|
|
|
if root is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
originproject = root.find('originproject')
|
|
|
|
if originproject is not None:
|
|
|
|
return originproject.text
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
def get_sourceinfo(self, project, package, rev=None):
|
|
|
|
root = ReviewBot._get_sourceinfo(self.apiurl, project, package, rev)
|
2015-03-31 11:33:39 +02:00
|
|
|
if root is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
props = ('package', 'rev', 'vrev', 'srcmd5', 'lsrcmd5', 'verifymd5')
|
|
|
|
return namedtuple('SourceInfo', props)(*[ root.get(p) for p in props ])
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
# TODO: what if there is more than _link?
|
|
|
|
def _get_linktarget_self(self, src_project, src_package):
|
|
|
|
""" if it's a link to a package in the same project return the name of the package"""
|
|
|
|
prj, pkg = self._get_linktarget(src_project, src_package)
|
|
|
|
if prj is None or prj == src_project:
|
|
|
|
return pkg
|
|
|
|
|
|
|
|
def _get_linktarget(self, src_project, src_package):
|
|
|
|
|
|
|
|
query = {}
|
|
|
|
url = osc.core.makeurl(self.apiurl, ('source', src_project, src_package), query=query)
|
|
|
|
try:
|
|
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
|
|
except urllib2.HTTPError:
|
|
|
|
return (None, None)
|
|
|
|
|
|
|
|
if root is not None:
|
|
|
|
linkinfo = root.find("linkinfo")
|
|
|
|
if linkinfo is not None:
|
|
|
|
return (linkinfo.get('project'), linkinfo.get('package'))
|
|
|
|
|
|
|
|
return (None, None)
|
|
|
|
|
2015-03-27 15:01:45 +01:00
|
|
|
def can_accept_review(self, request_id):
|
|
|
|
"""return True if there is a new review for the specified reviewer"""
|
2015-02-20 13:10:26 +01:00
|
|
|
states = set()
|
2014-10-08 14:31:55 +02:00
|
|
|
url = osc.core.makeurl(self.apiurl, ('request', str(request_id)))
|
|
|
|
try:
|
|
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
2015-03-27 15:12:52 +01:00
|
|
|
if self.review_user:
|
|
|
|
by_what = 'by_user'
|
|
|
|
reviewer = self.review_user
|
|
|
|
elif self.review_group:
|
|
|
|
by_what = 'by_group'
|
|
|
|
reviewer = self.review_group
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
states = set([review.get('state') for review in root.findall('review') if review.get(by_what) == reviewer])
|
2017-10-16 22:47:58 +02:00
|
|
|
except urllib2.HTTPError as e:
|
2014-10-08 14:31:55 +02:00
|
|
|
print('ERROR in URL %s [%s]' % (url, e))
|
2015-02-20 13:10:26 +01:00
|
|
|
if not states:
|
|
|
|
return None
|
|
|
|
elif 'new' in states:
|
|
|
|
return True
|
|
|
|
return False
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2015-03-27 15:01:45 +01:00
|
|
|
def set_request_ids_search_review(self):
|
2018-03-08 10:29:14 +01:00
|
|
|
review = None
|
2015-03-27 15:01:45 +01:00
|
|
|
if self.review_user:
|
2017-10-17 09:10:17 +02:00
|
|
|
review = "@by_user='%s' and @state='new'" % self.review_user
|
2018-03-08 10:29:14 +01:00
|
|
|
if self.review_group:
|
|
|
|
review = osc.core.xpath_join(review, "@by_group='%s' and @state='new'" % self.review_group)
|
2017-06-22 23:06:40 -05:00
|
|
|
url = osc.core.makeurl(self.apiurl, ('search', 'request'), { 'match': "state/@name='review' and review[%s]" % review, 'withfullhistory': 1 } )
|
2014-10-08 14:31:55 +02:00
|
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
|
|
|
2015-10-05 10:49:45 +02:00
|
|
|
self.requests = []
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
for request in root.findall('request'):
|
|
|
|
req = osc.core.Request()
|
|
|
|
req.read(request)
|
|
|
|
self.requests.append(req)
|
|
|
|
|
2015-09-29 08:44:04 +02:00
|
|
|
def set_request_ids_project(self, project, typename):
|
|
|
|
url = osc.core.makeurl(self.apiurl, ('search', 'request'),
|
2017-02-11 18:16:44 +01:00
|
|
|
{ 'match': "(state/@name='review' or state/@name='new') and (action/target/@project='%s' and action/@type='%s')" % (project, typename),
|
2017-06-22 23:06:40 -05:00
|
|
|
'withfullhistory': 1 })
|
2015-09-29 08:44:04 +02:00
|
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
|
|
|
|
|
|
self.requests = []
|
|
|
|
|
|
|
|
for request in root.findall('request'):
|
|
|
|
req = osc.core.Request()
|
|
|
|
req.read(request)
|
|
|
|
self.requests.append(req)
|
|
|
|
|
2017-02-10 15:41:45 -06:00
|
|
|
def comment_handler_add(self, level=logging.INFO):
|
|
|
|
"""Add handler to start recording log messages for comment."""
|
|
|
|
self.comment_handler = CommentFromLogHandler(level)
|
|
|
|
self.logger.addHandler(self.comment_handler)
|
|
|
|
|
|
|
|
def comment_handler_remove(self):
|
|
|
|
self.logger.removeHandler(self.comment_handler)
|
|
|
|
|
2017-02-15 09:47:59 -06:00
|
|
|
def comment_handler_lines_deduplicate(self):
|
|
|
|
self.comment_handler.lines = list(OrderedDict.fromkeys(self.comment_handler.lines))
|
|
|
|
|
2017-08-02 17:46:37 -05:00
|
|
|
def comment_write(self, state='done', result=None, project=None, package=None,
|
2017-09-29 09:33:07 -05:00
|
|
|
request=None, message=None, identical=False, only_replace=False,
|
2018-01-17 21:22:26 -06:00
|
|
|
info_extra=None, info_extra_identical=True, bot_name_suffix=None):
|
2017-09-29 09:33:46 -05:00
|
|
|
"""Write comment if not similar to previous comment and replace old one.
|
|
|
|
|
|
|
|
The state, result, and info_extra (dict) are combined to create the info
|
|
|
|
that is passed to CommentAPI methods for creating a marker and finding
|
|
|
|
previous comments. self.bot_name, which defaults to class, will be used
|
2017-12-29 18:22:48 -06:00
|
|
|
as the primary matching key. When info_extra_identical is set to False
|
|
|
|
info_extra will not be included when finding previous comments to
|
|
|
|
compare message against.
|
2017-09-29 09:33:46 -05:00
|
|
|
|
|
|
|
A comment from the same bot will be replaced when a new comment is
|
|
|
|
written. The only_replace flag will restrict to only writing a comment
|
|
|
|
if a prior one is being replaced. This can be useful for writing a final
|
|
|
|
comment that indicates a change from previous uncompleted state, but
|
|
|
|
only makes sense to post if a prior comment was posted.
|
|
|
|
|
|
|
|
The project, package, and request variables control where the comment is
|
|
|
|
placed. If no value is given the default is the request being reviewed.
|
|
|
|
|
|
|
|
If no message is provided the content will be extracted from
|
|
|
|
self.comment_handler.line which is provided by CommentFromLogHandler. To
|
|
|
|
use this call comment_handler_add() at the point which messages should
|
|
|
|
start being collected. Alternatively the self.comment_handler setting
|
|
|
|
may be set to True to automatically set one on each request.
|
|
|
|
|
|
|
|
The previous comment body line count is compared to see if too similar
|
|
|
|
to bother posting another comment which is useful for avoiding
|
|
|
|
re-posting comments that contain irrelevant minor changes. To force an
|
|
|
|
exact match use the identical flag to replace any non-identical
|
|
|
|
comment body.
|
|
|
|
"""
|
2017-06-22 23:10:44 -05:00
|
|
|
if project:
|
|
|
|
kwargs = {'project_name': project}
|
2017-08-02 17:46:37 -05:00
|
|
|
if package:
|
|
|
|
kwargs['package_name'] = package
|
2017-06-22 23:10:44 -05:00
|
|
|
else:
|
|
|
|
if request is None:
|
|
|
|
request = self.request
|
|
|
|
kwargs = {'request_id': request.reqid}
|
2017-08-29 22:19:21 -05:00
|
|
|
debug_key = '/'.join(kwargs.values())
|
2017-06-22 23:10:44 -05:00
|
|
|
|
2017-02-10 15:41:45 -06:00
|
|
|
if message is None:
|
2017-08-29 22:19:21 -05:00
|
|
|
if not len(self.comment_handler.lines):
|
|
|
|
self.logger.debug('skipping empty comment for {}'.format(debug_key))
|
|
|
|
return
|
2017-02-10 15:41:45 -06:00
|
|
|
message = '\n\n'.join(self.comment_handler.lines)
|
|
|
|
|
2018-01-17 21:22:26 -06:00
|
|
|
bot_name = self.bot_name
|
|
|
|
if bot_name_suffix:
|
|
|
|
bot_name = '::'.join([bot_name, bot_name_suffix])
|
|
|
|
|
2017-03-10 00:09:27 -06:00
|
|
|
info = {'state': state, 'result': result}
|
2017-12-29 18:22:48 -06:00
|
|
|
if info_extra and info_extra_identical:
|
2017-09-29 09:33:07 -05:00
|
|
|
info.update(info_extra)
|
2017-02-10 15:41:45 -06:00
|
|
|
|
2017-06-22 23:10:44 -05:00
|
|
|
comments = self.comment_api.get_comments(**kwargs)
|
2018-01-17 21:22:26 -06:00
|
|
|
comment, _ = self.comment_api.comment_find(comments, bot_name, info)
|
2017-12-29 18:22:48 -06:00
|
|
|
|
|
|
|
if info_extra and not info_extra_identical:
|
|
|
|
# Add info_extra once comment has already been matched.
|
|
|
|
info.update(info_extra)
|
|
|
|
|
2018-01-17 21:22:26 -06:00
|
|
|
message = self.comment_api.add_marker(message, bot_name, info)
|
2017-12-29 18:22:48 -06:00
|
|
|
message = self.comment_api.truncate(message.strip())
|
|
|
|
|
2017-06-22 23:11:46 -05:00
|
|
|
if (comment is not None and
|
2017-12-29 18:22:48 -06:00
|
|
|
((identical and
|
|
|
|
# Remove marker from comments since handled during comment_find().
|
|
|
|
self.comment_api.remove_marker(comment['comment']) ==
|
|
|
|
self.comment_api.remove_marker(message)) or
|
2017-06-22 23:11:46 -05:00
|
|
|
(not identical and comment['comment'].count('\n') == message.count('\n')))
|
|
|
|
):
|
2017-02-10 15:41:45 -06:00
|
|
|
# Assume same state/result and number of lines in message is duplicate.
|
2017-08-23 15:15:02 -05:00
|
|
|
self.logger.debug('previous comment too similar on {}'.format(debug_key))
|
2017-02-10 15:41:45 -06:00
|
|
|
return
|
|
|
|
|
2017-08-23 15:17:26 -05:00
|
|
|
if comment is None:
|
|
|
|
self.logger.debug('broadening search to include any state on {}'.format(debug_key))
|
2018-01-17 21:22:26 -06:00
|
|
|
comment, _ = self.comment_api.comment_find(comments, bot_name)
|
2017-08-23 15:17:26 -05:00
|
|
|
if comment is not None:
|
|
|
|
self.logger.debug('removing previous comment on {}'.format(debug_key))
|
|
|
|
if not self.dryrun:
|
|
|
|
self.comment_api.delete(comment['id'])
|
2017-08-23 15:19:36 -05:00
|
|
|
elif only_replace:
|
|
|
|
self.logger.debug('no previous comment to replace on {}'.format(debug_key))
|
|
|
|
return
|
2017-02-10 15:41:45 -06:00
|
|
|
|
2017-08-23 15:17:26 -05:00
|
|
|
self.logger.debug('adding comment to {}: {}'.format(debug_key, message))
|
2017-02-10 15:41:45 -06:00
|
|
|
if not self.dryrun:
|
2017-10-05 17:41:51 -05:00
|
|
|
self.comment_api.add_comment(comment=message, **kwargs)
|
2017-02-10 15:41:45 -06:00
|
|
|
|
|
|
|
self.comment_handler_remove()
|
|
|
|
|
|
|
|
|
|
|
|
class CommentFromLogHandler(logging.Handler):
|
|
|
|
def __init__(self, level=logging.INFO):
|
|
|
|
super(CommentFromLogHandler, self).__init__(level)
|
|
|
|
self.lines = []
|
|
|
|
|
|
|
|
def emit(self, record):
|
|
|
|
self.lines.append(record.getMessage())
|
|
|
|
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
class CommandLineInterface(cmdln.Cmdln):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
cmdln.Cmdln.__init__(self, args, kwargs)
|
2017-01-02 02:34:13 -06:00
|
|
|
self.clazz = ReviewBot
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
def get_optparser(self):
|
2015-05-28 13:07:51 +02:00
|
|
|
parser = cmdln.Cmdln.get_optparser(self)
|
2014-10-08 14:31:55 +02:00
|
|
|
parser.add_option("--apiurl", '-A', metavar="URL", help="api url")
|
|
|
|
parser.add_option("--user", metavar="USER", help="reviewer user name")
|
2015-03-27 15:01:45 +01:00
|
|
|
parser.add_option("--group", metavar="GROUP", help="reviewer group name")
|
2014-10-08 14:31:55 +02:00
|
|
|
parser.add_option("--dry", action="store_true", help="dry run")
|
|
|
|
parser.add_option("--debug", action="store_true", help="debug output")
|
|
|
|
parser.add_option("--osc-debug", action="store_true", help="osc debug output")
|
|
|
|
parser.add_option("--verbose", action="store_true", help="verbose")
|
2016-03-08 16:43:54 +01:00
|
|
|
parser.add_option("--review-mode", dest='review_mode', choices=ReviewBot.REVIEW_CHOICES, help="review behavior")
|
|
|
|
parser.add_option("--fallback-user", dest='fallback_user', metavar='USER', help="fallback review user")
|
|
|
|
parser.add_option("--fallback-group", dest='fallback_group', metavar='GROUP', help="fallback review group")
|
2016-12-23 13:59:17 +01:00
|
|
|
parser.add_option('-c', '--config', dest='config', metavar='FILE', help='read config file FILE')
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
return parser
|
|
|
|
|
|
|
|
def postoptparse(self):
|
2015-11-23 16:03:29 +01:00
|
|
|
level = None
|
2014-10-08 14:31:55 +02:00
|
|
|
if (self.options.debug):
|
2015-11-23 16:03:29 +01:00
|
|
|
level = logging.DEBUG
|
2014-10-08 14:31:55 +02:00
|
|
|
elif (self.options.verbose):
|
2015-11-23 16:03:29 +01:00
|
|
|
level = logging.INFO
|
|
|
|
|
2017-08-23 15:00:33 -05:00
|
|
|
logging.basicConfig(level=level, format='[%(levelname).1s] %(message)s')
|
2015-11-23 16:03:29 +01:00
|
|
|
self.logger = logging.getLogger(self.optparser.prog)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2017-07-18 17:06:37 -05:00
|
|
|
conf.get_config(override_apiurl = self.options.apiurl)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
if (self.options.osc_debug):
|
2017-07-18 17:06:37 -05:00
|
|
|
conf.config['debug'] = 1
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
self.checker = self.setup_checker()
|
2016-12-23 13:59:17 +01:00
|
|
|
if self.options.config:
|
|
|
|
self.checker.load_config(self.options.config)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2016-03-17 16:15:31 +01:00
|
|
|
if self.options.review_mode:
|
|
|
|
self.checker.review_mode = self.options.review_mode
|
|
|
|
|
|
|
|
if self.options.fallback_user:
|
|
|
|
self.checker.fallback_user = self.options.fallback_user
|
|
|
|
|
|
|
|
if self.options.fallback_group:
|
|
|
|
self.checker.fallback_group = self.options.fallback_group
|
|
|
|
|
2014-10-08 14:31:55 +02:00
|
|
|
def setup_checker(self):
|
|
|
|
""" reimplement this """
|
2017-07-18 17:06:37 -05:00
|
|
|
apiurl = conf.config['apiurl']
|
2014-10-08 14:31:55 +02:00
|
|
|
if apiurl is None:
|
|
|
|
raise osc.oscerr.ConfigError("missing apiurl")
|
|
|
|
user = self.options.user
|
2015-03-27 15:01:45 +01:00
|
|
|
group = self.options.group
|
|
|
|
# if no args are given, use the current oscrc "owner"
|
|
|
|
if user is None and group is None:
|
2017-07-18 17:06:37 -05:00
|
|
|
user = conf.get_apiurl_usr(apiurl)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2017-01-02 02:34:13 -06:00
|
|
|
return self.clazz(apiurl = apiurl, \
|
2014-10-08 14:31:55 +02:00
|
|
|
dryrun = self.options.dry, \
|
|
|
|
user = user, \
|
2015-03-27 15:01:45 +01:00
|
|
|
group = group, \
|
2014-10-08 14:31:55 +02:00
|
|
|
logger = self.logger)
|
|
|
|
|
|
|
|
def do_id(self, subcmd, opts, *args):
|
2015-09-29 08:44:04 +02:00
|
|
|
"""${cmd_name}: check the specified request ids
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
${cmd_usage}
|
|
|
|
${cmd_option_list}
|
|
|
|
"""
|
|
|
|
self.checker.set_request_ids(args)
|
|
|
|
self.checker.check_requests()
|
|
|
|
|
2015-09-29 08:44:04 +02:00
|
|
|
@cmdln.option('-n', '--interval', metavar="minutes", type="int", help="periodic interval in minutes")
|
2014-10-08 14:31:55 +02:00
|
|
|
def do_review(self, subcmd, opts, *args):
|
2015-09-29 08:44:04 +02:00
|
|
|
"""${cmd_name}: check requests that have the specified user or group as reviewer
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
${cmd_usage}
|
|
|
|
${cmd_option_list}
|
|
|
|
"""
|
2015-03-27 15:01:45 +01:00
|
|
|
if self.checker.review_user is None and self.checker.review_group is None:
|
|
|
|
raise osc.oscerr.WrongArgs("missing reviewer (user or group)")
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2015-09-29 08:44:04 +02:00
|
|
|
def work():
|
|
|
|
self.checker.set_request_ids_search_review()
|
|
|
|
self.checker.check_requests()
|
|
|
|
|
|
|
|
self.runner(work, opts.interval)
|
|
|
|
|
|
|
|
@cmdln.option('-n', '--interval', metavar="minutes", type="int", help="periodic interval in minutes")
|
|
|
|
def do_project(self, subcmd, opts, project, typename):
|
|
|
|
"""${cmd_name}: check all requests of specified type to specified
|
|
|
|
|
|
|
|
${cmd_usage}
|
|
|
|
${cmd_option_list}
|
|
|
|
"""
|
|
|
|
|
|
|
|
def work():
|
|
|
|
self.checker.set_request_ids_project(project, typename)
|
|
|
|
self.checker.check_requests()
|
|
|
|
|
|
|
|
self.runner(work, opts.interval)
|
2014-10-08 14:31:55 +02:00
|
|
|
|
2015-05-22 14:46:29 +02:00
|
|
|
def runner(self, workfunc, interval):
|
|
|
|
""" runs the specified callback every <interval> minutes or
|
|
|
|
once if interval is None or 0
|
|
|
|
"""
|
|
|
|
class ExTimeout(Exception):
|
|
|
|
"""raised on timeout"""
|
|
|
|
|
|
|
|
if interval:
|
|
|
|
def alarm_called(nr, frame):
|
|
|
|
raise ExTimeout()
|
|
|
|
signal.signal(signal.SIGALRM, alarm_called)
|
|
|
|
|
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
workfunc()
|
2017-10-16 22:47:58 +02:00
|
|
|
except Exception as e:
|
2016-06-17 10:59:29 +02:00
|
|
|
self.logger.exception(e)
|
2015-05-22 14:46:29 +02:00
|
|
|
|
|
|
|
if interval:
|
|
|
|
self.logger.info("sleeping %d minutes. Press enter to check now ..."%interval)
|
|
|
|
signal.alarm(interval*60)
|
|
|
|
try:
|
|
|
|
raw_input()
|
|
|
|
except ExTimeout:
|
|
|
|
pass
|
|
|
|
signal.alarm(0)
|
|
|
|
self.logger.info("recheck at %s"%datetime.datetime.now().isoformat())
|
|
|
|
continue
|
|
|
|
break
|
2014-10-08 14:31:55 +02:00
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
app = CommandLineInterface()
|
|
|
|
sys.exit( app.main() )
|
|
|
|
|