| # Copyright 2019 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import re |
| |
| from functools import total_ordering |
| from recipe_engine import recipe_api |
| |
| |
| @total_ordering |
| class ReleaseVersion(object): |
| """ |
| Representation of a release version, versioned by major number > date > |
| release number > candidate number. |
| |
| ReleaseVersions are guaranteed to be valid release versions. |
| An AssertionError will be raised if an invalid release version would be |
| created upon initialization or modification. |
| """ |
| |
| _TEMPLATE = 'releases/{major}.{date}.{release}.{candidate}' |
| _MAJOR_PATTERN = '[0-9]' |
| _DATE_PATTERN = '[0-9]{8}' |
| _RELEASE_PATTERN = '[0-9]{1,10}' |
| _CANDIDATE_PATTERN = '[1-9][0-9]{0,9}' |
| _MAJOR_REGEXP = re.compile('^%s$' % _MAJOR_PATTERN) |
| _DATE_REGEXP = re.compile('^%s$' % _DATE_PATTERN) |
| _RELEASE_REGEXP = re.compile('^%s$' % _RELEASE_PATTERN) |
| _CANDIDATE_REGEXP = re.compile('^%s$' % _CANDIDATE_PATTERN) |
| |
| def __init__(self, major, date, release, candidate): |
| self.major = major |
| self.date = date |
| self.release = release |
| self.candidate = candidate |
| |
| @property |
| def major(self): |
| return self._major |
| |
| @major.setter |
| def major(self, value): |
| """ |
| Set major number. |
| Must be a single digit. |
| """ |
| value = int(value) |
| assert re.match(self._MAJOR_REGEXP, str(value)), \ |
| '%s does not match regexp %s' % (value, self._MAJOR_REGEXP.pattern) |
| self._major = value |
| |
| @property |
| def date(self): |
| return self._date |
| |
| @date.setter |
| def date(self, value): |
| """ |
| Set date. |
| Must be in YYYYMMDD format. |
| """ |
| value = str(value) |
| assert re.match(self._DATE_REGEXP, value), \ |
| '%s does not match regexp %s' % (value, self._DATE_REGEXP.pattern) |
| self._date = value |
| |
| @property |
| def release(self): |
| return self._release |
| |
| @release.setter |
| def release(self, value): |
| """ |
| Set release number. |
| Must be 1-10 digits long. |
| """ |
| value = int(value) |
| assert re.match(self._RELEASE_REGEXP, str(value)), \ |
| '%s does not match regexp %s' % (value, self._RELEASE_REGEXP.pattern) |
| self._release = value |
| |
| @property |
| def candidate(self): |
| return self._candidate |
| |
| @candidate.setter |
| def candidate(self, value): |
| """ |
| Set release number. |
| Must be 1-10 digits long, and greater than 0. |
| """ |
| value = int(value) |
| assert re.match(self._CANDIDATE_REGEXP, str(value)), \ |
| '%s does not match regexp %s' % (value, self._CANDIDATE_REGEXP.pattern) |
| self._candidate = value |
| |
| @classmethod |
| def get_regexp(cls, |
| major=_MAJOR_PATTERN, |
| date=_DATE_PATTERN, |
| release=_RELEASE_PATTERN, |
| candidate=_CANDIDATE_PATTERN): |
| """ |
| Create a release version regexp. |
| Default values can be overriden to restrict regexp matching. |
| """ |
| return cls._TEMPLATE.format( |
| major='(?P<major>%s)' % major, |
| date='(?P<date>%s)' % date, |
| release='(?P<release>%s)' % release, |
| candidate='(?P<candidate>%s)' % candidate) |
| |
| @classmethod |
| def from_string(cls, string): |
| """ |
| Create a release version from a string. |
| """ |
| match = re.search(cls.get_regexp(), string) |
| assert match |
| return cls( |
| major=match.group('major'), |
| date=match.group('date'), |
| release=match.group('release'), |
| candidate=match.group('candidate')) |
| |
| def __repr__(self): |
| """ |
| Return a string representation of this release version. |
| """ |
| return self._TEMPLATE.format( |
| major=self.major, |
| date=self.date, |
| release=self.release, |
| candidate=self.candidate) |
| |
| def __eq__(self, other): |
| """ |
| Check whether this release version is equal to another. |
| """ |
| return ((self.major, self.date, self.release, self.candidate) == |
| (other.major, other.date, other.release, other.candidate)) # yapf: disable |
| |
| def __lt__(self, other): |
| """ |
| Check whether this release version is less than another. |
| A release version is less than another by this precedence: |
| major number < date < release number < candidate number |
| """ |
| return ((self.major, self.date, self.release, self.candidate) < |
| (other.major, other.date, other.release, other.candidate)) |
| |
| |
| class ReleaseApi(recipe_api.RecipeApi): |
| """ |
| The release module provides utilities for release commit and version |
| generation on a git repository. |
| """ |
| |
| _COMMIT_MESSAGE_PREFIX = '[release]' |
| _REVISION_LENGTH = 10 |
| _BUILDSET_TEMPLATE = 'commit/gitiles/{stripped_https_remote}/+/{revision}' |
| # Always set major number to 0 by default. |
| _MAJOR_NUMBER = 0 |
| |
| def __init__(self, *args, **kwargs): |
| super(ReleaseApi, self).__init__(*args, **kwargs) |
| |
| def get_initial_release_version(self, date): |
| """ |
| Format an initial release version. An initial release version only |
| depends on the input date. |
| |
| Args: |
| date (str): Release date as YYYYMMDD. |
| |
| Returns: |
| ReleaseVersion: Initial release version. |
| """ |
| return ReleaseVersion( |
| major=self._MAJOR_NUMBER, date=date, release=0, candidate=1) |
| |
| def get_next_release_version(self, ref, date, repo_path): |
| """ |
| Get next release version for a ref and date. |
| |
| If there is a release version reachable on ref for the input date, the |
| next release version is the max existing release version reachable on ref |
| with its release number incremented by 1, and candidate number reset to 1. |
| Otherwise, the next release version is an initial release version for |
| the input date. |
| |
| Args: |
| ref (str): Target git ref. |
| date (str): Release date as YYYYMMDD. |
| repo_path (Path): Local git repository to query release versions. |
| |
| Returns: |
| ReleaseVersion: Next release version. |
| """ |
| release_versions = self.get_release_versions( |
| ref=ref, date=date, repo_path=repo_path) |
| release_version = self.get_initial_release_version(date=date) |
| if release_versions: |
| latest_release_version = max(release_versions) |
| # If the latest release version on branch matches input date, increment |
| # release number by 1. |
| if latest_release_version.date == date: |
| release_version.release = latest_release_version.release + 1 |
| return release_version |
| |
| def get_next_candidate_version(self, ref, repo_path, add_track=False): |
| """ |
| Get next candidate version for a ref. |
| |
| The next candidate version is the maximum release version reachable on ref |
| with its candidate version incremented by 1. Note that a candidate cannot |
| be created on a revision without at least one existing release version in |
| the ref's history. |
| |
| Args: |
| ref (str): Target git ref. |
| repo_path (Path): Local git repository to query release versions. |
| add_track (bool): Whether to start a new track. |
| |
| Returns: |
| ReleaseVersion: Next candidate version. |
| |
| Raises: |
| StepFailure: No release versions found in ref's history. |
| """ |
| release_versions = self.get_release_versions(ref=ref, repo_path=repo_path) |
| # There must be existing release versions in ref's history. |
| if not release_versions: |
| raise self.m.step.StepFailure('No release versions found') |
| # Take the latest release version and increment its candidate version. |
| release_version = max(release_versions) |
| if add_track: |
| next_candidate = int(str(release_version.candidate) + '001') |
| else: |
| next_candidate = release_version.candidate + 1 |
| release_version.candidate = next_candidate |
| return release_version |
| |
| def get_release_versions(self, |
| ref, |
| repo_path, |
| date=ReleaseVersion._DATE_PATTERN): |
| """ |
| Get release versions for a ref and date. |
| |
| Args: |
| ref (str): Target git ref. |
| date (str): Release date as YYYYMMDD. Accepts pattern. |
| repo_path (Path): Local git repository to query release versions. |
| |
| Returns: |
| list<ReleaseVersion>: Matching release versions. |
| """ |
| with self.m.step.nest('get release versions on {ref}'.format(ref=ref)): |
| with self.m.context(cwd=repo_path): |
| tags = self.m.git( |
| '--no-pager', 'tag', '--merged', ref, |
| stdout=self.m.raw_io.output()).stdout |
| matches = re.findall( |
| ReleaseVersion.get_regexp(date=date), tags, re.MULTILINE) |
| return [ |
| ReleaseVersion( |
| major=major, date=date, release=release, candidate=candidate) |
| for major, date, release, candidate in matches |
| ] |
| |
| def ref_to_release_version(self, ref, repo_path): |
| """ |
| Convert a ref to a release version. |
| |
| Args: |
| ref (str): Target git ref. |
| repo_path (Path): Local git repository to describe ref in. |
| |
| Raises: |
| StepFailure: input ref is not a git tag, or it is a tag but not a |
| valid release version. |
| """ |
| with self.m.context(cwd=repo_path): |
| tag = self.m.git.describe(commit=ref, expected_num=1)[0] |
| try: |
| release_version = ReleaseVersion.from_string(tag) |
| except AssertionError: |
| raise self.m.step.StepFailure('Tag %s is not a release version' % tag) |
| return release_version |
| |
| def shorten_revision(self, revision): |
| """ |
| Shorten a revision. |
| |
| Args: |
| revision (str): A git revision SHA1. |
| |
| Returns: |
| str: Shortened revision. |
| """ |
| return revision[:ReleaseApi._REVISION_LENGTH] |