Skip to content

Commit

Permalink
ARROW-16654: [Dev][Archery] Support cherry-picking for major releases
Browse files Browse the repository at this point in the history
Run the following to see what would happens without executing it:

```
archery release --jira-cache /tmp/jiracache cherry-pick 9.0.0
```

Try creating a `maint-9.0.0` branch based off of the `master` branch:

```
archery release --jira-cache /tmp/jiracache cherry-pick 9.0.0 --execute
```

Now there should be the `maint-9.0.0` branch checked out locally. Rerunning the previous command with `--continue` option would do nothing since there are no new commits to apply:

```
archery release --jira-cache /tmp/jiracache cherry-pick 9.0.0 --execute --continue
```

So simulate new commits be resetting `maint-9.0.0` branch to three commits before the current master:

```
git branch -f maint-9.0.0 master~3
```

Apply the new patches:

```
archery release --jira-cache /tmp/jiracache cherry-pick 9.0.0 --execute --continue
```

Closes #13230 from kszucs/ARROW-16654

Authored-by: Krisztián Szűcs <szucs.krisztian@gmail.com>
Signed-off-by: Krisztián Szűcs <szucs.krisztian@gmail.com>
  • Loading branch information
kszucs committed May 30, 2022
1 parent 0066e0e commit 02af333
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 69 deletions.
16 changes: 5 additions & 11 deletions dev/archery/archery/cli.py
Expand Up @@ -900,22 +900,16 @@ def release_cherry_pick(obj, version, dry_run, recreate):
"""
Cherry pick commits.
"""
from .release import Release, MinorRelease, PatchRelease
from .release import Release

release = Release.from_jira(version, jira=obj['jira'], repo=obj['repo'])
if not isinstance(release, (MinorRelease, PatchRelease)):
raise click.UsageError('Cherry-pick command only supported for minor '
'and patch releases')

if not dry_run:
release.cherry_pick_commits(recreate_branch=recreate)
click.echo('Executed the following commands:\n')

click.echo(
'git checkout {} -b {}'.format(release.previous.tag, release.branch)
)
for commit in release.commits_to_pick():
click.echo('git cherry-pick {}'.format(commit.hexsha))
else:
click.echo(f'git checkout -b {release.branch} {release.base_branch}')
for commit in release.commits_to_pick():
click.echo('git cherry-pick {}'.format(commit.hexsha))


@archery.group("linking")
Expand Down
124 changes: 68 additions & 56 deletions dev/archery/archery/release.py
Expand Up @@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.

from abc import abstractmethod
from collections import defaultdict
import functools
import os
Expand All @@ -29,6 +30,7 @@

from .utils.source import ArrowSources
from .utils.report import JinjaReport
from .utils.logger import logger


def cached_property(fn):
Expand Down Expand Up @@ -256,20 +258,27 @@ class JiraChangelog(JinjaReport):

class Release:

def __init__(self):
raise TypeError("Do not initialize Release class directly, use "
"Release.from_jira(version) instead.")
def __new__(self, version, jira=None, repo=None):
if isinstance(version, str):
version = Version.parse(version)
elif not isinstance(version, Version):
raise TypeError(version)

def __repr__(self):
if self.version.released:
status = "released_at={!r}".format(self.version.release_date)
# decide the type of the release based on the version number
if version.patch == 0:
if version.minor == 0:
klass = MajorRelease
elif version.major == 0:
# handle minor releases before 1.0 as major releases
klass = MajorRelease
else:
klass = MinorRelease
else:
status = "pending"
return "<{} {!r} {}>".format(self.__class__.__name__,
str(self.version), status)
klass = PatchRelease

@staticmethod
def from_jira(version, jira=None, repo=None):
return super().__new__(klass)

def __init__(self, version, jira, repo):
if jira is None:
jira = Jira()
elif isinstance(jira, str):
Expand All @@ -292,44 +301,44 @@ def from_jira(version, jira=None, repo=None):
elif not isinstance(version, Version):
raise TypeError(version)

# decide the type of the release based on the version number
if version.patch == 0:
if version.minor == 0:
klass = MajorRelease
elif version.major == 0:
# handle minor releases before 1.0 as major releases
klass = MajorRelease
else:
klass = MinorRelease
else:
klass = PatchRelease
self.version = version
self.jira = jira
self.repo = repo

# prevent instantiating release object directly
obj = klass.__new__(klass)
obj.version = version
obj.jira = jira
obj.repo = repo
def __repr__(self):
if self.version.released:
status = "released_at={self.version.release_date!r}"
else:
status = "pending"
return f"<{self.__class__.__name__} {self.version!r} {status}>"

return obj
@staticmethod
def from_jira(version, jira=None, repo=None):
return Release(version, jira, repo)

@property
def is_released(self):
return self.version.released

@property
def tag(self):
return "apache-arrow-{}".format(str(self.version))
return f"apache-arrow-{self.version}"

@property
@abstractmethod
def branch(self):
raise NotImplementedError()
"""
Target branch that serves as the base for the release.
"""
...

@property
@abstractmethod
def siblings(self):
"""
Releases to consider when calculating previous and next releases.
"""
raise NotImplementedError()
...

@cached_property
def previous(self):
Expand All @@ -349,7 +358,7 @@ def next(self):
position = self.siblings.index(self.version)
if position <= 0:
raise ValueError("There is no upcoming release set in JIRA after "
"version {}".format(self.version))
f"version {self.version}")
upcoming = self.siblings[position - 1]
return Release.from_jira(upcoming, jira=self.jira, repo=self.repo)

Expand All @@ -375,11 +384,10 @@ def commits(self):
try:
upper = self.repo.branches[self.branch]
except IndexError:
warnings.warn("Release branch `{}` doesn't exist."
.format(self.branch))
warnings.warn(f"Release branch `{self.branch}` doesn't exist.")
return []

commit_range = "{}..{}".format(lower, upper)
commit_range = f"{lower}..{upper}"
return list(map(Commit, self.repo.iter_commits(commit_range)))

def curate(self):
Expand Down Expand Up @@ -436,29 +444,15 @@ def changelog(self):
categories[issue_types[issue.type]].append((issue, commit))

# sort issues by the issue key in ascending order
for name, issues in categories.items():
for issues in categories.values():
issues.sort(key=lambda pair: (pair[0].project, pair[0].number))

return JiraChangelog(release=self, categories=categories)


class MaintenanceMixin:
"""
Utility methods for cherry-picking commits from the main branch.
"""

def commits_to_pick(self, exclude_already_applied=True):
# collect commits applied on the main branch since the root of the
# maintenance branch (the previous major release)
if self.version.major == 0:
# treat minor releases as major releases preceeding 1.0.0 release
commit_range = "apache-arrow-0.{}.0..master".format(
self.version.minor
)
else:
commit_range = "apache-arrow-{}.0.0..master".format(
self.version.major
)
commit_range = f"{self.previous.tag}..master"

# keeping the original order of the commits helps to minimize the merge
# conflicts during cherry-picks
Expand All @@ -485,21 +479,31 @@ def cherry_pick_commits(self, recreate_branch=True):
# delete, create and checkout the maintenance branch based off of
# the previous tag
if self.branch in self.repo.branches:
logger.info(f"Deleting branch {self.branch}")
self.repo.git.branch('-D', self.branch)
self.repo.git.checkout(self.previous.tag, b=self.branch)
logger.info(
f"Creating branch {self.branch} from {self.base_branch} branch"
)
self.repo.git.checkout(self.base_branch, b=self.branch)
else:
# just checkout the already existing maintenance branch
logger.info(f"Checking out branch {self.branch}")
self.repo.git.checkout(self.branch)

# cherry pick the commits based on the jira tickets
for commit in self.commits_to_pick():
logger.info(f"Cherry-picking commit {commit.hexsha}")
self.repo.git.cherry_pick(commit.hexsha)


class MajorRelease(Release):

@property
def branch(self):
return f"maint-{self.version}"

@property
def base_branch(self):
return "master"

@cached_property
Expand All @@ -512,11 +516,15 @@ def siblings(self):
if v.patch == 0 and (v.major == 0 or v.minor == 0)]


class MinorRelease(Release, MaintenanceMixin):
class MinorRelease(Release):

@property
def branch(self):
return "maint-{}.x.x".format(self.version.major)
return f"maint-{self.version.major}.x.x"

@property
def base_branch(self):
return self.previous.tag

@cached_property
def siblings(self):
Expand All @@ -526,11 +534,15 @@ def siblings(self):
return [v for v in self.jira.project_versions('ARROW') if v.patch == 0]


class PatchRelease(Release, MaintenanceMixin):
class PatchRelease(Release):

@property
def branch(self):
return "maint-{}.{}.x".format(self.version.major, self.version.minor)
return f"maint-{self.version.major}.{self.version.minor}.x"

@property
def base_branch(self):
return self.previous.tag

@cached_property
def siblings(self):
Expand Down
4 changes: 2 additions & 2 deletions dev/archery/archery/tests/test_release.py
Expand Up @@ -216,7 +216,7 @@ def test_release_basics(fake_jira):
r = Release.from_jira("1.0.0", jira=fake_jira)
assert isinstance(r, MajorRelease)
assert r.is_released is True
assert r.branch == 'master'
assert r.branch == 'maint-1.0.0'
assert r.tag == 'apache-arrow-1.0.0'

r = Release.from_jira("1.1.0", jira=fake_jira)
Expand All @@ -229,7 +229,7 @@ def test_release_basics(fake_jira):
r = Release.from_jira("0.17.0", jira=fake_jira)
assert isinstance(r, MajorRelease)
assert r.is_released is True
assert r.branch == 'master'
assert r.branch == 'maint-0.17.0'
assert r.tag == 'apache-arrow-0.17.0'

r = Release.from_jira("0.17.1", jira=fake_jira)
Expand Down

0 comments on commit 02af333

Please sign in to comment.