From 13dd156f40aae7af175e9c333055e88454d762e1 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Fri, 10 Apr 2026 23:59:02 +0200 Subject: [PATCH 01/16] Replace deprecated pipes.quote with shlex.quote The pipes module was removed in Python 3.13. shlex.quote is the recommended replacement and has been available since Python 3.3. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/lib/git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bert_e/lib/git.py b/bert_e/lib/git.py index 8908a42b..728e49b9 100644 --- a/bert_e/lib/git.py +++ b/bert_e/lib/git.py @@ -16,7 +16,7 @@ import os import time from collections import defaultdict -from pipes import quote +from shlex import quote from shutil import rmtree from tempfile import mkdtemp From d57333410558bfb3d699e161db1555e3c6675451 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Fri, 10 Apr 2026 23:59:14 +0200 Subject: [PATCH 02/16] Ensure fast-forward merges in local clone regardless of host git config Set merge.ff=true in the local clone so that git fast-forwards when possible, regardless of any merge.ff=no set in the operator's global gitconfig. The queueing mechanism relies on q/ being fast-forwarded to pr.src_commit so that handle_commit can locate the queue branch from the commit sha. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/workflow/git_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bert_e/workflow/git_utils.py b/bert_e/workflow/git_utils.py index 09408a50..bc92fcb1 100644 --- a/bert_e/workflow/git_utils.py +++ b/bert_e/workflow/git_utils.py @@ -139,4 +139,5 @@ def clone_git_repo(job): repo.config('user.email', job.settings.robot_email) repo.config('user.name', job.settings.robot) repo.config('merge.renameLimit', '999999') + repo.config('merge.ff', 'true') return repo From f7f2100190c8d5de007fe1e541b2a110b3dc1366 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Fri, 10 Apr 2026 23:59:29 +0200 Subject: [PATCH 03/16] Extend hotfix branch support to pre-GA mode Before GA (no 10.0.0.X tag exists), a hotfix/X.Y.Z branch may already exist to receive the first GA patch series. Previously bert-e only handled hotfix branches post-GA (once a tag existed). This extends three areas: - HotfixBranch.hfrev now defaults to 0 instead of -1, and version is initialised to X.Y.Z.0. update_versions already increments hfrev past any existing tag, so post-GA behaviour is preserved. - BranchCascade stores hotfix branches as phantom entries when the PR destination is a development branch. This allows _update_major_versions to see minor=0 from hotfix/10.0.0 and produce target 10.1.0 for development/10, mirroring the post-GA behaviour that previously required the 10.0.0.0 tag to be present. - get_queue_integration_branch uses hfrev >= 0 (was > 0) to recognise pre-GA hotfix branches when naming the queue integration branch, producing q/w/PR/10.0.0.0/... instead of falling back to the integration branch version. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/workflow/gitwaterflow/branches.py | 40 +++++++++++++++++++----- bert_e/workflow/gitwaterflow/queueing.py | 2 +- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/bert_e/workflow/gitwaterflow/branches.py b/bert_e/workflow/gitwaterflow/branches.py index 0d40ee73..2e7ba32b 100644 --- a/bert_e/workflow/gitwaterflow/branches.py +++ b/bert_e/workflow/gitwaterflow/branches.py @@ -187,6 +187,12 @@ class HotfixBranch(GWFBranch): cascade_consumer = True can_be_destination = True allow_prefixes = FeatureBranch.all_prefixes + hfrev = 0 # pre-GA default: no tags yet (overrides GWFBranch.hfrev = -1) + + def __init__(self, repo, name): + super().__init__(repo, name) + # Default version includes .0 until update_versions() sees a tag + self.version = '%d.%d.%d.%d' % (self.major, self.minor, self.micro, 0) def __eq__(self, other): return (self.__class__ == other.__class__ and @@ -635,8 +641,9 @@ def failed_prs(self): qint = self._queues[version][QueueIntegrationBranch] if qint: qint = qint[0] + tip = qint.get_latest_commit() status = self.bbrepo.get_build_status( - qint.get_latest_commit(), + tip, self.build_key ) if status == 'FAILED': @@ -858,6 +865,7 @@ def __init__(self): self.ignored_branches = [] # store branch names (easier sort) self.target_versions = [] self._merge_paths = [] + self._phantom_hotfixes = [] # hotfix branches stored outside cascade for version calc def build(self, repo, dst_branch=None): flat_branches = set() @@ -927,7 +935,15 @@ def add_branch(self, branch, dst_branch=None): branch.micro != dst_branch.micro: # this is not the hotfix branch we want to add return + elif not dst_branch: + # No destination context (e.g. queue management) — skip + return else: + # Non-hotfix destination: store the hotfix as a phantom so + # _update_major_versions() can advance development/'s + # latest_minor past the hotfix line (e.g. dev/10 → 10.1.0 + # once hotfix/10.0.0 exists) without polluting the cascade. + self._phantom_hotfixes.append(branch) return # Create key based on full version tuple @@ -1071,15 +1087,22 @@ def _update_major_versions(self): major_branch: DevelopmentBranch = branch_set[DevelopmentBranch] major = version_tuple[0] - # Find all minor versions for this major + # Find all minor versions for this major from the cascade minors = [ - version_tuple[1] for version_tuple in self._cascade.keys() - if (len(version_tuple) >= 2 and - version_tuple[0] == major and - version_tuple[1] is not None) + vt[1] for vt in self._cascade.keys() + if (len(vt) >= 2 and vt[0] == major and + vt[1] is not None) ] minors.append(major_branch.latest_minor) + # Also include phantom hotfix branches (stored separately to + # avoid corrupting the cascade) so that e.g. hotfix/10.0.0 + # advances dev/10's latest_minor to 0 even without a tag. + minors.extend( + hf.minor for hf in self._phantom_hotfixes + if hf.major == major + ) + major_branch.latest_minor = max(minors) elif len(version_tuple) == 2 and version_tuple[1] is not None: minor_branch: DevelopmentBranch = branch_set[DevelopmentBranch] @@ -1237,8 +1260,9 @@ def finalize(self, dst_branch): # For hotfix destinations, ignore all dev branches self.ignored_branches.append(dev_branch.name) branch_set[DevelopmentBranch] = None - # Handle hotfix branches - if hf_branch: + # Handle hotfix branches — only a merge destination for hotfix PRs; + # for dev PRs they are phantom entries used only for version tracking + if hf_branch and dst_hf: self.dst_branches.append(hf_branch) # Clean up the cascade by removing keys with no branches diff --git a/bert_e/workflow/gitwaterflow/queueing.py b/bert_e/workflow/gitwaterflow/queueing.py index ccc4afab..ee726b2c 100644 --- a/bert_e/workflow/gitwaterflow/queueing.py +++ b/bert_e/workflow/gitwaterflow/queueing.py @@ -107,7 +107,7 @@ def get_queue_integration_branch(job, pr_id, wbranch: IntegrationBranch """Get the q/w/pr_id/x.y/* branch corresponding to a w/x.y/* branch.""" wbranch_version = None if len(job.git.cascade.dst_branches) == 1 and \ - job.git.cascade.dst_branches[0].hfrev > 0: + job.git.cascade.dst_branches[0].hfrev >= 0: wbranch_version = job.git.cascade.dst_branches[0].version else: wbranch_version = wbranch.version From d7fa65b40ed8a4e9a99eae29bdc461092fb61180 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Fri, 10 Apr 2026 23:59:39 +0200 Subject: [PATCH 04/16] Add tests for pre-GA hotfix branch support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hotfix/10.0.0 to the mock test repository setup so integration tests can target it. - test_branch_cascade_target_hotfix: add a pre-GA case (no 6.6.6.X tag) to verify the target version is 6.6.6.0. - test_branch_cascade_dev_with_pre_ga_hotfix: verify that when hotfix/10.0.0 exists but no 10.0.0.X tag is present, development/10 targets 10.1.0 (same result as post-GA). - test_pr_hotfix_pre_ga: end-to-end integration test — PR targeting hotfix/10.0.0 pre-GA is queued under q/w/PR/10.0.0.0/..., build succeeds, and the PR merges cleanly. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 90009e56..249b6e41 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -441,6 +441,10 @@ def test_branch_cascade_target_hotfix(self): 8: {'name': 'hotfix/10.0.4', 'ignore': True}, 9: {'name': 'development/10.0', 'ignore': True} }) + # Pre-GA: no 6.6.6.X tag yet — target version must be 6.6.6.0 + tags = ['4.3.16', '4.3.17', '5.1.3', '10.0.3.1'] + fixver = ['6.6.6.0'] + self.finalize_cascade(branches, tags, destination, fixver) tags = ['4.3.16', '4.3.17', '5.1.3', '6.6.6', '10.0.3.1'] fixver = ['6.6.6.1'] self.finalize_cascade(branches, tags, destination, fixver) @@ -455,6 +459,27 @@ def test_branch_cascade_target_hotfix(self): fixver = ['6.6.6.3'] self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_dev_with_pre_ga_hotfix(self): + """development/ must target ..0 once + hotfix/..0 exists, even without the GA tag.""" + # Use development/9.5 (2-digit) as source so tags on the 9.5 line + # don't conflict with the 3-digit dev branch path. + destination = 'development/9.5' + branches = OrderedDict({ + 1: {'name': 'development/9.5', 'ignore': False}, + 2: {'name': 'hotfix/10.0.0', 'ignore': True}, + 3: {'name': 'development/10', 'ignore': False}, + }) + # Pre-GA: hotfix/10.0.0 exists but no 10.0.0.X tag yet. + # development/10 must target 10.1.0, NOT 10.0.0. + tags = ['9.5.2'] + fixver = ['9.5.3', '10.1.0'] + self.finalize_cascade(branches, tags, destination, fixver) + + # Post-GA: 10.0.0.0 tag present — development/10 still targets 10.1.0 + tags = ['9.5.2', '10.0.0.0'] + self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_target_three_digit_dev(self): """Test cascade targeting three-digit development branch""" destination = 'development/4.3.17' @@ -5899,6 +5924,28 @@ def test_pr_dev_and_hotfix_merged_in_the_same_time(self): self.handle(sha1, options=self.bypass_all, backtrace=True) self.assertEqual(self.prs_in_queue(), set()) + def test_pr_hotfix_pre_ga(self): + """PR targeting a hotfix branch before GA (no 10.0.0.X tag yet) must + use version 10.0.0.0 for the queue branch and Jira fix version.""" + # No tag for 10.0.0 at all — pre-GA state + pr = self.create_pr('bugfix/TEST-00000', 'hotfix/10.0.0') + with self.assertRaises(exns.Queued): + self.handle(pr.id, options=self.bypass_all, backtrace=True) + self.assertEqual(self.prs_in_queue(), {pr.id}) + + # Queue branch must use 10.0.0.0 (not 10.0.0.1 or 10.0.0.-1) + sha1 = self.set_build_status_on_branch_tip( + 'q/w/%d/10.0.0.0/bugfix/TEST-00000' % pr.id, 'FAILED') + with self.assertRaises(exns.QueueBuildFailed): + self.handle(sha1, options=self.bypass_all, backtrace=True) + self.assertEqual(self.prs_in_queue(), {pr.id}) + + sha1 = self.set_build_status_on_branch_tip( + 'q/w/%d/10.0.0.0/bugfix/TEST-00000' % pr.id, 'SUCCESSFUL') + with self.assertRaises(exns.Merged): + self.handle(sha1, options=self.bypass_all, backtrace=True) + self.assertEqual(self.prs_in_queue(), set()) + def test_pr_hotfix_alone(self): self.gitrepo.cmd('git tag 10.0.0.0') self.gitrepo.cmd('git push --tags') From 31374d5acbf51a3d4b21be952bfd40b7c4193b50 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Sat, 11 Apr 2026 00:12:32 +0200 Subject: [PATCH 05/16] Add cascade test for mixed 3-digit and pre-GA hotfix scenario Verifies the full coexistence of development/9.5.3, hotfix/10.0.0 (pre-GA), development/10.0.1, development/10.1, and development/10: - Cascade from dev/9.5.3 goes through 10.0.1 -> 10.1 -> 10 - hotfix/10.0.0 (phantom) does not appear in merge paths or ignored list - dev/10.latest_minor is driven by dev/10.1 (minor=1), so dev/10 targets 10.2.0 regardless of the phantom hotfix contributing minor=0 - 3-digit branches target their own version when no 2-digit ancestor is present in the cascade - Post-GA (10.0.0.0 tag present): same targets, tag is effectively ignored since dev/10.0 and dev/10.0.0 are absent from the cascade Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 249b6e41..7bce0778 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -480,6 +480,40 @@ def test_branch_cascade_dev_with_pre_ga_hotfix(self): tags = ['9.5.2', '10.0.0.0'] self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_mixed_3digit_and_pre_ga_hotfix(self): + """Full mixed cascade: 3-digit dev branches, pre-GA hotfix, 2-digit + and 1-digit dev branches all coexist correctly. + + Scenario: dev/9.5.3, hotfix/10.0.0 (pre-GA), dev/10.0.1, dev/10.1, + dev/10. Verifies that: + - a PR from dev/9.5.3 cascades through 10.0.1 -> 10.1 -> 10 + - dev/10.0.1 targets 10.0.2 + - dev/10.1 targets 10.1.0 + - dev/10 targets 10.2.0 (latest_minor comes from dev/10.1, not hotfix) + - hotfix/10.0.0 does not appear in the cascade destination list + """ + destination = 'development/9.5.3' + branches = OrderedDict({ + 1: {'name': 'development/9.5.3', 'ignore': False}, + 2: {'name': 'hotfix/10.0.0', 'ignore': True}, + 3: {'name': 'development/10.0.1', 'ignore': False}, + 4: {'name': 'development/10.1', 'ignore': False}, + 5: {'name': 'development/10', 'ignore': False}, + }) + # Pre-GA: hotfix/10.0.0 exists but no 10.0.0.X tag yet. + # 3-digit branches (9.5.3, 10.0.1) target their own version because + # there are no ancestor 2-digit branches (dev/9.5, dev/10.0) in the + # cascade to drive _next_micro. + # dev/10.1 → 10.1.0, dev/10 → 10.2.0 (latest_minor=1 from dev/10.1). + tags = ['9.5.0', '9.5.1'] + fixver = ['9.5.3', '10.0.1', '10.1.0', '10.2.0'] + self.finalize_cascade(branches, tags, destination, fixver) + + # Post-GA: 10.0.0.0 tag is ignored (no dev/10.0 or dev/10.0.0 in + # cascade), so targets are unchanged. + tags = ['9.5.0', '9.5.1', '10.0.0.0'] + self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_target_three_digit_dev(self): """Test cascade targeting three-digit development branch""" destination = 'development/4.3.17' From d8dc58417b7f2bae70ca39a3bce63c40d6004030 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Sat, 11 Apr 2026 00:21:52 +0200 Subject: [PATCH 06/16] Add cascade test for 2-digit dev/10.0 coexisting with pre-GA hotfix/10.0.0 Verifies dev/9.5, hotfix/10.0.0, dev/10.0, dev/10: - Pre-GA: dev/10.0 targets 10.0.0, dev/10 targets 10.1.0 - Post-GA (10.0.0.0 tag): dev/10.0 advances to 10.0.1, dev/10 unchanged Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 7bce0778..22b5b444 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -514,6 +514,32 @@ def test_branch_cascade_mixed_3digit_and_pre_ga_hotfix(self): tags = ['9.5.0', '9.5.1', '10.0.0.0'] self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_2digit_with_pre_ga_hotfix(self): + """2-digit dev branches coexisting with pre-GA hotfix. + + Scenario: dev/9.5, hotfix/10.0.0 (pre-GA), dev/10.0, dev/10. + Note: in the rename-based workflow hotfix/10.0.0 replaces dev/10.0; + but both can coexist when the hotfix is branched off before GA. + """ + destination = 'development/9.5' + branches = OrderedDict({ + 1: {'name': 'development/9.5', 'ignore': False}, + 2: {'name': 'hotfix/10.0.0', 'ignore': True}, + 3: {'name': 'development/10.0', 'ignore': False}, + 4: {'name': 'development/10', 'ignore': False}, + }) + # Pre-GA: no tags for 10.x yet + # dev/10.0 targets 10.0.0, dev/10 targets 10.1.0 (latest_minor=0) + tags = ['9.5.2'] + fixver = ['9.5.3', '10.0.0', '10.1.0'] + self.finalize_cascade(branches, tags, destination, fixver) + + # Post-GA: tag 10.0.0.0 advances dev/10.0 to target 10.0.1 + # dev/10 still targets 10.1.0 (latest_minor=0 unchanged) + tags = ['9.5.2', '10.0.0.0'] + fixver = ['9.5.3', '10.0.1', '10.1.0'] + self.finalize_cascade(branches, tags, destination, fixver) + def test_branch_cascade_target_three_digit_dev(self): """Test cascade targeting three-digit development branch""" destination = 'development/4.3.17' From f12e28aed4634a276d65b16751313956ba7e42c6 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Mon, 13 Apr 2026 10:33:52 +0200 Subject: [PATCH 07/16] Fix E501 flake8 line-too-long violations in branches.py Co-Authored-By: Claude Sonnet 4.6 --- bert_e/workflow/gitwaterflow/branches.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bert_e/workflow/gitwaterflow/branches.py b/bert_e/workflow/gitwaterflow/branches.py index 2e7ba32b..5434ddcc 100644 --- a/bert_e/workflow/gitwaterflow/branches.py +++ b/bert_e/workflow/gitwaterflow/branches.py @@ -865,7 +865,8 @@ def __init__(self): self.ignored_branches = [] # store branch names (easier sort) self.target_versions = [] self._merge_paths = [] - self._phantom_hotfixes = [] # hotfix branches stored outside cascade for version calc + # hotfix branches stored outside cascade for version calc only + self._phantom_hotfixes = [] def build(self, repo, dst_branch=None): flat_branches = set() @@ -1260,8 +1261,9 @@ def finalize(self, dst_branch): # For hotfix destinations, ignore all dev branches self.ignored_branches.append(dev_branch.name) branch_set[DevelopmentBranch] = None - # Handle hotfix branches — only a merge destination for hotfix PRs; - # for dev PRs they are phantom entries used only for version tracking + # Handle hotfix branches — only a merge destination for + # hotfix PRs; for dev PRs they are phantom entries used + # only for version tracking if hf_branch and dst_hf: self.dst_branches.append(hf_branch) From c6523518c996c889bd9ffd679c82a1757782b16a Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Mon, 13 Apr 2026 12:09:36 +0200 Subject: [PATCH 08/16] Fix regressions from HotfixBranch.hfrev=0 default Two regressions introduced when HotfixBranch.version was changed to include .0 suffix by default (hfrev=0 class attribute): 1. create_branch.py: archive-tag check fired for HotfixBranch because version='X.Y.Z.0' matched the GA release tag 'X.Y.Z.0'. Skip this check for hotfix branches since their archive tags use the format 'X.Y.Z.hfrev.archived_hotfix_branch'. Also fix branch_from to use the 3-digit base form '%d.%d.%d.0' instead of version+'.0' which would produce 'X.Y.Z.0.0'. 2. branches.py: has_version_queued_prs() was only prefix-matching for hfrev==-1 (legacy sentinel), missing the case where branch_factory() creates a HotfixBranch with hfrev==0 but update_versions() already advanced the queue to hfrev==1 (post-GA). Extend the fallback prefix match to cover hfrev==0 as well, with an exact-match-first strategy to preserve correct pre-GA behaviour. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/jobs/create_branch.py | 11 ++++++++--- bert_e/workflow/gitwaterflow/branches.py | 15 +++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/bert_e/jobs/create_branch.py b/bert_e/jobs/create_branch.py index 4f4f22a5..d5731a97 100644 --- a/bert_e/jobs/create_branch.py +++ b/bert_e/jobs/create_branch.py @@ -62,7 +62,10 @@ def create_branch(job: CreateBranchJob): # do not allow recreating a previously existing identical branch # (unless archive tag is manually removed) - if new_branch.version in repo.cmd('git tag').split('\n')[:-1]: + # Hotfix archive tags use format 'X.Y.Z.hfrev.archived_hotfix_branch', + # not just 'X.Y.Z.hfrev', so skip this check for hotfix branches. + if not isinstance(new_branch, HotfixBranch) and \ + new_branch.version in repo.cmd('git tag').split('\n')[:-1]: raise exceptions.JobFailure('Cannot create branch %r because there is ' 'already an archive tag %r in the ' 'repository.' % @@ -81,8 +84,10 @@ def create_branch(job: CreateBranchJob): # ...or determine the branching point automatically else: if isinstance(new_branch, HotfixBranch): - # start from tag X.Y.Z.0 - job.settings.branch_from = new_branch.version + '.0' + # start from tag X.Y.Z.0 (use 3-digit base to avoid double .0) + base_ver = '%d.%d.%d' % (new_branch.major, + new_branch.minor, new_branch.micro) + job.settings.branch_from = base_ver + '.0' else: job.settings.branch_from = dev_branches[0] for dev_branch in dev_branches: diff --git a/bert_e/workflow/gitwaterflow/branches.py b/bert_e/workflow/gitwaterflow/branches.py index 5434ddcc..597cd10b 100644 --- a/bert_e/workflow/gitwaterflow/branches.py +++ b/bert_e/workflow/gitwaterflow/branches.py @@ -829,12 +829,19 @@ def queued_prs(self): return pr_hf_ids + pr_non_hf_ids def has_version_queued_prs(self, version): - # delete_branch() may call this property with a four numbers version - # finished by -1, so we can not rely on this last number to match. - if len(version) == 4 and version[3] == -1: + # delete_branch() may call this with a version whose hfrev has not + # been resolved by update_versions (hfrev == -1 legacy, or hfrev == 0 + # when branch_factory is called without a cascade). In that case we + # must not rely on the last number to match. + if len(version) == 4 and version[3] in (-1, 0): + # Try exact match first (covers true pre-GA queues where hfrev=0) + exact = self._queues.get(version, {}).get(QueueIntegrationBranch) + if exact is not None: + return True + # Fallback: prefix match (covers post-GA where update_versions + # advanced hfrev but branch_factory still returned hfrev=0) for queue_version in self._queues.keys(): if len(queue_version) == 4 and \ - len(version) == 4 and \ queue_version[:3] == version[:3]: queued_pr = self._queues.get(queue_version) if queued_pr is not None and \ From ebc838901b72ca36454ca0413f70645751895842 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Mon, 13 Apr 2026 17:12:03 +0200 Subject: [PATCH 09/16] (Risk 3) Guard hotfix queue name with isinstance, not hfrev >= 0 The condition `hfrev >= 0` in get_queue_integration_branch() was intended to mean "this is a hotfix destination", relying on the accident that DevelopmentBranch inherits hfrev=-1 from GWFBranch. Any future branch type with hfrev=0 would silently trigger the wrong code path. Replace with an explicit isinstance(dst, HotfixBranch) check, which expresses the intent directly and is robust to future branch types. Add a unit test that patches hfrev=0 onto a DevelopmentBranch and verifies the queue name still uses wbranch.version (not dst.version). The test fails on the old `hfrev >= 0` guard. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/unit/test_queueing.py | 61 ++++++++++++++++++++++++ bert_e/workflow/gitwaterflow/queueing.py | 6 +-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 bert_e/tests/unit/test_queueing.py diff --git a/bert_e/tests/unit/test_queueing.py b/bert_e/tests/unit/test_queueing.py new file mode 100644 index 00000000..45b11d2a --- /dev/null +++ b/bert_e/tests/unit/test_queueing.py @@ -0,0 +1,61 @@ +"""Unit tests for the queueing module.""" +from types import SimpleNamespace + +from bert_e.workflow.gitwaterflow.branches import ( + DevelopmentBranch, HotfixBranch +) +from bert_e.workflow.gitwaterflow.queueing import get_queue_integration_branch + + +class _FakeRepo: + """Minimal git-repo stub that satisfies GWFBranch.__init__.""" + def __init__(self): + self._url = '' + self._remote_branches = {} + + def cmd(self, *args, **kwargs): + return '' + + +def _make_job(dst_branch, src_branch='bugfix/TEST-00001'): + """Return a minimal job stub for get_queue_integration_branch.""" + cascade = SimpleNamespace(dst_branches=[dst_branch]) + git = SimpleNamespace(cascade=cascade, repo=_FakeRepo()) + return SimpleNamespace(git=git, + pull_request=SimpleNamespace(src_branch=src_branch)) + + +def test_get_queue_integration_branch_hotfix_uses_dst_version(): + """HotfixBranch destination — queue name must embed dst_branch.version.""" + dst = HotfixBranch(_FakeRepo(), 'hotfix/10.0.0') # hfrev=0 → '10.0.0.0' + wbranch = SimpleNamespace(version='10.0.0.0') + + result = get_queue_integration_branch(_make_job(dst), pr_id=1, + wbranch=wbranch) + assert result.name == 'q/w/1/10.0.0.0/bugfix/TEST-00001' + + +def test_get_queue_integration_branch_dev_uses_wbranch_version(): + """DevelopmentBranch destination — queue name must embed wbranch.version. + + This is the regression test for Risk 3: the old guard was ``hfrev >= 0`` + which would *incorrectly* activate for any non-hotfix branch whose hfrev + was patched to 0. The correct guard is ``isinstance(..., HotfixBranch)``. + + Without the fix (hfrev >= 0): hfrev=0 on a DevelopmentBranch would make + the condition True, so the queue name would use dst.version ('10') instead + of wbranch.version ('10.1') — the assertion below would then fail. + """ + dst = DevelopmentBranch(_FakeRepo(), 'development/10') + # Artificially set hfrev=0 to expose the old `hfrev >= 0` bug path. + dst.hfrev = 0 + + # wbranch.version intentionally differs from dst.version to make the + # wrong-branch-name visible. + wbranch = SimpleNamespace(version='10.1') + result = get_queue_integration_branch(_make_job(dst), pr_id=1, + wbranch=wbranch) + + # Must be based on wbranch.version, not dst.version + assert '/10.1/' in result.name + assert result.name == 'q/w/1/10.1/bugfix/TEST-00001' diff --git a/bert_e/workflow/gitwaterflow/queueing.py b/bert_e/workflow/gitwaterflow/queueing.py index ee726b2c..20bf34fa 100644 --- a/bert_e/workflow/gitwaterflow/queueing.py +++ b/bert_e/workflow/gitwaterflow/queueing.py @@ -24,8 +24,8 @@ from ..git_utils import clone_git_repo, consecutive_merge, robust_merge, push from ..pr_utils import notify_user from .branches import (BranchCascade, DevelopmentBranch, GWFBranch, - IntegrationBranch, QueueBranch, QueueCollection, - QueueIntegrationBranch, branch_factory, + HotfixBranch, IntegrationBranch, QueueBranch, + QueueCollection, QueueIntegrationBranch, branch_factory, build_queue_collection) from .integration import get_integration_branches from typing import List @@ -107,7 +107,7 @@ def get_queue_integration_branch(job, pr_id, wbranch: IntegrationBranch """Get the q/w/pr_id/x.y/* branch corresponding to a w/x.y/* branch.""" wbranch_version = None if len(job.git.cascade.dst_branches) == 1 and \ - job.git.cascade.dst_branches[0].hfrev >= 0: + isinstance(job.git.cascade.dst_branches[0], HotfixBranch): wbranch_version = job.git.cascade.dst_branches[0].version else: wbranch_version = wbranch.version From 49397dd421476399d8cef42c591afaf9a0ca70ec Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Mon, 13 Apr 2026 17:54:06 +0200 Subject: [PATCH 10/16] (Risk 2) Keep phantom hotfix hfrev/version fresh in update_versions Phantom hotfixes (stored in _phantom_hotfixes for non-hotfix PR cascades) were never iterated by update_versions(), leaving their .hfrev and .version frozen at the initial pre-GA values forever. Today only .minor is consumed (_update_major_versions), so there is no live bug, but any future caller reading .hfrev or .version would silently get stale data. Apply the same hfrev advancement logic to phantom hotfixes inside update_versions() so their state stays consistent with the cascade. Add a QuickTest that builds a cascade with a phantom hotfix, processes a GA tag, and asserts hfrev advances to 1. The test fails without the fix because the phantom is never updated. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 27 ++++++++++++++++++++++++ bert_e/workflow/gitwaterflow/branches.py | 11 ++++++++++ 2 files changed, 38 insertions(+) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 22b5b444..8186e857 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -540,6 +540,33 @@ def test_branch_cascade_2digit_with_pre_ga_hotfix(self): fixver = ['9.5.3', '10.0.1', '10.1.0'] self.finalize_cascade(branches, tags, destination, fixver) + def test_phantom_hotfix_hfrev_updated_by_ga_tag(self): + """Phantom hotfix hfrev and version must be updated by update_versions. + + When hotfix/10.0.0 is stored as a phantom (non-hotfix PR destination), + update_versions() must still advance its hfrev when GA tags arrive. + Without the fix the phantom's hfrev stays at 0 forever, exposing + stale data to any future caller that reads .hfrev or .version. + """ + my_dst = gwfb.branch_factory(FakeGitRepo(), 'development/10') + cascade = gwfb.BranchCascade() + for name in ('development/9.5', 'hotfix/10.0.0', 'development/10'): + cascade.add_branch(gwfb.branch_factory(FakeGitRepo(), name), + my_dst) + + self.assertEqual(len(cascade._phantom_hotfixes), 1) + phantom = cascade._phantom_hotfixes[0] + + # Pre-GA: no 10.0.0.X tags — hfrev must remain 0 + cascade.update_versions('9.5.2') + self.assertEqual(phantom.hfrev, 0) + self.assertEqual(phantom.version, '10.0.0.0') + + # GA tag 10.0.0.0 lands — phantom hfrev must advance to 1 + cascade.update_versions('10.0.0.0') + self.assertEqual(phantom.hfrev, 1) # fails without the fix + self.assertEqual(phantom.version, '10.0.0.1') + def test_branch_cascade_target_three_digit_dev(self): """Test cascade targeting three-digit development branch""" destination = 'development/4.3.17' diff --git a/bert_e/workflow/gitwaterflow/branches.py b/bert_e/workflow/gitwaterflow/branches.py index 597cd10b..9c12acc3 100644 --- a/bert_e/workflow/gitwaterflow/branches.py +++ b/bert_e/workflow/gitwaterflow/branches.py @@ -1018,6 +1018,17 @@ def update_versions(self, tag): hf_branch.micro, hf_branch.hfrev) + # Also update phantom hotfixes (stored outside _cascade for dev PRs). + # They are only consumed for their .minor today, but keeping .hfrev + # and .version current prevents stale data surprises in future callers. + for phantom in self._phantom_hotfixes: + if (phantom.major == major and phantom.minor == minor and + phantom.micro == micro): + phantom.hfrev = max(hfrev + 1, phantom.hfrev) + phantom.version = '%d.%d.%d.%d' % ( + phantom.major, phantom.minor, + phantom.micro, phantom.hfrev) + if micro_branch is not None and \ ([micro_branch.major, micro_branch.minor, micro_branch.micro] == [major, minor, micro]): From 3f1c9a1b11fe2aea4fb6dbcc2c62fcd4f788f901 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Mon, 13 Apr 2026 18:12:04 +0200 Subject: [PATCH 11/16] (Risk 1) Fix already_in_queue to detect pre-GA queue branches after GA tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a GA tag is pushed (e.g. 10.0.0.0), HotfixBranch.hfrev advances from 0 to 1. The next PR handle call would compute queue name q/w/{id}/10.0.0.1/... which does not exist, causing already_in_queue to return False and re-queue the PR — creating an orphaned q/10.0.0.1 branch alongside the valid one. Fix: after the exact-match lookup, fall back to a git branch -r --list prefix scan (origin/q/w/{id}/major.minor.micro.*) so the existing pre-GA queue branch is found regardless of which hfrev was in effect when the PR was first queued. Add test_pr_hotfix_no_requeue_after_ga: queues pre-GA, pushes 10.0.0.0 tag, then re-handles the PR — asserts NothingToDo (not Queued) and verifies no orphaned 10.0.0.1 branch is created. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 36 ++++++++++++++++++++++++ bert_e/workflow/gitwaterflow/queueing.py | 19 +++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 8186e857..f36ea8bb 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -6033,6 +6033,42 @@ def test_pr_hotfix_pre_ga(self): self.handle(sha1, options=self.bypass_all, backtrace=True) self.assertEqual(self.prs_in_queue(), set()) + def test_pr_hotfix_no_requeue_after_ga(self): + """PR queued pre-GA must not be re-queued after the GA tag is pushed. + + Without the already_in_queue prefix-scan fix, pushing 10.0.0.0 causes + already_in_queue to look for q/w/{id}/10.0.0.1/... (hfrev now 1) which + does not exist, so the PR is incorrectly re-queued and a new orphaned + q/10.0.0.1 branch is created. + """ + # Step 1 — queue PR before GA tag exists. + pr = self.create_pr('bugfix/TEST-00000', 'hotfix/10.0.0') + with self.assertRaises(exns.Queued): + self.handle(pr.id, options=self.bypass_all, backtrace=True) + self.assertEqual(self.prs_in_queue(), {pr.id}) + + # Step 2 — push the GA tag, advancing hfrev from 0 to 1. + self.gitrepo.cmd('git tag 10.0.0.0') + self.gitrepo.cmd('git push --tags') + + # Step 3 — re-handle the PR; must be NothingToDo, not Queued again. + with self.assertRaises(exns.NothingToDo): + self.handle(pr.id, options=self.bypass_all, backtrace=True) + + # No second q/w branch should have been created (would appear if + # the PR was incorrectly re-queued with 10.0.0.1). + qbranches = self.get_qint_branches() + self.assertFalse( + any('10.0.0.1' in b for b in qbranches), + 'orphaned 10.0.0.1 queue branch found: %s' % qbranches) + + # Step 4 — original pre-GA queue branch still builds and merges fine. + sha1 = self.set_build_status_on_branch_tip( + 'q/w/%d/10.0.0.0/bugfix/TEST-00000' % pr.id, 'SUCCESSFUL') + with self.assertRaises(exns.Merged): + self.handle(sha1, options=self.bypass_all, backtrace=True) + self.assertEqual(self.prs_in_queue(), set()) + def test_pr_hotfix_alone(self): self.gitrepo.cmd('git tag 10.0.0.0') self.gitrepo.cmd('git push --tags') diff --git a/bert_e/workflow/gitwaterflow/queueing.py b/bert_e/workflow/gitwaterflow/queueing.py index 20bf34fa..f9a43019 100644 --- a/bert_e/workflow/gitwaterflow/queueing.py +++ b/bert_e/workflow/gitwaterflow/queueing.py @@ -126,9 +126,22 @@ def already_in_queue(job, wbranches): """ pr_id = job.pull_request.id - return any( - get_queue_integration_branch(job, pr_id, w).exists() for w in wbranches - ) + if any(get_queue_integration_branch(job, pr_id, w).exists() + for w in wbranches): + return True + # Fallback for hotfix branches: the hfrev embedded in the queue branch + # name may have changed since the PR was queued (pre-GA → post-GA + # transition). Scan by major.minor.micro prefix to detect the existing + # queue branch regardless of which hfrev was in effect when first queued. + if (len(job.git.cascade.dst_branches) == 1 and + isinstance(job.git.cascade.dst_branches[0], HotfixBranch)): + dst = job.git.cascade.dst_branches[0] + prefix = 'origin/q/w/%d/%d.%d.%d.' % ( + pr_id, dst.major, dst.minor, dst.micro) + if job.git.repo.cmd( + 'git branch -r --list %s*' % prefix).strip(): + return True + return False def add_to_queue(job, wbranches): From 4533187f2a8419d22d850718fcba2e382f7e405d Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Tue, 14 Apr 2026 15:18:10 +0200 Subject: [PATCH 12/16] Accept 3-digit Jira fix version for pre-GA hotfix branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hotfix/X.Y.Z has no GA tag yet (hfrev == 0, target version ends in ".0"), the Jira project may not have an "X.Y.Z.0" release entry yet. Allow either the 4-digit form ("10.0.0.0") OR the 3-digit base ("10.0.0") to satisfy the fix-version check. Once the GA tag is pushed, hfrev advances to 1, the target becomes "10.0.0.1", and only that exact 4-digit version is accepted again — matching the existing post-GA behaviour. Also update the incorrect_fix_version.md template: the condition now checks whether the last expected version has 4 digits (covers both pre-GA with two alternatives and post-GA with one), replacing the prior "length == 1" check that would miss the pre-GA two-version case. Add bert_e/tests/unit/test_jira.py with 13 unit tests covering: - pre-GA: 4-digit accepted, 3-digit accepted, wrong/missing rejected - post-GA: correct hfrev accepted, 3-digit base and wrong hfrev rejected - dev-branch: existing behaviour unchanged Co-Authored-By: Claude Sonnet 4.6 --- bert_e/templates/incorrect_fix_version.md | 4 +- bert_e/tests/unit/test_jira.py | 121 ++++++++++++++++++++++ bert_e/workflow/gitwaterflow/jira.py | 12 ++- 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 bert_e/tests/unit/test_jira.py diff --git a/bert_e/templates/incorrect_fix_version.md b/bert_e/templates/incorrect_fix_version.md index 79f36f22..66b225ba 100644 --- a/bert_e/templates/incorrect_fix_version.md +++ b/bert_e/templates/incorrect_fix_version.md @@ -13,8 +13,8 @@ The `Fix Version/s` in issue {{ issue.key }} contains: * *None* {% endfor %} -{% if expect_versions|length == 1 and expect_versions[0].split('.')|length == 4 %} -Considering where you are trying to merge, I expected to find at least: +{% if expect_versions and expect_versions[-1].split('.')|length == 4 %} +Considering where you are trying to merge, I expected to find at least one of: {% else %} Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find: {% endif %} diff --git a/bert_e/tests/unit/test_jira.py b/bert_e/tests/unit/test_jira.py new file mode 100644 index 00000000..e4f257db --- /dev/null +++ b/bert_e/tests/unit/test_jira.py @@ -0,0 +1,121 @@ +"""Unit tests for check_fix_versions in jira.py. + +Covers the pre-GA hotfix dual-acceptance rule: + - When the hotfix branch has no GA tag yet (hfrev == 0 → target ends + in ".0"), both the 4-digit form ("10.0.0.0") AND the 3-digit base + ("10.0.0") must be accepted. + - Once the GA tag is pushed (hfrev advances to 1 → target becomes + "10.0.0.1"), only the exact 4-digit version is accepted again. +""" +import pytest +from types import SimpleNamespace + +from bert_e import exceptions +from bert_e.workflow.gitwaterflow.jira import check_fix_versions + + +def _make_issue(*version_names): + versions = [SimpleNamespace(name=v) for v in version_names] + return SimpleNamespace( + key='TEST-00001', + fields=SimpleNamespace(fixVersions=versions), + ) + + +def _make_job(*target_versions): + cascade = SimpleNamespace(target_versions=list(target_versions)) + git = SimpleNamespace(cascade=cascade) + return SimpleNamespace(git=git, active_options=[]) + + +# --------------------------------------------------------------------------- +# Pre-GA hotfix: target ends in ".0" (hfrev == 0, no GA tag yet) +# --------------------------------------------------------------------------- + +def test_pre_ga_hotfix_accepts_four_digit_version(): + """Jira with '10.0.0.0' is accepted for pre-GA hotfix/10.0.0.""" + check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.0.0')) + + +def test_pre_ga_hotfix_accepts_three_digit_version(): + """Jira with '10.0.0' is accepted for pre-GA hotfix/10.0.0 (no GA tag).""" + check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.0')) + + +def test_pre_ga_hotfix_accepts_three_digit_among_others(): + """Jira with '10.0.0' among other versions is still accepted.""" + check_fix_versions( + _make_job('10.0.0.0'), + _make_issue('9.5.3', '10.0.0', '11.0.0'), + ) + + +def test_pre_ga_hotfix_rejects_wrong_version(): + """Unrelated version is rejected for pre-GA hotfix/10.0.0.""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.0'), _make_issue('9.5.0')) + + +def test_pre_ga_hotfix_rejects_empty_versions(): + """No fix version in Jira is rejected for pre-GA hotfix/10.0.0.""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.0'), _make_issue()) + + +def test_pre_ga_hotfix_rejects_next_micro(): + """'10.0.1' is not a valid substitute for pre-GA '10.0.0.0'.""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.1')) + + +# --------------------------------------------------------------------------- +# Post-GA hotfix: target does NOT end in ".0" (hfrev >= 1, GA tag exists) +# --------------------------------------------------------------------------- + +def test_post_ga_hotfix_accepts_correct_4digit(): + """'10.0.0.1' in Jira is accepted for post-GA hotfix (hfrev=1).""" + check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0.1')) + + +def test_post_ga_hotfix_rejects_3digit_base(): + """'10.0.0' is NOT accepted once GA tag exists (target is '10.0.0.1').""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0')) + + +def test_post_ga_hotfix_rejects_pre_ga_4digit(): + """'10.0.0.0' is NOT accepted once GA tag exists (target is '10.0.0.1').""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0.0')) + + +def test_post_ga_second_hotfix(): + """'10.0.0.2' in Jira is accepted for second post-GA hotfix (hfrev=2).""" + check_fix_versions(_make_job('10.0.0.2'), _make_issue('10.0.0.2')) + + +def test_post_ga_hotfix_rejects_lower_hfrev(): + """'10.0.0.1' is NOT accepted when target is '10.0.0.2'.""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(_make_job('10.0.0.2'), _make_issue('10.0.0.1')) + + +# --------------------------------------------------------------------------- +# Regular dev-branch PR (not hotfix) — pre-existing behaviour unchanged +# --------------------------------------------------------------------------- + +def test_dev_branch_accepts_matching_versions(): + """Standard 3-digit dev-branch version check still works.""" + check_fix_versions( + _make_job('4.3.19', '5.1.4'), + _make_issue('4.3.19', '5.1.4'), + ) + + +def test_dev_branch_rejects_mismatch(): + """Wrong versions for a dev-branch PR are still rejected.""" + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions( + _make_job('4.3.19', '5.1.4'), + _make_issue('4.3.18', '5.1.4'), + ) diff --git a/bert_e/workflow/gitwaterflow/jira.py b/bert_e/workflow/gitwaterflow/jira.py index 1220de5c..b92e5bb4 100644 --- a/bert_e/workflow/gitwaterflow/jira.py +++ b/bert_e/workflow/gitwaterflow/jira.py @@ -165,11 +165,19 @@ def check_fix_versions(job, issue): hf_target = target_version if hf_target: - if hf_target not in issue_versions: + # Pre-GA: the hotfix branch has no tag yet (hfrev == 0 → target ends + # in ".0"). The Jira project may not have a ".0" release entry yet, + # so also accept the 3-digit base version (e.g. "10.0.0" for "10.0.0.0"). + # Once the GA tag is pushed hfrev advances to 1, the target becomes + # "10.0.0.1", and only that exact version is accepted again. + accepted = {hf_target} + if hf_target.endswith('.0'): + accepted.add(hf_target[:-2]) + if not (accepted & issue_versions): raise exceptions.IncorrectFixVersion( issue=issue, issue_versions=sorted(issue_versions), - expect_versions=sorted(expected_versions), + expect_versions=sorted(accepted), active_options=job.active_options ) elif checked_versions != expected_versions: From 4741a2ffb7cfe70483ebb60393577ba433f77567 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Tue, 14 Apr 2026 15:22:55 +0200 Subject: [PATCH 13/16] Fix E501 flake8 line-too-long in jira.py Co-Authored-By: Claude Sonnet 4.6 --- bert_e/workflow/gitwaterflow/jira.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bert_e/workflow/gitwaterflow/jira.py b/bert_e/workflow/gitwaterflow/jira.py index b92e5bb4..6fc3bacf 100644 --- a/bert_e/workflow/gitwaterflow/jira.py +++ b/bert_e/workflow/gitwaterflow/jira.py @@ -165,10 +165,10 @@ def check_fix_versions(job, issue): hf_target = target_version if hf_target: - # Pre-GA: the hotfix branch has no tag yet (hfrev == 0 → target ends + # Pre-GA: the hotfix branch has no tag yet (hfrev == 0, target ends # in ".0"). The Jira project may not have a ".0" release entry yet, - # so also accept the 3-digit base version (e.g. "10.0.0" for "10.0.0.0"). - # Once the GA tag is pushed hfrev advances to 1, the target becomes + # so also accept the 3-digit base (e.g. "10.0.0" for "10.0.0.0"). + # Once GA tag is pushed hfrev advances to 1, target becomes # "10.0.0.1", and only that exact version is accepted again. accepted = {hf_target} if hf_target.endswith('.0'): From 0d0cdcaf4cd280013fcf1993e2b028f1e38c555b Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Wed, 15 Apr 2026 11:33:32 +0200 Subject: [PATCH 14/16] Pre-GA hotfix: remind about cherry-pick PR, strip phantom version from dev check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hotfix/X.Y.Z is created pre-GA (hfrev=0, version X.Y.Z.0), and a PR targets a development branch in the same cascade, two things must happen: 1. **Jira fix-version check for the dev-branch PR** The ticket now carries three versions: e.g. 9.5.3, 10.0.0.0, 10.1.0. The 10.0.0.0 entry belongs to the hotfix branch and will be consumed by a separate cherry-pick PR; it must not cause the dev-branch check to fail. BranchCascade.phantom_hotfix_versions returns the X.Y.Z.0 form for each pre-GA phantom hotfix, and check_fix_versions subtracts them from checked_versions before comparing against expected_versions. Also drop the 3-digit-acceptance added in the previous commit: hotfix branches always require an exact 4-digit fix version (10.0.0.0 pre-GA, 10.0.0.1 post-GA first hotfix, etc.) — 3-digit aliases are rejected. 2. **Reminder message in Queued / SuccessMessage** BranchCascade.pending_hotfix_branches exposes phantom hotfixes to the templates. queued.md and successful_merge.md now include a warning block listing branches that need a separate cherry-pick PR. Queued.__init__ accepts pending_hotfixes= (default []) for backward compatibility; all raise sites pass job.git.cascade.pending_hotfix_branches. Tests added: - test_jira.py: 14 unit tests covering pre-GA/post-GA hotfix acceptance, 3-digit rejection, phantom exclusion for dev PRs, and no-phantom rejection. - test_bert_e.py: test_dev_pr_reminder_about_pre_ga_hotfix verifies the Queued message mentions hotfix/10.0.0 for a development/10 PR. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/exceptions.py | 5 +- bert_e/templates/incorrect_fix_version.md | 4 +- bert_e/templates/queued.md | 9 ++ bert_e/templates/successful_merge.md | 9 ++ bert_e/tests/test_bert_e.py | 12 +++ bert_e/tests/unit/test_jira.py | 110 +++++++++++++--------- bert_e/workflow/gitwaterflow/__init__.py | 2 + bert_e/workflow/gitwaterflow/branches.py | 28 ++++++ bert_e/workflow/gitwaterflow/jira.py | 36 +++---- bert_e/workflow/gitwaterflow/queueing.py | 1 + 10 files changed, 153 insertions(+), 63 deletions(-) diff --git a/bert_e/exceptions.py b/bert_e/exceptions.py index fb93bdcf..2f32f12e 100644 --- a/bert_e/exceptions.py +++ b/bert_e/exceptions.py @@ -191,16 +191,19 @@ class Queued(TemplateException): template = 'queued.md' status = "in_progress" - def __init__(self, branches, ignored, issue, author, active_options): + def __init__(self, branches, ignored, issue, author, active_options, + pending_hotfixes=None): """Save args for later use by tests.""" self.branches = branches self.ignored = ignored self.issue = issue self.author = author self.active_options = active_options + self.pending_hotfixes = pending_hotfixes or [] super(Queued, self).__init__( branches=branches, ignored=ignored, + pending_hotfixes=pending_hotfixes or [], issue=issue, author=author, active_options=active_options diff --git a/bert_e/templates/incorrect_fix_version.md b/bert_e/templates/incorrect_fix_version.md index 66b225ba..79f36f22 100644 --- a/bert_e/templates/incorrect_fix_version.md +++ b/bert_e/templates/incorrect_fix_version.md @@ -13,8 +13,8 @@ The `Fix Version/s` in issue {{ issue.key }} contains: * *None* {% endfor %} -{% if expect_versions and expect_versions[-1].split('.')|length == 4 %} -Considering where you are trying to merge, I expected to find at least one of: +{% if expect_versions|length == 1 and expect_versions[0].split('.')|length == 4 %} +Considering where you are trying to merge, I expected to find at least: {% else %} Considering where you are trying to merge, I ignored possible hotfix versions and I expected to find: {% endif %} diff --git a/bert_e/templates/queued.md b/bert_e/templates/queued.md index 2584f86b..462cb8cb 100644 --- a/bert_e/templates/queued.md +++ b/bert_e/templates/queued.md @@ -23,6 +23,15 @@ The following branches will **NOT** be impacted: {% endfor %} {% endif %} +{% if pending_hotfixes %} +:warning: This pull request does **not** target the following hotfix +branch(es). Please open a separate cherry-pick pull request to each: + +{% for branch in pending_hotfixes -%} +* `{{ branch.name }}` +{% endfor %} +{% endif %} + There is no action required on your side. You will be notified here once the changeset has been merged. In the unlikely event that the changeset fails permanently on the queue, a member of the admin team will diff --git a/bert_e/templates/successful_merge.md b/bert_e/templates/successful_merge.md index d861c037..bcfee5c0 100644 --- a/bert_e/templates/successful_merge.md +++ b/bert_e/templates/successful_merge.md @@ -16,6 +16,15 @@ The following branches have **NOT** changed: {% endfor %} {% endif %} +{% if pending_hotfixes %} +:warning: This pull request did **not** target the following hotfix +branch(es). Please open a separate cherry-pick pull request to each: + +{% for branch in pending_hotfixes -%} +* `{{ branch.name }}` +{% endfor %} +{% endif %} + Please check the status of the associated issue {{ issue }}. Goodbye {{ author }}. diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index f36ea8bb..859f770c 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -6033,6 +6033,18 @@ def test_pr_hotfix_pre_ga(self): self.handle(sha1, options=self.bypass_all, backtrace=True) self.assertEqual(self.prs_in_queue(), set()) + def test_dev_pr_reminder_about_pre_ga_hotfix(self): + """A PR to development/10 must mention hotfix/10.0.0 in the Queued + message (pending_hotfixes reminder) when hotfix/10.0.0 is pre-GA.""" + # No GA tag — hotfix/10.0.0 is a phantom for the dev/10 cascade. + pr = self.create_pr('bugfix/TEST-00001', 'development/10') + try: + self.handle(pr.id, options=self.bypass_all, backtrace=True) + self.fail('Expected Queued or SuccessMessage exception') + except (exns.Queued, exns.SuccessMessage) as e: + self.assertIn('hotfix/10.0.0', e.msg, + 'Reminder about hotfix/10.0.0 missing from message') + def test_pr_hotfix_no_requeue_after_ga(self): """PR queued pre-GA must not be re-queued after the GA tag is pushed. diff --git a/bert_e/tests/unit/test_jira.py b/bert_e/tests/unit/test_jira.py index e4f257db..1f3a4963 100644 --- a/bert_e/tests/unit/test_jira.py +++ b/bert_e/tests/unit/test_jira.py @@ -1,11 +1,13 @@ """Unit tests for check_fix_versions in jira.py. -Covers the pre-GA hotfix dual-acceptance rule: - - When the hotfix branch has no GA tag yet (hfrev == 0 → target ends - in ".0"), both the 4-digit form ("10.0.0.0") AND the 3-digit base - ("10.0.0") must be accepted. - - Once the GA tag is pushed (hfrev advances to 1 → target becomes - "10.0.0.1"), only the exact 4-digit version is accepted again. +Rules: +- Hotfix branch PRs require the exact 4-digit fix version in Jira + (e.g. "10.0.0.0" pre-GA, "10.0.0.1" first post-GA hotfix, …). + 3-digit aliases (e.g. "10.0.0") are NOT accepted. +- Development-branch PRs may have an extra 4-digit "X.Y.Z.0" entry in + the ticket when a pre-GA hotfix branch exists alongside the waterflow. + That entry is consumed by the separate cherry-pick PR to the hotfix + branch and must not cause the dev-branch check to fail. """ import pytest from types import SimpleNamespace @@ -22,38 +24,28 @@ def _make_issue(*version_names): ) -def _make_job(*target_versions): - cascade = SimpleNamespace(target_versions=list(target_versions)) +def _make_job(*target_versions, phantom_hotfix_versions=None): + cascade = SimpleNamespace( + target_versions=list(target_versions), + phantom_hotfix_versions=phantom_hotfix_versions or set(), + ) git = SimpleNamespace(cascade=cascade) return SimpleNamespace(git=git, active_options=[]) # --------------------------------------------------------------------------- -# Pre-GA hotfix: target ends in ".0" (hfrev == 0, no GA tag yet) +# Hotfix branch PRs — exact 4-digit version required # --------------------------------------------------------------------------- def test_pre_ga_hotfix_accepts_four_digit_version(): - """Jira with '10.0.0.0' is accepted for pre-GA hotfix/10.0.0.""" + """'10.0.0.0' in Jira is accepted for pre-GA hotfix/10.0.0.""" check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.0.0')) -def test_pre_ga_hotfix_accepts_three_digit_version(): - """Jira with '10.0.0' is accepted for pre-GA hotfix/10.0.0 (no GA tag).""" - check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.0')) - - -def test_pre_ga_hotfix_accepts_three_digit_among_others(): - """Jira with '10.0.0' among other versions is still accepted.""" - check_fix_versions( - _make_job('10.0.0.0'), - _make_issue('9.5.3', '10.0.0', '11.0.0'), - ) - - -def test_pre_ga_hotfix_rejects_wrong_version(): - """Unrelated version is rejected for pre-GA hotfix/10.0.0.""" +def test_pre_ga_hotfix_rejects_three_digit_version(): + """'10.0.0' alone is NOT accepted — only 4-digit '10.0.0.0' is valid.""" with pytest.raises(exceptions.IncorrectFixVersion): - check_fix_versions(_make_job('10.0.0.0'), _make_issue('9.5.0')) + check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.0')) def test_pre_ga_hotfix_rejects_empty_versions(): @@ -62,31 +54,27 @@ def test_pre_ga_hotfix_rejects_empty_versions(): check_fix_versions(_make_job('10.0.0.0'), _make_issue()) -def test_pre_ga_hotfix_rejects_next_micro(): - """'10.0.1' is not a valid substitute for pre-GA '10.0.0.0'.""" +def test_pre_ga_hotfix_rejects_wrong_version(): + """Unrelated version is rejected for pre-GA hotfix/10.0.0.""" with pytest.raises(exceptions.IncorrectFixVersion): - check_fix_versions(_make_job('10.0.0.0'), _make_issue('10.0.1')) + check_fix_versions(_make_job('10.0.0.0'), _make_issue('9.5.0')) -# --------------------------------------------------------------------------- -# Post-GA hotfix: target does NOT end in ".0" (hfrev >= 1, GA tag exists) -# --------------------------------------------------------------------------- - def test_post_ga_hotfix_accepts_correct_4digit(): """'10.0.0.1' in Jira is accepted for post-GA hotfix (hfrev=1).""" check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0.1')) -def test_post_ga_hotfix_rejects_3digit_base(): - """'10.0.0' is NOT accepted once GA tag exists (target is '10.0.0.1').""" +def test_post_ga_hotfix_rejects_pre_ga_version(): + """'10.0.0.0' is NOT accepted once GA tag exists (target is '10.0.0.1').""" with pytest.raises(exceptions.IncorrectFixVersion): - check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0')) + check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0.0')) -def test_post_ga_hotfix_rejects_pre_ga_4digit(): - """'10.0.0.0' is NOT accepted once GA tag exists (target is '10.0.0.1').""" +def test_post_ga_hotfix_rejects_3digit_base(): + """'10.0.0' is NOT accepted for post-GA hotfix (target is '10.0.0.1').""" with pytest.raises(exceptions.IncorrectFixVersion): - check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0.0')) + check_fix_versions(_make_job('10.0.0.1'), _make_issue('10.0.0')) def test_post_ga_second_hotfix(): @@ -94,14 +82,50 @@ def test_post_ga_second_hotfix(): check_fix_versions(_make_job('10.0.0.2'), _make_issue('10.0.0.2')) -def test_post_ga_hotfix_rejects_lower_hfrev(): - """'10.0.0.1' is NOT accepted when target is '10.0.0.2'.""" +# --------------------------------------------------------------------------- +# Development-branch PRs — phantom hotfix version excluded from check +# --------------------------------------------------------------------------- + +def test_dev_pr_phantom_hotfix_excluded_from_check(): + """Ticket with 9.5.3 + 10.0.0.0 + 10.1.0 passes the dev/9.5 PR check. + + The 10.0.0.0 entry belongs to the pre-GA hotfix branch and is consumed + by the separate cherry-pick PR. It must not cause a mismatch here. + """ + job = _make_job('9.5.3', '10.1.0', + phantom_hotfix_versions={'10.0.0.0'}) + issue = _make_issue('9.5.3', '10.0.0.0', '10.1.0') + check_fix_versions(job, issue) # must not raise + + +def test_dev_pr_phantom_hotfix_still_requires_all_dev_versions(): + """Missing dev version still fails even when phantom is excluded.""" + job = _make_job('9.5.3', '10.1.0', + phantom_hotfix_versions={'10.0.0.0'}) + issue = _make_issue('9.5.3', '10.0.0.0') # missing 10.1.0 + with pytest.raises(exceptions.IncorrectFixVersion): + check_fix_versions(job, issue) + + +def test_dev_pr_no_phantom_rejects_unexpected_4digit_version(): + """Without a phantom hotfix, X.Y.Z.0 in the ticket causes a mismatch.""" + job = _make_job('9.5.3', '10.1.0', + phantom_hotfix_versions=set()) + issue = _make_issue('9.5.3', '10.0.0.0', '10.1.0') with pytest.raises(exceptions.IncorrectFixVersion): - check_fix_versions(_make_job('10.0.0.2'), _make_issue('10.0.0.1')) + check_fix_versions(job, issue) + + +def test_dev_pr_without_hotfix_version_passes(): + """Dev PR still passes when the ticket has exactly the dev versions.""" + job = _make_job('9.5.3', '10.1.0', + phantom_hotfix_versions={'10.0.0.0'}) + issue = _make_issue('9.5.3', '10.1.0') + check_fix_versions(job, issue) # must not raise # --------------------------------------------------------------------------- -# Regular dev-branch PR (not hotfix) — pre-existing behaviour unchanged +# Regular dev-branch PR (no phantom hotfixes) — pre-existing behaviour # --------------------------------------------------------------------------- def test_dev_branch_accepts_matching_versions(): diff --git a/bert_e/workflow/gitwaterflow/__init__.py b/bert_e/workflow/gitwaterflow/__init__.py index 7bda9d6c..33fcf69b 100644 --- a/bert_e/workflow/gitwaterflow/__init__.py +++ b/bert_e/workflow/gitwaterflow/__init__.py @@ -225,6 +225,7 @@ def _handle_pull_request(job: PullRequestJob): raise messages.Queued( branches=job.git.cascade.dst_branches, ignored=job.git.cascade.ignored_branches, + pending_hotfixes=job.git.cascade.pending_hotfix_branches, issue=job.git.src_branch.jira_issue_key, author=job.pull_request.author_display_name, active_options=job.active_options) @@ -242,6 +243,7 @@ def _handle_pull_request(job: PullRequestJob): raise messages.SuccessMessage( branches=job.git.cascade.dst_branches, ignored=job.git.cascade.ignored_branches, + pending_hotfixes=job.git.cascade.pending_hotfix_branches, issue=job.git.src_branch.jira_issue_key, author=job.pull_request.author_display_name, active_options=job.active_options) diff --git a/bert_e/workflow/gitwaterflow/branches.py b/bert_e/workflow/gitwaterflow/branches.py index 9c12acc3..c07958e4 100644 --- a/bert_e/workflow/gitwaterflow/branches.py +++ b/bert_e/workflow/gitwaterflow/branches.py @@ -875,6 +875,34 @@ def __init__(self): # hotfix branches stored outside cascade for version calc only self._phantom_hotfixes = [] + @property + def pending_hotfix_branches(self): + """Hotfix branches stored as phantoms that require a separate PR. + + When a PR targets a development branch and a hotfix branch exists + for the same major version (pre-GA), the hotfix is kept outside the + cascade so version calculations stay correct without making it an + implicit merge target. The author should open a separate PR to + each of these hotfix branches. + """ + return list(self._phantom_hotfixes) + + @property + def phantom_hotfix_versions(self): + """4-digit version strings of pre-GA phantom hotfix branches. + + Pre-GA hotfix branches (hfrev == 0) produce an X.Y.Z.0 version that + matches the vfilter used in check_fix_versions. When such a version + appears in a Jira ticket alongside the dev-branch versions, it must + not be counted against the dev-branch PR — it is consumed by the + separate cherry-pick PR to the hotfix branch. + Post-GA hotfix versions (X.Y.Z.1, X.Y.Z.2, …) don't match vfilter + so they never interfere with the dev-branch check. + """ + return {hf.version + for hf in self._phantom_hotfixes + if hf.hfrev == 0} + def build(self, repo, dst_branch=None): flat_branches = set() for prefix in ['development', 'hotfix']: diff --git a/bert_e/workflow/gitwaterflow/jira.py b/bert_e/workflow/gitwaterflow/jira.py index 6fc3bacf..98f729bf 100644 --- a/bert_e/workflow/gitwaterflow/jira.py +++ b/bert_e/workflow/gitwaterflow/jira.py @@ -165,25 +165,27 @@ def check_fix_versions(job, issue): hf_target = target_version if hf_target: - # Pre-GA: the hotfix branch has no tag yet (hfrev == 0, target ends - # in ".0"). The Jira project may not have a ".0" release entry yet, - # so also accept the 3-digit base (e.g. "10.0.0" for "10.0.0.0"). - # Once GA tag is pushed hfrev advances to 1, target becomes - # "10.0.0.1", and only that exact version is accepted again. - accepted = {hf_target} - if hf_target.endswith('.0'): - accepted.add(hf_target[:-2]) - if not (accepted & issue_versions): + # Hotfix PR: the ticket must carry the exact 4-digit target version + # (e.g. "10.0.0.0" pre-GA, "10.0.0.1" post-GA first hotfix, …). + # 3-digit aliases are no longer accepted — a 4-digit entry makes it + # unambiguous whether the branch is still pre-GA or an actual hotfix. + if hf_target not in issue_versions: raise exceptions.IncorrectFixVersion( issue=issue, issue_versions=sorted(issue_versions), - expect_versions=sorted(accepted), + expect_versions=sorted(expected_versions), + active_options=job.active_options + ) + else: + # Development-branch PR: versions that belong to a phantom hotfix + # branch (pre-GA hotfix/X.Y.Z stored outside the cascade) will be + # consumed by the separate cherry-pick PR to that hotfix branch. + # Strip them so they don't cause a spurious mismatch here. + checked_versions -= job.git.cascade.phantom_hotfix_versions + if checked_versions != expected_versions: + raise exceptions.IncorrectFixVersion( + issue=issue, + issue_versions=sorted(issue_versions), + expect_versions=sorted(expected_versions), active_options=job.active_options ) - elif checked_versions != expected_versions: - raise exceptions.IncorrectFixVersion( - issue=issue, - issue_versions=sorted(issue_versions), - expect_versions=sorted(expected_versions), - active_options=job.active_options - ) diff --git a/bert_e/workflow/gitwaterflow/queueing.py b/bert_e/workflow/gitwaterflow/queueing.py index f9a43019..13d05e97 100644 --- a/bert_e/workflow/gitwaterflow/queueing.py +++ b/bert_e/workflow/gitwaterflow/queueing.py @@ -217,6 +217,7 @@ def close_queued_pull_request(job, pr_id, cascade): job.settings, pull_request, exceptions.SuccessMessage( branches=target_branches, ignored=job.git.cascade.ignored_branches, + pending_hotfixes=job.git.cascade.pending_hotfix_branches, issue=src.jira_issue_key, author=pull_request.author_display_name, active_options=[]) From ed91d54ada94887d0e56005b803062c2c480c181 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Wed, 15 Apr 2026 11:47:31 +0200 Subject: [PATCH 15/16] Add one-time reminder when ticket has a pre-GA hotfix fix version When a dev-branch PR's Jira ticket contains a X.Y.Z.0 fix version (belonging to a pre-GA hotfix branch), bert-e now posts an informational comment reminding the developer to open a cherry-pick PR to the corresponding hotfix branch. - New PendingHotfixVersionReminder exception (code 223, InformationException) - New template pending_hotfix_version_reminder.md - _notify_pending_hotfix_if_needed() called after check_fix_versions() in jira_checks; posts at most once per PR (NEVER_REPEAT) Co-Authored-By: Claude Sonnet 4.6 --- bert_e/exceptions.py | 5 ++++ .../pending_hotfix_version_reminder.md | 19 ++++++++++++++ bert_e/workflow/gitwaterflow/jira.py | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 bert_e/templates/pending_hotfix_version_reminder.md diff --git a/bert_e/exceptions.py b/bert_e/exceptions.py index 2f32f12e..ecdcef9d 100644 --- a/bert_e/exceptions.py +++ b/bert_e/exceptions.py @@ -435,6 +435,11 @@ class WrongDestination(TemplateException): template = 'incorrect_destination.md' +class PendingHotfixVersionReminder(InformationException): + code = 223 + template = 'pending_hotfix_version_reminder.md' + + class QueueValidationError(Exception): """Extend simple string class with an error code and recovery potential.""" code = 'Q000' diff --git a/bert_e/templates/pending_hotfix_version_reminder.md b/bert_e/templates/pending_hotfix_version_reminder.md new file mode 100644 index 00000000..a5925d5f --- /dev/null +++ b/bert_e/templates/pending_hotfix_version_reminder.md @@ -0,0 +1,19 @@ +{% extends "message.md" %} + +{% block title -%} +Pending hotfix branch +{% endblock %} + +{% block message %} +:information_source: Issue {{ issue.key }} contains the following +pre-GA hotfix fix version(s): + +{% for version in hotfix_versions %} +* `{{ version }}` +{% endfor %} + +This means the change is expected to land on the corresponding hotfix +branch as well. Please make sure to open a separate cherry-pick pull +request targeting that hotfix branch so that the fix is applied +everywhere it needs to be. +{% endblock %} diff --git a/bert_e/workflow/gitwaterflow/jira.py b/bert_e/workflow/gitwaterflow/jira.py index 98f729bf..7db1255c 100644 --- a/bert_e/workflow/gitwaterflow/jira.py +++ b/bert_e/workflow/gitwaterflow/jira.py @@ -25,6 +25,7 @@ from bert_e import exceptions from bert_e.lib import jira as jira_api +from ..pr_utils import notify_user from .utils import bypass_jira_check @@ -55,6 +56,7 @@ def jira_checks(job): if not job.settings.disable_version_checks: check_fix_versions(job, issue) + _notify_pending_hotfix_if_needed(job, issue) def get_jira_issue(job): @@ -189,3 +191,26 @@ def check_fix_versions(job, issue): expect_versions=sorted(expected_versions), active_options=job.active_options ) + + +def _notify_pending_hotfix_if_needed(job, issue): + """Post a one-time reminder when the ticket carries a pre-GA hotfix + fix version (X.Y.Z.0) so the developer knows to open a cherry-pick PR + to the corresponding hotfix branch. + + This is an informational message: it is posted at most once per PR + (dont_repeat_if_in_history = NEVER_REPEAT) and never blocks the flow. + """ + phantom_versions = job.git.cascade.phantom_hotfix_versions + if not phantom_versions: + return + issue_versions = {v.name for v in issue.fields.fixVersions} + matching = sorted(phantom_versions & issue_versions) + if not matching: + return + reminder = exceptions.PendingHotfixVersionReminder( + issue=issue, + hotfix_versions=matching, + active_options=job.active_options, + ) + notify_user(job.settings, job.pull_request, reminder) From c18c7cf4044efbf649bb42431b0fc351245a5477 Mon Sep 17 00:00:00 2001 From: Charles Prost Date: Wed, 15 Apr 2026 11:50:37 +0200 Subject: [PATCH 16/16] Fix SuccessMessage missing pending_hotfixes in test handle_legacy handle_legacy() re-raises SuccessMessage from a Queued exception's saved fields, but omitted the pending_hotfixes field added in 0d0cdca. With StrictUndefined in the Jinja2 environment this caused UndefinedError in successful_merge.md for every TestBertE test. Co-Authored-By: Claude Sonnet 4.6 --- bert_e/tests/test_bert_e.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bert_e/tests/test_bert_e.py b/bert_e/tests/test_bert_e.py index 859f770c..cd8ce7ea 100644 --- a/bert_e/tests/test_bert_e.py +++ b/bert_e/tests/test_bert_e.py @@ -1079,6 +1079,7 @@ def handle_legacy(self, token, backtrace): raise exns.SuccessMessage( branches=queued_excp.branches, ignored=queued_excp.ignored, + pending_hotfixes=queued_excp.pending_hotfixes, issue=queued_excp.issue, author=queued_excp.author, active_options=queued_excp.active_options)