Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
50 changes: 25 additions & 25 deletions .deps/resolved/linux-aarch64_3.13.txt

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions .deps/resolved/linux-x86_64_3.13.txt

Large diffs are not rendered by default.

42 changes: 21 additions & 21 deletions .deps/resolved/macos-aarch64_3.13.txt

Large diffs are not rendered by default.

42 changes: 21 additions & 21 deletions .deps/resolved/macos-x86_64_3.13.txt

Large diffs are not rendered by default.

48 changes: 24 additions & 24 deletions .deps/resolved/windows-x86_64_3.13.txt

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ddev/changelog.d/22254.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Check for existing open PRs before creating rc tag
38 changes: 36 additions & 2 deletions ddev/src/ddev/cli/release/branch/tag.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import re

import click
Expand All @@ -13,8 +14,15 @@
show_default=True,
help="Whether we're tagging the final release or a release candidate (rc).",
)
@click.option(
'--skip-open-pr-check',
is_flag=True,
default=False,
show_default=True,
help='Skip checking GitHub for open PRs targeting this release branch before tagging.',
)
@click.pass_obj
def tag(app, final):
def tag(app, final: bool, skip_open_pr_check: bool):
"""
Tag the release branch either as release candidate or final release.
"""
Expand All @@ -24,6 +32,7 @@ def tag(app, final):
app.abort(
f'Invalid branch name: {branch_name}. Branch name must match the pattern {BRANCH_NAME_REGEX.pattern}.'
)

click.echo(app.repo.git.pull(branch_name))
click.echo(app.repo.git.fetch_tags())

Expand Down Expand Up @@ -67,7 +76,32 @@ def tag(app, final):
'You are about to go back in time by creating an RC with a number less than that. Are you sure? [y/N]'
):
app.abort('Did not get confirmation, aborting. Did not create or push the tag.')
if not click.confirm(f'Create and push this tag: {new_tag}?'):

prs = []
if not skip_open_pr_check:
httpx_logger = logging.getLogger('httpx')
previous_level = httpx_logger.level
httpx_logger.setLevel(logging.WARNING)
try:
prs = app.github.list_open_pull_requests_targeting_base(branch_name)
except Exception as e:
click.secho(f'Warning: unable to check for open PRs: {e}', fg='yellow')
finally:
httpx_logger.setLevel(previous_level)

if prs:
click.secho('!!! WARNING !!!', fg='yellow')
click.secho(f'Found {len(prs)} open PR(s) targeting base branch {branch_name}:', fg='yellow')
for pr in prs[:20]:
click.secho(f' - #{pr.number} {pr.title} ({pr.html_url})', fg='yellow')
if len(prs) > 20:
click.secho(f' ... and {len(prs) - 20} more', fg='yellow')

prompt = f'Create and push this tag: {new_tag}?'
if prs:
prompt = f'Open PRs found targeting {branch_name}. Create and push this tag anyway: {new_tag}?'

if not click.confirm(prompt):
app.abort('Did not get confirmation, aborting. Did not create or push the tag.')
click.echo(app.repo.git.tag(new_tag, message=new_tag))
click.echo(app.repo.git.push(new_tag))
Expand Down
39 changes: 39 additions & 0 deletions ddev/src/ddev/utils/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ def get_pull_request(self, sha: str) -> PullRequest | None:

return PullRequest(data['items'][0])

def list_open_pull_requests_targeting_base(self, base_branch: str, *, limit: int = 100) -> list[PullRequest]:
"""
List open pull requests targeting the given base branch. Limit to 100 by default.
"""
if limit < 1:
return []

prs: list[PullRequest] = []
max_per_page = 100
page = 1

# https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests
query = f'repo:{self.repo_id} is:pull-request is:open base:{base_branch}'

while len(prs) < limit:
requested_count = min(max_per_page, limit - len(prs))
response = self.__api_get(
self.ISSUE_SEARCH_API,
params={
'q': query,
'sort': 'updated',
'order': 'desc',
'per_page': requested_count,
'page': page,
},
)
data = json.loads(response.text)
items = data.get('items', [])
if not items:
break

prs.extend(PullRequest(item) for item in items)
if len(items) < requested_count:
break

page += 1

return prs[:limit]

def get_pull_request_by_number(self, number: str) -> PullRequest | None:
response = self.__api_get(
self.ISSUE_SEARCH_API,
Expand Down
56 changes: 55 additions & 1 deletion ddev/tests/cli/release/branch/test_tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,60 @@

from ddev.utils.git import GitRepository


def test_tag_check_open_prs_warns_and_allows_continue(ddev, git, mocker, config_file):
config_file.model.github = {'user': 'test-user', 'token': 'test-token'}
config_file.save()

mock_pr = mocker.MagicMock()
mock_pr.number = 1234
mock_pr.title = 'Fix thing'
mock_pr.html_url = 'https://example.invalid/pr/1234'
list_prs = mocker.patch(
'ddev.utils.github.GitHubManager.list_open_pull_requests_targeting_base',
return_value=[mock_pr],
)

result = ddev('release', 'branch', 'tag', '--final', input='y\n')

assert result.exit_code == 0, result.output
assert git.method_calls[-2:] == [
c.tag('7.56.0', message='7.56.0'),
c.push('7.56.0'),
]
assert 'Found 1 open PR(s) targeting base branch 7.56.x' in result.output
assert '#1234 Fix thing' in result.output
assert 'Open PRs found targeting 7.56.x' in result.output
list_prs.assert_called_once_with('7.56.x')


def test_tag_skip_open_pr_check(ddev, git, mocker, config_file):
config_file.model.github = {'user': 'test-user', 'token': 'test-token'}
config_file.save()

list_prs = mocker.patch('ddev.utils.github.GitHubManager.list_open_pull_requests_targeting_base')

result = ddev('release', 'branch', 'tag', '--final', '--skip-open-pr-check', input='y\n')

_assert_tag_pushed(git, result, '7.56.0')
list_prs.assert_not_called()


def test_tag_github_api_error_degrades_gracefully(ddev, git, mocker, config_file):
config_file.model.github = {'user': 'test-user', 'token': 'test-token'}
config_file.save()

mocker.patch(
'ddev.utils.github.GitHubManager.list_open_pull_requests_targeting_base',
side_effect=Exception('API error'),
)

result = ddev('release', 'branch', 'tag', '--final', input='y\n')

_assert_tag_pushed(git, result, '7.56.0')
assert 'unable to check for open PRs' in result.output


NO_CONFIRMATION_SO_ABORT = 'Did not get confirmation, aborting. Did not create or push the tag.'
RC_NUMBER_PROMPT = 'What RC number are we tagging? (hit ENTER to accept suggestion) [{}]'

Expand Down Expand Up @@ -177,7 +231,7 @@ def test_abort_valid_rc(ddev, git, no_confirm):
"""
git.tags.return_value = []

result = ddev('release', 'branch', 'tag', input='\n{no_confirm}\n')
result = ddev('release', 'branch', 'tag', input=f'\n{no_confirm}\n')

assert RC_NUMBER_PROMPT.format('1') in result.output
assert result.exit_code == 1, result.output
Expand Down
8 changes: 7 additions & 1 deletion ddev/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def config_file(tmp_path, monkeypatch, local_repo, mocker) -> ConfigFileWithOver
config = ConfigFileWithOverrides(path)
config.reset()

# Disable upgrade check in tests to avoid messages spam.
config.global_model.upgrade_check = False
# Provide a real default for times when tests have no need to modify the repo
config.global_model.repos['core'] = str(local_repo)
config.save()
Expand All @@ -135,6 +137,8 @@ def temp_dir(tmp_path) -> Path:

@pytest.fixture(scope='session', autouse=True)
def isolation() -> Generator[Path, None, None]:
from unittest.mock import patch

with temp_directory() as d:
data_dir = d / 'data'
data_dir.mkdir()
Expand All @@ -147,7 +151,9 @@ def isolation() -> Generator[Path, None, None]:
'LINES': '24',
}
with d.as_cwd(default_env_vars):
yield d
# Prevent upgrade check from registering atexit handlers during tests
with patch('ddev.cli.upgrade_check.atexit.register'):
yield d


@pytest.fixture(scope='session')
Expand Down
25 changes: 25 additions & 0 deletions ddev/tests/utils/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ def test_pr_description(self, incoming_body, expected_body):
)


class TestListOpenPullRequestsTargetingBase:
def test_returns_pull_requests(self, github_manager, mocker):
response = mocker.MagicMock()
response.text = (
'{"items":[{"number":10,"title":"PR title","pull_request":{"html_url":"https://example.invalid/pr/10","diff_url":"https://example.invalid/pr/10.diff"},'
'"body":"","user":{"login":"someone"},"labels":[{"name":"foo"}]}]}'
)
mocker.patch('ddev.utils.github.GitHubManager._GitHubManager__api_get', return_value=response)

prs = github_manager.list_open_pull_requests_targeting_base('7.99.x')
assert len(prs) == 1
assert prs[0].number == 10
assert prs[0].title == 'PR title'
assert prs[0].html_url == 'https://example.invalid/pr/10'
assert prs[0].labels == ['foo']

def test_returns_empty_list_when_no_results(self, github_manager, mocker):
response = mocker.MagicMock()
response.text = '{"items":[],"total_count":0}'
mocker.patch('ddev.utils.github.GitHubManager._GitHubManager__api_get', return_value=response)

prs = github_manager.list_open_pull_requests_targeting_base('7.99.x')
assert prs == []


class TestCreateLabel:
def test_create_label(self, network_replay, github_manager):
network_replay('github/create_label.yaml', record_mode='none')
Expand Down
Loading