Reformat all source code using ruff
No functional changes were performed, only formatting changes. Related: #306
This commit is contained in:
parent
3d54a63596
commit
de4c2ba778
52 changed files with 2829 additions and 2280 deletions
|
|
@ -12,72 +12,72 @@ import sqlalchemy
|
|||
from . import config
|
||||
from . import _version
|
||||
|
||||
__version__ = _version.get_versions()['version']
|
||||
if __version__ == '0+unknown':
|
||||
__version__ = _version.get_versions()["version"]
|
||||
if __version__ == "0+unknown":
|
||||
# something is wrong, probably running from a tarball created from an untagged release
|
||||
# we'll print an error a bit later, once logging is set up
|
||||
__version__ += '.{}'.format(_version.get_versions()['full-revisionid'])
|
||||
__version__ += ".{}".format(_version.get_versions()["full-revisionid"])
|
||||
|
||||
# Flask App
|
||||
app: Flask = Flask(__name__)
|
||||
# Is this an OpenShift deployment?
|
||||
# this is a three state variable, undefined (not openshift), "0" (staging), "1" (prod)
|
||||
# e.g. OPENSHIFT_PROD="0" means _is openshift_ and staging instance
|
||||
openshift = os.getenv('OPENSHIFT_PROD')
|
||||
openshift = os.getenv("OPENSHIFT_PROD")
|
||||
|
||||
# set up basic logging so that initial messages are not lost (until we
|
||||
# configure logging properly)
|
||||
_logging_fmt = '%(asctime)s %(levelname)-7s [%(name)-13s] %(message)s'
|
||||
_logging_fmt_debug = '%(asctime)s %(levelname)-7s [%(name)-13s] %(message)s [%(module)s:%(lineno)s]'
|
||||
_logging_datefmt = '%Y-%m-%d %H:%M:%S'
|
||||
_logging_fmt = "%(asctime)s %(levelname)-7s [%(name)-13s] %(message)s"
|
||||
_logging_fmt_debug = "%(asctime)s %(levelname)-7s [%(name)-13s] %(message)s [%(module)s:%(lineno)s]"
|
||||
_logging_datefmt = "%Y-%m-%d %H:%M:%S"
|
||||
logging.basicConfig(format=_logging_fmt_debug, datefmt=_logging_datefmt)
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# load default configuration
|
||||
if os.getenv('DEV') == 'true':
|
||||
app.logger.debug('Using development config')
|
||||
app.config.from_object('blockerbugs.config.DevelopmentConfig')
|
||||
elif os.getenv('TEST') == 'true':
|
||||
app.logger.debug('Using testing config')
|
||||
app.config.from_object('blockerbugs.config.TestingConfig')
|
||||
if os.getenv("DEV") == "true":
|
||||
app.logger.debug("Using development config")
|
||||
app.config.from_object("blockerbugs.config.DevelopmentConfig")
|
||||
elif os.getenv("TEST") == "true":
|
||||
app.logger.debug("Using testing config")
|
||||
app.config.from_object("blockerbugs.config.TestingConfig")
|
||||
else:
|
||||
app.logger.debug('Using production config')
|
||||
app.config.from_object('blockerbugs.config.ProductionConfig')
|
||||
app.logger.debug("Using production config")
|
||||
app.config.from_object("blockerbugs.config.ProductionConfig")
|
||||
if openshift:
|
||||
app.logger.debug('Using openshift config')
|
||||
app.logger.debug("Using openshift config")
|
||||
config.openshift_config(app.config, openshift)
|
||||
|
||||
# load real configuration values to override defaults
|
||||
config_file = '/etc/blockerbugs/settings.py'
|
||||
config_file = "/etc/blockerbugs/settings.py"
|
||||
if not app.testing:
|
||||
if os.path.exists(config_file):
|
||||
app.logger.info('Loading configuration from %s' % config_file)
|
||||
app.logger.info("Loading configuration from %s" % config_file)
|
||||
app.config.from_pyfile(config_file)
|
||||
else:
|
||||
config_file = os.path.abspath('conf/settings.py')
|
||||
config_file = os.path.abspath("conf/settings.py")
|
||||
if os.path.exists(config_file):
|
||||
app.logger.info('Loading configuration from %s' % config_file)
|
||||
app.logger.info("Loading configuration from %s" % config_file)
|
||||
app.config.from_pyfile(config_file)
|
||||
else:
|
||||
app.logger.info('No extra config found, using defaults')
|
||||
app.logger.info("No extra config found, using defaults")
|
||||
|
||||
# Being able to set DEBUG from env might be useful
|
||||
if os.getenv('DEBUG') == 'true':
|
||||
if os.getenv("DEBUG") == "true":
|
||||
app.config["DEBUG"] = True
|
||||
|
||||
# Make sure config URLs end with a slash, so we have them always in the same format
|
||||
for key in ['BODHI_URL', 'FORGEJO_URL', 'FORGEJO_API']:
|
||||
if not app.config[key].endswith('/'):
|
||||
app.config[key] = app.config[key] + '/'
|
||||
for key in ["BODHI_URL", "FORGEJO_URL", "FORGEJO_API"]:
|
||||
if not app.config[key].endswith("/"):
|
||||
app.config[key] = app.config[key] + "/"
|
||||
|
||||
# Unfortunately BUGZILLA_URL mustn't end with a slash, otherwise bugzilla.Bugzilla() init fails
|
||||
app.config['BUGZILLA_URL'] = app.config['BUGZILLA_URL'].rstrip('/')
|
||||
app.config["BUGZILLA_URL"] = app.config["BUGZILLA_URL"].rstrip("/")
|
||||
|
||||
|
||||
def setup_logging():
|
||||
formatter = logging.Formatter(
|
||||
fmt=_logging_fmt_debug if app.debug else _logging_fmt,
|
||||
datefmt=_logging_datefmt)
|
||||
fmt=_logging_fmt_debug if app.debug else _logging_fmt, datefmt=_logging_datefmt
|
||||
)
|
||||
loglevel = logging.DEBUG if app.debug else logging.INFO
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
|
|
@ -85,47 +85,52 @@ def setup_logging():
|
|||
root_logger.handlers.clear()
|
||||
app.logger.handlers.clear()
|
||||
|
||||
if app.config['STREAM_LOGGING']:
|
||||
if app.config["STREAM_LOGGING"]:
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setLevel(loglevel)
|
||||
stream_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(stream_handler)
|
||||
app.logger.debug("doing stream logging")
|
||||
|
||||
if app.config['SYSLOG_LOGGING']:
|
||||
if app.config["SYSLOG_LOGGING"]:
|
||||
syslog_handler = logging.handlers.SysLogHandler(
|
||||
address='/dev/log', facility=logging.handlers.SysLogHandler.LOG_LOCAL4)
|
||||
address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_LOCAL4
|
||||
)
|
||||
syslog_handler.setLevel(loglevel)
|
||||
syslog_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(syslog_handler)
|
||||
app.logger.debug("doing syslog logging")
|
||||
|
||||
if app.config['FILE_LOGGING'] and app.config['LOGFILE']:
|
||||
if app.config["FILE_LOGGING"] and app.config["LOGFILE"]:
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
app.config['LOGFILE'], maxBytes=500000, backupCount=5)
|
||||
app.config["LOGFILE"], maxBytes=500000, backupCount=5
|
||||
)
|
||||
file_handler.setLevel(loglevel)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
app.logger.debug("doing file logging to %s" % app.config['LOGFILE'])
|
||||
app.logger.debug("doing file logging to %s" % app.config["LOGFILE"])
|
||||
|
||||
|
||||
setup_logging()
|
||||
|
||||
# version check
|
||||
if _version.get_versions()['error']:
|
||||
app.logger.warning('Could not reliably figure out app version, the error was: {}'.format(
|
||||
_version.get_versions()['error']))
|
||||
if _version.get_versions()["error"]:
|
||||
app.logger.warning(
|
||||
"Could not reliably figure out app version, the error was: {}".format(
|
||||
_version.get_versions()["error"]
|
||||
)
|
||||
)
|
||||
|
||||
# database
|
||||
if app.config['SHOW_DB_URI']:
|
||||
app.logger.debug('using DBURI: %s' % app.config['SQLALCHEMY_DATABASE_URI'])
|
||||
if app.config["SHOW_DB_URI"]:
|
||||
app.logger.debug("using DBURI: %s" % app.config["SQLALCHEMY_DATABASE_URI"])
|
||||
metadata = sqlalchemy.MetaData(
|
||||
naming_convention={
|
||||
'pk': 'pk_%(table_name)s',
|
||||
'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
|
||||
'ix': 'ix_%(column_0_label)s',
|
||||
'uq': 'uq_%(table_name)s_%(column_0_name)s',
|
||||
'ck': 'ck_%(table_name)s_%(constraint_name)s',
|
||||
naming_convention={
|
||||
"pk": "pk_%(table_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
}
|
||||
)
|
||||
db: SQLAlchemy = SQLAlchemy(app=app, metadata=metadata)
|
||||
|
|
@ -136,28 +141,30 @@ oidc = flask_oidc.OpenIDConnect(app)
|
|||
|
||||
# === Infra tweaks ===
|
||||
|
||||
|
||||
class PrefixMiddleware(object):
|
||||
def __init__(self, app, prefix=''):
|
||||
def __init__(self, app, prefix=""):
|
||||
self.app = app
|
||||
self.prefix = prefix
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
if environ['PATH_INFO'].startswith(self.prefix):
|
||||
environ['PATH_INFO'] = environ['PATH_INFO'][len(self.prefix):]
|
||||
environ['SCRIPT_NAME'] = self.prefix
|
||||
if environ["PATH_INFO"].startswith(self.prefix):
|
||||
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :]
|
||||
environ["SCRIPT_NAME"] = self.prefix
|
||||
return self.app(environ, start_response)
|
||||
else:
|
||||
start_response('404', [('Content-Type', 'text/plain')])
|
||||
start_response("404", [("Content-Type", "text/plain")])
|
||||
return ["This url does not belong to the app.".encode()]
|
||||
|
||||
|
||||
if openshift:
|
||||
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix='/blockerbugs') # type: ignore[assignment]
|
||||
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix="/blockerbugs") # type: ignore[assignment]
|
||||
|
||||
# "Hotfix" for proxy handling on current deployment, my guess is that the proxy
|
||||
# server is set differently than it was, but what do I know...
|
||||
if app.config["BEHIND_PROXY"]:
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_host=1) # type: ignore[assignment]
|
||||
|
||||
|
||||
|
|
@ -167,99 +174,103 @@ import blockerbugs.models.update as model_update # noqa: E402
|
|||
|
||||
|
||||
# === Flask views and stuff ===
|
||||
@app.template_filter('tagify')
|
||||
@app.template_filter("tagify")
|
||||
def tagify(value):
|
||||
return value.replace(' ', '')
|
||||
return value.replace(" ", "")
|
||||
|
||||
|
||||
@app.template_filter('urlsplit')
|
||||
@app.template_filter("urlsplit")
|
||||
def urlsplit(value):
|
||||
return value.split('/')
|
||||
return value.split("/")
|
||||
|
||||
|
||||
@app.template_filter('getname')
|
||||
@app.template_filter("getname")
|
||||
def getname(value, entry=1):
|
||||
return value.split('/')[entry]
|
||||
return value.split("/")[entry]
|
||||
|
||||
|
||||
@app.template_filter('getsubname')
|
||||
@app.template_filter("getsubname")
|
||||
def getsubname(value):
|
||||
urlparts = value.split('/')
|
||||
urlparts = value.split("/")
|
||||
if len(urlparts) >= 5:
|
||||
return ''.join(urlparts[2:4])
|
||||
return "".join(urlparts[2:4])
|
||||
else:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
|
||||
@app.template_filter('updatetype')
|
||||
@app.template_filter("updatetype")
|
||||
def updatetype(update: Optional[model_update.Update]) -> str:
|
||||
"""Inspect bugs linked by this update, and return a string 'Blocker', 'FreezeException' or
|
||||
'Prioritized' (in this priority order) if there's a at least one bug proposed as such.
|
||||
"""
|
||||
if not update:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
is_fe = False
|
||||
is_prio = False
|
||||
for bug in update.bugs:
|
||||
if (bug.proposed_blocker or
|
||||
bug.accepted_blocker or
|
||||
bug.accepted_0day or
|
||||
bug.accepted_prevrel):
|
||||
return 'Blocker'
|
||||
if (
|
||||
bug.proposed_blocker
|
||||
or bug.accepted_blocker
|
||||
or bug.accepted_0day
|
||||
or bug.accepted_prevrel
|
||||
):
|
||||
return "Blocker"
|
||||
elif bug.proposed_fe or bug.accepted_fe:
|
||||
is_fe = True
|
||||
elif bug.prioritized:
|
||||
is_prio = True
|
||||
if is_fe:
|
||||
return 'FreezeException'
|
||||
return "FreezeException"
|
||||
if is_prio:
|
||||
return 'Prioritized'
|
||||
return "Prioritized"
|
||||
|
||||
assert False, f"{update} doesn't seem to fix none of blocker/FE/prioritized"
|
||||
|
||||
|
||||
@app.template_filter('updatestatus')
|
||||
@app.template_filter("updatestatus")
|
||||
def updatestatus(update: Optional[model_update.Update]) -> str:
|
||||
"""Create a status description for an Update, regarding its current status and request. For
|
||||
example 'testing' or 'testing -> stable' or 'pending -> testing'. Enclose in HTML with CSS
|
||||
classes.
|
||||
"""
|
||||
if not update:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
text = f'<b>{update.status}</b>'
|
||||
text = f"<b>{update.status}</b>"
|
||||
if update.request:
|
||||
text += f' <span class="fas fa-arrow-right"></span> {update.request}'
|
||||
|
||||
if update.status == 'stable':
|
||||
css_class = 'badge badge-success'
|
||||
elif update.status == 'testing':
|
||||
css_class = 'badge badge-warning'
|
||||
if update.status == "stable":
|
||||
css_class = "badge badge-success"
|
||||
elif update.status == "testing":
|
||||
css_class = "badge badge-warning"
|
||||
else:
|
||||
css_class = 'badge badge-info'
|
||||
css_class = "badge badge-info"
|
||||
|
||||
return f'<span class="{css_class}">{text}</span>'
|
||||
|
||||
|
||||
@app.template_filter('datetime')
|
||||
def datetime_format(value, format='%Y-%m-%d %H:%M UTC'):
|
||||
@app.template_filter("datetime")
|
||||
def datetime_format(value, format="%Y-%m-%d %H:%M UTC"):
|
||||
if value is not None:
|
||||
return value.strftime(format)
|
||||
return ''
|
||||
return ""
|
||||
|
||||
|
||||
# register blueprints
|
||||
from blockerbugs.controllers.main import main # noqa: E402
|
||||
|
||||
app.register_blueprint(main)
|
||||
|
||||
from blockerbugs.controllers.admin import admin # noqa: F401,E402
|
||||
|
||||
from blockerbugs.controllers.api import api_v0 # noqa: E402
|
||||
|
||||
app.register_blueprint(api_v0)
|
||||
|
||||
|
||||
# setup error handling
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(e):
|
||||
return render_template('404.html', title="Page Not Found!"), 404
|
||||
return render_template("404.html", title="Page Not Found!"), 404
|
||||
|
|
|
|||
|
|
@ -26,13 +26,16 @@ def get_alembic_config():
|
|||
if os.path.exists("./alembic.ini"):
|
||||
alembic_cfg = al_Config("./alembic.ini")
|
||||
else:
|
||||
alembic_cfg = al_Config("/usr/share/blockerbugs/alembic.ini",
|
||||
ini_section='alembic-packaged')
|
||||
alembic_cfg = al_Config(
|
||||
"/usr/share/blockerbugs/alembic.ini", ini_section="alembic-packaged"
|
||||
)
|
||||
return alembic_cfg
|
||||
|
||||
|
||||
def init_db(args):
|
||||
initialize_db(destructive=args.destructive)
|
||||
|
||||
|
||||
def initialize_db(destructive=False):
|
||||
alembic_cfg = get_alembic_config()
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ def initialize_db(destructive=False):
|
|||
# if it does not, we assume that the database is empty
|
||||
insp = sqlalchemy.inspect(db.engine)
|
||||
table_names = insp.get_table_names()
|
||||
if 'milestone' not in table_names:
|
||||
if "milestone" not in table_names:
|
||||
print(" - Creating tables")
|
||||
db.create_all()
|
||||
print(" - Stamping alembic's current version to 'head'")
|
||||
|
|
@ -64,7 +67,10 @@ def initialize_db(destructive=False):
|
|||
print("Couldn't determine alembic version in db...")
|
||||
return False
|
||||
if set(context.get_current_heads()) == set(directory.get_heads()):
|
||||
print("Database already initialized and at the latest rev %s - not re-initializing" % current_rev)
|
||||
print(
|
||||
"Database already initialized and at the latest rev %s - not re-initializing"
|
||||
% current_rev
|
||||
)
|
||||
return True
|
||||
else:
|
||||
print("Database is behind alembic scripts, upgrading...")
|
||||
|
|
@ -99,13 +105,15 @@ def add_milestone(args):
|
|||
blocker = int(args.blocker)
|
||||
fe = int(args.fe)
|
||||
|
||||
existing_milestone = Milestone.query.filter_by(version=version,
|
||||
blocker_tracker=blocker,
|
||||
fe_tracker=fe).count()
|
||||
existing_milestone = Milestone.query.filter_by(
|
||||
version=version, blocker_tracker=blocker, fe_tracker=fe
|
||||
).count()
|
||||
|
||||
current_milestones = Milestone.query.filter_by(active=True, current=True).count()
|
||||
if existing_milestone > 0:
|
||||
print("Milestone already exists, not adding: %d-%s (%d, %d)" % (number, version, blocker, fe))
|
||||
print(
|
||||
"Milestone already exists, not adding: %d-%s (%d, %d)" % (number, version, blocker, fe)
|
||||
)
|
||||
else:
|
||||
print("Adding milestone: %d-%s (%d, %d)" % (number, version, blocker, fe))
|
||||
release = Release.query.filter_by(number=number).first()
|
||||
|
|
@ -113,8 +121,9 @@ def add_milestone(args):
|
|||
add_release(release)
|
||||
release = Release.query.filter_by(number=number).first()
|
||||
|
||||
new_milestone = Milestone(release, version, int(blocker), int(fe),
|
||||
'%d-%s' % (number, version))
|
||||
new_milestone = Milestone(
|
||||
release, version, int(blocker), int(fe), "%d-%s" % (number, version)
|
||||
)
|
||||
|
||||
# only active releases are synced but since this version is being added,
|
||||
# it's a good assumption to make it active.
|
||||
|
|
@ -143,12 +152,12 @@ def generate_config(args):
|
|||
"""
|
||||
dburi = args.dburi
|
||||
|
||||
config_filename = os.path.abspath('conf/settings.py')
|
||||
config_filename = os.path.abspath("conf/settings.py")
|
||||
if os.path.exists(config_filename):
|
||||
print("configuration file %s already exists, exiting" % config_filename)
|
||||
sys.exit(0)
|
||||
|
||||
with open(config_filename, 'w', encoding='utf-8') as config_file:
|
||||
with open(config_filename, "w", encoding="utf-8") as config_file:
|
||||
config_file.write(f"SECRET_KEY = '{secrets.token_hex()}'\n")
|
||||
config_file.write(f"SQLALCHEMY_DATABASE_URI = '{dburi or ''}'\n")
|
||||
# leave places for the other config values generally needed
|
||||
|
|
@ -212,7 +221,7 @@ def sync_bugs(args):
|
|||
|
||||
current_milestone = Milestone.query.filter_by(active=True).first()
|
||||
if not current_milestone:
|
||||
sys.stderr.write('BugSync ERROR: No active releases found!')
|
||||
sys.stderr.write("BugSync ERROR: No active releases found!")
|
||||
sys.exit(1)
|
||||
|
||||
bugsync = BugSync(db)
|
||||
|
|
@ -287,7 +296,7 @@ def recreate_discussion(args):
|
|||
bug = Bug.query.filter_by(bugid=bugid).first()
|
||||
|
||||
if not bug:
|
||||
app.logger.error('No bug %d in database.' % bugid)
|
||||
app.logger.error("No bug %d in database." % bugid)
|
||||
sys.exit(1)
|
||||
|
||||
discussion_sync.recreate_discussion(bugid)
|
||||
|
|
@ -365,116 +374,139 @@ def main() -> None:
|
|||
"""
|
||||
parser = ArgumentParser()
|
||||
|
||||
parser.add_argument('--debug', action='store_true', default=False,
|
||||
help='Enable debug logs')
|
||||
parser.add_argument("--debug", action="store_true", default=False, help="Enable debug logs")
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', metavar="<COMMAND>")
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="<COMMAND>")
|
||||
|
||||
init_db_parser = subparsers.add_parser('init_db', help='Initialize DB')
|
||||
init_db_parser.add_argument('-rm', '--destructive', action='store_true',
|
||||
dest='destructive', default=False,
|
||||
help='Force database recreation (will ERASE your DB!!!)')
|
||||
init_db_parser = subparsers.add_parser("init_db", help="Initialize DB")
|
||||
init_db_parser.add_argument(
|
||||
"-rm",
|
||||
"--destructive",
|
||||
action="store_true",
|
||||
dest="destructive",
|
||||
default=False,
|
||||
help="Force database recreation (will ERASE your DB!!!)",
|
||||
)
|
||||
init_db_parser.set_defaults(func=init_db)
|
||||
|
||||
add_milestone_parser = subparsers.add_parser('add_milestone',
|
||||
help='Add milestone')
|
||||
add_milestone_parser.add_argument('-r', '--release',
|
||||
dest='release', help='release number')
|
||||
add_milestone_parser.add_argument('-m', '--milestone',
|
||||
dest='milestone', help='Milestone name')
|
||||
add_milestone_parser.add_argument('-b', '--blocker',
|
||||
dest='blocker', help='blocker tracking bug id')
|
||||
add_milestone_parser.add_argument('-a', '--accepted',
|
||||
dest='fe', help='FE tracking bug id')
|
||||
add_milestone_parser = subparsers.add_parser("add_milestone", help="Add milestone")
|
||||
add_milestone_parser.add_argument("-r", "--release", dest="release", help="release number")
|
||||
add_milestone_parser.add_argument("-m", "--milestone", dest="milestone", help="Milestone name")
|
||||
add_milestone_parser.add_argument(
|
||||
"-b", "--blocker", dest="blocker", help="blocker tracking bug id"
|
||||
)
|
||||
add_milestone_parser.add_argument("-a", "--accepted", dest="fe", help="FE tracking bug id")
|
||||
add_milestone_parser.set_defaults(func=add_milestone)
|
||||
|
||||
add_release_parser = subparsers.add_parser('add_release', help='Add release')
|
||||
add_release_parser.add_argument('-r', '--release',
|
||||
dest='release', help='release number')
|
||||
add_release_parser = subparsers.add_parser("add_release", help="Add release")
|
||||
add_release_parser.add_argument("-r", "--release", dest="release", help="release number")
|
||||
add_release_parser.set_defaults(func=add_release)
|
||||
|
||||
generate_config_parser = subparsers.add_parser('generate_config',
|
||||
help='Generate config')
|
||||
generate_config_parser.add_argument('-d', '--dburi',
|
||||
dest='dburi', help='dburi to use')
|
||||
generate_config_parser = subparsers.add_parser("generate_config", help="Generate config")
|
||||
generate_config_parser.add_argument("-d", "--dburi", dest="dburi", help="dburi to use")
|
||||
generate_config_parser.set_defaults(func=generate_config)
|
||||
|
||||
sync_parser = subparsers.add_parser('sync',
|
||||
help='Synchronize all')
|
||||
sync_parser.add_argument('-f', '--full',
|
||||
action='store_true', default=False,
|
||||
help='Force full sync (ignored temporarily)')
|
||||
sync_parser.add_argument('-c', '--check',
|
||||
action='store_true', default=False,
|
||||
help='Force check for missing blocker bugs after sync')
|
||||
sync_parser = subparsers.add_parser("sync", help="Synchronize all")
|
||||
sync_parser.add_argument(
|
||||
"-f",
|
||||
"--full",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force full sync (ignored temporarily)",
|
||||
)
|
||||
sync_parser.add_argument(
|
||||
"-c",
|
||||
"--check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force check for missing blocker bugs after sync",
|
||||
)
|
||||
sync_parser.set_defaults(func=sync)
|
||||
|
||||
sync_bugs_parser = subparsers.add_parser('sync-bugs',
|
||||
help='Synchronize bugs only')
|
||||
sync_bugs_parser.add_argument('-f', '--full',
|
||||
action='store_true', default=False,
|
||||
help='Force full sync (ignored temporarily)')
|
||||
sync_bugs_parser.add_argument('-c', '--check',
|
||||
action='store_true', default=False,
|
||||
help='Force check for missing blocker bugs after sync')
|
||||
sync_bugs_parser = subparsers.add_parser("sync-bugs", help="Synchronize bugs only")
|
||||
sync_bugs_parser.add_argument(
|
||||
"-f",
|
||||
"--full",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force full sync (ignored temporarily)",
|
||||
)
|
||||
sync_bugs_parser.add_argument(
|
||||
"-c",
|
||||
"--check",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Force check for missing blocker bugs after sync",
|
||||
)
|
||||
sync_bugs_parser.set_defaults(func=sync_bugs)
|
||||
|
||||
sync_updates_parser = subparsers.add_parser('sync-updates',
|
||||
help='Synchronize updates only')
|
||||
sync_updates_parser.add_argument('-f', '--full',
|
||||
action='store_true', default=False,
|
||||
help='Deprecated, no longer does anything')
|
||||
sync_updates_parser = subparsers.add_parser("sync-updates", help="Synchronize updates only")
|
||||
sync_updates_parser.add_argument(
|
||||
"-f",
|
||||
"--full",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Deprecated, no longer does anything",
|
||||
)
|
||||
sync_updates_parser.set_defaults(func=sync_updates)
|
||||
|
||||
sync_discussions_parser = subparsers.add_parser('sync-discussions',
|
||||
help='Synchronize discussions only')
|
||||
sync_discussions_parser = subparsers.add_parser(
|
||||
"sync-discussions", help="Synchronize discussions only"
|
||||
)
|
||||
sync_discussions_parser.set_defaults(func=sync_discussions)
|
||||
|
||||
upgrade_db_parser = subparsers.add_parser('upgrade_db',
|
||||
help='Upgrade DB to newer revision')
|
||||
upgrade_db_parser = subparsers.add_parser("upgrade_db", help="Upgrade DB to newer revision")
|
||||
upgrade_db_parser.set_defaults(func=upgrade_db)
|
||||
|
||||
recreate_discussion_parser = subparsers.add_parser('recreate-discussion',
|
||||
help='Re-create discussion for given bug')
|
||||
recreate_discussion_parser.add_argument('bugid',
|
||||
metavar='<Bug ID>',
|
||||
help='Bug ID for which to re-create dicsussion')
|
||||
recreate_discussion_parser = subparsers.add_parser(
|
||||
"recreate-discussion", help="Re-create discussion for given bug"
|
||||
)
|
||||
recreate_discussion_parser.add_argument(
|
||||
"bugid", metavar="<Bug ID>", help="Bug ID for which to re-create dicsussion"
|
||||
)
|
||||
recreate_discussion_parser.set_defaults(func=recreate_discussion)
|
||||
|
||||
update_discussion_parser = subparsers.add_parser('update-discussion',
|
||||
help='Update discussion for a given ticket')
|
||||
update_discussion_parser.add_argument('issue_number',
|
||||
metavar='<Issue Number>',
|
||||
help='Issue number for which to update dicsussion')
|
||||
update_discussion_parser = subparsers.add_parser(
|
||||
"update-discussion", help="Update discussion for a given ticket"
|
||||
)
|
||||
update_discussion_parser.add_argument(
|
||||
"issue_number", metavar="<Issue Number>", help="Issue number for which to update dicsussion"
|
||||
)
|
||||
update_discussion_parser.set_defaults(func=update_discussion)
|
||||
|
||||
close_inactive_discussions_parser = subparsers.add_parser('close-inactive-discussions',
|
||||
help="Close discussions of inactive releases (which haven't been processed yet)")
|
||||
close_inactive_discussions_parser.add_argument('--dryrun', action='store_true',
|
||||
default=False,
|
||||
help="Don't make any actual changes")
|
||||
close_inactive_discussions_parser = subparsers.add_parser(
|
||||
"close-inactive-discussions",
|
||||
help="Close discussions of inactive releases (which haven't been processed yet)",
|
||||
)
|
||||
close_inactive_discussions_parser.add_argument(
|
||||
"--dryrun", action="store_true", default=False, help="Don't make any actual changes"
|
||||
)
|
||||
close_inactive_discussions_parser.set_defaults(func=close_inactive_discussions)
|
||||
|
||||
create_test_data_parser = subparsers.add_parser(
|
||||
'create-test-data', help='Create fake test data under release 101. Can be called '
|
||||
'repeatedly, always deletes everything under that release and creates test data anew. '
|
||||
'WARNING: Never run this if you have actual real data under release 101!')
|
||||
"create-test-data",
|
||||
help="Create fake test data under release 101. Can be called "
|
||||
"repeatedly, always deletes everything under that release and creates test data anew. "
|
||||
"WARNING: Never run this if you have actual real data under release 101!",
|
||||
)
|
||||
create_test_data_parser.set_defaults(func=create_test_data)
|
||||
|
||||
remove_test_data_parser = subparsers.add_parser(
|
||||
'remove-test-data', help='Remove fake test data. Removes release 101 and everything under '
|
||||
'it. WARNING: Never run this if you have actual real data under release 101!')
|
||||
"remove-test-data",
|
||||
help="Remove fake test data. Removes release 101 and everything under "
|
||||
"it. WARNING: Never run this if you have actual real data under release 101!",
|
||||
)
|
||||
remove_test_data_parser.set_defaults(func=remove_test_data)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not hasattr(args, 'func'):
|
||||
if not hasattr(args, "func"):
|
||||
# if func attribute is missing, no command was specified, print help and exit
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if args.debug:
|
||||
app.config['DEBUG'] = True
|
||||
app.config["DEBUG"] = True
|
||||
# re-initialize logging with changed params
|
||||
setup_logging()
|
||||
|
||||
|
|
@ -483,7 +515,7 @@ def main() -> None:
|
|||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
exit = main()
|
||||
if exit:
|
||||
sys.exit(exit)
|
||||
|
|
|
|||
|
|
@ -27,33 +27,34 @@ class Config(object):
|
|||
"""Default configuration for the whole application. Default values are tailored more towards a
|
||||
development environment.
|
||||
"""
|
||||
|
||||
PRODUCTION = False
|
||||
DEBUG = True
|
||||
SECRET_KEY = None # REPLACE THIS WITH A RANDOM STRING
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite://' # just 'sqlite://' without a path means an in-memory db
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite://" # just 'sqlite://' without a path means an in-memory db
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
BUGZILLA_URL = 'https://bugzilla.stage.redhat.com'
|
||||
BUGZILLA_API_KEY = ''
|
||||
BODHI_URL = 'https://bodhi.stg.fedoraproject.org/'
|
||||
BUGZILLA_URL = "https://bugzilla.stage.redhat.com"
|
||||
BUGZILLA_API_KEY = ""
|
||||
BODHI_URL = "https://bodhi.stg.fedoraproject.org/"
|
||||
OIDC_ENABLED = False
|
||||
"""When FAS is not enabled, a fake stub is used instead, which allows the user to log in (under
|
||||
the credentials+groups defined here in this config) without authentication."""
|
||||
FAS_ADMIN_GROUP = 'qa-admin'
|
||||
FAS_ADMIN_GROUP = "qa-admin"
|
||||
OIDC_TESTING_PROFILE = {
|
||||
'nickname': 'developer',
|
||||
'groups': [FAS_ADMIN_GROUP],
|
||||
"nickname": "developer",
|
||||
"groups": [FAS_ADMIN_GROUP],
|
||||
}
|
||||
"""This is mostly useful for developers, when they want to simulate a login for a certain
|
||||
user."""
|
||||
# https://flask-oidc.readthedocs.io/en/latest/#settings-reference
|
||||
OIDC_CLIENT_SECRETS = "conf/oidc.json"
|
||||
OIDC_SCOPES = (
|
||||
'openid email profile '
|
||||
'https://id.fedoraproject.org/scope/groups '
|
||||
'https://id.fedoraproject.org/scope/agreements'
|
||||
"openid email profile "
|
||||
"https://id.fedoraproject.org/scope/groups "
|
||||
"https://id.fedoraproject.org/scope/agreements"
|
||||
)
|
||||
OIDC_USER_INFO_ENABLED = True
|
||||
LOGFILE = '/var/log/blockerbugs/blockerbugs.log'
|
||||
LOGFILE = "/var/log/blockerbugs/blockerbugs.log"
|
||||
FILE_LOGGING = False
|
||||
SYSLOG_LOGGING = False
|
||||
STREAM_LOGGING = True
|
||||
|
|
@ -68,12 +69,12 @@ class Config(object):
|
|||
FORGEJO_REPO = "quality/blocker-review"
|
||||
FORGEJO_BOT_ACCESS_TOKEN = "YOUR SECRET FORGEJO API TOKEN"
|
||||
FORGEJO_REPO_WEBHOOK_SECRET = "YOUR WEBHOOK SECRET"
|
||||
FORGEJO_BOT_USERNAME = 'blockerbot'
|
||||
FORGEJO_BOT_USERNAME = "blockerbot"
|
||||
FORGEJO_BOT_ENABLED = True
|
||||
FORGEJO_BOT_LOOP_THRESHOLD = 2
|
||||
FORGEJO_ADMIN_ORG = "quality"
|
||||
FORGEJO_DISCUSSION_TITLE = "[$component] $summary | rhbz#$bugid"
|
||||
FORGEJO_DISCUSSION_CONTENT = '''\
|
||||
FORGEJO_DISCUSSION_CONTENT = """\
|
||||
Bug details: <strong> $bug_url </strong>
|
||||
Information from [BlockerBugs App]($blockerbugs_url):
|
||||

|
||||
|
|
@ -85,15 +86,16 @@ $forgejo_url$forgejo_repo
|
|||
A quick example: `BetaBlocker +1` (where the tracker name is one of \
|
||||
`BetaBlocker`/`FinalBlocker`/`BetaFE`/`FinalFE`/`0Day`/`PreviousRelease` \
|
||||
and the vote is one of `+1`/`0`/`-1`)
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""A `Config` subclass intended for a production deployment"""
|
||||
|
||||
PRODUCTION = True
|
||||
DEBUG = False
|
||||
BUGZILLA_URL = 'https://bugzilla.redhat.com'
|
||||
BODHI_URL = 'https://bodhi.fedoraproject.org/'
|
||||
BUGZILLA_URL = "https://bugzilla.redhat.com"
|
||||
BODHI_URL = "https://bodhi.fedoraproject.org/"
|
||||
OIDC_ENABLED = True
|
||||
FORGEJO_URL = "https://forge.fedoraproject.org/"
|
||||
FORGEJO_API = "https://forge.fedoraproject.org/api/v1/"
|
||||
|
|
@ -104,13 +106,15 @@ class ProductionConfig(Config):
|
|||
|
||||
class DevelopmentConfig(Config):
|
||||
"""A `Config` subclass intended for a development environment"""
|
||||
|
||||
TRAP_BAD_REQUEST_ERRORS = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:////var/tmp/blockerbugs_db.sqlite'
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:////var/tmp/blockerbugs_db.sqlite"
|
||||
SHOW_DB_URI = True
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""A `Config` subclass intended for running the test suite"""
|
||||
|
||||
TESTING = True
|
||||
SHOW_DB_URI = True
|
||||
|
||||
|
|
@ -123,26 +127,41 @@ def openshift_config(config_object, openshift_production):
|
|||
os.environ["POSTGRESQL_PASSWORD"],
|
||||
os.environ["POSTGRESQL_SERVICE_HOST"],
|
||||
os.environ["POSTGRESQL_SERVICE_PORT"],
|
||||
os.environ["POSTGRESQL_DATABASE"]
|
||||
os.environ["POSTGRESQL_DATABASE"],
|
||||
)
|
||||
except KeyError:
|
||||
print("OpenShift mode enabled but required values couldn't be fetched. "
|
||||
"Check, if you have these variables defined in your env: "
|
||||
"(POSTGRESQL_[USER, PASSWORD, DATABASE, SERVICE_HOST, SERVICE_PORT])", file=sys.stderr)
|
||||
print(
|
||||
"OpenShift mode enabled but required values couldn't be fetched. "
|
||||
"Check, if you have these variables defined in your env: "
|
||||
"(POSTGRESQL_[USER, PASSWORD, DATABASE, SERVICE_HOST, SERVICE_PORT])",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# And then try to get more data from OpenShift env
|
||||
additional_env_keys = ["FAS_ADMIN_GROUP", "FORGEJO_BOT_ACCESS_TOKEN", "FORGEJO_REPO_WEBHOOK_SECRET", "FORGEJO_REPO",
|
||||
"FORGEJO_BOT_USERNAME", "FORGEJO_BOT_ENABLED", "FORGEJO_URL", "FORGEJO_API",
|
||||
"FORGEJO_ADMIN_ORG",
|
||||
"BUGZILLA_URL", "BUGZILLA_API_KEY", "BODHI_URL", "BLOCKERBUGS_URL", "BLOCKERBUGS_API",
|
||||
"SECRET_KEY"]
|
||||
additional_env_keys = [
|
||||
"FAS_ADMIN_GROUP",
|
||||
"FORGEJO_BOT_ACCESS_TOKEN",
|
||||
"FORGEJO_REPO_WEBHOOK_SECRET",
|
||||
"FORGEJO_REPO",
|
||||
"FORGEJO_BOT_USERNAME",
|
||||
"FORGEJO_BOT_ENABLED",
|
||||
"FORGEJO_URL",
|
||||
"FORGEJO_API",
|
||||
"FORGEJO_ADMIN_ORG",
|
||||
"BUGZILLA_URL",
|
||||
"BUGZILLA_API_KEY",
|
||||
"BODHI_URL",
|
||||
"BLOCKERBUGS_URL",
|
||||
"BLOCKERBUGS_API",
|
||||
"SECRET_KEY",
|
||||
]
|
||||
missing_data = False
|
||||
|
||||
for key in additional_env_keys:
|
||||
try:
|
||||
config_object[key] = os.environ[key]
|
||||
except(KeyError):
|
||||
except KeyError:
|
||||
print("Expected aditional data to be defined OpenShift env: %s" % key, file=sys.stderr)
|
||||
missing_data = True
|
||||
if missing_data:
|
||||
|
|
@ -150,7 +169,7 @@ def openshift_config(config_object, openshift_production):
|
|||
|
||||
# Final touches to make it work flawlessly (fingers crossed)
|
||||
# to fix login issue for folks who are part of many FAS groups
|
||||
config_object["PREFERRED_URL_SCHEME"] = 'https'
|
||||
config_object["PREFERRED_URL_SCHEME"] = "https"
|
||||
|
||||
# Make browsers send session cookie only via HTTPS
|
||||
config_object["SESSION_COOKIE_SECURE"] = True
|
||||
|
|
|
|||
|
|
@ -37,14 +37,17 @@ class AdminIndexViewSqla(flask_admin.AdminIndexView):
|
|||
return check_admin_rights()
|
||||
|
||||
|
||||
admin = flask_admin.Admin(app, 'Blocker Bug Tracking Admin',
|
||||
base_template='admin_layout.html',
|
||||
template_mode='bootstrap3',
|
||||
index_view=AdminIndexViewSqla())
|
||||
admin = flask_admin.Admin(
|
||||
app,
|
||||
"Blocker Bug Tracking Admin",
|
||||
base_template="admin_layout.html",
|
||||
template_mode="bootstrap3",
|
||||
index_view=AdminIndexViewSqla(),
|
||||
)
|
||||
|
||||
|
||||
class ReleaseView(FasAuthModelView):
|
||||
form_excluded_columns = ('last_update_sync', )
|
||||
form_excluded_columns = ("last_update_sync",)
|
||||
|
||||
def create_model(self, form):
|
||||
"""
|
||||
|
|
@ -56,36 +59,39 @@ class ReleaseView(FasAuthModelView):
|
|||
self.on_model_change(form, model, True)
|
||||
self.session.commit()
|
||||
except Exception as ex:
|
||||
flash(gettext('Failed to create model. %(error)s', error=str(ex)),
|
||||
'error')
|
||||
logging.exception('Failed to create model')
|
||||
flash(gettext("Failed to create model. %(error)s", error=str(ex)), "error")
|
||||
logging.exception("Failed to create model")
|
||||
self.session.rollback()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class MilestoneView(FasAuthModelView):
|
||||
form_excluded_columns = ('last_bug_sync', )
|
||||
form_excluded_columns = ("last_bug_sync",)
|
||||
|
||||
def create_model(self, form):
|
||||
"""
|
||||
Implement Milestone model constructor with non-default parameters support
|
||||
"""
|
||||
try:
|
||||
model = self.model(form.release.data, form.version.data,
|
||||
form.blocker_tracker.data, form.fe_tracker.data,
|
||||
form.name.data, form.active.data,
|
||||
form.current.data)
|
||||
#now only one milestone can succeeds (like a chain)
|
||||
model = self.model(
|
||||
form.release.data,
|
||||
form.version.data,
|
||||
form.blocker_tracker.data,
|
||||
form.fe_tracker.data,
|
||||
form.name.data,
|
||||
form.active.data,
|
||||
form.current.data,
|
||||
)
|
||||
# now only one milestone can succeeds (like a chain)
|
||||
if form.succeeded_by.data:
|
||||
model.succeeded_by = form.succeeded_by.data
|
||||
self.session.add(model)
|
||||
self.on_model_change(form, model, True)
|
||||
self.session.commit()
|
||||
except Exception as ex:
|
||||
flash(gettext('Failed to create model. %(error)s', error=str(ex)),
|
||||
'error')
|
||||
logging.exception('Failed to create model')
|
||||
flash(gettext("Failed to create model. %(error)s", error=str(ex)), "error")
|
||||
logging.exception("Failed to create model")
|
||||
self.session.rollback()
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -34,14 +34,18 @@ from blockerbugs.util import forgejo_bot
|
|||
from . import errors
|
||||
from .utils import get_or_404, JsonResponse, SVGResponse, check_forgejo_signature
|
||||
|
||||
api_v0 = Blueprint('api', __name__, url_prefix='/api/v0')
|
||||
api_v0 = Blueprint("api", __name__, url_prefix="/api/v0")
|
||||
|
||||
ACCEPTED_BUGTYPES = [
|
||||
'proposed_blocker', 'proposed_fe',
|
||||
'rejected_blocker', 'rejected_fe',
|
||||
'accepted_blocker', 'accepted_fe',
|
||||
'accepted_0day', 'accepted_prevrel',
|
||||
'prioritized'
|
||||
"proposed_blocker",
|
||||
"proposed_fe",
|
||||
"rejected_blocker",
|
||||
"rejected_fe",
|
||||
"accepted_blocker",
|
||||
"accepted_fe",
|
||||
"accepted_0day",
|
||||
"accepted_prevrel",
|
||||
"prioritized",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -85,17 +89,29 @@ def get_update_info(update: Update) -> dict[str, Any]:
|
|||
- milestones: List of associated milestone objects with version and release
|
||||
- bugs: List of bug objects with bugid and type classifications
|
||||
"""
|
||||
update_simple_fields = ['updateid', 'title', 'url', 'karma', 'stable_karma', 'status',
|
||||
'request']
|
||||
update_data = dict(
|
||||
(attr, getattr(update, attr)) for attr in update_simple_fields)
|
||||
update_data['release'] = update.release.number
|
||||
update_data['milestones'] = [{'version': m.version,
|
||||
'release': m.release.number if m.release else -1, }
|
||||
for m in update_to_milestones(update)]
|
||||
update_data['bugs'] = [
|
||||
{'bugid': bug.bugid,
|
||||
'type': [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]}
|
||||
update_simple_fields = [
|
||||
"updateid",
|
||||
"title",
|
||||
"url",
|
||||
"karma",
|
||||
"stable_karma",
|
||||
"status",
|
||||
"request",
|
||||
]
|
||||
update_data = dict((attr, getattr(update, attr)) for attr in update_simple_fields)
|
||||
update_data["release"] = update.release.number
|
||||
update_data["milestones"] = [
|
||||
{
|
||||
"version": m.version,
|
||||
"release": m.release.number if m.release else -1,
|
||||
}
|
||||
for m in update_to_milestones(update)
|
||||
]
|
||||
update_data["bugs"] = [
|
||||
{
|
||||
"bugid": bug.bugid,
|
||||
"type": [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)],
|
||||
}
|
||||
for bug in update.bugs
|
||||
]
|
||||
return update_data
|
||||
|
|
@ -120,13 +136,13 @@ def get_bug_info(bug: Bug) -> dict[str, Any]:
|
|||
- discussion_link: Link to the blocker review discussion
|
||||
- type: List of bug type classifications (e.g., ['accepted_blocker', 'proposed_fe'])
|
||||
"""
|
||||
bug_simple_fields = ['bugid', 'url', 'summary', 'component', 'active', 'discussion_link']
|
||||
bug_simple_fields = ["bugid", "url", "summary", "component", "active", "discussion_link"]
|
||||
bug_info = dict((attr, getattr(bug, attr)) for attr in bug_simple_fields)
|
||||
bug_info['type'] = [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]
|
||||
bug_info["type"] = [tp for tp in ACCEPTED_BUGTYPES if getattr(bug, tp)]
|
||||
return bug_info
|
||||
|
||||
|
||||
@api_v0.route('/milestones/<int:rel_num>/<milestone_version>/updates')
|
||||
@api_v0.route("/milestones/<int:rel_num>/<milestone_version>/updates")
|
||||
def list_updates(rel_num: int, milestone_version: str) -> JsonResponse:
|
||||
"""List all updates that claim to fix active bugs tracked for a specific milestone.
|
||||
|
||||
|
|
@ -156,30 +172,33 @@ def list_updates(rel_num: int, milestone_version: str) -> JsonResponse:
|
|||
Only updates associated with active bugs are included in the results.
|
||||
"""
|
||||
release = get_or_404(Release, number=rel_num)
|
||||
milestone = get_or_404(Milestone, release=release,
|
||||
version=milestone_version)
|
||||
milestone = get_or_404(Milestone, release=release, version=milestone_version)
|
||||
|
||||
updates = Update.query.filter_by(
|
||||
updates = (
|
||||
Update.query.filter_by(
|
||||
release=milestone.release,
|
||||
).join(Update.bugs).filter(
|
||||
)
|
||||
.join(Update.bugs)
|
||||
.filter(
|
||||
Bug.milestone == milestone,
|
||||
Bug.active.is_(True),
|
||||
)
|
||||
)
|
||||
|
||||
if 'bugtype' in request.args:
|
||||
bugtype = request.args['bugtype']
|
||||
if "bugtype" in request.args:
|
||||
bugtype = request.args["bugtype"]
|
||||
if bugtype in ACCEPTED_BUGTYPES:
|
||||
bug_attr = getattr(Bug, bugtype)
|
||||
updates = updates.filter(bug_attr.is_(True))
|
||||
else:
|
||||
raise errors.InvalidArgumentError(arg_name='bugtype')
|
||||
raise errors.InvalidArgumentError(arg_name="bugtype")
|
||||
|
||||
updates = updates.order_by(Update.date_submitted.desc()).all() # type: ignore[attr-defined]
|
||||
updates_info = [get_update_info(up) for up in updates]
|
||||
return JsonResponse(updates_info)
|
||||
|
||||
|
||||
@api_v0.route('/milestones/<int:rel_num>/<milestone_version>/bugs')
|
||||
@api_v0.route("/milestones/<int:rel_num>/<milestone_version>/bugs")
|
||||
def list_bugs(rel_num: int, milestone_version: str) -> JsonResponse:
|
||||
"""List all bugs tracked for a specific milestone.
|
||||
|
||||
|
|
@ -206,22 +225,21 @@ def list_bugs(rel_num: int, milestone_version: str) -> JsonResponse:
|
|||
InvalidArgumentError: If an invalid bugtype is provided in query parameters.
|
||||
"""
|
||||
release = get_or_404(Release, number=rel_num)
|
||||
milestone = get_or_404(Milestone, release=release,
|
||||
version=milestone_version)
|
||||
milestone = get_or_404(Milestone, release=release, version=milestone_version)
|
||||
bugs = Bug.query.filter_by(milestone=milestone)
|
||||
if 'bugtype' in request.args:
|
||||
bugtype = request.args['bugtype']
|
||||
if "bugtype" in request.args:
|
||||
bugtype = request.args["bugtype"]
|
||||
if bugtype in ACCEPTED_BUGTYPES:
|
||||
bug_attr = getattr(Bug, bugtype)
|
||||
bugs = bugs.filter(bug_attr.is_(True))
|
||||
else:
|
||||
raise errors.InvalidArgumentError(arg_name='bugtype')
|
||||
raise errors.InvalidArgumentError(arg_name="bugtype")
|
||||
bugs = bugs.order_by(Bug.component, Bug.bugid).all()
|
||||
bugs_info = [get_bug_info(bug) for bug in bugs]
|
||||
return JsonResponse(bugs_info)
|
||||
|
||||
|
||||
@api_v0.route('/milestones/current')
|
||||
@api_v0.route("/milestones/current")
|
||||
def get_current_milestone() -> JsonResponse:
|
||||
"""Retrieve the currently active milestone.
|
||||
|
||||
|
|
@ -265,14 +283,14 @@ def forgejo_webhook() -> JsonResponse:
|
|||
the webhook was processed, ignored, or rejected.
|
||||
"""
|
||||
if not app.config.get("FORGEJO_BOT_ENABLED", False):
|
||||
msg = 'Forgejo bot disabled, ignoring request'
|
||||
msg = "Forgejo bot disabled, ignoring request"
|
||||
app.logger.info(msg)
|
||||
return JsonResponse({'msg': msg})
|
||||
return JsonResponse({"msg": msg})
|
||||
|
||||
if not check_forgejo_signature(request.headers, request.get_data()):
|
||||
msg = "Invalid signature, ignoring."
|
||||
app.logger.debug(msg)
|
||||
return JsonResponse({'msg': msg})
|
||||
return JsonResponse({"msg": msg})
|
||||
|
||||
data = request.json
|
||||
event = request.headers.get("X-Forgejo-Event", "")
|
||||
|
|
@ -286,13 +304,13 @@ def forgejo_webhook() -> JsonResponse:
|
|||
if event != "issue_comment":
|
||||
msg = f"Ignoring event: {event}"
|
||||
app.logger.debug(msg)
|
||||
return JsonResponse({'msg': msg})
|
||||
return JsonResponse({"msg": msg})
|
||||
|
||||
action = data.get("action", "") # created, edited, deleted
|
||||
if action not in ["created", "edited"]:
|
||||
msg = f"Ignoring issue_comment action: {action}"
|
||||
app.logger.debug(msg)
|
||||
return JsonResponse({'msg': msg})
|
||||
return JsonResponse({"msg": msg})
|
||||
|
||||
issue_number = data.get("issue", {}).get("number", None)
|
||||
state = data.get("issue", {}).get("state", "")
|
||||
|
|
@ -300,7 +318,7 @@ def forgejo_webhook() -> JsonResponse:
|
|||
if not issue_number or not state:
|
||||
msg = f"Unable to parse received message (issue number, state) from '{data}'"
|
||||
app.logger.debug(msg)
|
||||
return JsonResponse({'msg': msg})
|
||||
return JsonResponse({"msg": msg})
|
||||
|
||||
if state == "closed":
|
||||
msg = "Ignoring closed issue"
|
||||
|
|
@ -341,8 +359,10 @@ def _svg_response_text(info_all: list[str]) -> str:
|
|||
|
||||
# height of each line is 17px, text is rendered at y offset 13px (4px bellow as padding)
|
||||
# y_offset of nth line is 13 * (n + 1) + 4 * n = 13*n + 13 + 4*n = 17*n + 13
|
||||
info_lines = [info_line_template.format(y_offset=17 * index + 13,
|
||||
info=info) for index, info in enumerate(info_all)]
|
||||
info_lines = [
|
||||
info_line_template.format(y_offset=17 * index + 13, info=info)
|
||||
for index, info in enumerate(info_all)
|
||||
]
|
||||
return svg_template.format(total_height=17 * len(info_all), info_lines="".join(info_lines))
|
||||
|
||||
|
||||
|
|
@ -399,7 +419,7 @@ _UNKNOWN_BUG_SVG_TEXT = _svg_response_text(["unknown bug"])
|
|||
_BUG_CLOSED = "BUG CLOSED"
|
||||
|
||||
|
||||
@api_v0.route('/bugimg/<int:bug_id>')
|
||||
@api_v0.route("/bugimg/<int:bug_id>")
|
||||
def bug_image(bug_id: int) -> SVGResponse:
|
||||
"""Generate an SVG image displaying bug status and classification across milestones.
|
||||
|
||||
|
|
|
|||
|
|
@ -25,28 +25,29 @@ import http.client as httplib
|
|||
|
||||
class RestApiError(Exception):
|
||||
code = 1000
|
||||
name = 'UnknownError'
|
||||
message = 'An unknown error occurred.'
|
||||
detail = ''
|
||||
name = "UnknownError"
|
||||
message = "An unknown error occurred."
|
||||
detail = ""
|
||||
http_status_code = httplib.INTERNAL_SERVER_ERROR
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.detail = kwargs.pop('detail', '')
|
||||
self.detail = kwargs.pop("detail", "")
|
||||
self.message = self.message % (kwargs)
|
||||
super(RestApiError, self).__init__()
|
||||
|
||||
def to_dict(self):
|
||||
data = {
|
||||
'error': {'code': self.code,
|
||||
'name': self.name,
|
||||
'message': self.message,
|
||||
'detail': self.detail}
|
||||
"error": {
|
||||
"code": self.code,
|
||||
"name": self.name,
|
||||
"message": self.message,
|
||||
"detail": self.detail,
|
||||
}
|
||||
}
|
||||
return data
|
||||
|
||||
def __str__(self):
|
||||
return '%d (%s) - %s "%s"' % \
|
||||
(self.code, self.name, self.message, self.detail)
|
||||
return '%d (%s) - %s "%s"' % (self.code, self.name, self.message, self.detail)
|
||||
|
||||
|
||||
class ValidationError(RestApiError):
|
||||
|
|
@ -54,33 +55,33 @@ class ValidationError(RestApiError):
|
|||
self.message = message
|
||||
|
||||
code = 1001
|
||||
name = 'ValidationError'
|
||||
name = "ValidationError"
|
||||
http_status_code = httplib.BAD_REQUEST
|
||||
|
||||
|
||||
class NoSuchObjectError(RestApiError):
|
||||
code = 1002
|
||||
name = 'NoSuchObject'
|
||||
message = 'The specified %(obj_type)s does not exist'
|
||||
name = "NoSuchObject"
|
||||
message = "The specified %(obj_type)s does not exist"
|
||||
http_status_code = httplib.NOT_FOUND
|
||||
|
||||
|
||||
class MalformedJSONError(RestApiError):
|
||||
code = 1003
|
||||
name = 'MalformedJSON'
|
||||
name = "MalformedJSON"
|
||||
http_status_code = httplib.BAD_REQUEST
|
||||
message = 'The JSON you provided is not well-formed.'
|
||||
message = "The JSON you provided is not well-formed."
|
||||
|
||||
|
||||
class InvalidArgumentError(RestApiError):
|
||||
code = 1004
|
||||
name = 'InvalidArgument'
|
||||
message = 'The specified %(arg_name)s is invalid'
|
||||
name = "InvalidArgument"
|
||||
message = "The specified %(arg_name)s is invalid"
|
||||
http_status_code = httplib.BAD_REQUEST
|
||||
|
||||
|
||||
class AuthFailedError(RestApiError):
|
||||
code = 1005
|
||||
name = 'AuthenticationFailed'
|
||||
name = "AuthenticationFailed"
|
||||
http_status_code = httplib.FORBIDDEN
|
||||
message = 'The provided credentials does not match.'
|
||||
message = "The provided credentials does not match."
|
||||
|
|
|
|||
|
|
@ -43,15 +43,14 @@ class JsonEncoder(json.JSONEncoder):
|
|||
r = obj.isoformat()
|
||||
if obj.microsecond:
|
||||
r = r[:23] + r[26:]
|
||||
if r.endswith('+00:00'):
|
||||
r = r[:-6] + 'Z'
|
||||
if r.endswith("+00:00"):
|
||||
r = r[:-6] + "Z"
|
||||
return r
|
||||
elif isinstance(obj, datetime.date):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, datetime.time):
|
||||
#determines if a given datetime.datetime is aware.
|
||||
if obj.tzinfo is not None and obj.tzinfo.utcoffset(
|
||||
obj) is not None:
|
||||
# determines if a given datetime.datetime is aware.
|
||||
if obj.tzinfo is not None and obj.tzinfo.utcoffset(obj) is not None:
|
||||
raise ValueError("JSON can't represent timezone-aware times.")
|
||||
r = obj.isoformat()
|
||||
if obj.microsecond:
|
||||
|
|
@ -61,7 +60,7 @@ class JsonEncoder(json.JSONEncoder):
|
|||
|
||||
|
||||
class JsonResponse(Response):
|
||||
default_mimetype = 'application/json'
|
||||
default_mimetype = "application/json"
|
||||
|
||||
def __init__(self, response=None, *args, **kwargs):
|
||||
if response is None:
|
||||
|
|
@ -71,7 +70,7 @@ class JsonResponse(Response):
|
|||
|
||||
|
||||
class SVGResponse(Response):
|
||||
default_mimetype = 'image/svg+xml'
|
||||
default_mimetype = "image/svg+xml"
|
||||
|
||||
def __init__(self, response=None, *args, **kwargs):
|
||||
if response is None:
|
||||
|
|
@ -103,7 +102,7 @@ def check_forgejo_signature(headers: Headers, payload: bytes) -> bool:
|
|||
Returns:
|
||||
bool: True if signature is valid
|
||||
"""
|
||||
secret = app.config.get('FORGEJO_REPO_WEBHOOK_SECRET')
|
||||
secret = app.config.get("FORGEJO_REPO_WEBHOOK_SECRET")
|
||||
|
||||
# Reject if secret is not configured or empty
|
||||
if not secret:
|
||||
|
|
@ -112,7 +111,7 @@ def check_forgejo_signature(headers: Headers, payload: bytes) -> bool:
|
|||
|
||||
key = bytes(secret, encoding="ascii")
|
||||
computed = hmac.new(key, payload, hashlib.sha256).hexdigest()
|
||||
received = headers.get('X-Forgejo-Signature', '')
|
||||
received = headers.get("X-Forgejo-Signature", "")
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
return hmac.compare_digest(computed, received)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ try:
|
|||
except NameError:
|
||||
basestring = str
|
||||
|
||||
|
||||
class BaseValidator(object):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
|
|
@ -37,9 +38,9 @@ class BaseValidator(object):
|
|||
self.configure(args, kwargs)
|
||||
|
||||
def configure(self, args, kwargs):
|
||||
self.required = kwargs.pop('required', True)
|
||||
self.default_name = kwargs.pop('default_name', 'Data')
|
||||
self._name = kwargs.pop('name', None)
|
||||
self.required = kwargs.pop("required", True)
|
||||
self.default_name = kwargs.pop("default_name", "Data")
|
||||
self._name = kwargs.pop("name", None)
|
||||
|
||||
def _get_name(self):
|
||||
if self._name is not None:
|
||||
|
|
@ -69,36 +70,34 @@ class BaseValidator(object):
|
|||
"""
|
||||
Validation function that does not check required flag.
|
||||
"""
|
||||
raise NotImplementedError('To use this class inherit from it '
|
||||
'and define _check_data method')
|
||||
raise NotImplementedError("To use this class inherit from it and define _check_data method")
|
||||
|
||||
|
||||
class NumericValidator(BaseValidator):
|
||||
numeric_type = None
|
||||
|
||||
def configure(self, args, kwargs):
|
||||
self.max = kwargs.pop('max', None)
|
||||
self.min = kwargs.pop('min', None)
|
||||
self.max = kwargs.pop("max", None)
|
||||
self.min = kwargs.pop("min", None)
|
||||
super(NumericValidator, self).configure(args, kwargs)
|
||||
|
||||
def _check_data(self):
|
||||
if not isinstance(self.raw_data, self.numeric_type):
|
||||
raise ValidationError('%s must be integer' % (self.name))
|
||||
raise ValidationError("%s must be integer" % (self.name))
|
||||
if self.max is not None and self.raw_data > self.max:
|
||||
raise ValidationError('%s must be smaller than %i' % (self.name,
|
||||
self.max))
|
||||
raise ValidationError("%s must be smaller than %i" % (self.name, self.max))
|
||||
if self.max is not None and self.raw_data < self.min:
|
||||
raise ValidationError('%s must be larger than %i' % (self.name,
|
||||
self.min))
|
||||
raise ValidationError("%s must be larger than %i" % (self.name, self.min))
|
||||
|
||||
|
||||
class IntegerValidator(NumericValidator):
|
||||
numeric_type = int # type: ignore[assignment]
|
||||
|
||||
|
||||
class StringValidator(BaseValidator):
|
||||
def _check_data(self):
|
||||
if not isinstance(self.raw_data, basestring):
|
||||
raise ValidationError('%s must be string' % (self.name))
|
||||
raise ValidationError("%s must be string" % (self.name))
|
||||
|
||||
|
||||
class BooleanValidator(BaseValidator):
|
||||
|
|
@ -118,14 +117,13 @@ class ConstValidator(BaseValidator):
|
|||
|
||||
def _check_data(self):
|
||||
if self.const != self.raw_data:
|
||||
raise ValidationError('%s must be equal to %s' % (self.name,
|
||||
str(self.const)))
|
||||
raise ValidationError("%s must be equal to %s" % (self.name, str(self.const)))
|
||||
|
||||
|
||||
class DictValidator(BaseValidator):
|
||||
def configure(self, args, kwargs):
|
||||
if not isinstance(args[0], dict):
|
||||
raise TypeError('Argument must be dict')
|
||||
raise TypeError("Argument must be dict")
|
||||
self.items_validators = args[0]
|
||||
for key, validator in self.items_validators.items():
|
||||
if validator.name == validator.default_name:
|
||||
|
|
@ -134,7 +132,7 @@ class DictValidator(BaseValidator):
|
|||
|
||||
def _check_data(self):
|
||||
if not isinstance(self.raw_data, dict):
|
||||
raise ValidationError('%s must be dict' % (self.name))
|
||||
raise ValidationError("%s must be dict" % (self.name))
|
||||
for key, validator in self.items_validators.items():
|
||||
validator(self.raw_data.get(key, None))
|
||||
|
||||
|
|
@ -146,8 +144,7 @@ class ChoicesValidator(BaseValidator):
|
|||
|
||||
def _check_data(self):
|
||||
if not self.raw_data in self.choices:
|
||||
raise ValidationError(
|
||||
'%s must be one of %s' % (self.name, str(self.choices)))
|
||||
raise ValidationError("%s must be one of %s" % (self.name, str(self.choices)))
|
||||
|
||||
|
||||
class TypeValidator(BaseValidator):
|
||||
|
|
@ -158,8 +155,8 @@ class TypeValidator(BaseValidator):
|
|||
def _check_data(self):
|
||||
if not isinstance(self.raw_data, self.type):
|
||||
raise ValidationError(
|
||||
'%s must be instance of type %s' % (self.name,
|
||||
self.type.__name__))
|
||||
"%s must be instance of type %s" % (self.name, self.type.__name__)
|
||||
)
|
||||
|
||||
|
||||
class ListValidator(BaseValidator):
|
||||
|
|
@ -171,8 +168,8 @@ class ListValidator(BaseValidator):
|
|||
for item in self.raw_data:
|
||||
if not isinstance(item, self.values_type):
|
||||
raise ValidationError(
|
||||
'%s must be instance of type %s' %
|
||||
(self.name, self.values_type.__name__))
|
||||
"%s must be instance of type %s" % (self.name, self.values_type.__name__)
|
||||
)
|
||||
|
||||
|
||||
class DateTimeValidator(BaseValidator):
|
||||
|
|
@ -180,7 +177,7 @@ class DateTimeValidator(BaseValidator):
|
|||
try:
|
||||
parse_date(self.raw_data)
|
||||
except ParseError:
|
||||
raise ValidationError('%s must be datetime in ISO 8601 format' % (self.name))
|
||||
raise ValidationError("%s must be datetime in ISO 8601 format" % (self.name))
|
||||
|
||||
|
||||
class StringRegexValidator(BaseValidator):
|
||||
|
|
@ -190,18 +187,20 @@ class StringRegexValidator(BaseValidator):
|
|||
|
||||
def _check_data(self):
|
||||
if not self.regex.match(self.raw_data):
|
||||
raise ValidationError('%s invalid input string' % (self.name))
|
||||
raise ValidationError("%s invalid input string" % (self.name))
|
||||
|
||||
|
||||
class UrlValidator(BaseValidator):
|
||||
url_regex = re.compile(
|
||||
r'^(?:http|ftp)s?://' # http:// or https://
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' #domain...
|
||||
r'localhost|' #localhost...
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
||||
r'(?::\d+)?' # optional port
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||
r"^(?:http|ftp)s?://" # http:// or https://
|
||||
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" # domain...
|
||||
r"localhost|" # localhost...
|
||||
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # ...or ip
|
||||
r"(?::\d+)?" # optional port
|
||||
r"(?:/?|[/?]\S+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _check_data(self):
|
||||
if not self.url_regex.match(self.raw_data):
|
||||
raise ValidationError('%s must be valid URL' % (self.name))
|
||||
raise ValidationError("%s must be valid URL" % (self.name))
|
||||
|
|
|
|||
|
|
@ -27,13 +27,13 @@ from wtforms.validators import DataRequired
|
|||
|
||||
def one_proposal(form, field):
|
||||
if not (form.freeze_exception.data or form.blocker.data):
|
||||
raise ValidationError('You must propose the bug as either a blocker or a freeze exception')
|
||||
raise ValidationError("You must propose the bug as either a blocker or a freeze exception")
|
||||
|
||||
|
||||
class BugProposeForm(FlaskForm):
|
||||
bugid = IntegerField(u'Bug number', [DataRequired()])
|
||||
fas_login = StringField(u'Fedora account', [DataRequired()])
|
||||
milestone = SelectField(u'Milestone', [DataRequired()], coerce=int)
|
||||
blocker = BooleanField(u'Blocker', [one_proposal])
|
||||
freeze_exception = BooleanField(u'Freeze Exception', [one_proposal])
|
||||
justification = TextAreaField(u'Justification', [DataRequired()])
|
||||
bugid = IntegerField("Bug number", [DataRequired()])
|
||||
fas_login = StringField("Fedora account", [DataRequired()])
|
||||
milestone = SelectField("Milestone", [DataRequired()], coerce=int)
|
||||
blocker = BooleanField("Blocker", [one_proposal])
|
||||
freeze_exception = BooleanField("Freeze Exception", [one_proposal])
|
||||
justification = TextAreaField("Justification", [DataRequired()])
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ from blockerbugs.models.update import Update
|
|||
from blockerbugs.models.release import Release
|
||||
from blockerbugs.controllers.forms import BugProposeForm
|
||||
|
||||
HEALTH_CHECK_PATH = '/_health'
|
||||
HEALTH_CHECK_PATH = "/_health"
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
main = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@app.before_request
|
||||
|
|
@ -52,42 +52,60 @@ def before_request():
|
|||
def get_recent_modifications(milestoneid):
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
modcutoff = now - datetime.timedelta(1)
|
||||
recentmods = Bug.query.filter_by(milestone_id=milestoneid).filter(
|
||||
Bug.last_bug_sync > modcutoff).all()
|
||||
recentmods = (
|
||||
Bug.query.filter_by(milestone_id=milestoneid).filter(Bug.last_bug_sync > modcutoff).all()
|
||||
)
|
||||
return [bug.bugid for bug in recentmods]
|
||||
|
||||
|
||||
def get_recent_whiteboard_change(milestoneid):
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
modcutoff = now - datetime.timedelta(1)
|
||||
recentmods = Bug.query.filter_by(milestone_id=milestoneid).filter(
|
||||
Bug.last_whiteboard_change > modcutoff).all()
|
||||
recentmods = (
|
||||
Bug.query.filter_by(milestone_id=milestoneid)
|
||||
.filter(Bug.last_whiteboard_change > modcutoff)
|
||||
.all()
|
||||
)
|
||||
return [bug.bugid for bug in recentmods]
|
||||
|
||||
|
||||
def get_milestone_bugs(milestone):
|
||||
bugz = {}
|
||||
bugz['Accepted Blockers'] = Bug.query.filter_by(milestone=milestone,
|
||||
accepted_blocker=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Accepted 0-day Blockers'] = Bug.query.filter_by(milestone=milestone,
|
||||
accepted_0day=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Accepted Previous Release Blockers'] = Bug.query.filter_by(milestone=milestone,
|
||||
accepted_prevrel=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Proposed Blockers'] = Bug.query.filter_by(milestone=milestone,
|
||||
proposed_blocker=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Accepted Freeze Exceptions'] = Bug.query.filter_by(milestone=milestone,
|
||||
accepted_fe=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Proposed Freeze Exceptions'] = Bug.query.filter_by(milestone=milestone,
|
||||
proposed_fe=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz['Prioritized Bugs'] = Bug.query.filter_by(milestone=milestone,
|
||||
prioritized=True,
|
||||
active=True).order_by(Bug.component, Bug.bugid).all()
|
||||
bugz["Accepted Blockers"] = (
|
||||
Bug.query.filter_by(milestone=milestone, accepted_blocker=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Accepted 0-day Blockers"] = (
|
||||
Bug.query.filter_by(milestone=milestone, accepted_0day=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Accepted Previous Release Blockers"] = (
|
||||
Bug.query.filter_by(milestone=milestone, accepted_prevrel=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Proposed Blockers"] = (
|
||||
Bug.query.filter_by(milestone=milestone, proposed_blocker=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Accepted Freeze Exceptions"] = (
|
||||
Bug.query.filter_by(milestone=milestone, accepted_fe=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Proposed Freeze Exceptions"] = (
|
||||
Bug.query.filter_by(milestone=milestone, proposed_fe=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
bugz["Prioritized Bugs"] = (
|
||||
Bug.query.filter_by(milestone=milestone, prioritized=True, active=True)
|
||||
.order_by(Bug.component, Bug.bugid)
|
||||
.all()
|
||||
)
|
||||
return bugz
|
||||
|
||||
|
||||
|
|
@ -95,13 +113,18 @@ def get_milestone_updates(milestone: Milestone) -> list[Update]:
|
|||
"""Get all Updates which claim to fix some blocker/FE/prioritized bug which is proposed or
|
||||
accepted against the specified ``milestone``.
|
||||
"""
|
||||
updates = Update.query.filter_by(
|
||||
updates = (
|
||||
Update.query.filter_by(
|
||||
release=milestone.release,
|
||||
).join(Update.bugs).filter(
|
||||
)
|
||||
.join(Update.bugs)
|
||||
.filter(
|
||||
Bug.milestone == milestone,
|
||||
Bug.active.is_(True),
|
||||
Bug.is_proposed_accepted.is_(True),
|
||||
).all()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return updates
|
||||
|
||||
|
|
@ -110,11 +133,17 @@ def update_to_milestones(update: Update) -> list[Milestone]:
|
|||
"""Determine which milestones this update is relevant to. That is computed from update's
|
||||
release, and milestones of bugs which this update fixes.
|
||||
"""
|
||||
milestones = Milestone.query.filter_by(
|
||||
milestones = (
|
||||
Milestone.query.filter_by(
|
||||
release=update.release,
|
||||
).join(Milestone.bugs).join(Bug.updates).filter(
|
||||
)
|
||||
.join(Milestone.bugs)
|
||||
.join(Bug.updates)
|
||||
.filter(
|
||||
Update.id == update.id,
|
||||
).all()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return milestones
|
||||
|
||||
|
|
@ -126,14 +155,18 @@ def get_updates_nonstable_blockers(milestone: Milestone) -> list[Update]:
|
|||
|
||||
This is useful when creating requests for freeze pushes or new candidate composes.
|
||||
"""
|
||||
updates = Update.query.filter(
|
||||
updates = (
|
||||
Update.query.filter(
|
||||
Update.release == milestone.release,
|
||||
Update.status != 'stable',
|
||||
).join(Update.bugs).filter(
|
||||
Update.status != "stable",
|
||||
)
|
||||
.join(Update.bugs)
|
||||
.filter(
|
||||
Bug.milestone == milestone,
|
||||
or_(Bug.accepted_blocker.is_(True),
|
||||
Bug.accepted_0day.is_(True)),
|
||||
).all()
|
||||
or_(Bug.accepted_blocker.is_(True), Bug.accepted_0day.is_(True)),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return updates
|
||||
|
||||
|
|
@ -144,13 +177,18 @@ def get_updates_nonstable_FEs(milestone: Milestone) -> list[Update]:
|
|||
|
||||
This is useful when creating requests for freeze pushes or new candidate composes.
|
||||
"""
|
||||
updates = Update.query.filter(
|
||||
updates = (
|
||||
Update.query.filter(
|
||||
Update.release == milestone.release,
|
||||
Update.status != 'stable',
|
||||
).join(Update.bugs).filter(
|
||||
Update.status != "stable",
|
||||
)
|
||||
.join(Update.bugs)
|
||||
.filter(
|
||||
Bug.milestone == milestone,
|
||||
Bug.accepted_fe.is_(True),
|
||||
).all()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
return updates
|
||||
|
||||
|
|
@ -174,11 +212,14 @@ def get_current_milestone(fallback_active=True):
|
|||
|
||||
|
||||
def get_milestone_info(milestone):
|
||||
return {'number': milestone.release.number,
|
||||
'phase': milestone.version.title(),
|
||||
'last_updated': milestone.last_bug_sync.strftime('%Y-%m-%d %H:%M:%S UTC') if milestone.last_bug_sync else None,
|
||||
'blocker_tracker': milestone.blocker_tracker,
|
||||
'fe_tracker': milestone.fe_tracker
|
||||
return {
|
||||
"number": milestone.release.number,
|
||||
"phase": milestone.version.title(),
|
||||
"last_updated": milestone.last_bug_sync.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
if milestone.last_bug_sync
|
||||
else None,
|
||||
"blocker_tracker": milestone.blocker_tracker,
|
||||
"fe_tracker": milestone.fe_tracker,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -214,12 +255,12 @@ def meeting_voting_info(bugz):
|
|||
info = ""
|
||||
for tracker, vote in votes.items():
|
||||
# if no one voted +1 or -1, don't show the info at all
|
||||
if not (vote['-1'] or vote['+1']):
|
||||
if not (vote["-1"] or vote["+1"]):
|
||||
continue
|
||||
summary = f"(+{len(vote['+1'])},{len(vote['0'])},-{len(vote['-1'])})"
|
||||
pros = [f"+{person_vote}" for person_vote in vote['+1']]
|
||||
neutrals = [f"{person_vote}" for person_vote in vote['0']]
|
||||
cons = [f"-{person_vote}" for person_vote in vote['-1']]
|
||||
pros = [f"+{person_vote}" for person_vote in vote["+1"]]
|
||||
neutrals = [f"{person_vote}" for person_vote in vote["0"]]
|
||||
cons = [f"-{person_vote}" for person_vote in vote["-1"]]
|
||||
people = ", ".join(pros + neutrals + cons)
|
||||
info += f"!info Ticket vote: {forgejo_bot.NICER[tracker]} {summary} ({people})\n"
|
||||
voting_info[bugid] = info.strip()
|
||||
|
|
@ -235,7 +276,7 @@ def vote_count_tuple(votes, tracker_name):
|
|||
if not vote:
|
||||
return None
|
||||
|
||||
return (len(vote['+1']), len(vote['0']), len(vote['-1']))
|
||||
return (len(vote["+1"]), len(vote["0"]), len(vote["-1"]))
|
||||
|
||||
|
||||
def user_voted(bug_votes):
|
||||
|
|
@ -264,22 +305,22 @@ def web_voting_info(bugz, milestone):
|
|||
all_votes = bugz_to_votes(bugz)
|
||||
for bugid, votes in all_votes.items():
|
||||
voting_info[bugid] = {
|
||||
'Proposed Blockers': vote_count_tuple(votes, f'{milestone}blocker'),
|
||||
'Accepted Blockers': vote_count_tuple(votes, f'{milestone}blocker'),
|
||||
'Proposed Freeze Exceptions': vote_count_tuple(votes, f'{milestone}freezeexception'),
|
||||
'Accepted Freeze Exceptions': vote_count_tuple(votes, f'{milestone}freezeexception'),
|
||||
'Accepted 0-day Blockers': vote_count_tuple(votes, '0day'),
|
||||
'Accepted Previous Release Blockers': vote_count_tuple(votes, 'previousrelease'),
|
||||
'Prioritized Bugs': None, # no discussion for prioritized bugs
|
||||
'user_voted': user_voted(votes),
|
||||
"Proposed Blockers": vote_count_tuple(votes, f"{milestone}blocker"),
|
||||
"Accepted Blockers": vote_count_tuple(votes, f"{milestone}blocker"),
|
||||
"Proposed Freeze Exceptions": vote_count_tuple(votes, f"{milestone}freezeexception"),
|
||||
"Accepted Freeze Exceptions": vote_count_tuple(votes, f"{milestone}freezeexception"),
|
||||
"Accepted 0-day Blockers": vote_count_tuple(votes, "0day"),
|
||||
"Accepted Previous Release Blockers": vote_count_tuple(votes, "previousrelease"),
|
||||
"Prioritized Bugs": None, # no discussion for prioritized bugs
|
||||
"user_voted": user_voted(votes),
|
||||
}
|
||||
return voting_info
|
||||
|
||||
|
||||
@main.route('/')
|
||||
@main.route("/")
|
||||
def index():
|
||||
if app.debug:
|
||||
app.logger.debug('rendering index (i.e. the current milestone)')
|
||||
app.logger.debug("rendering index (i.e. the current milestone)")
|
||||
return display_current()
|
||||
|
||||
|
||||
|
|
@ -291,26 +332,26 @@ def get_health_check():
|
|||
"""
|
||||
try:
|
||||
# Simple DB query to verify database connectivity
|
||||
db.session.execute(db.text('SELECT 1')).scalar()
|
||||
response = make_response('OK')
|
||||
response.mimetype = 'text/plain'
|
||||
db.session.execute(db.text("SELECT 1")).scalar()
|
||||
response = make_response("OK")
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
app.logger.error('Health check failed: %s', e)
|
||||
app.logger.error("Health check failed: %s", e)
|
||||
abort(500)
|
||||
|
||||
|
||||
@main.route('/<int:num>/<release_name>')
|
||||
@main.route("/<int:num>/<release_name>")
|
||||
def old_display_buglist(num, release_name):
|
||||
return redirect(url_for('.display_buglist', num=num, release_name=release_name))
|
||||
return redirect(url_for(".display_buglist", num=num, release_name=release_name))
|
||||
|
||||
|
||||
@main.route('/milestone/<int:num>/<release_name>')
|
||||
@main.route("/milestone/<int:num>/<release_name>")
|
||||
def default_milestone_display(num, release_name):
|
||||
return redirect(url_for('.display_buglist', num=num, release_name=release_name))
|
||||
return redirect(url_for(".display_buglist", num=num, release_name=release_name))
|
||||
|
||||
|
||||
@main.route('/milestone/<int:num>/<release_name>/buglist')
|
||||
@main.route("/milestone/<int:num>/<release_name>/buglist")
|
||||
def display_buglist(num, release_name):
|
||||
release = Release.query.filter_by(number=num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=release_name).first()
|
||||
|
|
@ -321,38 +362,42 @@ def display_buglist(num, release_name):
|
|||
recent_bugs = get_recent_modifications(milestone.id)
|
||||
whiteboard_change = get_recent_whiteboard_change(milestone.id)
|
||||
vote_info = web_voting_info(bugz, milestone.version)
|
||||
return render_template('blocker_list.html',
|
||||
buglists=bugz,
|
||||
recent=recent_bugs,
|
||||
wb_change=whiteboard_change,
|
||||
info=release_info,
|
||||
title="Fedora %s %s Blocker Bugs" % (
|
||||
release_info['number'], release_info['phase']),
|
||||
vote_info=vote_info)
|
||||
return render_template(
|
||||
"blocker_list.html",
|
||||
buglists=bugz,
|
||||
recent=recent_bugs,
|
||||
wb_change=whiteboard_change,
|
||||
info=release_info,
|
||||
title="Fedora %s %s Blocker Bugs" % (release_info["number"], release_info["phase"]),
|
||||
vote_info=vote_info,
|
||||
)
|
||||
|
||||
|
||||
@main.route('/bug/<int:bugid>/updates')
|
||||
@main.route("/bug/<int:bugid>/updates")
|
||||
def display_bug_updates(bugid: int) -> str:
|
||||
"""Return HTML with all Bodhi updates related to a certain Bugzilla ticket.
|
||||
"""
|
||||
"""Return HTML with all Bodhi updates related to a certain Bugzilla ticket."""
|
||||
bug = Bug.query.filter_by(bugid=bugid).first()
|
||||
if not bug:
|
||||
abort(404)
|
||||
packagename = bug.component
|
||||
updates = bug.updates.all()
|
||||
return render_template('bug_tooltip.html', packagename=packagename, updates=updates,
|
||||
bz_url=app.config['BUGZILLA_URL'])
|
||||
return render_template(
|
||||
"bug_tooltip.html",
|
||||
packagename=packagename,
|
||||
updates=updates,
|
||||
bz_url=app.config["BUGZILLA_URL"],
|
||||
)
|
||||
|
||||
|
||||
@main.route('/bug/<int:bugid>/dependencies')
|
||||
@main.route("/bug/<int:bugid>/dependencies")
|
||||
def display_bug_dependencies(bugid: int):
|
||||
bug = Bug.query.filter_by(bugid=bugid).first()
|
||||
if not bug:
|
||||
abort(404)
|
||||
return render_template('bug_tooltip_deps.html', bug=bug, bz_url=app.config['BUGZILLA_URL'])
|
||||
return render_template("bug_tooltip_deps.html", bug=bug, bz_url=app.config["BUGZILLA_URL"])
|
||||
|
||||
|
||||
@main.route('/milestone/<int:num>/<release_name>/meeting')
|
||||
@main.route("/milestone/<int:num>/<release_name>/meeting")
|
||||
def display_meeting_bugs(num, release_name):
|
||||
release = Release.query.filter_by(number=num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=release_name).first()
|
||||
|
|
@ -361,31 +406,35 @@ def display_meeting_bugs(num, release_name):
|
|||
bugz = get_milestone_bugs(milestone)
|
||||
milestone_info = get_milestone_info(milestone)
|
||||
vote_info = meeting_voting_info(bugz)
|
||||
response = make_response(render_template('meeting_format.txt', buglists=bugz, info=milestone_info,
|
||||
vote_info=vote_info))
|
||||
response.mimetype = 'text/plain'
|
||||
response = make_response(
|
||||
render_template(
|
||||
"meeting_format.txt", buglists=bugz, info=milestone_info, vote_info=vote_info
|
||||
)
|
||||
)
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
||||
|
||||
|
||||
@main.route('/milestone/<int:release_num>/<milestone_version>/updates')
|
||||
@main.route("/milestone/<int:release_num>/<milestone_version>/updates")
|
||||
def display_release_updates(release_num: int, milestone_version: str) -> str:
|
||||
"""Render a template showing important updates for the selected milestone.
|
||||
"""
|
||||
"""Render a template showing important updates for the selected milestone."""
|
||||
release = Release.query.filter_by(number=release_num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=milestone_version).first()
|
||||
if not milestone:
|
||||
abort(404)
|
||||
milestone_info = get_milestone_info(milestone)
|
||||
updates = get_milestone_updates(milestone)
|
||||
return render_template('update_list.html',
|
||||
updates=updates,
|
||||
milestone=milestone,
|
||||
info=milestone_info,
|
||||
title="Fedora %s %s Blocker Bug Updates" % (milestone_info['number'],
|
||||
milestone_info['phase']))
|
||||
return render_template(
|
||||
"update_list.html",
|
||||
updates=updates,
|
||||
milestone=milestone,
|
||||
info=milestone_info,
|
||||
title="Fedora %s %s Blocker Bug Updates"
|
||||
% (milestone_info["number"], milestone_info["phase"]),
|
||||
)
|
||||
|
||||
|
||||
@main.route('/milestone/<int:num>/<release_name>/requests')
|
||||
@main.route("/milestone/<int:num>/<release_name>/requests")
|
||||
def display_release_requests(num, release_name):
|
||||
release = Release.query.filter_by(number=num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=release_name).first()
|
||||
|
|
@ -396,91 +445,130 @@ def display_release_requests(num, release_name):
|
|||
# if an update fixes both blockers and FEs, drop it from FE list
|
||||
fe_updates = [fe for fe in fe_updates if fe not in blocker_updates]
|
||||
# highlight accepted bugs which have some dependencies
|
||||
bugs_with_deps = [bugs for category, bugs in get_milestone_bugs(milestone).items()
|
||||
if category.startswith('Accepted')]
|
||||
bugs_with_deps = [
|
||||
bugs
|
||||
for category, bugs in get_milestone_bugs(milestone).items()
|
||||
if category.startswith("Accepted")
|
||||
]
|
||||
bugs_with_deps = set([bug for bug in itertools.chain(*bugs_with_deps) if bug.depends_on])
|
||||
|
||||
response = make_response(render_template(
|
||||
'requests.txt', blocker_updates=blocker_updates, fe_updates=fe_updates,
|
||||
milestone=milestone.id, bugs_with_deps=bugs_with_deps, release_num=num))
|
||||
response.mimetype = 'text/plain'
|
||||
response = make_response(
|
||||
render_template(
|
||||
"requests.txt",
|
||||
blocker_updates=blocker_updates,
|
||||
fe_updates=fe_updates,
|
||||
milestone=milestone.id,
|
||||
bugs_with_deps=bugs_with_deps,
|
||||
release_num=num,
|
||||
)
|
||||
)
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
||||
|
||||
|
||||
@main.route('/milestone/<int:release_num>/<milestone_version>/statusmail')
|
||||
@main.route("/milestone/<int:release_num>/<milestone_version>/statusmail")
|
||||
def display_statusmail(release_num: int, milestone_version: str) -> flask.Response:
|
||||
"""Render a "status summary" email template for the specified milestone.
|
||||
"""
|
||||
"""Render a "status summary" email template for the specified milestone."""
|
||||
release = Release.query.filter_by(number=release_num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=milestone_version).first()
|
||||
if not milestone:
|
||||
abort(404)
|
||||
bugz = get_milestone_bugs(milestone)
|
||||
accepted = bugz["Accepted Blockers"] + bugz["Accepted 0-day Blockers"] + bugz[
|
||||
"Accepted Previous Release Blockers"]
|
||||
accepted = (
|
||||
bugz["Accepted Blockers"]
|
||||
+ bugz["Accepted 0-day Blockers"]
|
||||
+ bugz["Accepted Previous Release Blockers"]
|
||||
)
|
||||
proposed = bugz["Proposed Blockers"]
|
||||
|
||||
response = make_response(render_template(
|
||||
'statusmail.txt', accepted=accepted, proposed=proposed, milestone=milestone_version,
|
||||
release_num=release_num))
|
||||
response.mimetype = 'text/plain'
|
||||
response = make_response(
|
||||
render_template(
|
||||
"statusmail.txt",
|
||||
accepted=accepted,
|
||||
proposed=proposed,
|
||||
milestone=milestone_version,
|
||||
release_num=release_num,
|
||||
)
|
||||
)
|
||||
response.mimetype = "text/plain"
|
||||
return response
|
||||
|
||||
|
||||
@main.route('/milestone/<int:num>/<milestone_name>/info')
|
||||
@main.route("/milestone/<int:num>/<milestone_name>/info")
|
||||
def display_milestone_info(num, milestone_name):
|
||||
release = Release.query.filter_by(number=num).first()
|
||||
milestone = Milestone.query.filter_by(release=release, version=milestone_name).first()
|
||||
if not milestone:
|
||||
abort(404)
|
||||
milestone_info = get_milestone_info(milestone)
|
||||
return render_template('milestone_info.html', info=milestone_info,
|
||||
title="Fedora %s %s Blocker Bug Info" % (milestone_info['number'], milestone_info['phase']),
|
||||
bz_url=app.config['BUGZILLA_URL'])
|
||||
return render_template(
|
||||
"milestone_info.html",
|
||||
info=milestone_info,
|
||||
title="Fedora %s %s Blocker Bug Info" % (milestone_info["number"], milestone_info["phase"]),
|
||||
bz_url=app.config["BUGZILLA_URL"],
|
||||
)
|
||||
|
||||
|
||||
@main.route('/current')
|
||||
@main.route("/current")
|
||||
def display_current():
|
||||
current_milestone = get_current_milestone()
|
||||
return redirect(url_for('.display_buglist', num=current_milestone.release.number, release_name=current_milestone.version))
|
||||
return redirect(
|
||||
url_for(
|
||||
".display_buglist",
|
||||
num=current_milestone.release.number,
|
||||
release_name=current_milestone.version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.route('/current/meeting')
|
||||
@main.route("/current/meeting")
|
||||
def display_current_meeting():
|
||||
current_milestone = get_current_milestone()
|
||||
return redirect(url_for('.display_meeting_bugs', num=current_milestone.release.number, release_name=current_milestone.version))
|
||||
return redirect(
|
||||
url_for(
|
||||
".display_meeting_bugs",
|
||||
num=current_milestone.release.number,
|
||||
release_name=current_milestone.version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_bugzilla():
|
||||
# create bz interface object using the app's config settings
|
||||
if app.testing:
|
||||
app.logger.error('Bugzilla connections with test configuration is not supported!')
|
||||
app.logger.error("Bugzilla connections with test configuration is not supported!")
|
||||
return None
|
||||
|
||||
if app.config.get('BUGZILLA_API_KEY'):
|
||||
app.logger.debug('logging into bugzilla (%s) with an api key', app.config['BUGZILLA_URL'])
|
||||
if app.config.get("BUGZILLA_API_KEY"):
|
||||
app.logger.debug("logging into bugzilla (%s) with an api key", app.config["BUGZILLA_URL"])
|
||||
else:
|
||||
app.logger.warning('No BUGZILLA_API_KEY configured. bugzilla modifications will NOT work!')
|
||||
app.logger.warning("No BUGZILLA_API_KEY configured. bugzilla modifications will NOT work!")
|
||||
|
||||
return bz_interface.create_bugzilla()
|
||||
|
||||
|
||||
def bugzilla_sync_proposal(bugid, milestone, blocker, fe):
|
||||
# Fetch the proposed stuff into the database right away
|
||||
sync = bug_sync.BugSync(db)
|
||||
trackers_str = []
|
||||
if blocker: trackers_str.append("Blocker")
|
||||
if fe: trackers_str.append("FreezeException")
|
||||
if blocker:
|
||||
trackers_str.append("Blocker")
|
||||
if fe:
|
||||
trackers_str.append("FreezeException")
|
||||
sync.fetch_single_bug(milestone, bugid, trackers_str)
|
||||
|
||||
# Create a discussion ticket
|
||||
discussion_sync.create_discussions_links(milestone, [misc.bug_from_db(bugid, milestone)])
|
||||
|
||||
@main.route('/propose_bug', methods=['GET', 'POST'])
|
||||
|
||||
@main.route("/propose_bug", methods=["GET", "POST"])
|
||||
@oidc.require_login
|
||||
def propose_bug():
|
||||
current_milestone = get_current_milestone()
|
||||
bugform = BugProposeForm()
|
||||
bugform.milestone.choices = [(m.id, m.name) for m in Milestone.query.filter_by(active=True).order_by(Milestone.id).all()]
|
||||
bugform.milestone.choices = [
|
||||
(m.id, m.name) for m in Milestone.query.filter_by(active=True).order_by(Milestone.id).all()
|
||||
]
|
||||
bugform.milestone.default = current_milestone.id
|
||||
|
||||
# we have to process the form after setting the milestone default so that it is properly set
|
||||
|
|
@ -488,26 +576,29 @@ def propose_bug():
|
|||
bugform.process(request.form, fas_login=g.oidc_user.name)
|
||||
|
||||
if bugform.validate_on_submit():
|
||||
app.logger.debug('bugid: %i' % bugform.bugid.data)
|
||||
app.logger.debug('fas_login: %s' % bugform.fas_login.data)
|
||||
app.logger.debug('milestone: %i' % bugform.milestone.data)
|
||||
app.logger.debug('blocker: %s' % bugform.blocker.data)
|
||||
app.logger.debug('freeze_exception: %s' % bugform.freeze_exception.data)
|
||||
app.logger.debug('justification: %s' % bugform.justification.data)
|
||||
app.logger.debug("bugid: %i" % bugform.bugid.data)
|
||||
app.logger.debug("fas_login: %s" % bugform.fas_login.data)
|
||||
app.logger.debug("milestone: %i" % bugform.milestone.data)
|
||||
app.logger.debug("blocker: %s" % bugform.blocker.data)
|
||||
app.logger.debug("freeze_exception: %s" % bugform.freeze_exception.data)
|
||||
app.logger.debug("justification: %s" % bugform.justification.data)
|
||||
|
||||
selected_milestone = Milestone.query.filter_by(id=bugform.milestone.data).first()
|
||||
bugid = bugform.bugid.data
|
||||
|
||||
trackers = {'blocker': selected_milestone.blocker_tracker,
|
||||
'fe': selected_milestone.fe_tracker}
|
||||
trackers = {
|
||||
"blocker": selected_milestone.blocker_tracker,
|
||||
"fe": selected_milestone.fe_tracker,
|
||||
}
|
||||
|
||||
app.logger.debug('selected trackers: %s' % trackers)
|
||||
app.logger.debug("selected trackers: %s" % trackers)
|
||||
|
||||
# The form does basic datatype validation but we need to check for valid
|
||||
# data
|
||||
bz = get_bugzilla()
|
||||
proposal = bz_interface.BlockerProposal(bz, bugid, trackers, bugform.blocker.data,
|
||||
bugform.freeze_exception.data)
|
||||
proposal = bz_interface.BlockerProposal(
|
||||
bz, bugid, trackers, bugform.blocker.data, bugform.freeze_exception.data
|
||||
)
|
||||
|
||||
# check bug to make sure it exists and that it isn't closed
|
||||
try:
|
||||
|
|
@ -519,35 +610,51 @@ def propose_bug():
|
|||
# only if the bug validation passed
|
||||
if len(bugform.bugid.errors) == 0:
|
||||
if bugform.blocker.data:
|
||||
app.logger.debug('checking for valid blocker proposal')
|
||||
app.logger.debug("checking for valid blocker proposal")
|
||||
if not proposal.check_blocker_proposal():
|
||||
bugform.blocker.errors = ['Bug %i is already proposed as a blocker' % bugid]
|
||||
bugform.blocker.errors = ["Bug %i is already proposed as a blocker" % bugid]
|
||||
if bugform.freeze_exception.data:
|
||||
app.logger.debug('checking for valid freeze exception proposal')
|
||||
app.logger.debug("checking for valid freeze exception proposal")
|
||||
if not proposal.check_fe_proposal():
|
||||
bugform.freeze_exception.errors = ['Bug %i is already proposed as a freeze exception' % bugid]
|
||||
bugform.freeze_exception.errors = [
|
||||
"Bug %i is already proposed as a freeze exception" % bugid
|
||||
]
|
||||
|
||||
if len(bugform.errors) == 0:
|
||||
user = 'Fedora user %s' % g.oidc_user.name
|
||||
app.logger.info('%s proposing %i as %s for %s' % (
|
||||
user, bugid, proposal.get_tracker_type(), selected_milestone.name))
|
||||
user = "Fedora user %s" % g.oidc_user.name
|
||||
app.logger.info(
|
||||
"%s proposing %i as %s for %s"
|
||||
% (user, bugid, proposal.get_tracker_type(), selected_milestone.name)
|
||||
)
|
||||
try:
|
||||
proposal.propose_bugs(user, selected_milestone.name, bugform.justification.data)
|
||||
bugzilla_sync_proposal(bugid, selected_milestone, bool(bugform.blocker.data), bool(bugform.freeze_exception.data))
|
||||
return render_template('thanks.html', bugid=bugid,
|
||||
isblocker=bugform.blocker.data,
|
||||
isfe=bugform.freeze_exception.data,
|
||||
bz_url=app.config['BUGZILLA_URL'])
|
||||
bugzilla_sync_proposal(
|
||||
bugid,
|
||||
selected_milestone,
|
||||
bool(bugform.blocker.data),
|
||||
bool(bugform.freeze_exception.data),
|
||||
)
|
||||
return render_template(
|
||||
"thanks.html",
|
||||
bugid=bugid,
|
||||
isblocker=bugform.blocker.data,
|
||||
isfe=bugform.freeze_exception.data,
|
||||
bz_url=app.config["BUGZILLA_URL"],
|
||||
)
|
||||
except bz_interface.BZInterfaceError as e:
|
||||
bugform.bugid.errors = [e.msg]
|
||||
app.logger.info('bug proposal form errors: %s' % e.msg)
|
||||
app.logger.info("bug proposal form errors: %s" % e.msg)
|
||||
else:
|
||||
app.logger.info('bug proposal form errors: %s' % bugform.errors)
|
||||
app.logger.info("bug proposal form errors: %s" % bugform.errors)
|
||||
|
||||
# this assumes that the current milestone is the release against which the
|
||||
# blocker is being proposed. It isn't an ideal assumption but it should hold
|
||||
# for most use cases.
|
||||
|
||||
current_release = current_milestone.release.number
|
||||
return render_template('propose_bug.html', title='Propose a Blocker or Freeze Exception',
|
||||
bugform=bugform, release=current_release)
|
||||
return render_template(
|
||||
"propose_bug.html",
|
||||
title="Propose a Blocker or Freeze Exception",
|
||||
bugform=bugform,
|
||||
release=current_release,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
from blockerbugs import db
|
||||
|
||||
update_fixes = db.Table(
|
||||
'update_fixes',
|
||||
"update_fixes",
|
||||
db.metadata,
|
||||
db.Column('update_id', db.Integer, db.ForeignKey('update.id'), primary_key=True),
|
||||
db.Column('bug_id', db.Integer, db.ForeignKey('bug.id'), primary_key=True),
|
||||
db.Column("update_id", db.Integer, db.ForeignKey("update.id"), primary_key=True),
|
||||
db.Column("bug_id", db.Integer, db.ForeignKey("bug.id"), primary_key=True),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class Bug(db.Model):
|
|||
`Milestone`. So e.g. there can only be a single `Bug` with `bugid=123456` under F35-Beta, but
|
||||
there can be another `Bug` with `bugid=123456` under F35-Final, or F36-Beta.
|
||||
"""
|
||||
|
||||
__tablename__ = "bug"
|
||||
|
||||
id: db.Mapped[int] = db.mapped_column(primary_key=True)
|
||||
|
|
@ -54,38 +55,50 @@ class Bug(db.Model):
|
|||
"""E.g. 'NEW' or 'ASSIGNED'"""
|
||||
component: db.Mapped[Optional[str]] = db.mapped_column(db.String(80))
|
||||
active: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='active_bool'))
|
||||
db.Boolean(create_constraint=True, name="active_bool")
|
||||
)
|
||||
"""An alias for 'open', i.e. `True` when open, `False` when closed"""
|
||||
last_bug_sync: db.Mapped[Optional[datetime.datetime]]
|
||||
needinfo: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='needinfo_bool'))
|
||||
db.Boolean(create_constraint=True, name="needinfo_bool")
|
||||
)
|
||||
needinfo_requestee: db.Mapped[Optional[str]] = db.mapped_column(db.String(1024))
|
||||
last_whiteboard_change: db.Mapped[Optional[datetime.datetime]]
|
||||
"""The time when we detected a blocker/FE keyword change. It doesn't really mark *any*
|
||||
whiteboard change, just for those tracked keywords."""
|
||||
proposed_blocker: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='proposed_blocker_bool'))
|
||||
db.Boolean(create_constraint=True, name="proposed_blocker_bool")
|
||||
)
|
||||
proposed_fe: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='proposed_fe_bool'))
|
||||
db.Boolean(create_constraint=True, name="proposed_fe_bool")
|
||||
)
|
||||
rejected_blocker: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='rejected_blocker_bool'))
|
||||
db.Boolean(create_constraint=True, name="rejected_blocker_bool")
|
||||
)
|
||||
rejected_fe: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='rejected_fe_bool'))
|
||||
db.Boolean(create_constraint=True, name="rejected_fe_bool")
|
||||
)
|
||||
accepted_blocker: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='accepted_blocker_bool'))
|
||||
db.Boolean(create_constraint=True, name="accepted_blocker_bool")
|
||||
)
|
||||
accepted_0day: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='accepted_0day_bool'))
|
||||
db.Boolean(create_constraint=True, name="accepted_0day_bool")
|
||||
)
|
||||
accepted_prevrel: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='accepted_prevrel_bool'))
|
||||
db.Boolean(create_constraint=True, name="accepted_prevrel_bool")
|
||||
)
|
||||
accepted_fe: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='accepted_fe_bool'))
|
||||
db.Boolean(create_constraint=True, name="accepted_fe_bool")
|
||||
)
|
||||
prioritized: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='prioritized_bool'))
|
||||
milestone_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey('milestone.id'))
|
||||
milestone: db.Mapped[Optional['Milestone']] = db.relationship( # noqa: F821
|
||||
back_populates='bugs', cascade_backrefs=False)
|
||||
db.Boolean(create_constraint=True, name="prioritized_bool")
|
||||
)
|
||||
milestone_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey("milestone.id"))
|
||||
milestone: db.Mapped[Optional["Milestone"]] = db.relationship( # noqa: F821
|
||||
back_populates="bugs", cascade_backrefs=False
|
||||
)
|
||||
discussion_link: db.Mapped[Optional[str]]
|
||||
votes: db.Mapped[str] = db.mapped_column(db.Text, default='{}')
|
||||
votes: db.Mapped[str] = db.mapped_column(db.Text, default="{}")
|
||||
"""A JSON representation of dict of all BugVoteTrackers and corresponding output of
|
||||
`BugVoteTracker.enumerate_votes()` with comment ids removed:
|
||||
```
|
||||
|
|
@ -94,26 +107,31 @@ class Bug(db.Model):
|
|||
"+1": ["nick3"]}
|
||||
}
|
||||
```"""
|
||||
_depends_on: db.Mapped[str] = db.mapped_column('depends_on', db.Text, default='[]')
|
||||
_depends_on: db.Mapped[str] = db.mapped_column("depends_on", db.Text, default="[]")
|
||||
"""A JSON list of bug numbers which this bug depends on"""
|
||||
updates: db.Mapped[List['Update']] = db.relationship( # noqa: F821
|
||||
secondary=models.update_fixes, back_populates='bugs', lazy='dynamic',
|
||||
order_by='[Update.status.desc(), Update.request.desc()]', cascade_backrefs=False)
|
||||
updates: db.Mapped[List["Update"]] = db.relationship( # noqa: F821
|
||||
secondary=models.update_fixes,
|
||||
back_populates="bugs",
|
||||
lazy="dynamic",
|
||||
order_by="[Update.status.desc(), Update.request.desc()]",
|
||||
cascade_backrefs=False,
|
||||
)
|
||||
|
||||
def __init__(self,
|
||||
bugid: Optional[int],
|
||||
url: Optional[str],
|
||||
summary: Optional[str],
|
||||
status: Optional[str],
|
||||
component: Optional[str],
|
||||
milestone: Optional['model_milestone.Milestone'],
|
||||
active: Optional[bool],
|
||||
needinfo: Optional[bool],
|
||||
needinfo_requestee: Optional[str],
|
||||
last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now(
|
||||
datetime.UTC),
|
||||
last_bug_sync: Optional[datetime] = datetime.datetime.now(datetime.UTC),
|
||||
depends_on: Optional[list[int]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
bugid: Optional[int],
|
||||
url: Optional[str],
|
||||
summary: Optional[str],
|
||||
status: Optional[str],
|
||||
component: Optional[str],
|
||||
milestone: Optional["model_milestone.Milestone"],
|
||||
active: Optional[bool],
|
||||
needinfo: Optional[bool],
|
||||
needinfo_requestee: Optional[str],
|
||||
last_whiteboard_change: Optional[datetime.datetime] = datetime.datetime.now(datetime.UTC),
|
||||
last_bug_sync: Optional[datetime] = datetime.datetime.now(datetime.UTC),
|
||||
depends_on: Optional[list[int]] = None,
|
||||
) -> None:
|
||||
self.bugid = bugid
|
||||
self.url = url
|
||||
self.summary = summary
|
||||
|
|
@ -151,13 +169,15 @@ class Bug(db.Model):
|
|||
"""Return ``True`` if this bug is either proposed or accepted as a Blocker, FreezeException
|
||||
or Prioritized.
|
||||
"""
|
||||
return (self.proposed_blocker or
|
||||
self.proposed_fe or
|
||||
self.accepted_blocker or
|
||||
self.accepted_0day or
|
||||
self.accepted_prevrel or
|
||||
self.accepted_fe or
|
||||
self.prioritized)
|
||||
return (
|
||||
self.proposed_blocker
|
||||
or self.proposed_fe
|
||||
or self.accepted_blocker
|
||||
or self.accepted_0day
|
||||
or self.accepted_prevrel
|
||||
or self.accepted_fe
|
||||
or self.prioritized
|
||||
)
|
||||
|
||||
@is_proposed_accepted.expression # type: ignore[no-redef]
|
||||
def is_proposed_accepted(self) -> bool:
|
||||
|
|
@ -165,69 +185,79 @@ class Bug(db.Model):
|
|||
or Prioritized.
|
||||
"""
|
||||
# The SQLAlchemy expression when using ``is_proposed_accepted`` in a query.
|
||||
return or_(self.proposed_blocker,
|
||||
self.proposed_fe,
|
||||
self.accepted_blocker,
|
||||
self.accepted_0day,
|
||||
self.accepted_prevrel,
|
||||
self.accepted_fe,
|
||||
self.prioritized)
|
||||
return or_(
|
||||
self.proposed_blocker,
|
||||
self.proposed_fe,
|
||||
self.accepted_blocker,
|
||||
self.accepted_0day,
|
||||
self.accepted_prevrel,
|
||||
self.accepted_fe,
|
||||
self.prioritized,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<bug %d: %s>' % (self.bugid, self.summary)
|
||||
return "<bug %d: %s>" % (self.bugid, self.summary)
|
||||
|
||||
def update(self, buginfo: dict, tracker_type: str, milestone: 'model_milestone.Milestone'
|
||||
) -> None:
|
||||
def update(
|
||||
self, buginfo: dict, tracker_type: str, milestone: "model_milestone.Milestone"
|
||||
) -> None:
|
||||
"""Update this Bug with new values. See `Bug.from_data()` for the description of
|
||||
arguments.
|
||||
"""
|
||||
self.url = buginfo['url']
|
||||
self.summary = buginfo['summary']
|
||||
self.status = buginfo['status']
|
||||
self.component = buginfo['component']
|
||||
self.last_bug_sync = buginfo['last_change_time']
|
||||
self.active = buginfo['active']
|
||||
self.needinfo = buginfo['needinfo']
|
||||
self.needinfo_requestee = buginfo['needinfo_requestee']
|
||||
self.depends_on = buginfo['depends_on']
|
||||
if tracker_type == 'Blocker':
|
||||
if self.proposed_blocker != buginfo['proposed'] or \
|
||||
self.accepted_blocker != buginfo['accepted'] or \
|
||||
self.rejected_blocker != buginfo['rejected']:
|
||||
self.url = buginfo["url"]
|
||||
self.summary = buginfo["summary"]
|
||||
self.status = buginfo["status"]
|
||||
self.component = buginfo["component"]
|
||||
self.last_bug_sync = buginfo["last_change_time"]
|
||||
self.active = buginfo["active"]
|
||||
self.needinfo = buginfo["needinfo"]
|
||||
self.needinfo_requestee = buginfo["needinfo_requestee"]
|
||||
self.depends_on = buginfo["depends_on"]
|
||||
if tracker_type == "Blocker":
|
||||
if (
|
||||
self.proposed_blocker != buginfo["proposed"]
|
||||
or self.accepted_blocker != buginfo["accepted"]
|
||||
or self.rejected_blocker != buginfo["rejected"]
|
||||
):
|
||||
self.last_whiteboard_change = datetime.datetime.now(datetime.UTC)
|
||||
self.proposed_blocker = buginfo['proposed']
|
||||
self.accepted_0day = buginfo['0day']
|
||||
self.accepted_prevrel = buginfo['prevrel']
|
||||
self.accepted_blocker = buginfo['accepted']
|
||||
self.rejected_blocker = buginfo['rejected']
|
||||
elif tracker_type == 'FreezeException':
|
||||
if self.proposed_fe != buginfo['proposed'] or \
|
||||
self.accepted_fe != buginfo['accepted'] or \
|
||||
self.rejected_fe != buginfo['rejected']:
|
||||
self.proposed_blocker = buginfo["proposed"]
|
||||
self.accepted_0day = buginfo["0day"]
|
||||
self.accepted_prevrel = buginfo["prevrel"]
|
||||
self.accepted_blocker = buginfo["accepted"]
|
||||
self.rejected_blocker = buginfo["rejected"]
|
||||
elif tracker_type == "FreezeException":
|
||||
if (
|
||||
self.proposed_fe != buginfo["proposed"]
|
||||
or self.accepted_fe != buginfo["accepted"]
|
||||
or self.rejected_fe != buginfo["rejected"]
|
||||
):
|
||||
self.last_whiteboard_change = datetime.datetime.now(datetime.UTC)
|
||||
self.proposed_fe = buginfo['proposed']
|
||||
self.accepted_fe = buginfo['accepted']
|
||||
self.rejected_fe = buginfo['rejected']
|
||||
self.prioritized = tracker_type == 'PrioritizedBug'
|
||||
self.proposed_fe = buginfo["proposed"]
|
||||
self.accepted_fe = buginfo["accepted"]
|
||||
self.rejected_fe = buginfo["rejected"]
|
||||
self.prioritized = tracker_type == "PrioritizedBug"
|
||||
if self.milestone and milestone.id != self.milestone.id:
|
||||
raise ValueError('Bugs with the same bugid and different milestones must be created'
|
||||
'as separate instances!')
|
||||
raise ValueError(
|
||||
"Bugs with the same bugid and different milestones must be created"
|
||||
"as separate instances!"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, buginfo: dict[str, Any], milestone: 'model_milestone.Milestone',
|
||||
tracker_type: str) -> 'Bug':
|
||||
def from_data(
|
||||
cls, buginfo: dict[str, Any], milestone: "model_milestone.Milestone", tracker_type: str
|
||||
) -> "Bug":
|
||||
"""Create a `Bug` instance from the provided arguments
|
||||
|
||||
:param buginfo: for a definition of this dict, see `BugSync.extract_information()`
|
||||
:param milestone: a `Milestone` instance
|
||||
:param tracker_type: one of 'Blocker', 'FreezeException' or 'PrioritizedBug'
|
||||
"""
|
||||
newbug = Bug(buginfo['bug_id'], '', '', '', '', milestone, buginfo['active'], False, '')
|
||||
newbug = Bug(buginfo["bug_id"], "", "", "", "", milestone, buginfo["active"], False, "")
|
||||
newbug.update(buginfo, tracker_type, milestone)
|
||||
return newbug
|
||||
|
||||
|
||||
def known_bugs(bugids: list[int]) -> list['Bug']:
|
||||
def known_bugs(bugids: list[int]) -> list["Bug"]:
|
||||
"""Return `Bug` instances for all bugs which match IDs present in `bugids`. If some bug ID isn't
|
||||
known (not present in DB), it is skipped.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
# Tim Flink <tflink@redhat.com>
|
||||
|
||||
"""ORM class for release milestone info stored in the database"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional, List
|
||||
|
||||
|
|
@ -34,12 +35,14 @@ class Milestone(db.Model):
|
|||
"""A `Milestone` represents e.g. a Beta or a Final milestone, in relation to a certain
|
||||
`Release` (like 35).
|
||||
"""
|
||||
|
||||
__tablename__ = "milestone"
|
||||
|
||||
id: db.Mapped[int] = db.mapped_column(primary_key=True)
|
||||
release_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey('release.id'))
|
||||
release: db.Mapped[Optional['Release']] = db.relationship( # noqa: F821
|
||||
back_populates='milestones', cascade_backrefs=False)
|
||||
release_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey("release.id"))
|
||||
release: db.Mapped[Optional["Release"]] = db.relationship( # noqa: F821
|
||||
back_populates="milestones", cascade_backrefs=False
|
||||
)
|
||||
version: db.Mapped[Optional[str]] = db.mapped_column(db.String(80))
|
||||
"""E.g. 'beta' or 'final'"""
|
||||
name: db.Mapped[Optional[str]] = db.mapped_column(db.String(80), unique=True)
|
||||
|
|
@ -50,33 +53,44 @@ class Milestone(db.Model):
|
|||
fe_tracker: db.Mapped[Optional[int]] = db.mapped_column(unique=True)
|
||||
"""A bugzilla ticket number representing the tracker for freeze exceptions"""
|
||||
active: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='active_bool'))
|
||||
db.Boolean(create_constraint=True, name="active_bool")
|
||||
)
|
||||
"""Only active milestones are synced and displayed"""
|
||||
last_bug_sync: db.Mapped[Optional[datetime]]
|
||||
current: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='current_bool'))
|
||||
db.Boolean(create_constraint=True, name="current_bool")
|
||||
)
|
||||
"""Current milestone is the most relevant one currently. Usually it is the nearest milestone
|
||||
in the future. There should be at most one milestone marked as current."""
|
||||
bugs: db.Mapped[List['Bug']] = db.relationship( # noqa: F821
|
||||
back_populates='milestone', lazy='dynamic', cascade_backrefs=False)
|
||||
succeeds_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey('milestone.id'))
|
||||
bugs: db.Mapped[List["Bug"]] = db.relationship( # noqa: F821
|
||||
back_populates="milestone", lazy="dynamic", cascade_backrefs=False
|
||||
)
|
||||
succeeds_id: db.Mapped[Optional[int]] = db.mapped_column(db.ForeignKey("milestone.id"))
|
||||
# FIXME: `succeeds` and `succeeded_by` should have `db.Mapped['Milestone']` type, but if we add
|
||||
# it, tests fail with: "On relationship Milestone.succeeds, 'dynamic' loaders cannot be used
|
||||
# with many-to-one/one-to-one relationships and/or uselist=False".
|
||||
succeeds = db.relationship('Milestone', back_populates='succeeded_by', lazy='dynamic',
|
||||
cascade_backrefs=False)
|
||||
succeeded_by = db.relationship('Milestone', back_populates='succeeds', remote_side=[id],
|
||||
uselist=False, cascade_backrefs=False)
|
||||
succeeds = db.relationship(
|
||||
"Milestone", back_populates="succeeded_by", lazy="dynamic", cascade_backrefs=False
|
||||
)
|
||||
succeeded_by = db.relationship(
|
||||
"Milestone",
|
||||
back_populates="succeeds",
|
||||
remote_side=[id],
|
||||
uselist=False,
|
||||
cascade_backrefs=False,
|
||||
)
|
||||
|
||||
def __init__(self,
|
||||
release: Optional['model_release.Release'],
|
||||
version: Optional[str],
|
||||
blocker_tracker: Optional[int],
|
||||
fe_tracker: Optional[int],
|
||||
name: Optional[str],
|
||||
active: Optional[bool] = False,
|
||||
current: Optional[bool] = False,
|
||||
succeeds: Optional['Milestone'] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
release: Optional["model_release.Release"],
|
||||
version: Optional[str],
|
||||
blocker_tracker: Optional[int],
|
||||
fe_tracker: Optional[int],
|
||||
name: Optional[str],
|
||||
active: Optional[bool] = False,
|
||||
current: Optional[bool] = False,
|
||||
succeeds: Optional["Milestone"] = None,
|
||||
) -> None:
|
||||
self.release = release
|
||||
self.version = version
|
||||
self.blocker_tracker = blocker_tracker
|
||||
|
|
@ -90,16 +104,17 @@ class Milestone(db.Model):
|
|||
|
||||
def __repr__(self) -> str:
|
||||
number = self.release.number if self.release else -1
|
||||
return 'milestone: %d-%s' % (number, self.version)
|
||||
return "milestone: %d-%s" % (number, self.version)
|
||||
|
||||
def simple(self) -> dict[str, Any]:
|
||||
"""Return a dict with basic Milestone information"""
|
||||
|
||||
return {'id': self.id,
|
||||
'release': self.release.number if self.release else -1,
|
||||
'version': self.version,
|
||||
'name': self.name,
|
||||
'blocker_tracker': self.blocker_tracker,
|
||||
'fe_tracker': self.fe_tracker,
|
||||
'current': self.current,
|
||||
}
|
||||
return {
|
||||
"id": self.id,
|
||||
"release": self.release.number if self.release else -1,
|
||||
"version": self.version,
|
||||
"name": self.name,
|
||||
"blocker_tracker": self.blocker_tracker,
|
||||
"fe_tracker": self.fe_tracker,
|
||||
"current": self.current,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,26 +32,32 @@ from blockerbugs import db
|
|||
|
||||
class Release(db.Model):
|
||||
"""This represents an OS release, primarily determined by its number."""
|
||||
|
||||
__tablename__ = "release"
|
||||
|
||||
id: db.Mapped[int] = db.mapped_column(primary_key=True)
|
||||
number: db.Mapped[Optional[int]]
|
||||
"""E.g. 35 (for Fedora 35)"""
|
||||
active: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='active_bool'))
|
||||
db.Boolean(create_constraint=True, name="active_bool")
|
||||
)
|
||||
"""Only active releases are synced and displayed"""
|
||||
discussions_closed: db.Mapped[Optional[bool]] = db.mapped_column(
|
||||
db.Boolean(create_constraint=True, name='discussions_closed_bool'))
|
||||
db.Boolean(create_constraint=True, name="discussions_closed_bool")
|
||||
)
|
||||
"""This is used for past (historical) releases. If this is `True`, all discussion tickets (as in
|
||||
`Bug.discussion_link`) have been closed ("cleaned up")."""
|
||||
last_update_sync: db.Mapped[Optional[datetime]]
|
||||
milestones: db.Mapped[List['Milestone']] = db.relationship( # noqa: F821
|
||||
back_populates='release', lazy='dynamic', cascade_backrefs=False)
|
||||
updates: db.Mapped[List['Update']] = db.relationship( # noqa: F821
|
||||
back_populates='release', lazy='dynamic', cascade_backrefs=False)
|
||||
milestones: db.Mapped[List["Milestone"]] = db.relationship( # noqa: F821
|
||||
back_populates="release", lazy="dynamic", cascade_backrefs=False
|
||||
)
|
||||
updates: db.Mapped[List["Update"]] = db.relationship( # noqa: F821
|
||||
back_populates="release", lazy="dynamic", cascade_backrefs=False
|
||||
)
|
||||
|
||||
def __init__(self, number: int, active: Optional[bool] = True,
|
||||
discussions_closed: Optional[bool] = False) -> None:
|
||||
def __init__(
|
||||
self, number: int, active: Optional[bool] = True, discussions_closed: Optional[bool] = False
|
||||
) -> None:
|
||||
self.number = number
|
||||
self.active = active
|
||||
self.discussions_closed = discussions_closed
|
||||
|
|
@ -59,4 +65,4 @@ class Release(db.Model):
|
|||
self.last_update_sync = datetime(1990, 1, 1, 1, 0, 0, 0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return 'release: %d' % self.number
|
||||
return "release: %d" % self.number
|
||||
|
|
|
|||
|
|
@ -40,17 +40,20 @@ class Update(db.Model):
|
|||
And here's a JSON schema for the different fields:
|
||||
https://bodhi.fedoraproject.org/docs/server_api/messages/update.html#json-schemas
|
||||
"""
|
||||
|
||||
__tablename__ = "update"
|
||||
|
||||
id: db.Mapped[int] = db.mapped_column(primary_key=True)
|
||||
updateid: db.Mapped[str] = db.mapped_column(db.Text, nullable=False, unique=True)
|
||||
"""E.g. FEDORA-2021-5282b5cafd. This is the same as the 'alias' field in a Bodhi object."""
|
||||
release_id: db.Mapped[int] = db.mapped_column(db.ForeignKey('release.id'))
|
||||
release: db.Mapped['Release'] = db.relationship( # noqa: F821
|
||||
back_populates='updates', cascade_backrefs=False)
|
||||
release_id: db.Mapped[int] = db.mapped_column(db.ForeignKey("release.id"))
|
||||
release: db.Mapped["Release"] = db.relationship( # noqa: F821
|
||||
back_populates="updates", cascade_backrefs=False
|
||||
)
|
||||
"""The release this update was created for in Bodhi"""
|
||||
status: db.Mapped[str] = db.mapped_column(
|
||||
db.Enum('stable', 'pending', 'testing', create_constraint=True, name='update_status_enum'))
|
||||
db.Enum("stable", "pending", "testing", create_constraint=True, name="update_status_enum")
|
||||
)
|
||||
"""One of: 'stable', 'pending', 'testing'. A Bodhi update can have additional values, but we
|
||||
only allow these three here. Because this is an enum, Updates can be sorted by this in the
|
||||
specified order (updates in a need of testing have higher value)."""
|
||||
|
|
@ -61,8 +64,16 @@ class Update(db.Model):
|
|||
date_submitted: db.Mapped[datetime.datetime]
|
||||
"""When the update was created"""
|
||||
request: db.Mapped[Optional[str]] = db.mapped_column(
|
||||
db.Enum('revoke', 'unpush', 'obsolete', 'stable', 'testing',
|
||||
create_constraint=True, name='update_request_enum'))
|
||||
db.Enum(
|
||||
"revoke",
|
||||
"unpush",
|
||||
"obsolete",
|
||||
"stable",
|
||||
"testing",
|
||||
create_constraint=True,
|
||||
name="update_request_enum",
|
||||
)
|
||||
)
|
||||
"""One of: 'revoke', 'unpush', 'obsolete', 'stable', 'testing', None. Because this is an enum,
|
||||
Updates can be sorted by this in the specified order (updates in a need of testing have higher
|
||||
value)."""
|
||||
|
|
@ -72,23 +83,26 @@ class Update(db.Model):
|
|||
this, but it might change in the future."""
|
||||
stable_karma: db.Mapped[Optional[int]]
|
||||
"""Target karma value for allowing the update to go stable (via auto or manual push)."""
|
||||
bugs: db.Mapped[List['Bug']] = db.relationship( # noqa: F821
|
||||
secondary=models.update_fixes, back_populates='updates', cascade_backrefs=False)
|
||||
bugs: db.Mapped[List["Bug"]] = db.relationship( # noqa: F821
|
||||
secondary=models.update_fixes, back_populates="updates", cascade_backrefs=False
|
||||
)
|
||||
"""A list of bugs this update claims to fix *and* we track them"""
|
||||
|
||||
_tmpstr = 'A placeholder value when creating incomplete Update objects'
|
||||
_tmpstr = "A placeholder value when creating incomplete Update objects"
|
||||
|
||||
def __init__(self,
|
||||
updateid: str,
|
||||
release: 'model_release.Release',
|
||||
status: str,
|
||||
karma: int,
|
||||
url: str,
|
||||
date_submitted: datetime.datetime,
|
||||
request: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
stable_karma: Optional[int] = None,
|
||||
bugs: list['bug.Bug'] = []) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
updateid: str,
|
||||
release: "model_release.Release",
|
||||
status: str,
|
||||
karma: int,
|
||||
url: str,
|
||||
date_submitted: datetime.datetime,
|
||||
request: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
stable_karma: Optional[int] = None,
|
||||
bugs: list["bug.Bug"] = [],
|
||||
) -> None:
|
||||
self.updateid = updateid
|
||||
self.release = release
|
||||
self.status = status
|
||||
|
|
@ -101,9 +115,9 @@ class Update(db.Model):
|
|||
self.bugs = bugs
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'<Update(id={self.id},updateid={self.updateid})>'
|
||||
return f"<Update(id={self.id},updateid={self.updateid})>"
|
||||
|
||||
def sync(self, updateinfo: dict[str, Any], bugs: list['bug.Bug'] = []) -> None:
|
||||
def sync(self, updateinfo: dict[str, Any], bugs: list["bug.Bug"] = []) -> None:
|
||||
"""Update the existing `Update` instance with a fresh data from an `updateinfo` dictionary
|
||||
(coming from Bodhi).
|
||||
|
||||
|
|
@ -114,42 +128,51 @@ class Update(db.Model):
|
|||
cases you don't want to run DB queries at the moment (e.g. during a new
|
||||
`Update` initialization) and want to run it beforehand and provide it here.
|
||||
"""
|
||||
self.updateid = updateinfo['updateid']
|
||||
self.status = updateinfo['status']
|
||||
self.karma = updateinfo['karma']
|
||||
self.url = updateinfo['url']
|
||||
self.date_submitted = updateinfo['date_submitted']
|
||||
self.request = updateinfo['request']
|
||||
self.title = updateinfo['title']
|
||||
self.stable_karma = updateinfo['stable_karma']
|
||||
self.updateid = updateinfo["updateid"]
|
||||
self.status = updateinfo["status"]
|
||||
self.karma = updateinfo["karma"]
|
||||
self.url = updateinfo["url"]
|
||||
self.date_submitted = updateinfo["date_submitted"]
|
||||
self.request = updateinfo["request"]
|
||||
self.title = updateinfo["title"]
|
||||
self.stable_karma = updateinfo["stable_karma"]
|
||||
|
||||
self.bugs.clear()
|
||||
if bugs:
|
||||
self.bugs.extend(bugs)
|
||||
else:
|
||||
self.bugs = bug.known_bugs(updateinfo['bugs'])
|
||||
self.bugs = bug.known_bugs(updateinfo["bugs"])
|
||||
|
||||
# a quick check that mandatory values seem initialized
|
||||
checkfields = [self.updateid, self.release, self.status, self.karma, self.url,
|
||||
self.date_submitted, self.bugs]
|
||||
checkfields = [
|
||||
self.updateid,
|
||||
self.release,
|
||||
self.status,
|
||||
self.karma,
|
||||
self.url,
|
||||
self.date_submitted,
|
||||
self.bugs,
|
||||
]
|
||||
assert None not in checkfields
|
||||
assert self._tmpstr not in checkfields
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, updateinfo: dict[str, Any], release: 'model_release.Release') -> 'Update':
|
||||
def from_data(cls, updateinfo: dict[str, Any], release: "model_release.Release") -> "Update":
|
||||
# retrieve `Bug` instances beforehand, otherwise SQLAlchemy complains when a DB query is
|
||||
# performed and at the same time a new `Update` gets associated with existing objects while
|
||||
# not yet added to a DB session
|
||||
bugs = bug.known_bugs(updateinfo['bugs'])
|
||||
newupdate = Update(updateid=updateinfo['updateid'],
|
||||
release=release,
|
||||
status=cls._tmpstr,
|
||||
karma=-99,
|
||||
url=cls._tmpstr,
|
||||
date_submitted=datetime.datetime.fromtimestamp(0, datetime.UTC),
|
||||
request=None,
|
||||
title=cls._tmpstr,
|
||||
stable_karma=None,
|
||||
bugs=[])
|
||||
bugs = bug.known_bugs(updateinfo["bugs"])
|
||||
newupdate = Update(
|
||||
updateid=updateinfo["updateid"],
|
||||
release=release,
|
||||
status=cls._tmpstr,
|
||||
karma=-99,
|
||||
url=cls._tmpstr,
|
||||
date_submitted=datetime.datetime.fromtimestamp(0, datetime.UTC),
|
||||
request=None,
|
||||
title=cls._tmpstr,
|
||||
stable_karma=None,
|
||||
bugs=[],
|
||||
)
|
||||
newupdate.sync(updateinfo, bugs)
|
||||
return newupdate
|
||||
|
|
|
|||
|
|
@ -30,15 +30,17 @@ import logging
|
|||
from typing import Any, Optional
|
||||
|
||||
|
||||
class BugSync():
|
||||
class BugSync:
|
||||
"""Main class for syncing existing and retrieving new blocker (and other) bugs"""
|
||||
|
||||
def __init__(self, db: flask_sqlalchemy.SQLAlchemy,
|
||||
bzinterface: Optional[bz_interface.BlockerBugs] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
db: flask_sqlalchemy.SQLAlchemy,
|
||||
bzinterface: Optional[bz_interface.BlockerBugs] = None,
|
||||
) -> None:
|
||||
self.db: flask_sqlalchemy.SQLAlchemy = db
|
||||
self.log: logging.Logger = logging.getLogger('bug_sync')
|
||||
self.bzinterface: bz_interface.BlockerBugs = (bzinterface or
|
||||
bz_interface.BlockerBugs())
|
||||
self.log: logging.Logger = logging.getLogger("bug_sync")
|
||||
self.bzinterface: bz_interface.BlockerBugs = bzinterface or bz_interface.BlockerBugs()
|
||||
|
||||
def extract_information(self, bug: bzBug, tracker_type: str) -> dict[str, Any]:
|
||||
"""Create a dict with extracted Bug information. See the code to learn the dict keyvals.
|
||||
|
|
@ -49,46 +51,49 @@ class BugSync():
|
|||
bug_whiteboard = bug.whiteboard
|
||||
|
||||
# get information about accepted/rejected
|
||||
buginfo['0day'] = 'Accepted0Day' in bug_whiteboard
|
||||
buginfo['prevrel'] = 'AcceptedPreviousRelease' in bug_whiteboard
|
||||
buginfo['accepted'] = ('Accepted%s' % tracker_type) in bug_whiteboard
|
||||
buginfo['rejected'] = ('Rejected%s' % tracker_type) in bug_whiteboard
|
||||
buginfo['proposed'] = not (buginfo['rejected'] or buginfo['accepted'] or
|
||||
buginfo['prevrel'] or buginfo['0day'])
|
||||
buginfo["0day"] = "Accepted0Day" in bug_whiteboard
|
||||
buginfo["prevrel"] = "AcceptedPreviousRelease" in bug_whiteboard
|
||||
buginfo["accepted"] = ("Accepted%s" % tracker_type) in bug_whiteboard
|
||||
buginfo["rejected"] = ("Rejected%s" % tracker_type) in bug_whiteboard
|
||||
buginfo["proposed"] = not (
|
||||
buginfo["rejected"] or buginfo["accepted"] or buginfo["prevrel"] or buginfo["0day"]
|
||||
)
|
||||
|
||||
# determine whether or not the bug is active (not CLOSED)
|
||||
buginfo['active'] = bug.is_open
|
||||
buginfo["active"] = bug.is_open
|
||||
|
||||
# basic info
|
||||
buginfo['bug_id'] = bug.bug_id
|
||||
buginfo['component'] = bug.component
|
||||
buginfo['summary'] = bug.summary
|
||||
buginfo['status'] = bug.status
|
||||
buginfo['url'] = bug.weburl
|
||||
buginfo['needinfo'] = False
|
||||
buginfo['needinfo_requestee'] = ''
|
||||
buginfo['depends_on'] = bug.dependson
|
||||
buginfo["bug_id"] = bug.bug_id
|
||||
buginfo["component"] = bug.component
|
||||
buginfo["summary"] = bug.summary
|
||||
buginfo["status"] = bug.status
|
||||
buginfo["url"] = bug.weburl
|
||||
buginfo["needinfo"] = False
|
||||
buginfo["needinfo_requestee"] = ""
|
||||
buginfo["depends_on"] = bug.dependson
|
||||
|
||||
# needinfo flag
|
||||
if hasattr(bug, 'flags'):
|
||||
if hasattr(bug, "flags"):
|
||||
needinfos = []
|
||||
for flag in bug.flags:
|
||||
if flag['name'] == 'needinfo' and flag['status'] == '?' and flag['is_active']:
|
||||
buginfo['needinfo'] = True
|
||||
if flag["name"] == "needinfo" and flag["status"] == "?" and flag["is_active"]:
|
||||
buginfo["needinfo"] = True
|
||||
try:
|
||||
needinfos.append(flag['requestee'])
|
||||
needinfos.append(flag["requestee"])
|
||||
except KeyError:
|
||||
pass
|
||||
buginfo['needinfo_requestee'] = ', '.join(needinfos)
|
||||
buginfo["needinfo_requestee"] = ", ".join(needinfos)
|
||||
|
||||
# according to bugzilla's docs, this is always in UTC
|
||||
last_change_time = bug.last_change_time.timetuple()
|
||||
buginfo['last_change_time'] = datetime.datetime(last_change_time.tm_year,
|
||||
last_change_time.tm_mon,
|
||||
last_change_time.tm_mday,
|
||||
last_change_time.tm_hour,
|
||||
last_change_time.tm_min,
|
||||
last_change_time.tm_sec)
|
||||
buginfo["last_change_time"] = datetime.datetime(
|
||||
last_change_time.tm_year,
|
||||
last_change_time.tm_mon,
|
||||
last_change_time.tm_mday,
|
||||
last_change_time.tm_hour,
|
||||
last_change_time.tm_min,
|
||||
last_change_time.tm_sec,
|
||||
)
|
||||
return buginfo
|
||||
|
||||
def get_tracker_id(self, milestone: Milestone, tracker_type: str) -> Optional[int]:
|
||||
|
|
@ -98,38 +103,53 @@ class BugSync():
|
|||
:param tracker_type: one of 'Blocker', 'FreezeException' or 'PrioritizedBug'
|
||||
:returns: the bug number, or `None` (for `tracker_type == 'PrioritizedBug'`)
|
||||
"""
|
||||
if tracker_type == 'Blocker':
|
||||
if tracker_type == "Blocker":
|
||||
return milestone.blocker_tracker
|
||||
elif tracker_type == 'FreezeException':
|
||||
elif tracker_type == "FreezeException":
|
||||
return milestone.fe_tracker
|
||||
elif tracker_type == 'PrioritizedBug':
|
||||
elif tracker_type == "PrioritizedBug":
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f'Invalid tracker_type: {tracker_type}')
|
||||
raise ValueError(f"Invalid tracker_type: {tracker_type}")
|
||||
|
||||
def update_milestone(self, milestone: Milestone, tracker_type: str,
|
||||
lastupdate: Optional[datetime.datetime] = None) -> None:
|
||||
def update_milestone(
|
||||
self,
|
||||
milestone: Milestone,
|
||||
tracker_type: str,
|
||||
lastupdate: Optional[datetime.datetime] = None,
|
||||
) -> None:
|
||||
"""Retrieve all new bugs for a `tracker_type` in a `milestone` since `lastupdate` (or ever,
|
||||
if not specified) from Bugzilla. Find bugs which already exist in the database and update
|
||||
them, and create new database entries for those yet untracked
|
||||
|
||||
:param tracker_type: one of 'Blocker', 'FreezeException' or 'PrioritizedBug'
|
||||
"""
|
||||
if tracker_type in ['Blocker', 'FreezeException']:
|
||||
if tracker_type in ["Blocker", "FreezeException"]:
|
||||
trackerid = self.get_tracker_id(milestone, tracker_type)
|
||||
assert trackerid
|
||||
self.log.info('Updating %s for Fedora %d %s (%d)' % (
|
||||
tracker_type, milestone.release.number if milestone.release else -1,
|
||||
milestone.version, trackerid))
|
||||
self.log.info(
|
||||
"Updating %s for Fedora %d %s (%d)"
|
||||
% (
|
||||
tracker_type,
|
||||
milestone.release.number if milestone.release else -1,
|
||||
milestone.version,
|
||||
trackerid,
|
||||
)
|
||||
)
|
||||
bugs = self.bzinterface.query_tracker(trackerid, last_update=lastupdate)
|
||||
elif tracker_type == 'PrioritizedBug':
|
||||
self.log.info('Updating %s for Fedora %d %s' % (
|
||||
tracker_type, milestone.release.number if milestone.release else -1,
|
||||
milestone.version))
|
||||
elif tracker_type == "PrioritizedBug":
|
||||
self.log.info(
|
||||
"Updating %s for Fedora %d %s"
|
||||
% (
|
||||
tracker_type,
|
||||
milestone.release.number if milestone.release else -1,
|
||||
milestone.version,
|
||||
)
|
||||
)
|
||||
bugs = self.bzinterface.query_prioritized()
|
||||
else:
|
||||
raise ValueError(f'Invalid tracker_type: {tracker_type}')
|
||||
self.log.debug('Found bugs: %s' % str(bugs))
|
||||
raise ValueError(f"Invalid tracker_type: {tracker_type}")
|
||||
self.log.debug("Found bugs: %s" % str(bugs))
|
||||
|
||||
# sanity check - all retrieved bugs should be unique
|
||||
bugids = [bug.bug_id for bug in bugs]
|
||||
|
|
@ -140,11 +160,11 @@ class BugSync():
|
|||
oldbug = Bug.query.filter_by(bugid=bug.bug_id, milestone=milestone).first()
|
||||
buginfo = self.extract_information(bug, tracker_type)
|
||||
if oldbug:
|
||||
self.log.debug('Updating bug %d' % buginfo['bug_id'])
|
||||
self.log.debug("Updating bug %d" % buginfo["bug_id"])
|
||||
oldbug.update(buginfo, tracker_type, milestone)
|
||||
self.db.session.add(oldbug)
|
||||
else:
|
||||
self.log.debug('Adding new bug %d' % buginfo['bug_id'])
|
||||
self.log.debug("Adding new bug %d" % buginfo["bug_id"])
|
||||
newbug = Bug.from_data(buginfo, milestone, tracker_type)
|
||||
self.db.session.add(newbug)
|
||||
|
||||
|
|
@ -180,52 +200,73 @@ class BugSync():
|
|||
:param tracker_type: one of 'Blocker', 'FreezeException' or 'PrioritizedBug'
|
||||
"""
|
||||
# retrieve all *current* bugs blocking the tracker
|
||||
if tracker_type in ['Blocker', 'FreezeException']:
|
||||
if tracker_type in ["Blocker", "FreezeException"]:
|
||||
trackerid = self.get_tracker_id(milestone, tracker_type)
|
||||
assert trackerid
|
||||
self.log.info('Cleaning up %s for Fedora %d %s (%d)' % (
|
||||
tracker_type, milestone.release.number if milestone.release else -1,
|
||||
milestone.version, trackerid))
|
||||
self.log.info(
|
||||
"Cleaning up %s for Fedora %d %s (%d)"
|
||||
% (
|
||||
tracker_type,
|
||||
milestone.release.number if milestone.release else -1,
|
||||
milestone.version,
|
||||
trackerid,
|
||||
)
|
||||
)
|
||||
tracker_depends = set(self.bzinterface.get_deps(trackerid))
|
||||
self.log.debug("Current dependencies for %d: %s" % (trackerid, str(tracker_depends)))
|
||||
elif tracker_type == 'PrioritizedBug':
|
||||
self.log.info('Cleaning up %s for Fedora %d %s' % (
|
||||
tracker_type, milestone.release.number if milestone.release else -1,
|
||||
milestone.version))
|
||||
elif tracker_type == "PrioritizedBug":
|
||||
self.log.info(
|
||||
"Cleaning up %s for Fedora %d %s"
|
||||
% (
|
||||
tracker_type,
|
||||
milestone.release.number if milestone.release else -1,
|
||||
milestone.version,
|
||||
)
|
||||
)
|
||||
tracker_depends = set()
|
||||
else:
|
||||
raise ValueError(f'Invalid tracker_type: {tracker_type}')
|
||||
raise ValueError(f"Invalid tracker_type: {tracker_type}")
|
||||
|
||||
# get all matching bugs from the DB
|
||||
blockers: list[Bug]
|
||||
if tracker_type == 'FreezeException':
|
||||
blockers = Bug.query.filter_by(milestone=milestone).filter(
|
||||
or_(Bug.proposed_fe.is_(True),
|
||||
Bug.accepted_fe.is_(True))).all()
|
||||
elif tracker_type == 'Blocker':
|
||||
blockers = Bug.query.filter_by(milestone=milestone).filter(
|
||||
or_(Bug.proposed_blocker.is_(True),
|
||||
Bug.accepted_blocker.is_(True),
|
||||
Bug.accepted_0day.is_(True),
|
||||
Bug.accepted_prevrel.is_(True))).all()
|
||||
elif tracker_type == 'PrioritizedBug':
|
||||
blockers = Bug.query.filter_by(milestone=milestone).filter(
|
||||
Bug.prioritized.is_(True)).all()
|
||||
if tracker_type == "FreezeException":
|
||||
blockers = (
|
||||
Bug.query.filter_by(milestone=milestone)
|
||||
.filter(or_(Bug.proposed_fe.is_(True), Bug.accepted_fe.is_(True)))
|
||||
.all()
|
||||
)
|
||||
elif tracker_type == "Blocker":
|
||||
blockers = (
|
||||
Bug.query.filter_by(milestone=milestone)
|
||||
.filter(
|
||||
or_(
|
||||
Bug.proposed_blocker.is_(True),
|
||||
Bug.accepted_blocker.is_(True),
|
||||
Bug.accepted_0day.is_(True),
|
||||
Bug.accepted_prevrel.is_(True),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
elif tracker_type == "PrioritizedBug":
|
||||
blockers = (
|
||||
Bug.query.filter_by(milestone=milestone).filter(Bug.prioritized.is_(True)).all()
|
||||
)
|
||||
|
||||
# clean up bugs which no longer block the tracker
|
||||
for blocker in blockers:
|
||||
if blocker.bugid not in tracker_depends:
|
||||
if tracker_type == 'FreezeException':
|
||||
if tracker_type == "FreezeException":
|
||||
blocker.proposed_fe = False
|
||||
blocker.accepted_fe = False
|
||||
blocker.rejected_fe = True
|
||||
elif tracker_type == 'Blocker':
|
||||
elif tracker_type == "Blocker":
|
||||
blocker.proposed_blocker = False
|
||||
blocker.accepted_blocker = False
|
||||
blocker.accepted_0day = False
|
||||
blocker.accepted_prevrel = False
|
||||
blocker.rejected_blocker = True
|
||||
elif tracker_type == 'PrioritizedBug':
|
||||
elif tracker_type == "PrioritizedBug":
|
||||
blocker.prioritized = False
|
||||
self.db.session.add(blocker)
|
||||
self.db.session.commit()
|
||||
|
|
@ -248,16 +289,16 @@ class BugSync():
|
|||
last_sync_time = None
|
||||
|
||||
self.log.info("Syncing blockers for %s" % milestone)
|
||||
self.cleanup_milestone(milestone, 'Blocker')
|
||||
self.update_milestone(milestone, 'Blocker', last_sync_time)
|
||||
self.cleanup_milestone(milestone, "Blocker")
|
||||
self.update_milestone(milestone, "Blocker", last_sync_time)
|
||||
|
||||
self.log.info("Syncing FE for %s" % milestone)
|
||||
self.cleanup_milestone(milestone, 'FreezeException')
|
||||
self.update_milestone(milestone, 'FreezeException', last_sync_time)
|
||||
self.cleanup_milestone(milestone, "FreezeException")
|
||||
self.update_milestone(milestone, "FreezeException", last_sync_time)
|
||||
|
||||
self.log.info("Syncing PrioritizedBug for %s" % milestone)
|
||||
self.cleanup_milestone(milestone, 'PrioritizedBug')
|
||||
self.update_milestone(milestone, 'PrioritizedBug', None)
|
||||
self.cleanup_milestone(milestone, "PrioritizedBug")
|
||||
self.update_milestone(milestone, "PrioritizedBug", None)
|
||||
|
||||
# update the last query time for the milestone
|
||||
milestone.last_bug_sync = starttime
|
||||
|
|
|
|||
|
|
@ -33,24 +33,27 @@ from blockerbugs import app
|
|||
# anonymous users can now query for at most 20 results at a time
|
||||
BUGZILLA_QUERY_LIMIT = 20
|
||||
|
||||
base_query = {'o1': 'anywords',
|
||||
'f1': 'blocked',
|
||||
'query_format': 'advanced',
|
||||
'extra_fields': ['flags'],
|
||||
'limit': BUGZILLA_QUERY_LIMIT}
|
||||
base_query = {
|
||||
"o1": "anywords",
|
||||
"f1": "blocked",
|
||||
"query_format": "advanced",
|
||||
"extra_fields": ["flags"],
|
||||
"limit": BUGZILLA_QUERY_LIMIT,
|
||||
}
|
||||
|
||||
|
||||
def create_bugzilla(url: Optional[str] = None,
|
||||
api_key: Optional[str] = None) -> bugzilla.Bugzilla:
|
||||
def create_bugzilla(url: Optional[str] = None, api_key: Optional[str] = None) -> bugzilla.Bugzilla:
|
||||
"""Create a `bugzilla.Bugzilla` instance. If function arguments are left empty, the values are
|
||||
loaded from the config file. If api_key is used (i.e. the query is not anonymous), Bugzilla is
|
||||
checked whether the key was accepted (otherwise an exception is raised).
|
||||
"""
|
||||
# FIXME: xmlrpc must be used until https://forge.fedoraproject.org/quality/blockerbugs/issues/184 is resolved
|
||||
bz = bugzilla.Bugzilla(url=url or app.config['BUGZILLA_URL'],
|
||||
api_key=api_key or app.config['BUGZILLA_API_KEY'],
|
||||
use_creds=False,
|
||||
force_xmlrpc=True)
|
||||
bz = bugzilla.Bugzilla(
|
||||
url=url or app.config["BUGZILLA_URL"],
|
||||
api_key=api_key or app.config["BUGZILLA_API_KEY"],
|
||||
use_creds=False,
|
||||
force_xmlrpc=True,
|
||||
)
|
||||
if bz.api_key:
|
||||
# Verify successful authentication. This immediately raises an exception if auth fails.
|
||||
ok = bz.logged_in
|
||||
|
|
@ -64,7 +67,7 @@ def get_bugzilla_url(bz: bugzilla.Bugzilla) -> str:
|
|||
`bugzilla.Bugzilla` instance.
|
||||
"""
|
||||
parsed_uri = urllib.parse.urlparse(bz.url)
|
||||
url = '{uri.scheme}://{uri.netloc}'.format(uri=parsed_uri)
|
||||
url = "{uri.scheme}://{uri.netloc}".format(uri=parsed_uri)
|
||||
return url
|
||||
|
||||
|
||||
|
|
@ -78,23 +81,25 @@ class BZInterfaceError(Exception):
|
|||
return repr(self.msg)
|
||||
|
||||
|
||||
class BlockerBugs():
|
||||
class BlockerBugs:
|
||||
"""The main class for querying Bugzilla"""
|
||||
|
||||
def __init__(self, bz: Optional[bugzilla.Bugzilla] = None,
|
||||
logger: Optional[logging.Logger] = None) -> None:
|
||||
def __init__(
|
||||
self, bz: Optional[bugzilla.Bugzilla] = None, logger: Optional[logging.Logger] = None
|
||||
) -> None:
|
||||
""":param bz: `bugzilla.Bugzilla` instance. Created automatically if not provided.
|
||||
:param logger: A custom `Logger` instance. Otherwise a default Logger is created.
|
||||
:param logger: A custom `Logger` instance. Otherwise a default Logger is created.
|
||||
"""
|
||||
self.logger = logger or logging.getLogger('bz_interface')
|
||||
self.logger = logger or logging.getLogger("bz_interface")
|
||||
self.bz: bugzilla.Bugzilla = bz or create_bugzilla()
|
||||
self.logger.info('Using bugzilla URL: %s', get_bugzilla_url(self.bz))
|
||||
self.logger.info("Using bugzilla URL: %s", get_bugzilla_url(self.bz))
|
||||
|
||||
# https://bugzilla.stage.redhat.com/buglist.cgi?bug_status=NEW&bug_status=ASSIGNED
|
||||
# &bug_status=POST&bug_status=MODIFIED&classification=Fedora&component=anaconda&f1=component
|
||||
# &o1=changedafter&product=Fedora&query_format=advanced&v1=2013-03-21%2012%3A25&version=19
|
||||
def get_bz_query(self, tracker: int, last_update: datetime.datetime = None, offset: int = 0
|
||||
) -> dict[str, Any]:
|
||||
def get_bz_query(
|
||||
self, tracker: int, last_update: datetime.datetime = None, offset: int = 0
|
||||
) -> dict[str, Any]:
|
||||
"""Build a Bugzilla query to retrieve all necessary info about all bugs which block the
|
||||
`tracker` bug.
|
||||
|
||||
|
|
@ -105,7 +110,7 @@ class BlockerBugs():
|
|||
"""
|
||||
query = {}
|
||||
query.update(base_query)
|
||||
query['v1'] = str(tracker)
|
||||
query["v1"] = str(tracker)
|
||||
|
||||
if last_update:
|
||||
last_update_string = last_update.strftime("%Y-%m-%d %H:%M GMT")
|
||||
|
|
@ -120,40 +125,43 @@ class BlockerBugs():
|
|||
|
||||
# FIXME: This seems to be tremendously slowing down Bugzilla5
|
||||
# Related: https://forge.fedoraproject.org/quality/blockerbugs/issues/184
|
||||
query.update({
|
||||
'f2': 'OP',
|
||||
'j2': 'OR',
|
||||
'f3': 'blocked',
|
||||
'o3': 'changedafter',
|
||||
'v3': last_update_string,
|
||||
'f4': 'status_whiteboard',
|
||||
'o4': 'changedafter',
|
||||
'v4': last_update_string,
|
||||
'f5': 'bug_status',
|
||||
'o5': 'changedafter',
|
||||
'v5': last_update_string,
|
||||
'f6': 'dependson',
|
||||
'o6': 'changedafter',
|
||||
'v6': last_update_string,
|
||||
'f7': 'creation_ts',
|
||||
'o7': 'greaterthaneq',
|
||||
'v7': last_update_string,
|
||||
'f8': 'short_desc',
|
||||
'o8': 'changedafter',
|
||||
'v8': last_update_string,
|
||||
'f9': 'component',
|
||||
'o9': 'changedafter',
|
||||
'v9': last_update_string,
|
||||
'f10': 'CP'
|
||||
})
|
||||
query.update(
|
||||
{
|
||||
"f2": "OP",
|
||||
"j2": "OR",
|
||||
"f3": "blocked",
|
||||
"o3": "changedafter",
|
||||
"v3": last_update_string,
|
||||
"f4": "status_whiteboard",
|
||||
"o4": "changedafter",
|
||||
"v4": last_update_string,
|
||||
"f5": "bug_status",
|
||||
"o5": "changedafter",
|
||||
"v5": last_update_string,
|
||||
"f6": "dependson",
|
||||
"o6": "changedafter",
|
||||
"v6": last_update_string,
|
||||
"f7": "creation_ts",
|
||||
"o7": "greaterthaneq",
|
||||
"v7": last_update_string,
|
||||
"f8": "short_desc",
|
||||
"o8": "changedafter",
|
||||
"v8": last_update_string,
|
||||
"f9": "component",
|
||||
"o9": "changedafter",
|
||||
"v9": last_update_string,
|
||||
"f10": "CP",
|
||||
}
|
||||
)
|
||||
|
||||
# update query with the offset to use, no change in behavior if the default 0 is used
|
||||
query.update({'offset': offset})
|
||||
query.update({"offset": offset})
|
||||
|
||||
return query
|
||||
|
||||
def query_tracker(self, tracker: int, last_update: Optional[datetime.datetime] = None
|
||||
) -> list[bzBug]:
|
||||
def query_tracker(
|
||||
self, tracker: int, last_update: Optional[datetime.datetime] = None
|
||||
) -> list[bzBug]:
|
||||
"""Perform a Bugzilla query and retrieve all necessary info about all bugs which block the
|
||||
`tracker` bug (i.e. Blocker or FE bugs).
|
||||
|
||||
|
|
@ -168,7 +176,6 @@ class BlockerBugs():
|
|||
# number of bugs returned for a query is 20
|
||||
# it seems to be working for now but may need more work going forward
|
||||
while last_query_len == BUGZILLA_QUERY_LIMIT:
|
||||
|
||||
new_query = self.get_bz_query(tracker, last_update, offset=len(buglist))
|
||||
new_buglist = self.bz.query(new_query)
|
||||
buglist.extend(new_buglist)
|
||||
|
|
@ -184,8 +191,8 @@ class BlockerBugs():
|
|||
# f1=flagtypes.name&o1=substring&query_format=advanced&v1=fedora_prioritized_bug%2B
|
||||
query = self.bz.url_to_query(
|
||||
"{}/buglist.cgi?bug_status=__open__&f1=flagtypes.name&o1=substring"
|
||||
"&query_format=advanced&v1=fedora_prioritized_bug%2B".format(
|
||||
get_bugzilla_url(self.bz)))
|
||||
"&query_format=advanced&v1=fedora_prioritized_bug%2B".format(get_bugzilla_url(self.bz))
|
||||
)
|
||||
buglist = self.bz.query(query)
|
||||
return buglist
|
||||
|
||||
|
|
@ -197,7 +204,7 @@ class BlockerBugs():
|
|||
return self.bz.getbug(bugid).dependson
|
||||
|
||||
|
||||
class BlockerProposal():
|
||||
class BlockerProposal:
|
||||
def __init__(self, bz, bugid, trackers, is_blocker=False, is_fe=False):
|
||||
self.bz = bz
|
||||
self.bugid = bugid
|
||||
|
|
@ -209,7 +216,7 @@ class BlockerProposal():
|
|||
self.bugdata = None
|
||||
self.blocks = None
|
||||
|
||||
self.log = logging.getLogger('bz_interface.bugproposal')
|
||||
self.log = logging.getLogger("bz_interface.bugproposal")
|
||||
|
||||
def get_bugdata(self):
|
||||
# get bug data, will raise XMLRPC fault if the bug does not exist
|
||||
|
|
@ -223,39 +230,44 @@ class BlockerProposal():
|
|||
|
||||
def get_tracker_type(self):
|
||||
if self.is_blocker and self.is_fe:
|
||||
return 'Blocker and Freeze Exception'
|
||||
return "Blocker and Freeze Exception"
|
||||
if self.is_blocker:
|
||||
return 'Blocker'
|
||||
return "Blocker"
|
||||
if self.is_fe:
|
||||
return 'Freeze Exception'
|
||||
return "Freeze Exception"
|
||||
if self.is_prioritized:
|
||||
return 'PrioritizedBug'
|
||||
return "PrioritizedBug"
|
||||
|
||||
def propose_bugs(self, proposer, milestone_name, justification, cc_add=None):
|
||||
comment = ['Proposed as a', self.get_tracker_type(), 'for', milestone_name, 'by',
|
||||
proposer, 'using the blocker tracking app because:\n\n',
|
||||
justification]
|
||||
comment = [
|
||||
"Proposed as a",
|
||||
self.get_tracker_type(),
|
||||
"for",
|
||||
milestone_name,
|
||||
"by",
|
||||
proposer,
|
||||
"using the blocker tracking app because:\n\n",
|
||||
justification,
|
||||
]
|
||||
tracker_bugs = []
|
||||
if self.is_blocker:
|
||||
tracker_bugs.append(self.trackers['blocker'])
|
||||
tracker_bugs.append(self.trackers["blocker"])
|
||||
if self.is_fe:
|
||||
tracker_bugs.append(self.trackers['fe'])
|
||||
self.log.info('comment: %s' % comment)
|
||||
tracker_bugs.append(self.trackers["fe"])
|
||||
self.log.info("comment: %s" % comment)
|
||||
try:
|
||||
self._do_proposal(tracker_bugs, self.bugid, ' '.join(comment), cc_add)
|
||||
self._do_proposal(tracker_bugs, self.bugid, " ".join(comment), cc_add)
|
||||
except Fault as e:
|
||||
if e.faultCode == 51:
|
||||
# bugzilla account does not exist, this should happen very rarely, if ever
|
||||
# so just redo the call with nothing to add to cc
|
||||
# TODO - it might be useful to ask the user to re-associate accounts again here
|
||||
self._do_proposal(tracker_bugs, self.bugid, ' '.join(comment), '')
|
||||
self._do_proposal(tracker_bugs, self.bugid, " ".join(comment), "")
|
||||
else:
|
||||
raise BZInterfaceError(e.faultString)
|
||||
|
||||
def _do_proposal(self, tracker, proposed_bugid, comment, cc_add):
|
||||
bug_update = self.bz.build_update(blocks_add=tracker,
|
||||
cc_add=cc_add or [],
|
||||
comment=comment)
|
||||
bug_update = self.bz.build_update(blocks_add=tracker, cc_add=cc_add or [], comment=comment)
|
||||
self.bz.update_bugs(proposed_bugid, bug_update)
|
||||
|
||||
def check_proposed_bug(self):
|
||||
|
|
@ -264,8 +276,8 @@ class BlockerProposal():
|
|||
|
||||
# check to make sure that bug is not already CLOSED
|
||||
bug_status = self.bugdata.bug_status
|
||||
if 'CLOSED' in bug_status.split():
|
||||
raise BZInterfaceError('Bug %i is CLOSED: %s' % (self.bugid, bug_status))
|
||||
if "CLOSED" in bug_status.split():
|
||||
raise BZInterfaceError("Bug %i is CLOSED: %s" % (self.bugid, bug_status))
|
||||
|
||||
def get_blocks(self):
|
||||
if not self.bugdata:
|
||||
|
|
@ -276,7 +288,7 @@ class BlockerProposal():
|
|||
if not self.blocks:
|
||||
self.get_blocks()
|
||||
|
||||
if self.trackers['blocker'] in self.blocks:
|
||||
if self.trackers["blocker"] in self.blocks:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
|
@ -284,6 +296,6 @@ class BlockerProposal():
|
|||
if not self.blocks:
|
||||
self.get_blocks()
|
||||
|
||||
if self.trackers['fe'] in self.blocks:
|
||||
if self.trackers["fe"] in self.blocks:
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -22,14 +22,16 @@ def needs_discussion(bug: Bug) -> bool:
|
|||
Returns:
|
||||
bool: True if the bug needs a discussion ticket, False otherwise
|
||||
"""
|
||||
return (bug.proposed_blocker
|
||||
or bug.proposed_fe
|
||||
or bug.rejected_blocker
|
||||
or bug.rejected_fe
|
||||
or bug.accepted_blocker
|
||||
or bug.accepted_0day
|
||||
or bug.accepted_prevrel
|
||||
or bug.accepted_fe)
|
||||
return (
|
||||
bug.proposed_blocker
|
||||
or bug.proposed_fe
|
||||
or bug.rejected_blocker
|
||||
or bug.rejected_fe
|
||||
or bug.accepted_blocker
|
||||
or bug.accepted_0day
|
||||
or bug.accepted_prevrel
|
||||
or bug.accepted_fe
|
||||
)
|
||||
|
||||
|
||||
def _link_key(milestone: Milestone, bug: Bug) -> str:
|
||||
|
|
@ -109,25 +111,29 @@ def create_discussions_links(
|
|||
for existing in query.all():
|
||||
links_to_reuse[_link_key(milestone, existing)] = existing.discussion_link
|
||||
|
||||
app.logger.info('Creating Forgejo discussion tickets for %d bugs under %s',
|
||||
len(bugs), milestone)
|
||||
app.logger.info(
|
||||
"Creating Forgejo discussion tickets for %d bugs under %s", len(bugs), milestone
|
||||
)
|
||||
|
||||
for bug in bugs:
|
||||
if not needs_discussion(bug):
|
||||
app.logger.debug('Skipping bug %d, no discussion needed', bug.bugid)
|
||||
app.logger.debug("Skipping bug %d, no discussion needed", bug.bugid)
|
||||
continue
|
||||
|
||||
link = links_to_reuse.get(_link_key(milestone, bug))
|
||||
if link:
|
||||
app.logger.debug('Reusing discussion link for bug %d', bug.bugid)
|
||||
app.logger.debug("Reusing discussion link for bug %d", bug.bugid)
|
||||
else:
|
||||
app.logger.debug('Creating Forgejo discussion for bug %d', bug.bugid)
|
||||
app.logger.debug("Creating Forgejo discussion for bug %d", bug.bugid)
|
||||
try:
|
||||
link = forgejo_interface.create_bug_discussion(bug)
|
||||
links_to_reuse[_link_key(milestone, bug)] = link
|
||||
except (forgejo_interface.ForgejoAPIException, ValueError) as e:
|
||||
app.logger.error('Unable to create Forgejo discussion for bug %s. '
|
||||
'Forgejo error: %s', bug.bugid, e)
|
||||
app.logger.error(
|
||||
"Unable to create Forgejo discussion for bug %s. Forgejo error: %s",
|
||||
bug.bugid,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
|
||||
bug.discussion_link = link
|
||||
|
|
@ -166,8 +172,9 @@ def sync_discussions() -> None:
|
|||
if app.config["FORGEJO_BOT_ACCESS_TOKEN"] in (bb_Config.FORGEJO_BOT_ACCESS_TOKEN, ""):
|
||||
# skip this if the API token is set to the placeholder value
|
||||
# or empty string as we are not going to be able to create anything
|
||||
app.logger.info('Not syncing discussions, because FORGEJO_BOT_ACCESS_TOKEN '
|
||||
'is not configured.')
|
||||
app.logger.info(
|
||||
"Not syncing discussions, because FORGEJO_BOT_ACCESS_TOKEN is not configured."
|
||||
)
|
||||
return
|
||||
|
||||
links: dict[str, str] = {}
|
||||
|
|
@ -239,11 +246,11 @@ def close_discussions_inactive_releases(dry_run: bool = False) -> None:
|
|||
After all issues in a release are successfully closed (and not in dry_run mode),
|
||||
sets Release.discussions_closed=True to prevent reprocessing in future runs.
|
||||
"""
|
||||
app.logger.info('Closing Forgejo discussion tickets in inactive releases...')
|
||||
app.logger.info("Closing Forgejo discussion tickets in inactive releases...")
|
||||
inactive_releases = Release.query.filter_by(active=False, discussions_closed=False).all()
|
||||
|
||||
for inactive_release in inactive_releases:
|
||||
app.logger.info(f'Closing discussion tickets in release F{inactive_release.number}...')
|
||||
app.logger.info(f"Closing discussion tickets in release F{inactive_release.number}...")
|
||||
all_closed = True
|
||||
milestones = Milestone.query.filter_by(release=inactive_release).all()
|
||||
for milestone in milestones:
|
||||
|
|
@ -256,13 +263,16 @@ def close_discussions_inactive_releases(dry_run: bool = False) -> None:
|
|||
# Only process Forgejo links (verify URL matches configured Forgejo instance and repo)
|
||||
forgejo_prefix = f"{app.config['FORGEJO_URL']}{app.config['FORGEJO_REPO']}/issues/"
|
||||
if not bug.discussion_link.startswith(forgejo_prefix):
|
||||
app.logger.warning(f"Skipping bug {bug.bugid}, unsupported link: "
|
||||
f"{bug.discussion_link}")
|
||||
app.logger.warning(
|
||||
f"Skipping bug {bug.bugid}, unsupported link: {bug.discussion_link}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract issue number from URL
|
||||
try:
|
||||
issue_number = int(urlparse(bug.discussion_link).path.rstrip("/").split("/")[-1])
|
||||
issue_number = int(
|
||||
urlparse(bug.discussion_link).path.rstrip("/").split("/")[-1]
|
||||
)
|
||||
except ValueError:
|
||||
app.logger.error(
|
||||
f"Unable to extract issue number from link: '{bug.discussion_link}'"
|
||||
|
|
@ -296,16 +306,19 @@ def close_discussions_inactive_releases(dry_run: bool = False) -> None:
|
|||
forgejo_interface.close_issue(issue_number)
|
||||
forgejo_interface.post_comment(issue_number, comment)
|
||||
except (forgejo_interface.ForgejoAPIException, ValueError) as e:
|
||||
app.logger.error(f'Unable to close Forgejo discussion #{issue_number} for bug '
|
||||
f'{bug.bugid}. Forgejo error: {e}')
|
||||
app.logger.error(
|
||||
f"Unable to close Forgejo discussion #{issue_number} for bug "
|
||||
f"{bug.bugid}. Forgejo error: {e}"
|
||||
)
|
||||
all_closed = False
|
||||
continue
|
||||
|
||||
if all_closed and not dry_run:
|
||||
app.logger.debug("Setting dicussions_closed=True for release F%d" %
|
||||
inactive_release.number)
|
||||
app.logger.debug(
|
||||
"Setting dicussions_closed=True for release F%d" % inactive_release.number
|
||||
)
|
||||
inactive_release.discussions_closed = True
|
||||
db.session.add(inactive_release)
|
||||
db.session.commit()
|
||||
|
||||
app.logger.info('Closing Forgejo discussion tickets in inactive releases done.')
|
||||
app.logger.info("Closing Forgejo discussion tickets in inactive releases done.")
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ AgreedCommand = collections.namedtuple(
|
|||
"AgreedCommand", ["tracker", "outcome", "summary"], defaults=[None]
|
||||
)
|
||||
AgreedCommand.__doc__ = (
|
||||
'AgreedCommand(tracker, outcome, summary): A parsed AGREED command, '
|
||||
"AgreedCommand(tracker, outcome, summary): A parsed AGREED command, "
|
||||
'e.g. tracker="betablocker", outcome="accepted", summary="Breaks boot."'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -307,9 +307,7 @@ class ForgejoInterface:
|
|||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# If membership check fails, log but don't raise
|
||||
# This allows the bot to continue working even if the API is unavailable
|
||||
app.logger.warning(
|
||||
f"Unable to check public membership for {username} in {org}: {e}"
|
||||
)
|
||||
app.logger.warning(f"Unable to check public membership for {username} in {org}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,9 +26,12 @@ from blockerbugs import app, oidc
|
|||
|
||||
@oidc.require_login
|
||||
def check_admin_rights():
|
||||
if app.config['FAS_ADMIN_GROUP'] in g.oidc_user.groups:
|
||||
if app.config["FAS_ADMIN_GROUP"] in g.oidc_user.groups:
|
||||
return None
|
||||
|
||||
app.logger.info('Failed admin access to {url} by {user} (not in group "{admin}")'.format(
|
||||
url=request.url, user=g.oidc_user.name, admin=app.config['FAS_ADMIN_GROUP']))
|
||||
app.logger.info(
|
||||
'Failed admin access to {url} by {user} (not in group "{admin}")'.format(
|
||||
url=request.url, user=g.oidc_user.name, admin=app.config["FAS_ADMIN_GROUP"]
|
||||
)
|
||||
)
|
||||
abort(403, "You are not a member of the required group.")
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ from typing import Optional
|
|||
from blockerbugs import _version
|
||||
from blockerbugs.models.bug import Bug
|
||||
|
||||
|
||||
def version_date() -> Optional[str]:
|
||||
"""Return the date (just the date portion) of the app version identifier, as returned by
|
||||
versioneer. The output format is `YYYY-MM-DD`. If the date isn't known, return `None`.
|
||||
"""
|
||||
date = _version.get_versions()['date']
|
||||
date = _version.get_versions()["date"]
|
||||
if not date:
|
||||
return None
|
||||
parts = date.split(sep='T')
|
||||
parts = date.split(sep="T")
|
||||
assert len(parts) == 2
|
||||
return parts[0]
|
||||
|
||||
|
||||
def bug_from_db(bugid, milestone):
|
||||
return Bug.query.filter_by(bugid=bugid, milestone=milestone).first()
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from blockerbugs.models.release import Release
|
|||
from blockerbugs.models.milestone import Milestone
|
||||
|
||||
# we need some random strings for test data, let's use something better than lorem ipsum:
|
||||
zen = str.splitlines('''\
|
||||
zen = str.splitlines("""\
|
||||
Beautiful is better than ugly.
|
||||
Explicit is better than implicit.
|
||||
Simple is better than complex.
|
||||
|
|
@ -28,42 +28,42 @@ Now is better than never.
|
|||
Although never is often better than *right* now.
|
||||
If the implementation is hard to explain, it's a bad idea.
|
||||
If the implementation is easy to explain, it may be a good idea.
|
||||
Namespaces are one honking great idea -- let's do more of those!''')
|
||||
Namespaces are one honking great idea -- let's do more of those!""")
|
||||
virtues = [
|
||||
'perfection',
|
||||
'generosity',
|
||||
'proper conduct',
|
||||
'renunciation',
|
||||
'wisdom',
|
||||
'energy',
|
||||
'patience',
|
||||
'honesty',
|
||||
'determination',
|
||||
'goodwill',
|
||||
'equanimity',
|
||||
'non-attachment',
|
||||
'benevolence',
|
||||
'understanding',
|
||||
'compassion',
|
||||
'empathetic joy',
|
||||
'heedfulness',
|
||||
'mindfulness',
|
||||
'clear comprehension',
|
||||
'discrimination',
|
||||
'trust',
|
||||
'confidence',
|
||||
'self-respect',
|
||||
'decorum',
|
||||
'giving',
|
||||
'non-violence',
|
||||
"perfection",
|
||||
"generosity",
|
||||
"proper conduct",
|
||||
"renunciation",
|
||||
"wisdom",
|
||||
"energy",
|
||||
"patience",
|
||||
"honesty",
|
||||
"determination",
|
||||
"goodwill",
|
||||
"equanimity",
|
||||
"non-attachment",
|
||||
"benevolence",
|
||||
"understanding",
|
||||
"compassion",
|
||||
"empathetic joy",
|
||||
"heedfulness",
|
||||
"mindfulness",
|
||||
"clear comprehension",
|
||||
"discrimination",
|
||||
"trust",
|
||||
"confidence",
|
||||
"self-respect",
|
||||
"decorum",
|
||||
"giving",
|
||||
"non-violence",
|
||||
]
|
||||
|
||||
zen_counter = 0
|
||||
'''The next zen line to use'''
|
||||
"""The next zen line to use"""
|
||||
virtues_counter = 0
|
||||
'''The next virtues line to use'''
|
||||
"""The next virtues line to use"""
|
||||
pkg_counter = 0
|
||||
''' This makes generated package names different '''
|
||||
""" This makes generated package names different """
|
||||
|
||||
current_date = datetime.datetime.now(datetime.UTC)
|
||||
month_old_date = current_date - datetime.timedelta(days=30)
|
||||
|
|
@ -77,72 +77,91 @@ def get_zen():
|
|||
try:
|
||||
wisdom = zen[zen_counter]
|
||||
except IndexError:
|
||||
wisdom = f'Zen quotes are a finite resource, use them well (advice #{zen_counter})'
|
||||
wisdom = f"Zen quotes are a finite resource, use them well (advice #{zen_counter})"
|
||||
zen_counter += 1
|
||||
return wisdom
|
||||
|
||||
|
||||
def get_virtue():
|
||||
"""Be told a next virtue. You can use it for bug components, but also for self-reflection.
|
||||
"""
|
||||
"""Be told a next virtue. You can use it for bug components, but also for self-reflection."""
|
||||
global virtues_counter
|
||||
try:
|
||||
virtue = virtues[virtues_counter]
|
||||
except IndexError:
|
||||
virtue = f'life virtue #{virtues_counter}'
|
||||
virtue = f"life virtue #{virtues_counter}"
|
||||
virtues_counter += 1
|
||||
return virtue
|
||||
|
||||
|
||||
def add_bug(bugid, milestone, summary=None, status='NEW', active=True, needinfo=False,
|
||||
needinfo_requestee=None, depends_on=None, last_whiteboard_change=month_old_date,
|
||||
last_bug_sync=month_old_date, **kwargs):
|
||||
def add_bug(
|
||||
bugid,
|
||||
milestone,
|
||||
summary=None,
|
||||
status="NEW",
|
||||
active=True,
|
||||
needinfo=False,
|
||||
needinfo_requestee=None,
|
||||
depends_on=None,
|
||||
last_whiteboard_change=month_old_date,
|
||||
last_bug_sync=month_old_date,
|
||||
**kwargs,
|
||||
):
|
||||
"""Create a new Bug and return it. Use `**kwargs` for specifying additional attributes not
|
||||
exposed in the Bug constructor.
|
||||
"""
|
||||
depends_on = depends_on or []
|
||||
bug = Bug(bugid=bugid,
|
||||
url=f'http://localhost/bug/{bugid}',
|
||||
summary=summary or get_zen(),
|
||||
status=status,
|
||||
component=get_virtue(),
|
||||
milestone=milestone,
|
||||
active=active,
|
||||
needinfo=needinfo,
|
||||
needinfo_requestee=needinfo_requestee,
|
||||
last_whiteboard_change=last_whiteboard_change,
|
||||
last_bug_sync=last_bug_sync,
|
||||
depends_on=depends_on)
|
||||
bug = Bug(
|
||||
bugid=bugid,
|
||||
url=f"http://localhost/bug/{bugid}",
|
||||
summary=summary or get_zen(),
|
||||
status=status,
|
||||
component=get_virtue(),
|
||||
milestone=milestone,
|
||||
active=active,
|
||||
needinfo=needinfo,
|
||||
needinfo_requestee=needinfo_requestee,
|
||||
last_whiteboard_change=last_whiteboard_change,
|
||||
last_bug_sync=last_bug_sync,
|
||||
depends_on=depends_on,
|
||||
)
|
||||
for key, val in kwargs.items():
|
||||
setattr(bug, key, val)
|
||||
return bug
|
||||
|
||||
|
||||
def add_update(updateid_hash: str, release: Release, bugs: list[Bug], status: str = 'testing',
|
||||
karma: int = 0, date_submitted: datetime.datetime = month_old_date,
|
||||
request: str | None = None, stable_karma: int | None = 3):
|
||||
"""Create a new Update and return it.
|
||||
"""
|
||||
updateid = f'FEDORA-{release.number}-{updateid_hash}'
|
||||
def add_update(
|
||||
updateid_hash: str,
|
||||
release: Release,
|
||||
bugs: list[Bug],
|
||||
status: str = "testing",
|
||||
karma: int = 0,
|
||||
date_submitted: datetime.datetime = month_old_date,
|
||||
request: str | None = None,
|
||||
stable_karma: int | None = 3,
|
||||
):
|
||||
"""Create a new Update and return it."""
|
||||
updateid = f"FEDORA-{release.number}-{updateid_hash}"
|
||||
|
||||
# the update title consists of space-delimited NVRs
|
||||
global pkg_counter
|
||||
nvrs = []
|
||||
for _ in bugs:
|
||||
pkg_counter += 1
|
||||
nvrs.append(f'pkg{pkg_counter}-1-1.fc{release.number}')
|
||||
title = ' '.join(nvrs) or updateid
|
||||
nvrs.append(f"pkg{pkg_counter}-1-1.fc{release.number}")
|
||||
title = " ".join(nvrs) or updateid
|
||||
|
||||
update = Update(updateid=updateid,
|
||||
release=release,
|
||||
status=status,
|
||||
karma=karma,
|
||||
url=f'http://localhost/updates/{updateid}',
|
||||
date_submitted=date_submitted,
|
||||
request=request,
|
||||
title=title,
|
||||
stable_karma=stable_karma,
|
||||
bugs=bugs)
|
||||
update = Update(
|
||||
updateid=updateid,
|
||||
release=release,
|
||||
status=status,
|
||||
karma=karma,
|
||||
url=f"http://localhost/updates/{updateid}",
|
||||
date_submitted=date_submitted,
|
||||
request=request,
|
||||
title=title,
|
||||
stable_karma=stable_karma,
|
||||
bugs=bugs,
|
||||
)
|
||||
return update
|
||||
|
||||
|
||||
|
|
@ -178,50 +197,73 @@ def create_test_data(release_num=101):
|
|||
db.session.add(release)
|
||||
|
||||
# create milestones
|
||||
beta_milestone = Milestone(release=release,
|
||||
version='beta',
|
||||
name=f'{release_num}-beta',
|
||||
blocker_tracker=10101,
|
||||
fe_tracker=10102,
|
||||
active=True,
|
||||
current=False)
|
||||
beta_milestone = Milestone(
|
||||
release=release,
|
||||
version="beta",
|
||||
name=f"{release_num}-beta",
|
||||
blocker_tracker=10101,
|
||||
fe_tracker=10102,
|
||||
active=True,
|
||||
current=False,
|
||||
)
|
||||
db.session.add(beta_milestone)
|
||||
final_milestone = Milestone(release=release,
|
||||
version='final',
|
||||
name=f'{release_num}-final',
|
||||
blocker_tracker=10103,
|
||||
fe_tracker=10104,
|
||||
active=True,
|
||||
current=False)
|
||||
final_milestone = Milestone(
|
||||
release=release,
|
||||
version="final",
|
||||
name=f"{release_num}-final",
|
||||
blocker_tracker=10103,
|
||||
fe_tracker=10104,
|
||||
active=True,
|
||||
current=False,
|
||||
)
|
||||
db.session.add(final_milestone)
|
||||
|
||||
# create bugs
|
||||
# -- beta bugs
|
||||
bug101 = add_bug(101, beta_milestone, proposed_blocker=True)
|
||||
db.session.add(bug101)
|
||||
bug102 = add_bug(102, beta_milestone, status='ASSIGNED', accepted_blocker=True,
|
||||
discussion_link='http://localhost/discuss102',
|
||||
last_whiteboard_change=current_date)
|
||||
bug102 = add_bug(
|
||||
102,
|
||||
beta_milestone,
|
||||
status="ASSIGNED",
|
||||
accepted_blocker=True,
|
||||
discussion_link="http://localhost/discuss102",
|
||||
last_whiteboard_change=current_date,
|
||||
)
|
||||
db.session.add(bug102)
|
||||
bug103 = add_bug(103, beta_milestone, accepted_0day=True, depends_on=[12, 378])
|
||||
db.session.add(bug103)
|
||||
bug104 = add_bug(104, beta_milestone, accepted_prevrel=True)
|
||||
db.session.add(bug104)
|
||||
bug105 = add_bug(105, beta_milestone, status='POST', proposed_fe=True, depends_on=[463],
|
||||
last_bug_sync=current_date, last_whiteboard_change=current_date,
|
||||
needinfo=True, needinfo_requestee='Buddha',
|
||||
discussion_link='http://localhost/discuss105')
|
||||
bug105 = add_bug(
|
||||
105,
|
||||
beta_milestone,
|
||||
status="POST",
|
||||
proposed_fe=True,
|
||||
depends_on=[463],
|
||||
last_bug_sync=current_date,
|
||||
last_whiteboard_change=current_date,
|
||||
needinfo=True,
|
||||
needinfo_requestee="Buddha",
|
||||
discussion_link="http://localhost/discuss105",
|
||||
)
|
||||
db.session.add(bug105)
|
||||
bug106 = add_bug(106, beta_milestone, status='VERIFIED', accepted_fe=True)
|
||||
bug106 = add_bug(106, beta_milestone, status="VERIFIED", accepted_fe=True)
|
||||
db.session.add(bug106)
|
||||
bug107 = add_bug(107, beta_milestone, prioritized=True)
|
||||
db.session.add(bug107)
|
||||
bug108 = add_bug(108, beta_milestone, status='MODIFIED', last_bug_sync=current_date,
|
||||
proposed_blocker=True, accepted_fe=True,
|
||||
discussion_link='http://localhost/discuss108')
|
||||
bug108.votes = '''
|
||||
bug108 = add_bug(
|
||||
108,
|
||||
beta_milestone,
|
||||
status="MODIFIED",
|
||||
last_bug_sync=current_date,
|
||||
proposed_blocker=True,
|
||||
accepted_fe=True,
|
||||
discussion_link="http://localhost/discuss108",
|
||||
)
|
||||
bug108.votes = """
|
||||
{"betablocker": {"-1": ["person1"], "0": [], "+1": ["person2", "person3"]}}
|
||||
'''
|
||||
"""
|
||||
db.session.add(bug108)
|
||||
bug109 = add_bug(109, beta_milestone, accepted_blocker=True)
|
||||
db.session.add(bug109)
|
||||
|
|
@ -230,43 +272,56 @@ def create_test_data(release_num=101):
|
|||
# -- final bugs
|
||||
bug200 = add_bug(200, final_milestone, proposed_blocker=True)
|
||||
db.session.add(bug200)
|
||||
bug201 = add_bug(201, final_milestone, status='ASSIGNED', proposed_blocker=True)
|
||||
bug201 = add_bug(201, final_milestone, status="ASSIGNED", proposed_blocker=True)
|
||||
db.session.add(bug201)
|
||||
# -- special bugs
|
||||
bug900 = add_bug(900, beta_milestone, active=False, status='CLOSED',
|
||||
summary="This shouldn't show up, because the bug is inactive")
|
||||
bug900 = add_bug(
|
||||
900,
|
||||
beta_milestone,
|
||||
active=False,
|
||||
status="CLOSED",
|
||||
summary="This shouldn't show up, because the bug is inactive",
|
||||
)
|
||||
db.session.add(bug900)
|
||||
|
||||
# create updates
|
||||
# -- updates for beta bugs
|
||||
update1 = add_update('1', release, [bug101])
|
||||
update1 = add_update("1", release, [bug101])
|
||||
db.session.add(update1)
|
||||
update2 = add_update('2', release, [bug108], request='stable', karma=5)
|
||||
update2 = add_update("2", release, [bug108], request="stable", karma=5)
|
||||
db.session.add(update2)
|
||||
update3 = add_update('3', release, [bug104], status='stable', stable_karma=0)
|
||||
update3 = add_update("3", release, [bug104], status="stable", stable_karma=0)
|
||||
db.session.add(update3)
|
||||
update4 = add_update('4', release, [bug102], status='pending', request='testing')
|
||||
update4 = add_update("4", release, [bug102], status="pending", request="testing")
|
||||
db.session.add(update4)
|
||||
update5 = add_update('5', release, [bug101], status='pending', date_submitted=month_old_date,
|
||||
stable_karma=0, karma=-2)
|
||||
update5 = add_update(
|
||||
"5",
|
||||
release,
|
||||
[bug101],
|
||||
status="pending",
|
||||
date_submitted=month_old_date,
|
||||
stable_karma=0,
|
||||
karma=-2,
|
||||
)
|
||||
db.session.add(update5)
|
||||
update6 = add_update('6', release, [bug106], status='pending')
|
||||
update6 = add_update("6", release, [bug106], status="pending")
|
||||
db.session.add(update6)
|
||||
update7 = add_update('7', release, [bug107])
|
||||
update7 = add_update("7", release, [bug107])
|
||||
db.session.add(update7)
|
||||
# Fix both a blocker and an FE
|
||||
update8 = add_update('8', release, [bug109, bug106], status='pending')
|
||||
update8 = add_update("8", release, [bug109, bug106], status="pending")
|
||||
db.session.add(update8)
|
||||
update9 = add_update('9', release, [bug110], request='stable')
|
||||
update9.title += f' otherpkg1-1.1.fc{release_num}'
|
||||
update9 = add_update("9", release, [bug110], request="stable")
|
||||
update9.title += f" otherpkg1-1.1.fc{release_num}"
|
||||
db.session.add(update9)
|
||||
# -- updates for final bugs
|
||||
update20 = add_update('20', release, [bug201])
|
||||
update20 = add_update("20", release, [bug201])
|
||||
db.session.add(update20)
|
||||
# -- special updates
|
||||
# Update for several bugs and milestones
|
||||
update90 = add_update('90', release, [bug101, bug102, bug201], status='pending',
|
||||
request='stable')
|
||||
update90 = add_update(
|
||||
"90", release, [bug101, bug102, bug201], status="pending", request="stable"
|
||||
)
|
||||
db.session.add(update90)
|
||||
# save
|
||||
db.session.commit()
|
||||
|
|
|
|||
|
|
@ -33,33 +33,35 @@ from blockerbugs.models.release import Release
|
|||
|
||||
|
||||
class ServerError(Exception):
|
||||
'''Unable to talk to the server properly.
|
||||
"""Unable to talk to the server properly.
|
||||
|
||||
This includes network errors and 500 response codes. If the error was
|
||||
generated from an http response, :attr:`code` is the HTTP response code.
|
||||
Otherwise, :attr:`code` will be -1.
|
||||
'''
|
||||
"""
|
||||
|
||||
def __init__(self, url, status, msg):
|
||||
self.filename = url
|
||||
self.code = status
|
||||
self.msg = msg
|
||||
|
||||
def __str__(self):
|
||||
return 'ServerError(%s, %s, %s)' % (self.filename, self.code, self.msg)
|
||||
return "ServerError(%s, %s, %s)" % (self.filename, self.code, self.msg)
|
||||
|
||||
def __repr__(self):
|
||||
return 'ServerError(%r, %r, %r)' % (self.filename, self.code, self.msg)
|
||||
return "ServerError(%r, %r, %r)" % (self.filename, self.code, self.msg)
|
||||
|
||||
|
||||
class UpdateSync(object):
|
||||
"""The main class for perfoming Update synchronization with Bodhi."""
|
||||
|
||||
def __init__(self, db: flask_sqlalchemy.SQLAlchemy, bodhiclient: Optional[BodhiClient] = None
|
||||
) -> None:
|
||||
def __init__(
|
||||
self, db: flask_sqlalchemy.SQLAlchemy, bodhiclient: Optional[BodhiClient] = None
|
||||
) -> None:
|
||||
self.db = db
|
||||
# disable saving session on disk by cache_session=False
|
||||
self.bodhi = bodhiclient or BodhiClient(base_url=app.config['BODHI_URL'])
|
||||
self.log = logging.getLogger('update_sync')
|
||||
self.bodhi = bodhiclient or BodhiClient(base_url=app.config["BODHI_URL"])
|
||||
self.log = logging.getLogger("update_sync")
|
||||
self._releases: list[dict[str, Any]] = []
|
||||
"""All releases known to Bodhi"""
|
||||
|
||||
|
|
@ -70,8 +72,8 @@ class UpdateSync(object):
|
|||
# already retrieved
|
||||
return self._releases
|
||||
|
||||
self._releases = self.bodhi.get_releases(rows_per_page=100)['releases']
|
||||
self.log.debug('Retrieved %d known releases from Bodhi', len(self._releases))
|
||||
self._releases = self.bodhi.get_releases(rows_per_page=100)["releases"]
|
||||
self.log.debug("Retrieved %d known releases from Bodhi", len(self._releases))
|
||||
return self._releases
|
||||
|
||||
def extract_information(self, update: dict) -> dict[str, Any]:
|
||||
|
|
@ -80,16 +82,17 @@ class UpdateSync(object):
|
|||
:param update: the update object as retrieved from Bodhi API
|
||||
"""
|
||||
updateinfo: dict[str, Any] = {}
|
||||
updateinfo['updateid'] = update['updateid']
|
||||
updateinfo['status'] = update['status']
|
||||
updateinfo['karma'] = update['karma']
|
||||
updateinfo['url'] = update['url']
|
||||
updateinfo['date_submitted'] = datetime.datetime.strptime(update['date_submitted'],
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
updateinfo['title'] = update['title']
|
||||
updateinfo['request'] = update['request']
|
||||
updateinfo['stable_karma'] = update['stable_karma']
|
||||
updateinfo['bugs'] = [buginfo['bug_id'] for buginfo in update['bugs']]
|
||||
updateinfo["updateid"] = update["updateid"]
|
||||
updateinfo["status"] = update["status"]
|
||||
updateinfo["karma"] = update["karma"]
|
||||
updateinfo["url"] = update["url"]
|
||||
updateinfo["date_submitted"] = datetime.datetime.strptime(
|
||||
update["date_submitted"], "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
updateinfo["title"] = update["title"]
|
||||
updateinfo["request"] = update["request"]
|
||||
updateinfo["stable_karma"] = update["stable_karma"]
|
||||
updateinfo["bugs"] = [buginfo["bug_id"] for buginfo in update["bugs"]]
|
||||
|
||||
return updateinfo
|
||||
|
||||
|
|
@ -115,29 +118,33 @@ class UpdateSync(object):
|
|||
:return: a list of update info dictionaries, as provided by ``extract_information()``
|
||||
"""
|
||||
query_releases = [
|
||||
'f%d' % release_num, # rpms
|
||||
'f%df' % release_num, # flatpaks
|
||||
'f%dm' % release_num, # modules
|
||||
'f%dc' % release_num, # containers
|
||||
"f%d" % release_num, # rpms
|
||||
"f%df" % release_num, # flatpaks
|
||||
"f%dm" % release_num, # modules
|
||||
"f%dc" % release_num, # containers
|
||||
]
|
||||
# not all releases exist all the time (before branching, before Bodhi activation point,
|
||||
# etc), so drop those which Bodhi doesn't currently know of
|
||||
known_releases = [rel['name'].lower() for rel in self.releases]
|
||||
known_releases = [rel["name"].lower() for rel in self.releases]
|
||||
for rel in query_releases.copy():
|
||||
if rel not in known_releases:
|
||||
self.log.debug("Release %s not found in Bodhi (might be normal depending on the "
|
||||
"release life cycle)", rel)
|
||||
self.log.debug(
|
||||
"Release %s not found in Bodhi (might be normal depending on the "
|
||||
"release life cycle)",
|
||||
rel,
|
||||
)
|
||||
query_releases.remove(rel)
|
||||
if not query_releases:
|
||||
self.log.warning("No releases related to F%d found in Bodhi! Nothing to query.",
|
||||
release_num)
|
||||
self.log.warning(
|
||||
"No releases related to F%d found in Bodhi! Nothing to query.", release_num
|
||||
)
|
||||
return []
|
||||
|
||||
queries_data = {
|
||||
'bugs': [str(bug_id) for bug_id in bugids],
|
||||
'release': query_releases,
|
||||
'limit': 100,
|
||||
'status': ['pending', 'testing', 'stable'],
|
||||
"bugs": [str(bug_id) for bug_id in bugids],
|
||||
"release": query_releases,
|
||||
"limit": 100,
|
||||
"status": ["pending", "testing", "stable"],
|
||||
}
|
||||
updates_dict = {}
|
||||
# Bodhi counts pages from 1
|
||||
|
|
@ -145,20 +152,24 @@ class UpdateSync(object):
|
|||
while page <= pages:
|
||||
result = self.bodhi.query(page=page, **queries_data)
|
||||
|
||||
if 'status' in result:
|
||||
raise ServerError('', 400, result['errors'][0]['description'])
|
||||
if "status" in result:
|
||||
raise ServerError("", 400, result["errors"][0]["description"])
|
||||
|
||||
for update in result['updates']:
|
||||
assert update['release']['version'] == str(release_num)
|
||||
assert update['status'] in ['pending', 'testing', 'stable']
|
||||
updates_dict[update['updateid']] = update
|
||||
for update in result["updates"]:
|
||||
assert update["release"]["version"] == str(release_num)
|
||||
assert update["status"] in ["pending", "testing", "stable"]
|
||||
updates_dict[update["updateid"]] = update
|
||||
|
||||
page += 1
|
||||
pages = result['pages']
|
||||
pages = result["pages"]
|
||||
|
||||
updates = updates_dict.values() # updates without duplicates
|
||||
self.log.info('Found %d updates in Bodhi for %d bugs in release F%d', len(updates),
|
||||
len(bugids), release_num)
|
||||
self.log.info(
|
||||
"Found %d updates in Bodhi for %d bugs in release F%d",
|
||||
len(updates),
|
||||
len(bugids),
|
||||
release_num,
|
||||
)
|
||||
return [self.extract_information(update) for update in updates]
|
||||
|
||||
def get_release_bugs(self, release: Release) -> list[Bug]:
|
||||
|
|
@ -181,39 +192,40 @@ class UpdateSync(object):
|
|||
Bodhi (related to all bugs which we track in the release), and removing no-longer-relevant
|
||||
updates from the database.
|
||||
"""
|
||||
self.log.info('Syncing updates for release F%d ...', release.number)
|
||||
self.log.info("Syncing updates for release F%d ...", release.number)
|
||||
|
||||
bugs = self.get_release_bugs(release)
|
||||
self.log.debug('Found %d relevant bugs in release F%d', len(bugs), release.number)
|
||||
self.log.debug("Found %d relevant bugs in release F%d", len(bugs), release.number)
|
||||
|
||||
updateinfos = []
|
||||
synctime = datetime.datetime.now(datetime.UTC)
|
||||
if bugs:
|
||||
bugs_ids = [bug.bugid for bug in bugs]
|
||||
self.log.debug('Searching Bodhi for updates for bugs %s', bugs_ids)
|
||||
self.log.debug("Searching Bodhi for updates for bugs %s", bugs_ids)
|
||||
try:
|
||||
updateinfos = self.search_updates(bugs_ids, release.number)
|
||||
except ServerError as ex:
|
||||
self.log.error(
|
||||
'F{r.number} sync updates failed: {e.code} {e.msg}'.format(e=ex, r=release))
|
||||
"F{r.number} sync updates failed: {e.code} {e.msg}".format(e=ex, r=release)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.log.debug('Skipping Bodhi query due to no available bugs')
|
||||
self.log.debug("Skipping Bodhi query due to no available bugs")
|
||||
|
||||
# remove no longer relevant updates from the database
|
||||
updateids = [u['updateid'] for u in updateinfos]
|
||||
updateids = [u["updateid"] for u in updateinfos]
|
||||
self.clean_updates(updateids, release)
|
||||
|
||||
# update the existing Update objects or create new ones
|
||||
for updateinfo in updateinfos:
|
||||
oldupdate = Update.query.filter_by(updateid=updateinfo['updateid']).one_or_none()
|
||||
oldupdate = Update.query.filter_by(updateid=updateinfo["updateid"]).one_or_none()
|
||||
if oldupdate:
|
||||
self.log.debug('Updating existing %r', oldupdate)
|
||||
self.log.debug("Updating existing %r", oldupdate)
|
||||
oldupdate.sync(updateinfo)
|
||||
self.db.session.add(oldupdate)
|
||||
else:
|
||||
newupdate = Update.from_data(updateinfo, release)
|
||||
self.log.debug('Created new %r', newupdate)
|
||||
self.log.debug("Created new %r", newupdate)
|
||||
self.db.session.add(newupdate)
|
||||
|
||||
release.last_update_sync = synctime
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@ import blockerbugs
|
|||
from blockerbugs import app
|
||||
from blockerbugs import cli
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
# verify we have a database ready, bail out if not or if it's not in
|
||||
# consistent state
|
||||
|
||||
|
|
@ -38,7 +37,7 @@ if __name__ == '__main__':
|
|||
# now that we have FAS integration, we don't want to default to production
|
||||
# when we're not running through mod_wsgi
|
||||
|
||||
if not (os.getenv('TEST') == 'true' or os.getenv('PROD') == 'true'):
|
||||
os.environ['DEV'] = 'true'
|
||||
if not (os.getenv("TEST") == "true" or os.getenv("PROD") == "true"):
|
||||
os.environ["DEV"] = "true"
|
||||
|
||||
blockerbugs.app.run(debug=True, host="0.0.0.0", port=9999)
|
||||
|
|
|
|||
87
setup.py
87
setup.py
|
|
@ -11,55 +11,64 @@ here = os.path.abspath(os.path.dirname(__file__))
|
|||
|
||||
class PyTest(Command):
|
||||
user_options = [] # type: ignore
|
||||
|
||||
def initialize_options(self):
|
||||
pass
|
||||
|
||||
def finalize_options(self):
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
import subprocess
|
||||
errno = subprocess.call(['pytest-3'])
|
||||
|
||||
errno = subprocess.call(["pytest-3"])
|
||||
raise SystemExit(errno)
|
||||
|
||||
|
||||
def read(*parts):
|
||||
return codecs.open(os.path.join(here, *parts), 'r').read()
|
||||
return codecs.open(os.path.join(here, *parts), "r").read()
|
||||
|
||||
|
||||
setup(name='blockerbugs',
|
||||
version=versioneer.get_version(),
|
||||
description='Web application for tracking blocker and nth bugs in Fedora releases',
|
||||
author='Tim Flink',
|
||||
author_email='tflink@fedoraproject.org',
|
||||
license='GPLv2+',
|
||||
url='https://forge.fedoraproject.org/quality/blockerbugs',
|
||||
packages=['blockerbugs', 'blockerbugs.controllers',
|
||||
'blockerbugs.util', 'blockerbugs.models',
|
||||
'blockerbugs.controllers.api',
|
||||
'blockerbugs.controllers.admin'],
|
||||
package_dir={'blockerbugs': 'blockerbugs'},
|
||||
entry_points=dict(console_scripts=['blockerbugs=blockerbugs.cli:main']),
|
||||
include_package_data=True,
|
||||
cmdclass=versioneer.get_cmdclass({'test': PyTest}),
|
||||
install_requires=[
|
||||
'alembic',
|
||||
'Flask-Admin',
|
||||
'Flask-SQLAlchemy',
|
||||
'Flask-WTF',
|
||||
'flask-oidc',
|
||||
'Flask',
|
||||
'iso8601',
|
||||
'Jinja2',
|
||||
'kitchen',
|
||||
'munch',
|
||||
'pycurl',
|
||||
# bugzilla 3.2.0 because of API auth changes:
|
||||
# https://listman.redhat.com/archives/bugzilla-announce-list/2022-February/msg00000.html
|
||||
'python-bugzilla >= 3.2.0',
|
||||
'python-openid-cla',
|
||||
'python-openid-teams',
|
||||
'python3-openid',
|
||||
'SQLAlchemy',
|
||||
'Werkzeug',
|
||||
'WTForms',
|
||||
]
|
||||
setup(
|
||||
name="blockerbugs",
|
||||
version=versioneer.get_version(),
|
||||
description="Web application for tracking blocker and nth bugs in Fedora releases",
|
||||
author="Tim Flink",
|
||||
author_email="tflink@fedoraproject.org",
|
||||
license="GPLv2+",
|
||||
url="https://forge.fedoraproject.org/quality/blockerbugs",
|
||||
packages=[
|
||||
"blockerbugs",
|
||||
"blockerbugs.controllers",
|
||||
"blockerbugs.util",
|
||||
"blockerbugs.models",
|
||||
"blockerbugs.controllers.api",
|
||||
"blockerbugs.controllers.admin",
|
||||
],
|
||||
package_dir={"blockerbugs": "blockerbugs"},
|
||||
entry_points=dict(console_scripts=["blockerbugs=blockerbugs.cli:main"]),
|
||||
include_package_data=True,
|
||||
cmdclass=versioneer.get_cmdclass({"test": PyTest}),
|
||||
install_requires=[
|
||||
"alembic",
|
||||
"Flask-Admin",
|
||||
"Flask-SQLAlchemy",
|
||||
"Flask-WTF",
|
||||
"flask-oidc",
|
||||
"Flask",
|
||||
"iso8601",
|
||||
"Jinja2",
|
||||
"kitchen",
|
||||
"munch",
|
||||
"pycurl",
|
||||
# bugzilla 3.2.0 because of API auth changes:
|
||||
# https://listman.redhat.com/archives/bugzilla-announce-list/2022-February/msg00000.html
|
||||
"python-bugzilla >= 3.2.0",
|
||||
"python-openid-cla",
|
||||
"python-openid-teams",
|
||||
"python3-openid",
|
||||
"SQLAlchemy",
|
||||
"Werkzeug",
|
||||
"WTForms",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ def _setup_forgejo_org_and_team(
|
|||
timeout=10,
|
||||
)
|
||||
if add_response.status_code not in (200, 201, 204):
|
||||
raise RuntimeError(f"Failed to add {member_username} to team: " f"{add_response.text}")
|
||||
raise RuntimeError(f"Failed to add {member_username} to team: {add_response.text}")
|
||||
|
||||
# Publicize membership so org_is_public_member API works.
|
||||
# This must be done using the member's own token — Forgejo does not
|
||||
|
|
@ -188,8 +188,7 @@ def _setup_forgejo_org_and_team(
|
|||
)
|
||||
if pub_response.status_code not in (200, 204):
|
||||
raise RuntimeError(
|
||||
f"Failed to publicize {member_username} in {org_name}: "
|
||||
f"{pub_response.text}"
|
||||
f"Failed to publicize {member_username} in {org_name}: {pub_response.text}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -233,10 +232,7 @@ def _create_forgejo_token_via_api(
|
|||
timeout=10,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"Failed to create API token for {username}: "
|
||||
f"{response.text}"
|
||||
)
|
||||
raise RuntimeError(f"Failed to create API token for {username}: {response.text}")
|
||||
|
||||
return response.json()["sha1"]
|
||||
|
||||
|
|
@ -277,9 +273,7 @@ def _create_forgejo_user_via_api(
|
|||
timeout=10,
|
||||
)
|
||||
if response.status_code not in (200, 201, 409, 422):
|
||||
raise RuntimeError(
|
||||
f"Failed to create user {username}: {response.text}"
|
||||
)
|
||||
raise RuntimeError(f"Failed to create user {username}: {response.text}")
|
||||
|
||||
|
||||
def _setup_forgejo_via_api(
|
||||
|
|
@ -318,24 +312,36 @@ def _setup_forgejo_via_api(
|
|||
|
||||
# Create admin token
|
||||
admin_token = _create_forgejo_token_via_api(
|
||||
api_url, FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD, "test-token",
|
||||
api_url,
|
||||
FORGEJO_ADMIN_USER,
|
||||
FORGEJO_ADMIN_PASSWORD,
|
||||
"test-token",
|
||||
)
|
||||
|
||||
# Create test user via admin API
|
||||
_create_forgejo_user_via_api(
|
||||
api_url, admin_token,
|
||||
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, FORGEJO_TEST_EMAIL,
|
||||
api_url,
|
||||
admin_token,
|
||||
FORGEJO_TEST_USER,
|
||||
FORGEJO_TEST_PASSWORD,
|
||||
FORGEJO_TEST_EMAIL,
|
||||
)
|
||||
|
||||
# Create test user token
|
||||
test_token = _create_forgejo_token_via_api(
|
||||
api_url, FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, "test-user-token",
|
||||
api_url,
|
||||
FORGEJO_TEST_USER,
|
||||
FORGEJO_TEST_PASSWORD,
|
||||
"test-user-token",
|
||||
)
|
||||
|
||||
# Set up org and team
|
||||
_setup_forgejo_org_and_team(
|
||||
api_url, admin_token,
|
||||
FORGEJO_TEST_ORG, FORGEJO_TEST_TEAM, FORGEJO_TEST_USER,
|
||||
api_url,
|
||||
admin_token,
|
||||
FORGEJO_TEST_ORG,
|
||||
FORGEJO_TEST_TEAM,
|
||||
FORGEJO_TEST_USER,
|
||||
member_token=test_token,
|
||||
)
|
||||
|
||||
|
|
@ -361,12 +367,7 @@ def _create_forgejo_user_via_cli(
|
|||
Raises:
|
||||
RuntimeError: If user creation fails (and user doesn't already exist)
|
||||
"""
|
||||
cmd = (
|
||||
f"forgejo admin user create "
|
||||
f"--username {username} "
|
||||
f"--password {password} "
|
||||
f"--email {email}"
|
||||
)
|
||||
cmd = f"forgejo admin user create --username {username} --password {password} --email {email}"
|
||||
if is_admin:
|
||||
cmd += " --admin"
|
||||
else:
|
||||
|
|
@ -374,9 +375,7 @@ def _create_forgejo_user_via_cli(
|
|||
|
||||
exit_code, output = exec_fn(cmd)
|
||||
if exit_code != 0 and "already exists" not in output.decode("utf-8"):
|
||||
raise RuntimeError(
|
||||
f"Failed to create user {username}: {output.decode('utf-8')}"
|
||||
)
|
||||
raise RuntimeError(f"Failed to create user {username}: {output.decode('utf-8')}")
|
||||
|
||||
|
||||
def _create_forgejo_token_via_cli(
|
||||
|
|
@ -422,10 +421,7 @@ def _create_forgejo_token_via_cli(
|
|||
f"--scopes all"
|
||||
)
|
||||
if exit_code != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to create API token for {username}: "
|
||||
f"{output.decode('utf-8')}"
|
||||
)
|
||||
raise RuntimeError(f"Failed to create API token for {username}: {output.decode('utf-8')}")
|
||||
|
||||
# Extract token
|
||||
# (format: "Access token was successfully created: <token>")
|
||||
|
|
@ -452,29 +448,41 @@ def _setup_forgejo_via_cli(
|
|||
"""
|
||||
_create_forgejo_user_via_cli(
|
||||
exec_fn,
|
||||
FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD,
|
||||
FORGEJO_ADMIN_EMAIL, is_admin=True,
|
||||
FORGEJO_ADMIN_USER,
|
||||
FORGEJO_ADMIN_PASSWORD,
|
||||
FORGEJO_ADMIN_EMAIL,
|
||||
is_admin=True,
|
||||
)
|
||||
|
||||
admin_token = _create_forgejo_token_via_cli(
|
||||
exec_fn, api_url,
|
||||
FORGEJO_ADMIN_USER, FORGEJO_ADMIN_PASSWORD, "test-token",
|
||||
exec_fn,
|
||||
api_url,
|
||||
FORGEJO_ADMIN_USER,
|
||||
FORGEJO_ADMIN_PASSWORD,
|
||||
"test-token",
|
||||
)
|
||||
|
||||
_create_forgejo_user_via_cli(
|
||||
exec_fn,
|
||||
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD,
|
||||
FORGEJO_TEST_USER,
|
||||
FORGEJO_TEST_PASSWORD,
|
||||
FORGEJO_TEST_EMAIL,
|
||||
)
|
||||
|
||||
test_token = _create_forgejo_token_via_cli(
|
||||
exec_fn, api_url,
|
||||
FORGEJO_TEST_USER, FORGEJO_TEST_PASSWORD, "test-user-token",
|
||||
exec_fn,
|
||||
api_url,
|
||||
FORGEJO_TEST_USER,
|
||||
FORGEJO_TEST_PASSWORD,
|
||||
"test-user-token",
|
||||
)
|
||||
|
||||
_setup_forgejo_org_and_team(
|
||||
api_url, admin_token,
|
||||
FORGEJO_TEST_ORG, FORGEJO_TEST_TEAM, FORGEJO_TEST_USER,
|
||||
api_url,
|
||||
admin_token,
|
||||
FORGEJO_TEST_ORG,
|
||||
FORGEJO_TEST_TEAM,
|
||||
FORGEJO_TEST_USER,
|
||||
member_token=test_token,
|
||||
)
|
||||
|
||||
|
|
@ -580,15 +588,13 @@ def _forgejo_container() -> Generator[ForgejoContainerConfig, None, None]:
|
|||
else:
|
||||
# Reusing existing container — use docker SDK
|
||||
docker_client = docker.from_env()
|
||||
sdk_container = docker_client.containers.get(
|
||||
FORGEJO_SERVICE.name)
|
||||
sdk_container = docker_client.containers.get(FORGEJO_SERVICE.name)
|
||||
|
||||
def exec_in_container(cmd: str):
|
||||
return sdk_container.exec_run(cmd)
|
||||
|
||||
try:
|
||||
admin_token, test_token = _setup_forgejo_via_cli(
|
||||
exec_in_container, api_url)
|
||||
admin_token, test_token = _setup_forgejo_via_cli(exec_in_container, api_url)
|
||||
finally:
|
||||
if docker_client is not None:
|
||||
docker_client.close()
|
||||
|
|
@ -733,7 +739,7 @@ def _postgres_container() -> Generator[DbContainerConfig, None, None]:
|
|||
yield config
|
||||
return
|
||||
if is_ci:
|
||||
pytest.exit(f"Failed to connect to CI service container " f"at {host}:{port}")
|
||||
pytest.exit(f"Failed to connect to CI service container at {host}:{port}")
|
||||
LOGGER.debug(
|
||||
"Container '%s' not responsive, removing and recreating",
|
||||
DB_SERVICE.name,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ LOGGER = logging.getLogger(__name__)
|
|||
@dataclasses.dataclass
|
||||
class DbContainerConfig:
|
||||
"""Database connection configuration."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
database: str
|
||||
|
|
@ -45,8 +46,7 @@ class DbContainerConfig:
|
|||
def url(self) -> str:
|
||||
"""SQLAlchemy-compatible database connection URL."""
|
||||
return (
|
||||
f"{self.driver}://{self.user}:{self.password}"
|
||||
f"@{self.host}:{self.port}/{self.database}"
|
||||
f"{self.driver}://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -72,7 +72,5 @@ def is_db_available(config: DbContainerConfig) -> bool:
|
|||
LOGGER.info("Connected to database at %s:%s", config.host, config.port)
|
||||
return True
|
||||
except (sqlalchemy.exc.SQLAlchemyError, OSError):
|
||||
LOGGER.debug(
|
||||
"Failed to connect to database at %s:%s",
|
||||
config.host, config.port)
|
||||
LOGGER.debug("Failed to connect to database at %s:%s", config.host, config.port)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import pytest
|
|||
import requests
|
||||
|
||||
# Set TEST environment variable before importing app
|
||||
os.environ['TEST'] = 'true'
|
||||
os.environ["TEST"] = "true"
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from blockerbugs import app, db # noqa: E402
|
||||
|
|
@ -295,7 +295,7 @@ def _test_bug_with_discussion(
|
|||
|
||||
# Create bug in database linked to discussion
|
||||
discussion_url = (
|
||||
f"{forgejo_container.base_url}/{forgejo_test_repo.full_name}" f"/issues/{issue_number}"
|
||||
f"{forgejo_container.base_url}/{forgejo_test_repo.full_name}/issues/{issue_number}"
|
||||
)
|
||||
bug = Bug(
|
||||
bugid=987654,
|
||||
|
|
|
|||
|
|
@ -93,21 +93,21 @@ class TestProposeBugE2E: # pylint: disable=too-many-locals
|
|||
).first()
|
||||
|
||||
assert bug is not None, "Bug should be created in database"
|
||||
assert (
|
||||
bug.proposed_blocker is True
|
||||
), "Bug should be marked as proposed blocker"
|
||||
assert bug.proposed_blocker is True, (
|
||||
"Bug should be marked as proposed blocker"
|
||||
)
|
||||
assert bug.proposed_fe is False, "Bug should not be marked as FE"
|
||||
assert bug.summary == mock_bz_bug.summary
|
||||
assert bug.status == mock_bz_bug.status
|
||||
assert bug.component == mock_bz_bug.component
|
||||
|
||||
# Verify Forgejo discussion link is created
|
||||
assert (
|
||||
bug.discussion_link is not None
|
||||
), "Bug should have discussion link"
|
||||
assert (
|
||||
"/issues/" in bug.discussion_link
|
||||
), "Discussion link should be a Forgejo issue URL"
|
||||
assert bug.discussion_link is not None, (
|
||||
"Bug should have discussion link"
|
||||
)
|
||||
assert "/issues/" in bug.discussion_link, (
|
||||
"Discussion link should be a Forgejo issue URL"
|
||||
)
|
||||
|
||||
# Extract issue number from URL
|
||||
issue_number = int(bug.discussion_link.split("/")[-1])
|
||||
|
|
@ -119,17 +119,15 @@ class TestProposeBugE2E: # pylint: disable=too-many-locals
|
|||
)
|
||||
issue_response = requests.get(
|
||||
issue_url,
|
||||
headers={
|
||||
"Authorization": f"token {forgejo_container.test_token}"
|
||||
},
|
||||
headers={"Authorization": f"token {forgejo_container.test_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
assert issue_response.status_code == 200, "Forgejo issue should exist"
|
||||
issue_data = issue_response.json()
|
||||
assert (
|
||||
str(mock_bz_bug.bug_id) in issue_data["title"]
|
||||
), "Issue title should contain bug ID"
|
||||
assert str(mock_bz_bug.bug_id) in issue_data["title"], (
|
||||
"Issue title should contain bug ID"
|
||||
)
|
||||
assert issue_data["state"] == "open", "Issue should be open"
|
||||
|
||||
def test_propose_bug_as_freeze_exception(
|
||||
|
|
|
|||
|
|
@ -43,22 +43,22 @@ def create_webhook_payload(
|
|||
dict: Webhook payload
|
||||
"""
|
||||
return {
|
||||
'action': action,
|
||||
'issue': {
|
||||
'number': issue_number,
|
||||
'state': 'open',
|
||||
'title': 'Test Bug 987654 - Test blocker discussion',
|
||||
'body': 'Initial discussion for voting test',
|
||||
"action": action,
|
||||
"issue": {
|
||||
"number": issue_number,
|
||||
"state": "open",
|
||||
"title": "Test Bug 987654 - Test blocker discussion",
|
||||
"body": "Initial discussion for voting test",
|
||||
},
|
||||
'comment': {
|
||||
'id': int(time.time()), # Unique comment ID
|
||||
'body': comment_body,
|
||||
'user': {
|
||||
'login': forgejo_container.test_user,
|
||||
"comment": {
|
||||
"id": int(time.time()), # Unique comment ID
|
||||
"body": comment_body,
|
||||
"user": {
|
||||
"login": forgejo_container.test_user,
|
||||
},
|
||||
},
|
||||
'repository': {
|
||||
'full_name': forgejo_test_repo.full_name,
|
||||
"repository": {
|
||||
"full_name": forgejo_test_repo.full_name,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -73,11 +73,7 @@ def compute_webhook_signature(payload: bytes, secret: str) -> str:
|
|||
Returns:
|
||||
str: Hex-encoded HMAC signature
|
||||
"""
|
||||
return hmac.new(
|
||||
secret.encode('ascii'),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.new(secret.encode("ascii"), payload, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
||||
|
|
@ -103,19 +99,19 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
|
||||
# Create webhook payload with a vote
|
||||
payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
|
||||
|
||||
# Post comment to Forgejo (simulating user action)
|
||||
headers = {
|
||||
'Authorization': f"token {forgejo_container.test_token}",
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": f"token {forgejo_container.test_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
comment_url = (
|
||||
f"{forgejo_container.api_url}repos/"
|
||||
|
|
@ -124,26 +120,26 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
requests.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json={'body': 'BetaBlocker +1'},
|
||||
json={"body": "BetaBlocker +1"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Send webhook to app
|
||||
with app.test_client() as client:
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": signature,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify webhook was accepted
|
||||
assert response.status_code == 200
|
||||
response_data = response.get_json()
|
||||
assert 'successfully' in response_data['msg'].lower()
|
||||
assert "successfully" in response_data["msg"].lower()
|
||||
|
||||
# Verify issue description was updated
|
||||
issue_url = (
|
||||
|
|
@ -158,17 +154,17 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
issue_data = issue_response.json()
|
||||
|
||||
# Issue body should contain vote summary
|
||||
assert 'betablocker' in issue_data['body'].lower()
|
||||
assert '+1' in issue_data['body']
|
||||
assert forgejo_container.test_user in issue_data['body']
|
||||
assert "betablocker" in issue_data["body"].lower()
|
||||
assert "+1" in issue_data["body"]
|
||||
assert forgejo_container.test_user in issue_data["body"]
|
||||
|
||||
# Verify database was updated
|
||||
db.session.refresh(bug)
|
||||
assert bug.votes is not None
|
||||
|
||||
votes_data = json.loads(bug.votes)
|
||||
assert 'betablocker' in votes_data
|
||||
assert forgejo_container.test_user in votes_data['betablocker']['+1']
|
||||
assert "betablocker" in votes_data
|
||||
assert forgejo_container.test_user in votes_data["betablocker"]["+1"]
|
||||
|
||||
def test_multiple_votes_from_different_users(
|
||||
self,
|
||||
|
|
@ -189,13 +185,13 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
|
||||
# Simulate votes from test_user
|
||||
votes = [
|
||||
('BetaBlocker +1', forgejo_container.test_user),
|
||||
('FinalBlocker -1', forgejo_container.test_user),
|
||||
("BetaBlocker +1", forgejo_container.test_user),
|
||||
("FinalBlocker -1", forgejo_container.test_user),
|
||||
]
|
||||
|
||||
headers = {
|
||||
'Authorization': f"token {forgejo_container.test_token}",
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": f"token {forgejo_container.test_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
with app.test_client() as client:
|
||||
|
|
@ -208,29 +204,29 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
requests.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json={'body': vote_text},
|
||||
json={"body": vote_text},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Create and send webhook
|
||||
payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body=vote_text,
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
payload['comment']['user']['login'] = username
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
payload["comment"]["user"]["login"] = username
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
|
||||
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": signature,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
|
@ -240,10 +236,10 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
assert bug.votes is not None
|
||||
|
||||
votes_data = json.loads(bug.votes)
|
||||
assert 'betablocker' in votes_data
|
||||
assert 'finalblocker' in votes_data
|
||||
assert username in votes_data['betablocker']['+1']
|
||||
assert username in votes_data['finalblocker']['-1']
|
||||
assert "betablocker" in votes_data
|
||||
assert "finalblocker" in votes_data
|
||||
assert username in votes_data["betablocker"]["+1"]
|
||||
assert username in votes_data["finalblocker"]["-1"]
|
||||
|
||||
def test_admin_agreed_command(
|
||||
self,
|
||||
|
|
@ -263,23 +259,21 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
_bug, issue_number = test_bug_with_discussion
|
||||
|
||||
headers = {
|
||||
'Authorization': f"token {forgejo_container.test_token}",
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": f"token {forgejo_container.test_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
with app.test_client() as client:
|
||||
# First, add some votes
|
||||
vote_payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
vote_payload_bytes = json.dumps(vote_payload).encode('utf-8')
|
||||
vote_signature = compute_webhook_signature(
|
||||
vote_payload_bytes, _webhook_secret
|
||||
)
|
||||
vote_payload_bytes = json.dumps(vote_payload).encode("utf-8")
|
||||
vote_signature = compute_webhook_signature(vote_payload_bytes, _webhook_secret)
|
||||
|
||||
comment_url = (
|
||||
f"{forgejo_container.api_url}repos/"
|
||||
|
|
@ -288,47 +282,45 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
requests.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json={'body': 'BetaBlocker +1'},
|
||||
json={"body": "BetaBlocker +1"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=vote_payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': vote_signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": vote_signature,
|
||||
},
|
||||
)
|
||||
|
||||
# Now post AGREED command
|
||||
agreed_payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='AGREED AcceptedBetaBlocker',
|
||||
comment_body="AGREED AcceptedBetaBlocker",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
agreed_payload_bytes = json.dumps(agreed_payload).encode('utf-8')
|
||||
agreed_signature = compute_webhook_signature(
|
||||
agreed_payload_bytes, _webhook_secret
|
||||
)
|
||||
agreed_payload_bytes = json.dumps(agreed_payload).encode("utf-8")
|
||||
agreed_signature = compute_webhook_signature(agreed_payload_bytes, _webhook_secret)
|
||||
|
||||
requests.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json={'body': 'AGREED AcceptedBetaBlocker'},
|
||||
json={"body": "AGREED AcceptedBetaBlocker"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=agreed_payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': agreed_signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": agreed_signature,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -349,9 +341,9 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
# Look for summary comment from bot
|
||||
summary_found = False
|
||||
for comment in comments:
|
||||
if 'following votes have been closed' in comment['body'].lower():
|
||||
if "following votes have been closed" in comment["body"].lower():
|
||||
summary_found = True
|
||||
assert 'betablocker' in comment['body'].lower()
|
||||
assert "betablocker" in comment["body"].lower()
|
||||
break
|
||||
|
||||
assert summary_found, "Summary comment should be posted after AGREED"
|
||||
|
|
@ -374,25 +366,25 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
bug, issue_number = test_bug_with_discussion
|
||||
|
||||
payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
|
||||
# Use wrong signature
|
||||
wrong_signature = 'wrong-signature-value'
|
||||
wrong_signature = "wrong-signature-value"
|
||||
|
||||
with app.test_client() as client:
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': wrong_signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": wrong_signature,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -400,14 +392,14 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
assert response.status_code == 200 # Returns 200 but with rejection msg
|
||||
response_data = response.get_json()
|
||||
is_rejected = (
|
||||
'invalid' in response_data['msg'].lower() or
|
||||
'ignoring' in response_data['msg'].lower()
|
||||
"invalid" in response_data["msg"].lower()
|
||||
or "ignoring" in response_data["msg"].lower()
|
||||
)
|
||||
assert is_rejected
|
||||
|
||||
# Database should not be updated
|
||||
db.session.refresh(bug)
|
||||
assert bug.votes is None or bug.votes == 'null' or bug.votes == '{}'
|
||||
assert bug.votes is None or bug.votes == "null" or bug.votes == "{}"
|
||||
|
||||
def test_closed_issue_ignored(
|
||||
self,
|
||||
|
|
@ -427,33 +419,33 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
|
||||
# Create payload for closed issue
|
||||
payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
payload['issue']['state'] = 'closed' # Mark as closed
|
||||
payload["issue"]["state"] = "closed" # Mark as closed
|
||||
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
|
||||
|
||||
with app.test_client() as client:
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": signature,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_data = response.get_json()
|
||||
is_ignored = (
|
||||
'closed' in response_data['msg'].lower() and
|
||||
'ignoring' in response_data['msg'].lower()
|
||||
"closed" in response_data["msg"].lower()
|
||||
and "ignoring" in response_data["msg"].lower()
|
||||
)
|
||||
assert is_ignored
|
||||
|
||||
|
|
@ -475,8 +467,8 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
bug, issue_number = test_bug_with_discussion
|
||||
|
||||
headers = {
|
||||
'Authorization': f"token {forgejo_container.test_token}",
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": f"token {forgejo_container.test_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
with app.test_client() as client:
|
||||
|
|
@ -488,32 +480,32 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
comment_response = requests.post(
|
||||
comment_url,
|
||||
headers=headers,
|
||||
json={'body': 'BetaBlocker +1'},
|
||||
json={"body": "BetaBlocker +1"},
|
||||
timeout=10,
|
||||
)
|
||||
comment_data = comment_response.json()
|
||||
comment_id = comment_data['id']
|
||||
comment_id = comment_data["id"]
|
||||
|
||||
# Send initial webhook
|
||||
initial_payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
initial_payload['comment']['id'] = comment_id
|
||||
initial_payload["comment"]["id"] = comment_id
|
||||
|
||||
initial_bytes = json.dumps(initial_payload).encode('utf-8')
|
||||
initial_bytes = json.dumps(initial_payload).encode("utf-8")
|
||||
initial_signature = compute_webhook_signature(initial_bytes, _webhook_secret)
|
||||
|
||||
client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=initial_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': initial_signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": initial_signature,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -525,30 +517,30 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
requests.patch(
|
||||
edit_comment_url,
|
||||
headers=headers,
|
||||
json={'body': 'BetaBlocker -1'}, # Changed vote
|
||||
json={"body": "BetaBlocker -1"}, # Changed vote
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Send webhook for the edit
|
||||
edit_payload = create_webhook_payload(
|
||||
action='edited',
|
||||
action="edited",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker -1', # Changed vote
|
||||
comment_body="BetaBlocker -1", # Changed vote
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
edit_payload['comment']['id'] = comment_id
|
||||
edit_payload["comment"]["id"] = comment_id
|
||||
|
||||
edit_bytes = json.dumps(edit_payload).encode('utf-8')
|
||||
edit_bytes = json.dumps(edit_payload).encode("utf-8")
|
||||
edit_signature = compute_webhook_signature(edit_bytes, _webhook_secret)
|
||||
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=edit_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': edit_signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": edit_signature,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -559,8 +551,8 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
votes_data = json.loads(bug.votes)
|
||||
|
||||
# User should now be in -1, not in +1
|
||||
assert forgejo_container.test_user in votes_data['betablocker']['-1']
|
||||
assert forgejo_container.test_user not in votes_data['betablocker']['+1']
|
||||
assert forgejo_container.test_user in votes_data["betablocker"]["-1"]
|
||||
assert forgejo_container.test_user not in votes_data["betablocker"]["+1"]
|
||||
|
||||
def test_bot_disabled(
|
||||
self,
|
||||
|
|
@ -579,35 +571,35 @@ class TestVotingWebhookE2E: # pylint: disable=too-many-locals
|
|||
_bug, issue_number = test_bug_with_discussion
|
||||
|
||||
# Temporarily disable bot
|
||||
original_enabled = app.config['FORGEJO_BOT_ENABLED']
|
||||
app.config['FORGEJO_BOT_ENABLED'] = False
|
||||
original_enabled = app.config["FORGEJO_BOT_ENABLED"]
|
||||
app.config["FORGEJO_BOT_ENABLED"] = False
|
||||
|
||||
try:
|
||||
payload = create_webhook_payload(
|
||||
action='created',
|
||||
action="created",
|
||||
issue_number=issue_number,
|
||||
comment_body='BetaBlocker +1',
|
||||
comment_body="BetaBlocker +1",
|
||||
forgejo_container=forgejo_container,
|
||||
forgejo_test_repo=forgejo_test_repo,
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode('utf-8')
|
||||
payload_bytes = json.dumps(payload).encode("utf-8")
|
||||
signature = compute_webhook_signature(payload_bytes, _webhook_secret)
|
||||
|
||||
with app.test_client() as client:
|
||||
response = client.post(
|
||||
'/api/v0/webhook/forgejo',
|
||||
"/api/v0/webhook/forgejo",
|
||||
data=payload_bytes,
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Forgejo-Event': 'issue_comment',
|
||||
'X-Forgejo-Signature': signature,
|
||||
"Content-Type": "application/json",
|
||||
"X-Forgejo-Event": "issue_comment",
|
||||
"X-Forgejo-Signature": signature,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_data = response.get_json()
|
||||
assert 'disabled' in response_data['msg'].lower()
|
||||
assert "disabled" in response_data["msg"].lower()
|
||||
|
||||
finally:
|
||||
# Restore original setting
|
||||
app.config['FORGEJO_BOT_ENABLED'] = original_enabled
|
||||
app.config["FORGEJO_BOT_ENABLED"] = original_enabled
|
||||
|
|
|
|||
|
|
@ -54,21 +54,20 @@ def is_forgejo_available(
|
|||
try:
|
||||
response = requests.get(url, timeout=2)
|
||||
if response.status_code == 200:
|
||||
LOGGER.info(
|
||||
"Forgejo is available at %s:%s", host, port)
|
||||
LOGGER.info("Forgejo is available at %s:%s", host, port)
|
||||
return True
|
||||
except requests.exceptions.RequestException:
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
LOGGER.debug(
|
||||
"Forgejo not available at %s:%s after %ss", host, port, timeout)
|
||||
LOGGER.debug("Forgejo not available at %s:%s after %ss", host, port, timeout)
|
||||
return False
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ForgejoRepoConfig:
|
||||
"""Test repository configuration."""
|
||||
|
||||
owner: str
|
||||
name: str
|
||||
full_name: str
|
||||
|
|
@ -77,6 +76,7 @@ class ForgejoRepoConfig:
|
|||
@dataclasses.dataclass
|
||||
class ForgejoApiClientConfig:
|
||||
"""Authenticated Forgejo API client configuration."""
|
||||
|
||||
api_url: str
|
||||
token: str
|
||||
|
||||
|
|
@ -84,14 +84,15 @@ class ForgejoApiClientConfig:
|
|||
def headers(self) -> Dict[str, str]:
|
||||
"""HTTP headers for authenticated API requests."""
|
||||
return {
|
||||
'Authorization': f"token {self.token}",
|
||||
'Content-Type': 'application/json',
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ForgejoContainerConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Forgejo container connection and test account configuration."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
admin_user: str
|
||||
|
|
|
|||
|
|
@ -119,10 +119,7 @@ class TestForgejoTestRepoFixture:
|
|||
assert forgejo_test_repo.owner is not None
|
||||
assert forgejo_test_repo.name is not None
|
||||
assert forgejo_test_repo.full_name is not None
|
||||
assert (
|
||||
forgejo_test_repo.full_name
|
||||
== f"{forgejo_test_repo.owner}/{forgejo_test_repo.name}"
|
||||
)
|
||||
assert forgejo_test_repo.full_name == f"{forgejo_test_repo.owner}/{forgejo_test_repo.name}"
|
||||
|
||||
# Verify repository exists via API
|
||||
response = requests.get(
|
||||
|
|
@ -200,9 +197,7 @@ class TestForgejoIssueOperations:
|
|||
WHEN an issue is created and then retrieved
|
||||
THEN the retrieved issue should match the created issue
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create issue
|
||||
create_response = requests.post(
|
||||
base_issues_url,
|
||||
|
|
@ -263,9 +258,7 @@ class TestForgejoIssueOperations:
|
|||
WHEN a comment is posted to the issue
|
||||
THEN the comment should be added successfully with the correct body
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create issue
|
||||
issue_response = requests.post(
|
||||
base_issues_url,
|
||||
|
|
@ -294,9 +287,7 @@ class TestForgejoIssueOperations:
|
|||
WHEN the issue is closed via API
|
||||
THEN the issue state should be updated to closed
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create issue
|
||||
issue_response = requests.post(
|
||||
base_issues_url,
|
||||
|
|
@ -325,9 +316,7 @@ class TestForgejoIssueOperations:
|
|||
WHEN the issue title is updated via API
|
||||
THEN the issue should reflect the new title
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create issue
|
||||
issue_response = requests.post(
|
||||
base_issues_url,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import requests
|
|||
from blockerbugs.util import forgejo_bot
|
||||
from blockerbugs.util.forgejo_interface import ForgejoInterface
|
||||
from testing.forgejo_container import (
|
||||
ForgejoApiClientConfig, ForgejoContainerConfig, ForgejoRepoConfig,
|
||||
ForgejoApiClientConfig,
|
||||
ForgejoContainerConfig,
|
||||
ForgejoRepoConfig,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -128,9 +130,7 @@ class TestWebhookHandlerIntegration:
|
|||
WHEN the webhook handler is called
|
||||
THEN votes are processed and real API calls are made
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
|
||||
# Create an issue
|
||||
response = requests.post(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ from blockerbugs.util.forgejo_interface import (
|
|||
BBValueError,
|
||||
)
|
||||
from testing.forgejo_container import (
|
||||
ForgejoApiClientConfig, ForgejoContainerConfig, ForgejoRepoConfig,
|
||||
ForgejoApiClientConfig,
|
||||
ForgejoContainerConfig,
|
||||
ForgejoRepoConfig,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -67,9 +69,7 @@ class TestForgejoInterfaceInitialization:
|
|||
"FORGEJO_BOT_ACCESS_TOKEN": None,
|
||||
"FORGEJO_REPO": "owner/repo",
|
||||
}
|
||||
with pytest.raises(
|
||||
BBValueError, match="FORGEJO_BOT_ACCESS_TOKEN value invalid"
|
||||
):
|
||||
with pytest.raises(BBValueError, match="FORGEJO_BOT_ACCESS_TOKEN value invalid"):
|
||||
ForgejoInterface()
|
||||
|
||||
def test_invalid_repo_format(self) -> None:
|
||||
|
|
@ -84,9 +84,7 @@ class TestForgejoInterfaceInitialization:
|
|||
"FORGEJO_BOT_ACCESS_TOKEN": "token",
|
||||
"FORGEJO_REPO": "invalid-format", # Missing the slash
|
||||
}
|
||||
with pytest.raises(
|
||||
BBValueError, match="FORGEJO_REPO value invalid"
|
||||
):
|
||||
with pytest.raises(BBValueError, match="FORGEJO_REPO value invalid"):
|
||||
ForgejoInterface()
|
||||
|
||||
def test_successful_initialization(
|
||||
|
|
@ -150,9 +148,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN retrieving the issue by number
|
||||
THEN the issue details are returned correctly
|
||||
"""
|
||||
issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# First create an issue directly via API
|
||||
response = requests.post(
|
||||
issues_url,
|
||||
|
|
@ -183,9 +179,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN updating the issue with new title and body
|
||||
THEN the issue is updated successfully
|
||||
"""
|
||||
issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# First create an issue directly via API
|
||||
response = requests.post(
|
||||
issues_url,
|
||||
|
|
@ -196,9 +190,7 @@ class TestForgejoInterfaceIntegration:
|
|||
issue_number = response.json()["number"]
|
||||
|
||||
# Update the issue via ForgejoInterface
|
||||
forgejo_interface.update_issue(
|
||||
issue_number, title="Updated Title", body="Updated Body"
|
||||
)
|
||||
forgejo_interface.update_issue(issue_number, title="Updated Title", body="Updated Body")
|
||||
|
||||
# Verify the update via ForgejoInterface
|
||||
issue = forgejo_interface.get_issue(issue_number)
|
||||
|
|
@ -216,9 +208,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN closing the issue
|
||||
THEN the issue state changes to closed
|
||||
"""
|
||||
issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create an issue first via direct API
|
||||
response = requests.post(
|
||||
issues_url,
|
||||
|
|
@ -246,9 +236,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN posting a comment to the issue
|
||||
THEN the comment is created successfully
|
||||
"""
|
||||
issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create an issue first via direct API
|
||||
response = requests.post(
|
||||
issues_url,
|
||||
|
|
@ -279,9 +267,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN retrieving the comments
|
||||
THEN all comments are returned with correct data
|
||||
"""
|
||||
base_issues_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
)
|
||||
base_issues_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}/issues"
|
||||
# Create an issue first via direct API
|
||||
response = requests.post(
|
||||
base_issues_url,
|
||||
|
|
@ -363,9 +349,7 @@ class TestForgejoInterfaceIntegration:
|
|||
WHEN creating an issue with that label
|
||||
THEN the issue is created successfully
|
||||
"""
|
||||
repo_base_url = (
|
||||
f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}"
|
||||
)
|
||||
repo_base_url = f"{forgejo_api_client.api_url}repos/{forgejo_test_repo.full_name}"
|
||||
# First create a label via direct API
|
||||
label_response = requests.post(
|
||||
f"{repo_base_url}/labels",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from blockerbugs.models.bug import Bug
|
|||
from blockerbugs.util import testdata
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('_postgres_db')
|
||||
@pytest.mark.usefixtures("_postgres_db")
|
||||
class TestTestDataPostgres:
|
||||
"""Integration tests for testdata utility using PostgreSQL"""
|
||||
|
||||
|
|
@ -98,14 +98,36 @@ class TestTestDataPostgres:
|
|||
"""
|
||||
release = Release(1, active=True)
|
||||
db.session.add(release)
|
||||
milestone = Milestone(release, 'beta', blocker_tracker=1, fe_tracker=2, name='1-beta',
|
||||
active=True, current=True)
|
||||
milestone = Milestone(
|
||||
release,
|
||||
"beta",
|
||||
blocker_tracker=1,
|
||||
fe_tracker=2,
|
||||
name="1-beta",
|
||||
active=True,
|
||||
current=True,
|
||||
)
|
||||
db.session.add(milestone)
|
||||
bug = Bug(bugid=1, url=None, summary='bug', status='NEW', component='distro',
|
||||
milestone=milestone, active=True, needinfo=False, needinfo_requestee=None)
|
||||
bug = Bug(
|
||||
bugid=1,
|
||||
url=None,
|
||||
summary="bug",
|
||||
status="NEW",
|
||||
component="distro",
|
||||
milestone=milestone,
|
||||
active=True,
|
||||
needinfo=False,
|
||||
needinfo_requestee=None,
|
||||
)
|
||||
db.session.add(bug)
|
||||
update = Update(updateid='U1', release=release, status='testing', karma=0, url='url',
|
||||
date_submitted=datetime.datetime.now(datetime.UTC))
|
||||
update = Update(
|
||||
updateid="U1",
|
||||
release=release,
|
||||
status="testing",
|
||||
karma=0,
|
||||
url="url",
|
||||
date_submitted=datetime.datetime.now(datetime.UTC),
|
||||
)
|
||||
db.session.add(update)
|
||||
db.session.commit()
|
||||
|
||||
|
|
@ -136,7 +158,7 @@ class TestTestDataPostgres:
|
|||
|
||||
# Verify we can use PostgreSQL-specific queries
|
||||
# Check that the database dialect is PostgreSQL
|
||||
assert db.engine.dialect.name == 'postgresql'
|
||||
assert db.engine.dialect.name == "postgresql"
|
||||
|
||||
# Test that transactions work properly
|
||||
# PostgreSQL has better transaction support than SQLite
|
||||
|
|
@ -166,21 +188,21 @@ class TestTestDataPostgres:
|
|||
|
||||
# Simulate multiple "concurrent" operations by creating multiple objects
|
||||
# without committing in between
|
||||
beta_milestone = Milestone.query.filter_by(version='beta').first()
|
||||
beta_milestone = Milestone.query.filter_by(version="beta").first()
|
||||
|
||||
# Create multiple bugs without intermediate commits
|
||||
bugs = []
|
||||
for i in range(1000, 1010):
|
||||
bug = Bug(
|
||||
bugid=i,
|
||||
url=f'http://localhost/bug/{i}',
|
||||
summary=f'Concurrent test bug {i}',
|
||||
status='NEW',
|
||||
component='test',
|
||||
url=f"http://localhost/bug/{i}",
|
||||
summary=f"Concurrent test bug {i}",
|
||||
status="NEW",
|
||||
component="test",
|
||||
milestone=beta_milestone,
|
||||
active=True,
|
||||
needinfo=False,
|
||||
needinfo_requestee=None
|
||||
needinfo_requestee=None,
|
||||
)
|
||||
db.session.add(bug)
|
||||
bugs.append(bug)
|
||||
|
|
|
|||
|
|
@ -163,11 +163,7 @@ class ServiceContainer:
|
|||
return None
|
||||
|
||||
# Extract host port
|
||||
ports = (
|
||||
container.attrs.get("NetworkSettings", {})
|
||||
.get("Ports", {})
|
||||
.get(port_key, [])
|
||||
)
|
||||
ports = container.attrs.get("NetworkSettings", {}).get("Ports", {}).get(port_key, [])
|
||||
if not ports:
|
||||
LOGGER.debug(
|
||||
"No port mapping found for %s on container '%s'",
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ from blockerbugs import db
|
|||
from blockerbugs import app
|
||||
from blockerbugs.models.milestone import Milestone
|
||||
from blockerbugs.models.bug import Bug
|
||||
from testing.test_controllers import add_release, add_milestone, \
|
||||
add_bug, add_update
|
||||
from testing.test_controllers import add_release, add_milestone, add_bug, add_update
|
||||
from blockerbugs.controllers.api import api, errors
|
||||
from blockerbugs.controllers.api.api import _get_bugtypes, _get_pretty_milestone_name, \
|
||||
_UNKNOWN_BUG_SVG_TEXT, _BUG_CLOSED
|
||||
from blockerbugs.controllers.api.api import (
|
||||
_get_bugtypes,
|
||||
_get_pretty_milestone_name,
|
||||
_UNKNOWN_BUG_SVG_TEXT,
|
||||
_BUG_CLOSED,
|
||||
)
|
||||
from blockerbugs.util import forgejo_bot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('_postgres_db', 'setup_teardown')
|
||||
@pytest.mark.usefixtures("_postgres_db", "setup_teardown")
|
||||
class TestRestAPI:
|
||||
@pytest.fixture
|
||||
def setup_teardown(self):
|
||||
|
|
@ -28,45 +31,46 @@ class TestRestAPI:
|
|||
db.create_all()
|
||||
self.client = app.test_client()
|
||||
self.release = add_release(99)
|
||||
self.milestone = add_milestone(self.release, 'final', 100, 101,
|
||||
'99-final', True)
|
||||
self.milestone = add_milestone(self.release, "final", 100, 101, "99-final", True)
|
||||
self.milestone.current = True
|
||||
|
||||
self.milestone2 = add_milestone(self.release, 'beta', 200, 201,
|
||||
'99-beta')
|
||||
bug1 = add_bug(9000, 'testbug1', self.milestone)
|
||||
self.milestone2 = add_milestone(self.release, "beta", 200, 201, "99-beta")
|
||||
bug1 = add_bug(9000, "testbug1", self.milestone)
|
||||
bug1.accepted_fe = True
|
||||
bug1.status = 'CLOSED'
|
||||
bug1.discussion_link = 'example.com'
|
||||
bug1copy = add_bug(9000, 'testbug1', self.milestone2) # different milestone than bug1
|
||||
bug1.status = "CLOSED"
|
||||
bug1.discussion_link = "example.com"
|
||||
bug1copy = add_bug(9000, "testbug1", self.milestone2) # different milestone than bug1
|
||||
bug1copy.accepted_fe = True
|
||||
bug1copy.status = 'CLOSED'
|
||||
bug2 = add_bug(9002, 'testbug2', self.milestone)
|
||||
bug1copy.status = "CLOSED"
|
||||
bug2 = add_bug(9002, "testbug2", self.milestone)
|
||||
bug2.accepted_blocker = False
|
||||
bug2.proposed_fe = True
|
||||
bug2copy = add_bug(9002, 'testbug2', self.milestone2) # different milestone than bug2
|
||||
bug2copy = add_bug(9002, "testbug2", self.milestone2) # different milestone than bug2
|
||||
bug2copy.accepted_blocker = False
|
||||
bug2copy.proposed_fe = True
|
||||
bug3 = add_bug(9003, 'testbug3', self.milestone)
|
||||
bug3 = add_bug(9003, "testbug3", self.milestone)
|
||||
bug3.accepted_blocker = False
|
||||
bug3.proposed_fe = True
|
||||
bug4 = add_bug(9003, 'testbug3', self.milestone2) # different milestone and proposals than bug3
|
||||
bug4 = add_bug(
|
||||
9003, "testbug3", self.milestone2
|
||||
) # different milestone and proposals than bug3
|
||||
bug4.accepted_blocker = True
|
||||
bug4.proposed_fe = False
|
||||
bug4.status = 'CLOSED'
|
||||
self.update_pending_stable = add_update('test-pending-stable.fc99', self.release, 'testing',
|
||||
[bug1, bug1copy])
|
||||
bug4.status = "CLOSED"
|
||||
self.update_pending_stable = add_update(
|
||||
"test-pending-stable.fc99", self.release, "testing", [bug1, bug1copy]
|
||||
)
|
||||
self.update_pending_stable.date_submitted = datetime(1990, 1, 1)
|
||||
self.update_pending_stable.request = 'stable'
|
||||
self.update_pending_stable.title = 'mega fixer'
|
||||
self.update_testing2 = add_update('test-testing2.fc99', self.release, 'testing', [bug2])
|
||||
self.update_pending_stable.request = "stable"
|
||||
self.update_pending_stable.title = "mega fixer"
|
||||
self.update_testing2 = add_update("test-testing2.fc99", self.release, "testing", [bug2])
|
||||
|
||||
self.webhook_data = {
|
||||
'issue': {
|
||||
'number': 6666,
|
||||
'state': 'open',
|
||||
"issue": {
|
||||
"number": 6666,
|
||||
"state": "open",
|
||||
},
|
||||
'action': 'created',
|
||||
"action": "created",
|
||||
}
|
||||
|
||||
db.session.commit()
|
||||
|
|
@ -81,100 +85,100 @@ class TestRestAPI:
|
|||
# === /api/v0/milestones ===
|
||||
|
||||
def test_unknown_release(self):
|
||||
url = '/api/v0/milestones/9999/unknown/bugs'
|
||||
url = "/api/v0/milestones/9999/unknown/bugs"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.NOT_FOUND
|
||||
error = json.loads(resp.data)['error']
|
||||
assert error['code'] == errors.NoSuchObjectError.code
|
||||
assert 'Release' in error['message']
|
||||
error = json.loads(resp.data)["error"]
|
||||
assert error["code"] == errors.NoSuchObjectError.code
|
||||
assert "Release" in error["message"]
|
||||
|
||||
def test_unknown_milestone(self):
|
||||
url = '/api/v0/milestones/99/unknown/bugs'
|
||||
url = "/api/v0/milestones/99/unknown/bugs"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.NOT_FOUND
|
||||
error = json.loads(resp.data)['error']
|
||||
assert error['code'] == errors.NoSuchObjectError.code
|
||||
assert 'Milestone' in error['message']
|
||||
error = json.loads(resp.data)["error"]
|
||||
assert error["code"] == errors.NoSuchObjectError.code
|
||||
assert "Milestone" in error["message"]
|
||||
|
||||
def test_list_all_bugs(self):
|
||||
url = '/api/v0/milestones/99/final/bugs'
|
||||
url = "/api/v0/milestones/99/final/bugs"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = json.loads(resp.data)
|
||||
assert len(data) == 3
|
||||
bug1 = data[0]
|
||||
bug2 = data[1]
|
||||
assert bug1['bugid'] == 9000
|
||||
assert bug1['url'] == 'https://bugzilla.redhat.com/show_bug.cgi?id=9000'
|
||||
assert bug1['summary'] == 'testbug1'
|
||||
assert bug1['component'] == 'testcomponent'
|
||||
assert bug1['active']
|
||||
assert bug1['discussion_link'] == 'example.com'
|
||||
assert bug2['discussion_link'] is None
|
||||
assert set(bug1['type']) == set(('accepted_blocker', 'accepted_fe'))
|
||||
assert bug1["bugid"] == 9000
|
||||
assert bug1["url"] == "https://bugzilla.redhat.com/show_bug.cgi?id=9000"
|
||||
assert bug1["summary"] == "testbug1"
|
||||
assert bug1["component"] == "testcomponent"
|
||||
assert bug1["active"]
|
||||
assert bug1["discussion_link"] == "example.com"
|
||||
assert bug2["discussion_link"] is None
|
||||
assert set(bug1["type"]) == set(("accepted_blocker", "accepted_fe"))
|
||||
|
||||
def test_list_accepted_blocker_bugs(self):
|
||||
url = '/api/v0/milestones/99/final/bugs?bugtype=accepted_blocker&'
|
||||
url = "/api/v0/milestones/99/final/bugs?bugtype=accepted_blocker&"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = json.loads(resp.data)
|
||||
assert len(data) == 1
|
||||
assert data[0]['bugid'] == 9000
|
||||
assert data[0]["bugid"] == 9000
|
||||
|
||||
def test_list_all_updates(self):
|
||||
url = '/api/v0/milestones/99/final/updates'
|
||||
url = "/api/v0/milestones/99/final/updates"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = json.loads(resp.data)
|
||||
assert len(data) == 2
|
||||
update = data[-1]
|
||||
assert update['updateid'] == u'test-pending-stable.fc99'
|
||||
assert update['title'] == 'mega fixer'
|
||||
assert update['url'] == f'https://bodhi.fedoraproject.org/updates/{update["updateid"]}'
|
||||
assert update['karma'] == 1
|
||||
assert update['stable_karma'] == 3
|
||||
assert update['status'] == 'testing'
|
||||
assert update['request'] == 'stable'
|
||||
assert update['bugs'][0]['bugid'] == 9000
|
||||
assert set(update['bugs'][0]['type']) == set(('accepted_blocker', 'accepted_fe'))
|
||||
assert update['release'] == 99
|
||||
assert len(update['milestones']) == 2
|
||||
assert {'version': 'final', 'release': 99} in update['milestones']
|
||||
assert {'version': 'beta', 'release': 99} in update['milestones']
|
||||
assert update["updateid"] == "test-pending-stable.fc99"
|
||||
assert update["title"] == "mega fixer"
|
||||
assert update["url"] == f"https://bodhi.fedoraproject.org/updates/{update['updateid']}"
|
||||
assert update["karma"] == 1
|
||||
assert update["stable_karma"] == 3
|
||||
assert update["status"] == "testing"
|
||||
assert update["request"] == "stable"
|
||||
assert update["bugs"][0]["bugid"] == 9000
|
||||
assert set(update["bugs"][0]["type"]) == set(("accepted_blocker", "accepted_fe"))
|
||||
assert update["release"] == 99
|
||||
assert len(update["milestones"]) == 2
|
||||
assert {"version": "final", "release": 99} in update["milestones"]
|
||||
assert {"version": "beta", "release": 99} in update["milestones"]
|
||||
|
||||
def test_list_updates_with_proposed_fe_bugs(self):
|
||||
url = '/api/v0/milestones/99/final/updates?bugtype=proposed_fe&'
|
||||
url = "/api/v0/milestones/99/final/updates?bugtype=proposed_fe&"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = json.loads(resp.data)
|
||||
assert len(data) == 1
|
||||
update = data[0]
|
||||
assert update['updateid'] == u'test-testing2.fc99'
|
||||
assert update["updateid"] == "test-testing2.fc99"
|
||||
|
||||
def test_bad_bugtype_list_bugs(self):
|
||||
url = '/api/v0/milestones/99/final/updates?bugtype=foo&'
|
||||
url = "/api/v0/milestones/99/final/updates?bugtype=foo&"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.BAD_REQUEST
|
||||
error = json.loads(resp.data)['error']
|
||||
assert error['code'] == errors.InvalidArgumentError.code
|
||||
assert 'bugtype' in error['message']
|
||||
error = json.loads(resp.data)["error"]
|
||||
assert error["code"] == errors.InvalidArgumentError.code
|
||||
assert "bugtype" in error["message"]
|
||||
|
||||
def test_get_current_milestone(self):
|
||||
url = '/api/v0/milestones/current'
|
||||
url = "/api/v0/milestones/current"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = json.loads(resp.data)
|
||||
assert data['name'] == self.milestone.name
|
||||
assert data["name"] == self.milestone.name
|
||||
|
||||
# === /api/v0/bugimg ===
|
||||
|
||||
def test_get_bugimg(self):
|
||||
url = '/api/v0/bugimg/9002'
|
||||
url = "/api/v0/bugimg/9002"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
||||
bug2 = db.session.query(Bug).filter_by(bugid = 9002).first()
|
||||
bug2 = db.session.query(Bug).filter_by(bugid=9002).first()
|
||||
|
||||
pretty_name = _get_pretty_milestone_name(bug2)
|
||||
assert pretty_name in data
|
||||
|
|
@ -182,7 +186,7 @@ class TestRestAPI:
|
|||
assert bugtype in data
|
||||
|
||||
def test_get_bugimg_wrong_bugid(self):
|
||||
url = '/api/v0/bugimg/90210'
|
||||
url = "/api/v0/bugimg/90210"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
|
@ -191,7 +195,7 @@ class TestRestAPI:
|
|||
assert _UNKNOWN_BUG_SVG_TEXT in data
|
||||
|
||||
def test_get_bugimg_open_bug(self):
|
||||
url = '/api/v0/bugimg/9002'
|
||||
url = "/api/v0/bugimg/9002"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
|
@ -199,16 +203,16 @@ class TestRestAPI:
|
|||
assert _BUG_CLOSED not in data
|
||||
|
||||
def test_get_bugimg_multiple_milestones_sorted(self):
|
||||
url = '/api/v0/bugimg/9002'
|
||||
url = "/api/v0/bugimg/9002"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
||||
beta_milestone = db.session.query(Milestone).filter_by(version = 'beta').first()
|
||||
bug2beta = db.session.query(Bug).filter_by(bugid = 9002, milestone = beta_milestone).first()
|
||||
beta_milestone = db.session.query(Milestone).filter_by(version="beta").first()
|
||||
bug2beta = db.session.query(Bug).filter_by(bugid=9002, milestone=beta_milestone).first()
|
||||
|
||||
final_milestone = db.session.query(Milestone).filter_by(version = 'final').first()
|
||||
bug2final = db.session.query(Bug).filter_by(bugid = 9002, milestone = final_milestone).first()
|
||||
final_milestone = db.session.query(Milestone).filter_by(version="final").first()
|
||||
bug2final = db.session.query(Bug).filter_by(bugid=9002, milestone=final_milestone).first()
|
||||
|
||||
pretty_name_beta = _get_pretty_milestone_name(bug2beta)
|
||||
pretty_name_final = _get_pretty_milestone_name(bug2final)
|
||||
|
|
@ -216,7 +220,7 @@ class TestRestAPI:
|
|||
assert data.index(pretty_name_beta) < data.index(pretty_name_final)
|
||||
|
||||
def test_get_bugimg_closed_bug(self):
|
||||
url = '/api/v0/bugimg/9000'
|
||||
url = "/api/v0/bugimg/9000"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
|
@ -224,12 +228,12 @@ class TestRestAPI:
|
|||
assert _BUG_CLOSED in data
|
||||
|
||||
def test_get_bugimg_multiple_bugs_unsynced_status(self):
|
||||
url = '/api/v0/bugimg/9003'
|
||||
url = "/api/v0/bugimg/9003"
|
||||
resp = self.client.get(url)
|
||||
assert resp.status_code == httplib.OK
|
||||
data = str(resp.data)
|
||||
|
||||
bugs = db.session.query(Bug).filter_by(bugid = 9003).all()
|
||||
bugs = db.session.query(Bug).filter_by(bugid=9003).all()
|
||||
|
||||
for bug in bugs:
|
||||
assert _get_pretty_milestone_name(bug) in data
|
||||
|
|
@ -238,120 +242,120 @@ class TestRestAPI:
|
|||
# === /api/v0/webhook ===
|
||||
|
||||
def test_webhook_bot_disabled(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(app.config, 'FORGEJO_BOT_ENABLED', False)
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(app.config, "FORGEJO_BOT_ENABLED", False)
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'] == 'Forgejo bot disabled, ignoring request'
|
||||
assert respdata["msg"] == "Forgejo bot disabled, ignoring request"
|
||||
|
||||
def test_webhook_bad_signature(self, monkeypatch):
|
||||
# If we don't mock the signature, it doesn't match
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
print(respdata['msg'])
|
||||
print(respdata["msg"])
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'] == 'Invalid signature, ignoring.'
|
||||
assert respdata["msg"] == "Invalid signature, ignoring."
|
||||
|
||||
def test_webhook_wrong_topic(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
|
||||
headers = {'X-Forgejo-Event': 'invalid_event'}
|
||||
headers = {"X-Forgejo-Event": "invalid_event"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'].startswith('Ignoring event:')
|
||||
assert respdata["msg"].startswith("Ignoring event:")
|
||||
|
||||
def test_webhook_wrong_action(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(self.webhook_data, 'action', 'deleted')
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(self.webhook_data, "action", "deleted")
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'].startswith('Ignoring issue_comment action:')
|
||||
assert respdata["msg"].startswith("Ignoring issue_comment action:")
|
||||
|
||||
def test_webhook_missing_fields(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
for field in ['number', 'state']:
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
for field in ["number", "state"]:
|
||||
webhook_data = copy.deepcopy(self.webhook_data)
|
||||
del webhook_data['issue'][field]
|
||||
del webhook_data["issue"][field]
|
||||
|
||||
resp = self.client.post(url, json=webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'].startswith('Unable to parse received message')
|
||||
assert respdata["msg"].startswith("Unable to parse received message")
|
||||
|
||||
for field in ['number', 'state']:
|
||||
for value in ['', None]:
|
||||
for field in ["number", "state"]:
|
||||
for value in ["", None]:
|
||||
webhook_data = copy.deepcopy(self.webhook_data)
|
||||
webhook_data['issue'][field] = value
|
||||
webhook_data["issue"][field] = value
|
||||
|
||||
resp = self.client.post(url, json=webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'].startswith('Unable to parse received message')
|
||||
assert respdata["msg"].startswith("Unable to parse received message")
|
||||
|
||||
def test_webhook_closed_issue(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(self.webhook_data['issue'], 'state', 'closed')
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
monkeypatch.setitem(self.webhook_data["issue"], "state", "closed")
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert not mock_webhook_handler.called
|
||||
assert respdata['msg'] == 'Ignoring closed issue'
|
||||
assert respdata["msg"] == "Ignoring closed issue"
|
||||
|
||||
def test_webhook_executed(self, monkeypatch):
|
||||
url = '/api/v0/webhook/forgejo'
|
||||
url = "/api/v0/webhook/forgejo"
|
||||
mock_webhook_handler = mock.MagicMock()
|
||||
monkeypatch.setattr(forgejo_bot, 'webhook_handler', mock_webhook_handler)
|
||||
monkeypatch.setattr(api, 'check_forgejo_signature', mock.MagicMock(return_value=True))
|
||||
monkeypatch.setattr(forgejo_bot, "webhook_handler", mock_webhook_handler)
|
||||
monkeypatch.setattr(api, "check_forgejo_signature", mock.MagicMock(return_value=True))
|
||||
|
||||
headers = {'X-Forgejo-Event': 'issue_comment'}
|
||||
headers = {"X-Forgejo-Event": "issue_comment"}
|
||||
resp = self.client.post(url, json=self.webhook_data, headers=headers)
|
||||
assert resp.status_code == httplib.OK
|
||||
respdata = json.loads(resp.data)
|
||||
|
||||
assert mock_webhook_handler.call_count == 1
|
||||
assert mock_webhook_handler.call_args.args == (self.webhook_data['issue']['number'],)
|
||||
assert respdata['msg'] == 'Message successfully parsed'
|
||||
assert mock_webhook_handler.call_args.args == (self.webhook_data["issue"]["number"],)
|
||||
assert respdata["msg"] == "Message successfully parsed"
|
||||
|
|
|
|||
|
|
@ -6,24 +6,30 @@ import pytest
|
|||
|
||||
from blockerbugs.util.bz_interface import BlockerProposal, BZInterfaceError
|
||||
|
||||
bz_success = {'bugs': [{'alias': [],
|
||||
'changes': {},
|
||||
'id': 806497,
|
||||
'last_change_time': datetime.datetime.now(datetime.UTC)}]}
|
||||
bz_success = {
|
||||
"bugs": [
|
||||
{
|
||||
"alias": [],
|
||||
"changes": {},
|
||||
"id": 806497,
|
||||
"last_change_time": datetime.datetime.now(datetime.UTC),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('setup_teardown')
|
||||
@pytest.mark.usefixtures("setup_teardown")
|
||||
class TestProposeBlocker:
|
||||
@pytest.fixture
|
||||
def setup_teardown(self):
|
||||
# === setup ===
|
||||
self.ref_trackers = {'blocker': 123456, 'fe': 234567}
|
||||
self.ref_trackers = {"blocker": 123456, "fe": 234567}
|
||||
self.ref_proposed = 345678
|
||||
self.ref_justification = 'because I said so'
|
||||
self.ref_tracker_type = 'Blocker'
|
||||
self.ref_milestone = 'F19-alpha'
|
||||
self.ref_user = 'joe@allthebugs.com'
|
||||
self.ref_cc = 'cc@allthebugs.com'
|
||||
self.ref_justification = "because I said so"
|
||||
self.ref_tracker_type = "Blocker"
|
||||
self.ref_milestone = "F19-alpha"
|
||||
self.ref_user = "joe@allthebugs.com"
|
||||
self.ref_cc = "cc@allthebugs.com"
|
||||
|
||||
# === run method ===
|
||||
yield
|
||||
|
|
@ -36,10 +42,10 @@ class TestProposeBlocker:
|
|||
stubbz.update_bugs.return_value = True
|
||||
stubbz.build_update.return_value = bz_success
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz._do_proposal(self.ref_trackers, self.ref_proposed,
|
||||
self.ref_justification, self.ref_cc)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_bz._do_proposal(
|
||||
self.ref_trackers, self.ref_proposed, self.ref_justification, self.ref_cc
|
||||
)
|
||||
|
||||
assert stubbz.build_update.calls().once()
|
||||
assert stubbz.update_bugs.calls().once()
|
||||
|
|
@ -49,24 +55,22 @@ class TestProposeBlocker:
|
|||
stubbz._update_bug.return_value = True
|
||||
stubbz.build_update.return_value = bz_success
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone,
|
||||
self.ref_justification)
|
||||
test_comment = stubbz.build_update.mock_calls[0][2]['comment']
|
||||
assert test_comment.startswith('Proposed as a Blocker for F19-alpha by joe@allthebugs.com')
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone, self.ref_justification)
|
||||
test_comment = stubbz.build_update.mock_calls[0][2]["comment"]
|
||||
assert test_comment.startswith("Proposed as a Blocker for F19-alpha by joe@allthebugs.com")
|
||||
|
||||
def test_propose_blocker_cc(self):
|
||||
stubbz = mock.Mock()
|
||||
stubbz._update_bug.return_value = True
|
||||
stubbz.build_update.return_value = bz_success
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone,
|
||||
self.ref_justification, cc_add=self.ref_cc)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_bz.propose_bugs(
|
||||
self.ref_user, self.ref_milestone, self.ref_justification, cc_add=self.ref_cc
|
||||
)
|
||||
|
||||
test_cc = stubbz.build_update.mock_calls[0][2]['cc_add']
|
||||
test_cc = stubbz.build_update.mock_calls[0][2]["cc_add"]
|
||||
|
||||
assert self.ref_cc in test_cc
|
||||
|
||||
|
|
@ -77,12 +81,12 @@ class TestProposeBlocker:
|
|||
|
||||
ref_trackerids = list(self.ref_trackers.values())
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True, is_fe=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone,
|
||||
self.ref_justification)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True, is_fe=True
|
||||
)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone, self.ref_justification)
|
||||
|
||||
test_trackers = stubbz.build_update.mock_calls[0][2]['blocks_add']
|
||||
test_trackers = stubbz.build_update.mock_calls[0][2]["blocks_add"]
|
||||
|
||||
assert test_trackers == ref_trackerids
|
||||
|
||||
|
|
@ -91,14 +95,12 @@ class TestProposeBlocker:
|
|||
stubbz._update_bug.return_value = True
|
||||
stubbz.build_update.return_value = bz_success
|
||||
|
||||
ref_trackerid = [self.ref_trackers['blocker']]
|
||||
ref_trackerid = [self.ref_trackers["blocker"]]
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone,
|
||||
self.ref_justification)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone, self.ref_justification)
|
||||
|
||||
test_trackers = stubbz.build_update.mock_calls[0][2]['blocks_add']
|
||||
test_trackers = stubbz.build_update.mock_calls[0][2]["blocks_add"]
|
||||
|
||||
assert test_trackers == ref_trackerid
|
||||
|
||||
|
|
@ -107,25 +109,25 @@ class TestProposeBlocker:
|
|||
stubbz._update_bug.return_value = True
|
||||
stubbz.build_update.return_value = bz_success
|
||||
|
||||
stubbz.update_bugs = mock.Mock(side_effect=Fault(50, 'not 51 exception'))
|
||||
stubbz.update_bugs = mock.Mock(side_effect=Fault(50, "not 51 exception"))
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
|
||||
with pytest.raises(BZInterfaceError) as excinfo:
|
||||
test_bz.propose_bugs(self.ref_user, self.ref_milestone,
|
||||
self.ref_justification, cc_add=self.ref_cc)
|
||||
test_bz.propose_bugs(
|
||||
self.ref_user, self.ref_milestone, self.ref_justification, cc_add=self.ref_cc
|
||||
)
|
||||
|
||||
assert excinfo.value.msg == 'not 51 exception'
|
||||
assert excinfo.value.msg == "not 51 exception"
|
||||
|
||||
|
||||
class TestBugProposalTrackerCheck:
|
||||
def setup_method(self, method):
|
||||
self.ref_trackers = {'blocker': 123456, 'fe': 234567}
|
||||
self.ref_justification = 'because I said so'
|
||||
self.ref_user = 'joe@allthebugs.com'
|
||||
self.ref_tracker_type = 'Blocker'
|
||||
self.ref_milestone = 'F19-alpha'
|
||||
self.ref_trackers = {"blocker": 123456, "fe": 234567}
|
||||
self.ref_justification = "because I said so"
|
||||
self.ref_user = "joe@allthebugs.com"
|
||||
self.ref_tracker_type = "Blocker"
|
||||
self.ref_milestone = "F19-alpha"
|
||||
self.ref_proposed = 345678
|
||||
|
||||
def test_check_proposal_calls(self):
|
||||
|
|
@ -134,8 +136,7 @@ class TestBugProposalTrackerCheck:
|
|||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_bz.check_blocker_proposal()
|
||||
|
||||
assert stubbz.getbug.calls().once()
|
||||
|
|
@ -146,120 +147,120 @@ class TestBugProposalTrackerCheck:
|
|||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_result = test_bz.check_blocker_proposal()
|
||||
|
||||
assert test_result
|
||||
|
||||
def test_check_propose_blocker_already(self):
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.ref_trackers['blocker']]
|
||||
refbug.blocked = [self.ref_trackers["blocker"]]
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
test_result = test_bz.check_blocker_proposal()
|
||||
|
||||
assert test_result == False
|
||||
|
||||
def test_check_bothpropose_blocker_already(self):
|
||||
self.ref_tracker_type = 'Blocker and Freeze Exception'
|
||||
self.ref_tracker_type = "Blocker and Freeze Exception"
|
||||
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.ref_trackers['blocker']]
|
||||
refbug.blocked = [self.ref_trackers["blocker"]]
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True, is_fe=True)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True, is_fe=True
|
||||
)
|
||||
test_result = test_bz.check_blocker_proposal()
|
||||
|
||||
assert test_result == False
|
||||
|
||||
def test_check_bothpropose_blocker_already_fe_ok(self):
|
||||
self.ref_tracker_type = 'Blocker and Freeze Exception'
|
||||
self.ref_tracker_type = "Blocker and Freeze Exception"
|
||||
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.ref_trackers['blocker']]
|
||||
refbug.blocked = [self.ref_trackers["blocker"]]
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True, is_fe=True)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True, is_fe=True
|
||||
)
|
||||
test_result = test_bz.check_fe_proposal()
|
||||
|
||||
assert test_result == True
|
||||
|
||||
def test_check_bothpropose_fe_already(self):
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.ref_trackers['fe']]
|
||||
refbug.blocked = [self.ref_trackers["fe"]]
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True, is_fe=True)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True, is_fe=True
|
||||
)
|
||||
test_result = test_bz.check_fe_proposal()
|
||||
|
||||
assert test_result == False
|
||||
|
||||
def test_check_bothpropose_fe_already_blocker_ok(self):
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.ref_trackers['fe']]
|
||||
refbug.blocked = [self.ref_trackers["fe"]]
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True, is_fe=True)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True, is_fe=True
|
||||
)
|
||||
test_result = test_bz.check_blocker_proposal()
|
||||
|
||||
assert test_result == True
|
||||
|
||||
def test_proposed_bug_notexist(self):
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug = mock.Mock(side_effect=Fault(101, 'bug does not exist'))
|
||||
stubbz.getbug = mock.Mock(side_effect=Fault(101, "bug does not exist"))
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers,
|
||||
is_blocker=True)
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed, self.ref_trackers, is_blocker=True)
|
||||
with pytest.raises(BZInterfaceError):
|
||||
test_bz.check_blocker_proposal()
|
||||
|
||||
|
||||
class TestProposedBug():
|
||||
class TestProposedBug:
|
||||
def setup_method(self, method):
|
||||
self.blocker_tracker = 123456
|
||||
self.fe_tracker = 234567
|
||||
self.ref_justification = 'because I said so'
|
||||
self.ref_user = 'joe@allthebugs.com'
|
||||
self.ref_tracker_type = 'Blocker'
|
||||
self.ref_milestone = 'F19-alpha'
|
||||
self.ref_justification = "because I said so"
|
||||
self.ref_user = "joe@allthebugs.com"
|
||||
self.ref_tracker_type = "Blocker"
|
||||
self.ref_milestone = "F19-alpha"
|
||||
self.ref_proposed = 345678
|
||||
|
||||
def test_proposed_bug_closed(self):
|
||||
ref_status = 'CLOSED NOTABUG'
|
||||
ref_status = "CLOSED NOTABUG"
|
||||
refbug = mock.MagicMock()
|
||||
refbug.blocked = [self.blocker_tracker]
|
||||
refbug.bug_status = ref_status
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug.return_value = refbug
|
||||
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed,
|
||||
[self.blocker_tracker],
|
||||
self.ref_tracker_type)
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, [self.blocker_tracker], self.ref_tracker_type
|
||||
)
|
||||
with pytest.raises(BZInterfaceError) as excinfo:
|
||||
test_bz.check_proposed_bug()
|
||||
|
||||
assert excinfo.value.msg == 'Bug %i is CLOSED: %s' % (
|
||||
self.ref_proposed, ref_status)
|
||||
assert excinfo.value.msg == "Bug %i is CLOSED: %s" % (self.ref_proposed, ref_status)
|
||||
|
||||
def test_proposed_bug_invalid(self):
|
||||
stubbz = mock.MagicMock()
|
||||
stubbz.getbug = mock.Mock(side_effect=Fault(101, 'bug does not exist'))
|
||||
test_bz = BlockerProposal(stubbz, self.ref_proposed,
|
||||
[self.blocker_tracker],
|
||||
self.ref_tracker_type)
|
||||
stubbz.getbug = mock.Mock(side_effect=Fault(101, "bug does not exist"))
|
||||
test_bz = BlockerProposal(
|
||||
stubbz, self.ref_proposed, [self.blocker_tracker], self.ref_tracker_type
|
||||
)
|
||||
with pytest.raises(BZInterfaceError) as excinfo:
|
||||
test_bz.check_proposed_bug()
|
||||
|
||||
assert excinfo.value.msg == 'bug does not exist'
|
||||
assert excinfo.value.msg == "bug does not exist"
|
||||
|
|
|
|||
|
|
@ -5,20 +5,23 @@ from blockerbugs.util.bug_sync import BugSync
|
|||
from mock import MagicMock
|
||||
import datetime
|
||||
|
||||
basicbug = Munch({'bug_id': 123456,
|
||||
'weburl': 'https://bugzilla.redhat.com/show_bug.cgi?id=123456',
|
||||
'component': 'randomcomponent',
|
||||
'status': 'NEW',
|
||||
'is_open': True,
|
||||
'summary': 'this is a test bug',
|
||||
'whiteboard': '',
|
||||
'dependson': [333],
|
||||
'last_change_time': datetime.datetime.now(datetime.UTC),
|
||||
'flags': [{'name': 'needinfo', 'status': '?', 'is_active': True, 'requestee': 'John Doe'}]
|
||||
})
|
||||
basicbug = Munch(
|
||||
{
|
||||
"bug_id": 123456,
|
||||
"weburl": "https://bugzilla.redhat.com/show_bug.cgi?id=123456",
|
||||
"component": "randomcomponent",
|
||||
"status": "NEW",
|
||||
"is_open": True,
|
||||
"summary": "this is a test bug",
|
||||
"whiteboard": "",
|
||||
"dependson": [333],
|
||||
"last_change_time": datetime.datetime.now(datetime.UTC),
|
||||
"flags": [{"name": "needinfo", "status": "?", "is_active": True, "requestee": "John Doe"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('setup_teardown')
|
||||
@pytest.mark.usefixtures("setup_teardown")
|
||||
class TestSyncExtractInformation:
|
||||
@pytest.fixture
|
||||
def setup_teardown(self):
|
||||
|
|
@ -33,158 +36,155 @@ class TestSyncExtractInformation:
|
|||
# not needed
|
||||
|
||||
def test_nth_isaccepted(self):
|
||||
self.testbug.whiteboard = 'AcceptedFreezeException'
|
||||
self.testbug.whiteboard = "AcceptedFreezeException"
|
||||
ref_isaccepted = True
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug,
|
||||
'FreezeException')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "FreezeException")
|
||||
|
||||
assert buginfo['accepted'] == ref_isaccepted
|
||||
assert buginfo["accepted"] == ref_isaccepted
|
||||
|
||||
def test_nth_isrejected(self):
|
||||
self.testbug.whiteboard = 'RejectedFreezeException'
|
||||
self.testbug.whiteboard = "RejectedFreezeException"
|
||||
ref_isrejected = True
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug,
|
||||
'FreezeException')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "FreezeException")
|
||||
|
||||
assert buginfo['rejected'] == ref_isrejected
|
||||
assert buginfo["rejected"] == ref_isrejected
|
||||
|
||||
def test_nth_isproposed(self):
|
||||
self.testbug.whiteboard = ''
|
||||
self.testbug.whiteboard = ""
|
||||
ref_isproposed = True
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug,
|
||||
'FreezeException')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "FreezeException")
|
||||
|
||||
assert buginfo['proposed'] == ref_isproposed
|
||||
assert buginfo["proposed"] == ref_isproposed
|
||||
|
||||
def test_nth_isproposed_notrejected(self):
|
||||
self.testbug.whiteboard = ''
|
||||
self.testbug.whiteboard = ""
|
||||
ref_isrejected = False
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug,
|
||||
'FreezeException')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "FreezeException")
|
||||
|
||||
assert buginfo['rejected'] == ref_isrejected
|
||||
assert buginfo["rejected"] == ref_isrejected
|
||||
|
||||
def test_nth_isproposed_notaccepted(self):
|
||||
self.testbug.whiteboard = ''
|
||||
self.testbug.whiteboard = ""
|
||||
ref_isaccepted = False
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug,
|
||||
'FreezeException')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "FreezeException")
|
||||
|
||||
assert buginfo['accepted'] == ref_isaccepted
|
||||
assert buginfo["accepted"] == ref_isaccepted
|
||||
|
||||
def test_blocker_isaccepted(self):
|
||||
self.testbug.whiteboard = 'AcceptedBlocker'
|
||||
self.testbug.whiteboard = "AcceptedBlocker"
|
||||
ref_isaccepted = True
|
||||
|
||||
buginfo = self.testsync.extract_information(self.testbug, 'Blocker')
|
||||
buginfo = self.testsync.extract_information(self.testbug, "Blocker")
|
||||