432 lines
19 KiB
Python
Executable File
432 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# Release estimator for HAProxy
|
|
#
|
|
# A tool that monitors the HAProxy stable branches and calculates a proposed
|
|
# release date for the next minor release based on the bug fixes that are in
|
|
# the queue.
|
|
#
|
|
# Copyright 2020 HAProxy Technologies, Daniel Corbett <dcorbett@haproxy.com>
|
|
#
|
|
# This program is free software; you can redistribute it and/or
|
|
# modify it under the terms of the GNU General Public License
|
|
# as published by the Free Software Foundation; either version
|
|
# 3 of the License, or (at your option) any later version.
|
|
#
|
|
#
|
|
|
|
from lxml import html
|
|
from urllib.parse import urljoin
|
|
import requests
|
|
import traceback
|
|
import smtplib
|
|
import math
|
|
import copy
|
|
import time
|
|
import sys
|
|
import argparse
|
|
from datetime import datetime
|
|
from datetime import timedelta
|
|
from email.mime.text import MIMEText
|
|
|
|
# Do not report on versions older than
|
|
# MAX_VERSION_AGE.
|
|
MAX_VERSION_AGE = 1095 # days
|
|
|
|
# Do not report on non-lts releases (odd releases) that
|
|
# are older than MAX_VERSION_AGE_NONLTS
|
|
MAX_VERSION_AGE_NONLTS = 547 # days
|
|
|
|
# For each severity/issue type, set thresholds
|
|
# count - indicates how many bugs for this issue type should be in the queue
|
|
# time - indicates how many days should be added to the release date
|
|
THRESHOLDS = {
|
|
'BUG' :{ 'count' : 1, 'time' : 28},
|
|
'BUILD' :{ 'count' : 1, 'time' : 28},
|
|
'MINOR' : { 'count' : 1, 'time' : 28},
|
|
'MEDIUM' : { 'count' : 1, 'time' : 30},
|
|
'MAJOR' : { 'count' : 1, 'time' : 14 },
|
|
'CRITICAL' : { 'count' : 1, 'time' : 2 }
|
|
}
|
|
|
|
# Increase the urgency of a release as estimated time (in days) gets closer.
|
|
RELEASE_URGENCY = { 'WARNING' : 7, 'NOTICE' : 21, 'INFO' : '' }
|
|
|
|
def search_set(s, f):
|
|
for t in s:
|
|
if f in t:
|
|
return True
|
|
|
|
def check_for_email(s, parser):
|
|
if "@" not in s:
|
|
parser.print_help()
|
|
sys.exit()
|
|
|
|
def main():
|
|
global MAX_VERSION_AGE
|
|
global MAX_VERSION_AGE_NONLTS
|
|
global THRESHOLDS
|
|
global RELEASE_URGENCY
|
|
|
|
SEND_MAIL=False
|
|
VERSIONS = []
|
|
issues = {}
|
|
BUGQUEUE = {}
|
|
BUGS = { "bugs" :[] }
|
|
email_message = """Hi,
|
|
|
|
This is a friendly bot that watches fixes pending for the next haproxy-stable release! One such e-mail is sent periodically once patches are waiting in the last maintenance branch, and an ideal release date is computed based on the severity of these fixes and their merge date. Responses to this mail must be sent to the mailing list.
|
|
|
|
"""
|
|
|
|
parser = argparse.ArgumentParser(description='HAProxy Stable Release Estimator')
|
|
parser.add_argument('--print', action="store_true",
|
|
help='Print email only')
|
|
parser.add_argument('--to-email', nargs=1, required=False,
|
|
help='Send email to <email>')
|
|
parser.add_argument('--from-email', nargs=1, required=False,
|
|
help='Send email from <email>')
|
|
parser.add_argument('--send-mail', action="store_true",
|
|
help='Send email')
|
|
args = parser.parse_args()
|
|
|
|
if not args.print and not args.send_mail and not args.to_email and not args.from_email:
|
|
parser.print_help()
|
|
sys.exit()
|
|
|
|
if args.send_mail and (not args.to_email or not args.from_email):
|
|
parser.print_help()
|
|
sys.exit()
|
|
|
|
if args.to_email:
|
|
check_for_email(args.to_email[0], parser)
|
|
TO_EMAIL = args.to_email[0]
|
|
|
|
if args.from_email:
|
|
check_for_email(args.from_email[0], parser)
|
|
FROM_EMAIL = args.from_email[0]
|
|
|
|
if args.send_mail:
|
|
SEND_MAIL = True
|
|
|
|
if SEND_MAIL:
|
|
try:
|
|
TO_EMAIL
|
|
FROM_EMAIL
|
|
except:
|
|
parser.print_help()
|
|
sys.exit()
|
|
|
|
#
|
|
# Let's get the list of the current stable versions
|
|
#
|
|
|
|
page = requests.get('http://www.haproxy.org/bugs/')
|
|
tree = html.fromstring(page.content)
|
|
|
|
for x in (tree.xpath('//th')):
|
|
if x.xpath('./a/text()'):
|
|
VERSIONS.append(x.xpath('./a/text()')[0])
|
|
|
|
|
|
#
|
|
# For each version let's check it's age. We'll apply the following logic:
|
|
# - Skip the release if it's:
|
|
# * older than MAX_VERSION_AGE days
|
|
# * older than MAX_VERSION_AGE_NONLTS days and an odd numbered release (1.9,2.1,2.3)
|
|
#
|
|
# For all other valid releases we will then collect the number of bug fixes
|
|
# in queue for each of the defined severity levels:
|
|
# - BUG
|
|
# - BUILD
|
|
# - MINOR
|
|
# - MEDIUM
|
|
# - MAJOR
|
|
# - CRITICAL
|
|
#
|
|
# We'll then begin calculating the proposed release date based on the last
|
|
# release date plus the first commit date of the first bug fix for the defined
|
|
# severity level.
|
|
#
|
|
# By default the proposed release dates use the following padding:
|
|
# (Can be modified in THRESHOLDS)
|
|
# - BUG/BUILD/MINOR - 28 days
|
|
# - MEDIUM - 30 days
|
|
# - MAJOR - 14 days
|
|
# - CRITICAL - 2 days
|
|
#
|
|
# After we have a proposed release date we will assign a release urgency
|
|
# to it. As we get closer to the proposed release date the urgency level changes.
|
|
# By default the urgency levels and their times are:
|
|
# - WARNING - proposed date is 7 days or less
|
|
# - NOTICE - proposed date is 21 days or less
|
|
# - INFO - proposed date is longer than the above
|
|
#
|
|
|
|
for version in VERSIONS:
|
|
BUGQUEUE[version] = { "total" : 0, "last": "" }
|
|
VERSION_THRESHOLDS = copy.deepcopy(THRESHOLDS)
|
|
print("Collecting information on %s" % (version))
|
|
page = requests.get('http://www.haproxy.org/bugs/bugs-%s.html' % (version))
|
|
tree = html.fromstring(page.content)
|
|
|
|
issues[version] = {}
|
|
issues_count = {}
|
|
release_soon = False
|
|
num_to_word = {
|
|
1 : 'one',
|
|
2 : 'two',
|
|
3 : 'three',
|
|
4 : 'four',
|
|
5 : 'five',
|
|
6 : 'six',
|
|
7 : 'seven',
|
|
8 : 'eight',
|
|
9 : 'nine',
|
|
10 : 'ten',
|
|
11 : 'eleven',
|
|
12 : 'twelve',
|
|
13 : 'thirteen',
|
|
}
|
|
|
|
# parse out the CHANGELOG link
|
|
CHANGELOG = tree.xpath('//a[contains(@href,"CHANGELOG")]/@href')[0]
|
|
CHANGELOG = urljoin("https://", CHANGELOG)
|
|
|
|
last_version = tree.xpath('//td[contains(text(), "last")]/../td/a/text()')[0]
|
|
first_version = "%s.0" % (version)
|
|
|
|
# Get CHANGELOG for release
|
|
changelog_page = requests.get(CHANGELOG)
|
|
try:
|
|
for l in changelog_page.content.decode('utf-8').split('\n'):
|
|
# the below is a bit of a hack to parse out valid years in the CHANGELOG
|
|
if (last_version in l) and ('201' in l or '202' in l or '200' in l) and '/' in l:
|
|
# set the date in which this version was last released
|
|
last_release_date = l.split(' ')[0]
|
|
last_release_datetime = datetime.strptime(last_release_date.strip(), '%Y/%m/%d')
|
|
BUGQUEUE[version]['last'] = last_release_date
|
|
break
|
|
for l in changelog_page.content.decode('utf-8').split('\n'):
|
|
# the below is a bit of a hack to parse out valid years in the CHANGELOG
|
|
if (first_version in l) and ('201' in l or '202' in l or '200' in l) and '/' in l:
|
|
# set the date in which this version was first released
|
|
first_release_date = l.split(' ')[0]
|
|
first_release_datetime = datetime.strptime(first_release_date.strip(), '%Y/%m/%d')
|
|
BUGQUEUE[version]['first'] = first_release_datetime
|
|
break
|
|
except:
|
|
print(traceback.format_exc())
|
|
last_release_date = False
|
|
|
|
# get unix timestamp for today and timestamp of first release date
|
|
today_ts = datetime.today().timestamp()
|
|
first_version_ts = BUGQUEUE[version]['first'].timestamp()
|
|
|
|
# calculate the age of this version in days and years
|
|
version_age = math.ceil((today_ts-first_version_ts)/86400)
|
|
version_age_years = math.ceil(version_age/365)
|
|
|
|
# We do not want to monitor versions that are older
|
|
# than MAX_VERSION_AGE or MAX_VERSION_AGE_NONLTS
|
|
if version_age >= MAX_VERSION_AGE:
|
|
print("\t - Version: %s is older than %d days, skipping" % (version, MAX_VERSION_AGE))
|
|
continue
|
|
|
|
if version_age > MAX_VERSION_AGE_NONLTS:
|
|
if int(version.split('.')[1]) % 2 > 0:
|
|
print("\t - Version: %s is not LTS and is older than %d days, skipping" % (version, MAX_VERSION_AGE_NONLTS))
|
|
continue
|
|
|
|
# If the release is older than 1 year let's increase the time until
|
|
# a release is due. <base time threshold> * <version age years>
|
|
if version_age_years > 1:
|
|
for k in VERSION_THRESHOLDS.keys():
|
|
VERSION_THRESHOLDS[k]['time'] *= int(version_age_years)
|
|
|
|
# Let's capture the bug table which contains each bug & their severity
|
|
bug_table = tree.xpath('//th[contains(text(), "Severity")]/ancestor::table[last()]')[0]
|
|
|
|
# Loop through bug table and parse out the title of each bug
|
|
# found within the links and their merge date.
|
|
# Example is: 2020-10-19 BUG/MINOR: disable dynamic OCSP load with BoringSSL
|
|
for x in bug_table.xpath('.//a[contains(@href,"commitdiff")]'):
|
|
# Capture the bug label
|
|
# Example: BUG/MINOR: disable dynamic OCSP load with BoringSSL
|
|
issue_tmp = x.xpath('./text()')[0]
|
|
# Capture the date
|
|
# Example: 2020-10-19
|
|
date_tmp = x.xpath('../preceding-sibling::td/text()')[0]
|
|
|
|
# Split the bug into a severity
|
|
if "/" in issue_tmp:
|
|
bug_type = issue_tmp.split(':')[0].split('/')[1].strip()
|
|
else:
|
|
bug_type = issue_tmp.split(':')[0].strip()
|
|
bug_text = ":".join(issue_tmp.split(':')[1:]).strip()
|
|
if bug_type not in issues[version].keys():
|
|
issues[version][bug_type] = set()
|
|
issues[version][bug_type].add("%s|%s" % (date_tmp, bug_text))
|
|
|
|
# Loop through the issue_types (severities) (MINOR, MEDIUM, MAJOR, etc.)
|
|
# We'll check if the severity has already been accounted for
|
|
# If not, we'll set the timestamp to the timestamp of the current issue
|
|
# If so, we'll check if the current bugs timestamp is less than the
|
|
# previous one. This will help us to determine when we first saw this
|
|
# severity type as calculations are based on the first time seeing a
|
|
# severity type. We'll then set the number of issues for each severity.
|
|
for issue_type in issues[version]:
|
|
issues_count[issue_type] = {}
|
|
for k in issues[version][issue_type]:
|
|
if 'timestamp' not in issues_count[issue_type].keys():
|
|
issues_count[issue_type]['timestamp'] = int(time.mktime(datetime.strptime(k.split('|')[0], "%Y-%m-%d").timetuple()))
|
|
else:
|
|
if issues_count[issue_type]['timestamp'] > int(time.mktime(datetime.strptime(k.split('|')[0], "%Y-%m-%d").timetuple())):
|
|
issues_count[issue_type]['timestamp'] = int(time.mktime(datetime.strptime(k.split('|')[0], "%Y-%m-%d").timetuple()))
|
|
issues_count[issue_type]['count'] = len(issues[version][issue_type])
|
|
|
|
release_date = None
|
|
total_count = 0
|
|
|
|
# Let's check the count for each severity type and see if they
|
|
# are greater than our thresholds count. This can be used to
|
|
# hold off on calculating release estimates until a certain number of
|
|
# MINOR bugs have accumulated.
|
|
for issue_type in issues_count.keys():
|
|
if issues_count[issue_type]['count'] >= VERSION_THRESHOLDS[issue_type]['count']:
|
|
# If the total number of issues is greater than the threshold
|
|
# for a severity we'll attempt to set a release date.
|
|
# We'll use the timestamp from the first time an issue was
|
|
# seen and add on the number of days specified within the
|
|
# THRESHOLDS for that issue type. We'll also increment
|
|
# the total number of issues that have been fixed in this
|
|
# version across all severities/issue types.
|
|
total_count += issues_count[issue_type]['count']
|
|
issue_timestamp_delta = datetime.fromtimestamp(int(issues_count[issue_type]['timestamp'])) + timedelta(days=int(VERSION_THRESHOLDS[issue_type]['time']))
|
|
if not release_date: release_date = issue_timestamp_delta
|
|
elif release_date > issue_timestamp_delta: release_date = issue_timestamp_delta
|
|
|
|
if release_date: release_soon = True
|
|
if release_soon:
|
|
time_until_release = release_date - datetime.now()
|
|
|
|
# If a release date has been sent, let's calculate how long
|
|
# in words until that release. i.e. "less than 2 weeks"
|
|
if release_soon:
|
|
for k in sorted(RELEASE_URGENCY.keys()):
|
|
if not RELEASE_URGENCY[k]:
|
|
release_urgency_msg = k
|
|
elif time_until_release.days <= RELEASE_URGENCY[k]:
|
|
release_urgency_msg = k
|
|
rounded_week_time = math.ceil(time_until_release.days/7.0)
|
|
if abs(rounded_week_time) > 1:
|
|
week_word = 'weeks'
|
|
else:
|
|
week_word = 'week'
|
|
try:
|
|
# We now have all of the required information for building
|
|
# the email message.
|
|
# TODO: Fix alignment
|
|
email_message = """%s
|
|
Last release %s was issued on %s. There are currently %d patches in the queue cut down this way:
|
|
""" % (email_message, last_version, last_release_datetime.strftime("%Y-%m-%d"), total_count)
|
|
for issue_type in sorted(issues_count.keys()):
|
|
email_message = "%s - %d %s, first one merged on %s\n" % (email_message, issues_count[issue_type]['count'],issue_type,datetime.fromtimestamp(int(issues_count[issue_type]['timestamp'])).strftime("%Y-%m-%d"))
|
|
email_message = "%s\nThus the computed ideal release date for %s would be %s, " % (email_message, ".".join(last_version.split(".")[:-1])+"."+str(int(last_version.split(".")[-1])+1), release_date.strftime("%Y-%m-%d"))
|
|
if rounded_week_time < 0:
|
|
email_message = "%swhich was %s %s ago.\n" % (email_message, num_to_word[abs(rounded_week_time)], week_word)
|
|
elif rounded_week_time == 0:
|
|
email_message = "%swhich was within the last week.\n" % (email_message)
|
|
else:
|
|
email_message = "%swhich is in %s %s or less.\n" % (email_message, num_to_word[rounded_week_time], week_word)
|
|
except Exception as err:
|
|
print(traceback.format_exc())
|
|
sys.exit()
|
|
# Total number of bugs fixed in this version
|
|
# since last release.
|
|
BUGQUEUE[version]['total'] = total_count
|
|
|
|
email_subject = "stable-bot: Bugfixes waiting for a release "
|
|
|
|
# Add each version & their number of bugs to the subject
|
|
for k in sorted(BUGQUEUE.keys(), reverse=True):
|
|
if BUGQUEUE[k]['total'] > 0:
|
|
email_subject = "%s %s (%d)," % ( email_subject, k, BUGQUEUE[k]['total'])
|
|
|
|
email_subject = email_subject.rstrip(",")
|
|
email_message = "%s\nThe current list of patches in the queue is:\n" % (email_message)
|
|
uniq_issues = set()
|
|
|
|
# Parse out unique issues across all versions so that we can
|
|
# print them once with the list of affected versions.
|
|
for k in BUGQUEUE.keys():
|
|
for issue_type in sorted(issues[k].keys()):
|
|
for issue in issues[k][issue_type]:
|
|
uniq_issues.add("%s|%s" % (issue_type,issue.split('|')[1]))
|
|
|
|
# Loop through the unique issues and determine which versions
|
|
# are affected.
|
|
for i in uniq_issues:
|
|
affected_versions = []
|
|
for k in BUGQUEUE.keys():
|
|
try:
|
|
if search_set(issues[k][i.split('|')[0]], i.split('|')[1]):
|
|
affected_versions.append(k)
|
|
except Exception as e:
|
|
pass
|
|
if affected_versions:
|
|
affected_versions.sort()
|
|
try:
|
|
BUGS["bugs"].append({ "affected_versions" : affected_versions, "bug":i.split('|')[1], "severity":i.split('|')[0] })
|
|
except:
|
|
BUGS["bugs"] = [ { "affected_versions" : affected_versions, "bug":i.split('|')[1], "severity":i.split('|')[0] } ]
|
|
|
|
BUGS["bugs"] = sorted(BUGS["bugs"], key = lambda i: i['severity'])
|
|
|
|
# Add each issue with affected versions to email message
|
|
# Example:
|
|
# - 1.8, 2.0, 2.1, 2.2 - MINOR : stats: fix validity of the json schema
|
|
for bug in BUGS["bugs"]:
|
|
email_message = "%s - %s %s %s : %s\n" % (email_message, ", ".join(bug["affected_versions"]).ljust(14), "-".rjust(12), bug["severity"].ljust(7), bug["bug"])
|
|
|
|
email_message="%s\n-- \nThe haproxy stable-bot is freely provided by HAProxy Technologies to help improve the quality of each HAProxy release. If you have any issue with these emails or if you want to suggest some improvements, please post them on the list so that the solutions suiting the most users can be found.\n" % (email_message)
|
|
|
|
# If a message with actual issues exists let's either print it or send out
|
|
# an email.
|
|
if "first one merged on" in email_message:
|
|
if args.print:
|
|
print(email_subject)
|
|
print(email_message)
|
|
if SEND_MAIL:
|
|
print('Send email to:%s from:%s' % (TO_EMAIL, FROM_EMAIL), end="")
|
|
msg = MIMEText(email_message)
|
|
msg['to'] = TO_EMAIL
|
|
msg['from'] = FROM_EMAIL
|
|
msg['subject'] = email_subject
|
|
msg.add_header('reply-to', TO_EMAIL)
|
|
try:
|
|
server = smtplib.SMTP('127.0.0.1', timeout=10)
|
|
server.sendmail(msg['from'], [msg['to']], msg.as_string())
|
|
print(" - Email sent")
|
|
except (ConnectionRefusedError, smtplib.SMTPConnectError):
|
|
print("- Error: SMTP Connection Error")
|
|
sys.exit()
|
|
except smtplib.SMTPServerDisconnected:
|
|
print('- Error: SMTP Server Disconnect (possible timeout)')
|
|
sys.exit()
|
|
except (smtplib.SMTPRecipientsRefused, smtplib.SMTPSenderRefused):
|
|
print('- Error: Recipients or Sender Refused')
|
|
sys.exit()
|
|
except (smtplib.SMTPHeloError, smtplib.SMTPAuthenticationError):
|
|
print('- Error: SMTP rejected HELO or requires Authentication')
|
|
sys.exit()
|
|
except:
|
|
print(traceback.format_exc())
|
|
sys.exit()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|
|
sys.exit()
|