17ec681f3Smrg#!/usr/bin/env python3
27ec681f3Smrg# SPDX-License-Identifier: MIT
37ec681f3Smrg
47ec681f3Smrg# Copyright © 2021 Intel Corporation
57ec681f3Smrg
67ec681f3Smrg# Permission is hereby granted, free of charge, to any person obtaining a copy
77ec681f3Smrg# of this software and associated documentation files (the "Software"), to deal
87ec681f3Smrg# in the Software without restriction, including without limitation the rights
97ec681f3Smrg# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
107ec681f3Smrg# copies of the Software, and to permit persons to whom the Software is
117ec681f3Smrg# furnished to do so, subject to the following conditions:
127ec681f3Smrg
137ec681f3Smrg# The above copyright notice and this permission notice shall be included in
147ec681f3Smrg# all copies or substantial portions of the Software.
157ec681f3Smrg
167ec681f3Smrg# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
177ec681f3Smrg# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
187ec681f3Smrg# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
197ec681f3Smrg# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
207ec681f3Smrg# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
217ec681f3Smrg# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
227ec681f3Smrg# SOFTWARE.
237ec681f3Smrg
247ec681f3Smrg"""Helper script for manipulating the release calendar."""
257ec681f3Smrg
267ec681f3Smrgfrom __future__ import annotations
277ec681f3Smrgimport argparse
287ec681f3Smrgimport csv
297ec681f3Smrgimport contextlib
307ec681f3Smrgimport datetime
317ec681f3Smrgimport pathlib
327ec681f3Smrgimport subprocess
337ec681f3Smrgimport typing
347ec681f3Smrg
357ec681f3Smrgif typing.TYPE_CHECKING:
367ec681f3Smrg    import _csv
377ec681f3Smrg    from typing_extensions import Protocol
387ec681f3Smrg
397ec681f3Smrg    class RCArguments(Protocol):
407ec681f3Smrg        """Typing information for release-candidate command arguments."""
417ec681f3Smrg
427ec681f3Smrg        manager: str
437ec681f3Smrg
447ec681f3Smrg    class FinalArguments(Protocol):
457ec681f3Smrg        """Typing information for release command arguments."""
467ec681f3Smrg
477ec681f3Smrg        series: str
487ec681f3Smrg        manager: str
497ec681f3Smrg        zero_released: bool
507ec681f3Smrg
517ec681f3Smrg    class ExtendArguments(Protocol):
527ec681f3Smrg        """Typing information for extend command arguments."""
537ec681f3Smrg
547ec681f3Smrg        series: str
557ec681f3Smrg        count: int
567ec681f3Smrg
577ec681f3Smrg
587ec681f3Smrg    CalendarRowType = typing.Tuple[typing.Optional[str], str, str, str, typing.Optional[str]]
597ec681f3Smrg
607ec681f3Smrg
617ec681f3Smrg_ROOT = pathlib.Path(__file__).parent.parent
627ec681f3SmrgCALENDAR_CSV = _ROOT / 'docs' / 'release-calendar.csv'
637ec681f3SmrgVERSION = _ROOT / 'VERSION'
647ec681f3SmrgLAST_RELEASE = 'This is the last planned release of the {}.x series.'
657ec681f3SmrgOR_FINAL = 'Or {}.0 final.'
667ec681f3Smrg
677ec681f3Smrg
687ec681f3Smrgdef read_calendar() -> typing.List[CalendarRowType]:
697ec681f3Smrg    """Read the calendar and return a list of it's rows."""
707ec681f3Smrg    with CALENDAR_CSV.open('r') as f:
717ec681f3Smrg        return [typing.cast('CalendarRowType', tuple(r)) for r in csv.reader(f)]
727ec681f3Smrg
737ec681f3Smrg
747ec681f3Smrgdef commit(message: str) -> None:
757ec681f3Smrg    """Commit the changes the the release-calendar.csv file."""
767ec681f3Smrg    subprocess.run(['git', 'commit', str(CALENDAR_CSV), '--message', message])
777ec681f3Smrg
787ec681f3Smrg
797ec681f3Smrg
807ec681f3Smrgdef _calculate_release_start(major: str, minor: str) -> datetime.date:
817ec681f3Smrg    """Calclulate the start of the release for release candidates.
827ec681f3Smrg
837ec681f3Smrg    This is quarterly, on the second wednesday, in Januray, April, July, and Octobor.
847ec681f3Smrg    """
857ec681f3Smrg    quarter = datetime.date.fromisoformat(f'20{major}-0{[1, 4, 7, 10][int(minor)]}-01')
867ec681f3Smrg
877ec681f3Smrg    # Wednesday is 3
887ec681f3Smrg    day = quarter.isoweekday()
897ec681f3Smrg    if day > 3:
907ec681f3Smrg        # this will walk back into the previous month, it's much simpler to
917ec681f3Smrg        # duplicate the 14 than handle the calculations for the month and year
927ec681f3Smrg        # changing.
937ec681f3Smrg        return quarter.replace(day=quarter.day - day + 3 + 14)
947ec681f3Smrg    elif day < 3:
957ec681f3Smrg        quarter = quarter.replace(day=quarter.day + 3 - day)
967ec681f3Smrg    return quarter.replace(day=quarter.day + 14)
977ec681f3Smrg
987ec681f3Smrg
997ec681f3Smrgdef release_candidate(args: RCArguments) -> None:
1007ec681f3Smrg    """Add release candidate entries."""
1017ec681f3Smrg    with VERSION.open('r') as f:
1027ec681f3Smrg        version = f.read().rstrip('-devel')
1037ec681f3Smrg    major, minor, _ = version.split('.')
1047ec681f3Smrg    date = _calculate_release_start(major, minor)
1057ec681f3Smrg
1067ec681f3Smrg    data = read_calendar()
1077ec681f3Smrg
1087ec681f3Smrg    with CALENDAR_CSV.open('w') as f:
1097ec681f3Smrg        writer = csv.writer(f)
1107ec681f3Smrg        writer.writerows(data)
1117ec681f3Smrg
1127ec681f3Smrg        writer.writerow([f'{major}.{minor}', date.isoformat(), f'{major}.{minor}.0-rc1', args.manager])
1137ec681f3Smrg        for row in range(2, 4):
1147ec681f3Smrg            date = date + datetime.timedelta(days=7)
1157ec681f3Smrg            writer.writerow([None, date.isoformat(), f'{major}.{minor}.0-rc{row}', args.manager])
1167ec681f3Smrg        date = date + datetime.timedelta(days=7)
1177ec681f3Smrg        writer.writerow([None, date.isoformat(), f'{major}.{minor}.0-rc4', args.manager, OR_FINAL.format(f'{major}.{minor}')])
1187ec681f3Smrg
1197ec681f3Smrg    commit(f'docs: Add calendar entries for {major}.{minor} release candidates.')
1207ec681f3Smrg
1217ec681f3Smrg
1227ec681f3Smrgdef _calculate_next_release_date(next_is_zero: bool) -> datetime.date:
1237ec681f3Smrg    """Calculate the date of the next release.
1247ec681f3Smrg
1257ec681f3Smrg    If the next is .0, we have the release in seven days, if the next is .1,
1267ec681f3Smrg    then it's in 14
1277ec681f3Smrg    """
1287ec681f3Smrg    date = datetime.date.today()
1297ec681f3Smrg    day = date.isoweekday()
1307ec681f3Smrg    if day < 3:
1317ec681f3Smrg        delta = 3 - day
1327ec681f3Smrg    elif day > 3:
1337ec681f3Smrg        # this will walk back into the previous month, it's much simpler to
1347ec681f3Smrg        # duplicate the 14 than handle the calculations for the month and year
1357ec681f3Smrg        # changing.
1367ec681f3Smrg        delta = (3 - day)
1377ec681f3Smrg    else:
1387ec681f3Smrg        delta = 0
1397ec681f3Smrg    delta += 7
1407ec681f3Smrg    if not next_is_zero:
1417ec681f3Smrg        delta += 7
1427ec681f3Smrg    return date + datetime.timedelta(days=delta)
1437ec681f3Smrg
1447ec681f3Smrg
1457ec681f3Smrgdef final_release(args: FinalArguments) -> None:
1467ec681f3Smrg    """Add final release entries."""
1477ec681f3Smrg    data = read_calendar()
1487ec681f3Smrg    date = _calculate_next_release_date(not args.zero_released)
1497ec681f3Smrg
1507ec681f3Smrg    with CALENDAR_CSV.open('w') as f:
1517ec681f3Smrg        writer = csv.writer(f)
1527ec681f3Smrg        writer.writerows(data)
1537ec681f3Smrg
1547ec681f3Smrg        base = 1 if args.zero_released else 0
1557ec681f3Smrg
1567ec681f3Smrg        writer.writerow([args.series, date.isoformat(), f'{args.series}.{base}', args.manager])
1577ec681f3Smrg        for row in range(base + 1, 3):
1587ec681f3Smrg            date = date + datetime.timedelta(days=14)
1597ec681f3Smrg            writer.writerow([None, date.isoformat(), f'{args.series}.{row}', args.manager])
1607ec681f3Smrg        date = date + datetime.timedelta(days=14)
1617ec681f3Smrg        writer.writerow([None, date.isoformat(), f'{args.series}.3', args.manager, LAST_RELEASE.format(args.series)])
1627ec681f3Smrg
1637ec681f3Smrg    commit(f'docs: Add calendar entries for {args.series} release.')
1647ec681f3Smrg
1657ec681f3Smrg
1667ec681f3Smrgdef extend(args: ExtendArguments) -> None:
1677ec681f3Smrg    """Extend a release."""
1687ec681f3Smrg    @contextlib.contextmanager
1697ec681f3Smrg    def write_existing(writer: _csv._writer, current: typing.List[CalendarRowType]) -> typing.Iterator[CalendarRowType]:
1707ec681f3Smrg        """Write the orinal file, yield to insert new entries.
1717ec681f3Smrg
1727ec681f3Smrg        This is a bit clever, basically what happens it writes out the
1737ec681f3Smrg        original csv file until it reaches the start of the release after the
1747ec681f3Smrg        one we're appending, then it yields the last row. When control is
1757ec681f3Smrg        returned it writes out the rest of the original calendar data.
1767ec681f3Smrg        """
1777ec681f3Smrg        last_row: typing.Optional[CalendarRowType] = None
1787ec681f3Smrg        in_wanted = False
1797ec681f3Smrg        for row in current:
1807ec681f3Smrg            if in_wanted and row[0]:
1817ec681f3Smrg                in_wanted = False
1827ec681f3Smrg                assert last_row is not None
1837ec681f3Smrg                yield last_row
1847ec681f3Smrg            if row[0] == args.series:
1857ec681f3Smrg                in_wanted = True
1867ec681f3Smrg            if in_wanted and len(row) >= 5 and row[4] in {LAST_RELEASE.format(args.series), OR_FINAL.format(args.series)}:
1877ec681f3Smrg                # If this was the last planned release and we're adding more,
1887ec681f3Smrg                # then we need to remove that message and add it elsewhere
1897ec681f3Smrg                r = list(row)
1907ec681f3Smrg                r[4] = None
1917ec681f3Smrg                # Mypy can't figure this out…
1927ec681f3Smrg                row = typing.cast('CalendarRowType', tuple(r))
1937ec681f3Smrg            last_row = row
1947ec681f3Smrg            writer.writerow(row)
1957ec681f3Smrg        # If this is the only entry we can hit a case where the contextmanager
1967ec681f3Smrg        # hasn't yielded
1977ec681f3Smrg        if in_wanted:
1987ec681f3Smrg            yield row
1997ec681f3Smrg
2007ec681f3Smrg    current = read_calendar()
2017ec681f3Smrg
2027ec681f3Smrg    with CALENDAR_CSV.open('w') as f:
2037ec681f3Smrg        writer = csv.writer(f)
2047ec681f3Smrg        with write_existing(writer, current) as row:
2057ec681f3Smrg            # Get rid of -rcX as well
2067ec681f3Smrg            if '-rc' in row[2]:
2077ec681f3Smrg                first_point = int(row[2].split('rc')[-1]) + 1
2087ec681f3Smrg                template = '{}.0-rc{}'
2097ec681f3Smrg                days = 7
2107ec681f3Smrg            else:
2117ec681f3Smrg                first_point = int(row[2].split('-')[0].split('.')[-1]) + 1
2127ec681f3Smrg                template = '{}.{}'
2137ec681f3Smrg                days = 14
2147ec681f3Smrg
2157ec681f3Smrg            date = datetime.date.fromisoformat(row[1])
2167ec681f3Smrg            for i in range(first_point, first_point + args.count):
2177ec681f3Smrg                date = date + datetime.timedelta(days=days)
2187ec681f3Smrg                r = [None, date.isoformat(), template.format(args.series, i), row[3], None]
2197ec681f3Smrg                if i == first_point + args.count - 1:
2207ec681f3Smrg                    if days == 14:
2217ec681f3Smrg                        r[4] = LAST_RELEASE.format(args.series)
2227ec681f3Smrg                    else:
2237ec681f3Smrg                        r[4] = OR_FINAL.format(args.series)
2247ec681f3Smrg                writer.writerow(r)
2257ec681f3Smrg
2267ec681f3Smrg    commit(f'docs: Extend calendar entries for {args.series} by {args.count} releases.')
2277ec681f3Smrg
2287ec681f3Smrg
2297ec681f3Smrgdef main() -> None:
2307ec681f3Smrg    parser = argparse.ArgumentParser()
2317ec681f3Smrg    sub = parser.add_subparsers()
2327ec681f3Smrg
2337ec681f3Smrg    rc = sub.add_parser('release-candidate', aliases=['rc'], help='Generate calendar entries for a release candidate.')
2347ec681f3Smrg    rc.add_argument('manager', help="the name of the person managing the release.")
2357ec681f3Smrg    rc.set_defaults(func=release_candidate)
2367ec681f3Smrg
2377ec681f3Smrg    fr = sub.add_parser('release', help='Generate calendar entries for a final release.')
2387ec681f3Smrg    fr.add_argument('manager', help="the name of the person managing the release.")
2397ec681f3Smrg    fr.add_argument('series', help='The series to extend, such as "29.3" or "30.0".')
2407ec681f3Smrg    fr.add_argument('--zero-released', action='store_true', help='The .0 release was today, the next release is .1')
2417ec681f3Smrg    fr.set_defaults(func=final_release)
2427ec681f3Smrg
2437ec681f3Smrg    ex = sub.add_parser('extend', help='Generate additional entries for a release.')
2447ec681f3Smrg    ex.add_argument('series', help='The series to extend, such as "29.3" or "30.0".')
2457ec681f3Smrg    ex.add_argument('count', type=int, help='The number of new entries to add.')
2467ec681f3Smrg    ex.set_defaults(func=extend)
2477ec681f3Smrg
2487ec681f3Smrg    args = parser.parse_args()
2497ec681f3Smrg    args.func(args)
2507ec681f3Smrg
2517ec681f3Smrg
2527ec681f3Smrgif __name__ == "__main__":
2537ec681f3Smrg    main()
254