Warn when releases have not been updated for a long time (#521)
Raise an alert during the daily auto-update about stale releases, e.g.: - non-EOL releases with latest releases not updated in a year, - non-EOL releases with a release date older than a year. The threshold is configurable.
This commit is contained in:
@@ -4,7 +4,7 @@ from common import dates, releasedata
|
|||||||
|
|
||||||
"""Remove empty releases or releases which are released in the future."""
|
"""Remove empty releases or releases which are released in the future."""
|
||||||
|
|
||||||
TODAY = dates.today()
|
TODAY = dates.today_at_midnight()
|
||||||
|
|
||||||
frontmatter, _ = releasedata.parse_argv(ignore_auto_config=True)
|
frontmatter, _ = releasedata.parse_argv(ignore_auto_config=True)
|
||||||
with releasedata.ProductData(frontmatter.name) as product_data:
|
with releasedata.ProductData(frontmatter.name) as product_data:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import calendar
|
import calendar
|
||||||
from datetime import datetime, timezone
|
import datetime
|
||||||
|
|
||||||
|
|
||||||
def parse_date(text: str, formats: list[str] = frozenset([
|
def parse_date(text: str, formats: list[str] = frozenset([
|
||||||
@@ -15,7 +15,7 @@ def parse_date(text: str, formats: list[str] = frozenset([
|
|||||||
"%Y/%m/%d", # 2020/01/25
|
"%Y/%m/%d", # 2020/01/25
|
||||||
"%A %d %B %Y", # Wednesday 1 January 2020
|
"%A %d %B %Y", # Wednesday 1 January 2020
|
||||||
"%A %d %b %Y", # Wednesday 1 Jan 2020
|
"%A %d %b %Y", # Wednesday 1 Jan 2020
|
||||||
])) -> datetime:
|
])) -> datetime.datetime:
|
||||||
"""Parse a given text representing a date using a list of formats.
|
"""Parse a given text representing a date using a list of formats.
|
||||||
"""
|
"""
|
||||||
return parse_datetime(text, formats, to_utc=False)
|
return parse_datetime(text, formats, to_utc=False)
|
||||||
@@ -28,7 +28,7 @@ def parse_month_year_date(text: str, formats: list[str] = frozenset([
|
|||||||
"%Y/%m", # 2020/01
|
"%Y/%m", # 2020/01
|
||||||
"%m-%Y", # 01-2020
|
"%m-%Y", # 01-2020
|
||||||
"%m/%Y", # 01/2020
|
"%m/%Y", # 01/2020
|
||||||
])) -> datetime:
|
])) -> datetime.datetime:
|
||||||
"""Parse a given text representing a partial date using a list of formats,
|
"""Parse a given text representing a partial date using a list of formats,
|
||||||
adjusting it to the last day of the month.
|
adjusting it to the last day of the month.
|
||||||
"""
|
"""
|
||||||
@@ -37,7 +37,7 @@ def parse_month_year_date(text: str, formats: list[str] = frozenset([
|
|||||||
return date.replace(day=last_day)
|
return date.replace(day=last_day)
|
||||||
|
|
||||||
|
|
||||||
def parse_date_or_month_year_date(text: str) -> datetime:
|
def parse_date_or_month_year_date(text: str) -> datetime.datetime:
|
||||||
"""Parse a given text representing a date or a partial date using the default list of formats.
|
"""Parse a given text representing a date or a partial date using the default list of formats.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +60,7 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
|
|||||||
"%a %b %d %H:%M:%S %z %Y", # Wed Jan 01 00:00:00 -0400 2020
|
"%a %b %d %H:%M:%S %z %Y", # Wed Jan 01 00:00:00 -0400 2020
|
||||||
"%b %d %Y %I:%M %p", # Jan 1 2020 0:00 pm
|
"%b %d %Y %I:%M %p", # Jan 1 2020 0:00 pm
|
||||||
"%Y%m%d%H%M%S", # 20230501083234
|
"%Y%m%d%H%M%S", # 20230501083234
|
||||||
]), to_utc: bool = True) -> datetime:
|
]), to_utc: bool = True) -> datetime.datetime:
|
||||||
"""Parse a given text representing a datetime using a list of formats,
|
"""Parse a given text representing a datetime using a list of formats,
|
||||||
optionally converting it to UTC.
|
optionally converting it to UTC.
|
||||||
"""
|
"""
|
||||||
@@ -78,12 +78,12 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
|
|||||||
)
|
)
|
||||||
for fmt in formats:
|
for fmt in formats:
|
||||||
try:
|
try:
|
||||||
dt = datetime.strptime(text, fmt) # NOQA: DTZ007, timezone is handled below
|
dt = datetime.datetime.strptime(text, fmt) # NOQA: DTZ007, timezone is handled below
|
||||||
|
|
||||||
if to_utc:
|
if to_utc:
|
||||||
dt = dt.astimezone(timezone.utc)
|
dt = dt.astimezone(datetime.timezone.utc)
|
||||||
elif dt.tzinfo is None:
|
elif dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
return dt
|
return dt
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -93,11 +93,15 @@ def parse_datetime(text: str, formats: list[str] = frozenset([
|
|||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
|
||||||
def date(year: int, month: int, day: int) -> datetime:
|
def date(year: int, month: int, day: int) -> datetime.datetime:
|
||||||
"""Create a datetime object with the given year, month and day, at midnight."""
|
"""Create a datetime object with the given year, month and day, at midnight."""
|
||||||
return datetime(year, month, day, tzinfo=timezone.utc)
|
return datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def today() -> datetime:
|
def today_at_midnight() -> datetime.datetime:
|
||||||
"""Create a datetime object with today's date, at midnight."""
|
"""Create a datetime object with today's date, at midnight."""
|
||||||
return datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
return datetime.datetime.now(tz=datetime.timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
def today() -> datetime.date:
|
||||||
|
"""Create a date object with today's date."""
|
||||||
|
return datetime.datetime.now(tz=datetime.timezone.utc).date()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ with ProductData(config.product) as product_data:
|
|||||||
|
|
||||||
date_text = cells[date_index].get_text().strip()
|
date_text = cells[date_index].get_text().strip()
|
||||||
date = dates.parse_date(date_text)
|
date = dates.parse_date(date_text)
|
||||||
if date > dates.today():
|
if date > dates.today_at_midnight():
|
||||||
logging.info(f"Skipping future version {cells}")
|
logging.info(f"Skipping future version {cells}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This script works cumulatively: when a model is not listed anymore on https://se
|
|||||||
it retains the date and use it as the model's EOL date.
|
it retains the date and use it as the model's EOL date.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TODAY = dates.today()
|
TODAY = dates.today_at_midnight()
|
||||||
|
|
||||||
frontmatter, config = parse_argv()
|
frontmatter, config = parse_argv()
|
||||||
with ProductData(config.product) as product_data:
|
with ProductData(config.product) as product_data:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ruamel.yaml import YAML, StringIO
|
|||||||
from ruamel.yaml.representer import RoundTripRepresenter
|
from ruamel.yaml.representer import RoundTripRepresenter
|
||||||
from ruamel.yaml.resolver import Resolver
|
from ruamel.yaml.resolver import Resolver
|
||||||
|
|
||||||
|
from src.common.dates import today_at_midnight
|
||||||
from src.common.endoflife import list_products
|
from src.common.endoflife import list_products
|
||||||
from src.common.gha import GitHubOutput
|
from src.common.gha import GitHubOutput
|
||||||
from src.common.releasedata import DATA_DIR
|
from src.common.releasedata import DATA_DIR
|
||||||
@@ -74,9 +75,18 @@ class ReleaseCycle:
|
|||||||
self.data["latestReleaseDate"] = date
|
self.data["latestReleaseDate"] = date
|
||||||
self.updated = True
|
self.updated = True
|
||||||
|
|
||||||
|
def release_date(self) -> datetime.date | None:
|
||||||
|
return self.__as_date(self.data.get("releaseDate", None))
|
||||||
|
|
||||||
|
def eol(self) -> datetime.date | bool | None:
|
||||||
|
return self.__as_date(self.data.get("eol", None))
|
||||||
|
|
||||||
def latest(self) -> str | None:
|
def latest(self) -> str | None:
|
||||||
return self.data.get("latest", None)
|
return self.data.get("latest", None)
|
||||||
|
|
||||||
|
def latest_release_date(self) -> datetime.date | None:
|
||||||
|
return self.__as_date(self.data.get("latestReleaseDate", None))
|
||||||
|
|
||||||
def includes(self, version: str) -> bool:
|
def includes(self, version: str) -> bool:
|
||||||
"""matches releases that are exact (such as 4.1 being the first release for the 4.1 release cycle)
|
"""matches releases that are exact (such as 4.1 being the first release for the 4.1 release cycle)
|
||||||
or releases that include a dot just after the release cycle (4.1.*)
|
or releases that include a dot just after the release cycle (4.1.*)
|
||||||
@@ -93,6 +103,19 @@ class ReleaseCycle:
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.product + '#' + self.name
|
return self.product + '#' + self.name
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __as_date(o: str | bool | datetime.datetime | datetime.date | None) -> datetime.date | bool | None:
|
||||||
|
if isinstance(o, datetime.date):
|
||||||
|
return o
|
||||||
|
if isinstance(o, datetime.datetime):
|
||||||
|
return o.date()
|
||||||
|
if isinstance(o, str):
|
||||||
|
return datetime.date.fromisoformat(o)
|
||||||
|
if isinstance(o, bool):
|
||||||
|
return o
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Product:
|
class Product:
|
||||||
def __init__(self, name: str, product_dir: Path, versions_dir: Path) -> None:
|
def __init__(self, name: str, product_dir: Path, versions_dir: Path) -> None:
|
||||||
@@ -196,6 +219,7 @@ def update_product(name: str, product_dir: Path, releases_dir: Path, output: Git
|
|||||||
today = datetime.datetime.now(tz=datetime.timezone.utc).date()
|
today = datetime.datetime.now(tz=datetime.timezone.utc).date()
|
||||||
__raise_alert_for_unmatched_versions(name, output, product, today, 30)
|
__raise_alert_for_unmatched_versions(name, output, product, today, 30)
|
||||||
__raise_alert_for_unmatched_releases(name, output, product)
|
__raise_alert_for_unmatched_releases(name, output, product)
|
||||||
|
__raise_alert_for_stale_releases(name, output, product)
|
||||||
|
|
||||||
|
|
||||||
def __raise_alert_for_unmatched_versions(name: str, output: GitHubOutput, product: Product, today: datetime.date,
|
def __raise_alert_for_unmatched_versions(name: str, output: GitHubOutput, product: Product, today: datetime.date,
|
||||||
@@ -235,6 +259,36 @@ def __raise_alert_for_unmatched_releases(name: str, output: GitHubOutput, produc
|
|||||||
__print_unmatched_releases_as_yaml(product)
|
__print_unmatched_releases_as_yaml(product)
|
||||||
|
|
||||||
|
|
||||||
|
def __raise_alert_for_stale_releases(name: str, output: GitHubOutput, product: Product) -> None:
|
||||||
|
threshold = product.data.get("staleReleaseThresholdYears", 1) * 365
|
||||||
|
|
||||||
|
for release in product.releases:
|
||||||
|
logging.debug(f"checking staleness of {name}:{release.name}")
|
||||||
|
eol = release.eol()
|
||||||
|
|
||||||
|
if isinstance(eol, datetime.date):
|
||||||
|
continue # explicitly set, skip
|
||||||
|
|
||||||
|
if eol:
|
||||||
|
continue # explicitly EOL, skip
|
||||||
|
|
||||||
|
latest_release_date = release.latest_release_date()
|
||||||
|
if latest_release_date:
|
||||||
|
days_since_latest = (today_at_midnight().date() - latest_release_date).days
|
||||||
|
if days_since_latest > threshold:
|
||||||
|
message = f"{name}:{release.name} is not EOL and has not had a release in {days_since_latest} days"
|
||||||
|
logging.warning(message)
|
||||||
|
output.println(message)
|
||||||
|
continue
|
||||||
|
|
||||||
|
release_date = release.release_date()
|
||||||
|
days_since_release = (today_at_midnight().date() - release_date).days
|
||||||
|
if days_since_release > threshold:
|
||||||
|
message = f"{name}:{release.name} was released {days_since_release} days ago and has no EOL date"
|
||||||
|
logging.warning(message)
|
||||||
|
output.println(message)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description='Update product releases.')
|
parser = argparse.ArgumentParser(description='Update product releases.')
|
||||||
parser.add_argument('product', nargs='?', help='restrict update to the given product')
|
parser.add_argument('product', nargs='?', help='restrict update to the given product')
|
||||||
|
|||||||
Reference in New Issue
Block a user