Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
13dd156
Replace deprecated pipes.quote with shlex.quote
charlesprost Apr 10, 2026
d573334
Ensure fast-forward merges in local clone regardless of host git config
charlesprost Apr 10, 2026
f7f2100
Extend hotfix branch support to pre-GA mode
charlesprost Apr 10, 2026
d7fa65b
Add tests for pre-GA hotfix branch support
charlesprost Apr 10, 2026
31374d5
Add cascade test for mixed 3-digit and pre-GA hotfix scenario
charlesprost Apr 10, 2026
d8dc584
Add cascade test for 2-digit dev/10.0 coexisting with pre-GA hotfix/1…
charlesprost Apr 10, 2026
f12e28a
Fix E501 flake8 line-too-long violations in branches.py
charlesprost Apr 13, 2026
c652351
Fix regressions from HotfixBranch.hfrev=0 default
charlesprost Apr 13, 2026
ebc8389
(Risk 3) Guard hotfix queue name with isinstance, not hfrev >= 0
charlesprost Apr 13, 2026
49397dd
(Risk 2) Keep phantom hotfix hfrev/version fresh in update_versions
charlesprost Apr 13, 2026
3f1c9a1
(Risk 1) Fix already_in_queue to detect pre-GA queue branches after G…
charlesprost Apr 13, 2026
4533187
Accept 3-digit Jira fix version for pre-GA hotfix branches
charlesprost Apr 14, 2026
4741a2f
Fix E501 flake8 line-too-long in jira.py
charlesprost Apr 14, 2026
0d0cdca
Pre-GA hotfix: remind about cherry-pick PR, strip phantom version fro…
charlesprost Apr 15, 2026
ed91d54
Add one-time reminder when ticket has a pre-GA hotfix fix version
charlesprost Apr 15, 2026
c18c7cf
Fix SuccessMessage missing pending_hotfixes in test handle_legacy
charlesprost Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion bert_e/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -432,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'
Expand Down
11 changes: 8 additions & 3 deletions bert_e/jobs/create_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.' %
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion bert_e/lib/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions bert_e/templates/pending_hotfix_version_reminder.md
Original file line number Diff line number Diff line change
@@ -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 %}
9 changes: 9 additions & 0 deletions bert_e/templates/queued.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions bert_e/templates/successful_merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}.
Expand Down
183 changes: 183 additions & 0 deletions bert_e/tests/test_bert_e.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,10 @@
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)
Expand All @@ -455,6 +459,114 @@
fixver = ['6.6.6.3']
self.finalize_cascade(branches, tags, destination, fixver)

def test_branch_cascade_dev_with_pre_ga_hotfix(self):
"""development/<major> must target <major>.<minor+1>.0 once
hotfix/<major>.<minor>.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_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']
Comment thread
charlesprost marked this conversation as resolved.
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_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_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'
Expand Down Expand Up @@ -967,6 +1079,7 @@
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)
Expand Down Expand Up @@ -5899,6 +6012,76 @@
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_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')

Check warning on line 6044 in bert_e/tests/test_bert_e.py

View check run for this annotation

Codecov / codecov/patch

bert_e/tests/test_bert_e.py#L6044

Added line #L6044 was not covered by tests
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.

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')
Expand Down
Loading
Loading