Reformat all source code using ruff
Some checks failed
Run tests and linters / test (pull_request) Successful in 2m55s
Run tests and linters / lint (pull_request) Failing after 2s

No functional changes were performed, only formatting changes.

Related: #306
This commit is contained in:
Kamil Páral 2026-06-08 14:59:29 +02:00
commit de4c2ba778
52 changed files with 2829 additions and 2280 deletions

View file

@ -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

View file

@ -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)

View file

@ -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):
![$bugid]($bug_img)
@ -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

View file

@ -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

View file

@ -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.

View file

@ -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."

View file

@ -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)

View file

@ -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))

View file

@ -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()])

View file

@ -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,
)

View file

@ -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),
)

View file

@ -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.
"""

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.")

View file

@ -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."'
)

View file

@ -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

View file

@ -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.")

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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",
],
)

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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",

View file

@ -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)

View file

@ -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'",

View file

@ -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"

View file

@ -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"

View file

@ -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")