From 2243a2567a1e74c4fc571f8a01db8303ff106c63 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Wed, 9 Aug 2023 16:53:42 -0400 Subject: [PATCH] Add changelog enforcement (#15459) * Add changelog enforcement * Apply suggestions from code review Co-authored-by: Ilia Kurenkov * address review * Apply suggestions from code review Co-authored-by: Ilia Kurenkov --------- Co-authored-by: Ilia Kurenkov --- .github/workflows/pr-check.yml | 14 + .github/workflows/pr-quick-check.yml | 50 ++ ddev/CHANGELOG.md | 4 + ddev/src/ddev/cli/release/__init__.py | 2 +- .../ddev/cli/release/changelog/__init__.py | 18 + ddev/src/ddev/cli/release/changelog/fix.py | 52 ++ ddev/src/ddev/cli/release/changelog/new.py | 101 +++ ddev/src/ddev/integration/core.py | 3 + ddev/src/ddev/release/__init__.py | 3 + ddev/src/ddev/release/constants.py | 4 + ddev/src/ddev/repo/core.py | 37 + ddev/src/ddev/utils/git.py | 3 +- ddev/src/ddev/utils/github.py | 38 +- ddev/src/ddev/utils/scripts/check_pr.py | 191 +++++ ddev/tests/cli/release/test_changelog.py | 662 ++++++++++++++++++ .../release/changelog/fix_existing_pr.yaml | 343 +++++++++ .../network/release/changelog/fix_no_pr.yaml | 172 +++++ ddev/tests/utils/test_github.py | 2 +- 18 files changed, 1691 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/pr-quick-check.yml create mode 100644 ddev/src/ddev/cli/release/changelog/__init__.py create mode 100644 ddev/src/ddev/cli/release/changelog/fix.py create mode 100644 ddev/src/ddev/cli/release/changelog/new.py create mode 100644 ddev/src/ddev/release/__init__.py create mode 100644 ddev/src/ddev/release/constants.py create mode 100644 ddev/src/ddev/utils/scripts/check_pr.py create mode 100644 ddev/tests/cli/release/test_changelog.py create mode 100644 ddev/tests/fixtures/network/release/changelog/fix_existing_pr.yaml create mode 100644 ddev/tests/fixtures/network/release/changelog/fix_no_pr.yaml diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000000000..82fea5a1b61eb --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,14 @@ +name: Check PR + +on: pull_request + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + run: + uses: ./.github/workflows/pr-quick-check.yml + with: + repo: core + secrets: inherit diff --git a/.github/workflows/pr-quick-check.yml b/.github/workflows/pr-quick-check.yml new file mode 100644 index 0000000000000..821ded203f9dd --- /dev/null +++ b/.github/workflows/pr-quick-check.yml @@ -0,0 +1,50 @@ +name: Quick check PR + +on: + workflow_call: + inputs: + repo: + required: true + type: string + +defaults: + run: + shell: bash + +env: + PYTHON_VERSION: "3.11" + CHECK_SCRIPT: "ddev/src/ddev/utils/scripts/check_pr.py" + +jobs: + check: + name: Check PR + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + if: inputs.repo == 'core' + with: + ref: "${{ github.event.pull_request.head.sha }}" + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v4 + with: + python-version: "${{ env.PYTHON_VERSION }}" + + - name: Fetch PR data + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: |- + diff_url=$(cat "$GITHUB_EVENT_PATH" | jq -r '.pull_request.diff_url') + curl --header "Authorization: Bearer $GITHUB_TOKEN" -sLo /tmp/diff "$diff_url" + cat "$GITHUB_EVENT_PATH" | jq -r '.pull_request.number' > /tmp/number + + - name: Fetch script + if: inputs.repo != 'core' + run: |- + mkdir -p $(dirname ${{ env.CHECK_SCRIPT }}) + curl -sLo ${{ env.CHECK_SCRIPT }} https://raw.githubusercontent.com/DataDog/integrations-core/master/${{ env.CHECK_SCRIPT }} + + - name: Check + run: |- + python ${{ env.CHECK_SCRIPT }} changelog --diff-file /tmp/diff --pr-number $(cat /tmp/number) diff --git a/ddev/CHANGELOG.md b/ddev/CHANGELOG.md index 4bfffc346413d..e11ab6015051d 100644 --- a/ddev/CHANGELOG.md +++ b/ddev/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +***Added***: + +* Add changelog enforcement (#15459) + ## 3.3.0 / 2023-07-20 ***Added***: diff --git a/ddev/src/ddev/cli/release/__init__.py b/ddev/src/ddev/cli/release/__init__.py index 24371d3cbe695..870e46b6c801b 100644 --- a/ddev/src/ddev/cli/release/__init__.py +++ b/ddev/src/ddev/cli/release/__init__.py @@ -3,13 +3,13 @@ # Licensed under a 3-clause BSD style license (see LICENSE) import click from datadog_checks.dev.tooling.commands.release.build import build -from datadog_checks.dev.tooling.commands.release.changelog import changelog from datadog_checks.dev.tooling.commands.release.make import make from datadog_checks.dev.tooling.commands.release.tag import tag from datadog_checks.dev.tooling.commands.release.trello import trello from datadog_checks.dev.tooling.commands.release.upload import upload from ddev.cli.release.agent import agent +from ddev.cli.release.changelog import changelog from ddev.cli.release.list_versions import list_versions from ddev.cli.release.show import show from ddev.cli.release.stats import stats diff --git a/ddev/src/ddev/cli/release/changelog/__init__.py b/ddev/src/ddev/cli/release/changelog/__init__.py new file mode 100644 index 0000000000000..8cfecf2d26c6d --- /dev/null +++ b/ddev/src/ddev/cli/release/changelog/__init__.py @@ -0,0 +1,18 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import click + +from ddev.cli.release.changelog.fix import fix +from ddev.cli.release.changelog.new import new + + +@click.group(short_help='Manage changelogs') +def changelog(): + """ + Manage changelogs. + """ + + +changelog.add_command(fix) +changelog.add_command(new) diff --git a/ddev/src/ddev/cli/release/changelog/fix.py b/ddev/src/ddev/cli/release/changelog/fix.py new file mode 100644 index 0000000000000..8a636a7b965f6 --- /dev/null +++ b/ddev/src/ddev/cli/release/changelog/fix.py @@ -0,0 +1,52 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command(short_help='Fix changelog entries') +@click.pass_obj +def fix(app: Application): + """ + Fix changelog entries. + """ + from ddev.utils.scripts.check_pr import get_changelog_errors + + latest_commit = app.repo.git.latest_commit + pr = app.github.get_pull_request(latest_commit.sha) + if pr is not None: + git_diff = app.github.get_diff(pr) + pr_number = pr.number + else: + git_diff = app.repo.git.capture('diff', 'origin/master...') + pr_number = app.github.get_next_issue_number() + + expected_suffix = f' (#{pr_number})' + fixed = 0 + for path, line_number, _ in get_changelog_errors(git_diff, expected_suffix): + if line_number == 1: + continue + + changelog = app.repo.path / path + lines = changelog.read_text().splitlines(keepends=True) + index = line_number - 1 + original_line = lines[index] + new_line = original_line.rstrip() + if new_line.endswith(expected_suffix): + continue + + lines[index] = f'{new_line}{expected_suffix}{original_line[len(new_line) :]}' + changelog.write_text(''.join(lines)) + fixed += 1 + + if not fixed: + app.display_info('No changelog entries need fixing') + else: + app.display_success(f'Fixed {fixed} changelog entr{"ies" if fixed > 1 else "y"}') diff --git a/ddev/src/ddev/cli/release/changelog/new.py b/ddev/src/ddev/cli/release/changelog/new.py new file mode 100644 index 0000000000000..04cc889e78f3d --- /dev/null +++ b/ddev/src/ddev/cli/release/changelog/new.py @@ -0,0 +1,101 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from ddev.cli.application import Application + + +@click.command(short_help='Create changelog entries') +@click.argument('entry_type', required=False) +@click.argument('targets', nargs=-1, required=False) +@click.option( + '--message', + '-m', + help='The changelog text, defaulting to the PR title followed by the most recent commit message', +) +@click.pass_obj +def new(app: Application, entry_type: str | None, targets: tuple[str], message: str | None): + """ + Create changelog entries. + """ + from ddev.release.constants import ENTRY_TYPES + + derive_message = message is None + latest_commit = app.repo.git.latest_commit + pr = app.github.get_pull_request(latest_commit.sha) + if pr is not None: + pr_number = pr.number + if message is None: + message = pr.title + else: + pr_number = app.github.get_next_issue_number() + if message is None: + message = latest_commit.subject + + if entry_type is not None: + entry_type = entry_type.capitalize() + if entry_type not in ENTRY_TYPES: + app.abort(f'Unknown entry type: {entry_type}') + else: + entry_type = click.prompt('Entry type?', type=click.Choice(ENTRY_TYPES, case_sensitive=False)) + + expected_suffix = f' (#{pr_number})' + entry = f'* {message.rstrip()}{expected_suffix}' + if derive_message and (new_entry := click.edit(entry)) is not None: + entry = new_entry + entry = entry.strip() + + entry_priority = ENTRY_TYPES.index(entry_type) + edited = 0 + + for target in app.repo.integrations.iter_changed_code(targets): + changelog = target.path / 'CHANGELOG.md' + lines = changelog.read_text().splitlines() + + unreleased = False + current_entry_type: str | None = None + i = 0 + for i, line in enumerate(lines): + if line == '## Unreleased': + unreleased = True + continue + elif unreleased and line.startswith('## '): + break + elif line.startswith('***'): + # e.g. ***Added***: + current_entry_type = line[3:-4] + + try: + current_entry_priority = ENTRY_TYPES.index(current_entry_type) + except ValueError: + app.abort( + f'{changelog.relative_to(app.repo.path)}, line {i}: unknown entry type {current_entry_type}' + ) + + if current_entry_priority > entry_priority: + break + + if current_entry_type is None or current_entry_type != entry_type: + for line in reversed( + ( + f'***{entry_type}***:', + '', + entry, + '', + ) + ): + lines.insert(i, line) + else: + lines.insert(i - 1, entry) + + lines.append('') + changelog.write_text('\n'.join(lines)) + edited += 1 + + app.display_success(f'Added {edited} changelog entr{"ies" if edited > 1 else "y"}') diff --git a/ddev/src/ddev/integration/core.py b/ddev/src/ddev/integration/core.py index de5bd349a68b7..766efa7ec17b0 100644 --- a/ddev/src/ddev/integration/core.py +++ b/ddev/src/ddev/integration/core.py @@ -67,6 +67,9 @@ def package_files(self) -> Iterator[Path]: if f.endswith('.py'): yield Path(root, f) + def requires_changelog_entry(self, path: Path) -> bool: + return self.package_directory in path.parents or (self.is_package and path == (self.path / 'pyproject.toml')) + @property def release_tag_pattern(self) -> str: version_part = r'\d+\.\d+\.\d+' diff --git a/ddev/src/ddev/release/__init__.py b/ddev/src/ddev/release/__init__.py new file mode 100644 index 0000000000000..e0cc3d0a7662c --- /dev/null +++ b/ddev/src/ddev/release/__init__.py @@ -0,0 +1,3 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) diff --git a/ddev/src/ddev/release/constants.py b/ddev/src/ddev/release/constants.py new file mode 100644 index 0000000000000..3da15eb710097 --- /dev/null +++ b/ddev/src/ddev/release/constants.py @@ -0,0 +1,4 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +ENTRY_TYPES = ('Removed', 'Changed', 'Security', 'Deprecated', 'Added', 'Fixed') diff --git a/ddev/src/ddev/repo/core.py b/ddev/src/ddev/repo/core.py index 521e273c955fe..ccb8affbca5e6 100644 --- a/ddev/src/ddev/repo/core.py +++ b/ddev/src/ddev/repo/core.py @@ -87,48 +87,85 @@ def get(self, name: str) -> Integration: return integration def iter(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all integrations. + """ for integration in self.__iter_filtered(selection): if integration.is_integration: yield integration def iter_all(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all targets i.e. any integration or Python package. + """ for integration in self.__iter_filtered(selection): if integration.is_valid: yield integration def iter_packages(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all Python packages. + """ for integration in self.__iter_filtered(selection): if integration.is_package: yield integration def iter_tiles(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all tile-only integrations. + """ for integration in self.__iter_filtered(selection): if integration.is_tile: yield integration def iter_testable(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all targets that can be tested. + """ for integration in self.__iter_filtered(selection): if integration.is_testable: yield integration def iter_shippable(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all integrations that can be shipped by the Agent. + """ for integration in self.__iter_filtered(selection): if integration.is_shippable: yield integration def iter_agent_checks(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all Python checks. + """ for integration in self.__iter_filtered(selection): if integration.is_agent_check: yield integration def iter_jmx_checks(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all JMX checks. + """ for integration in self.__iter_filtered(selection): if integration.is_jmx_check: yield integration def iter_changed(self) -> Iterable[Integration]: + """ + Iterate over all integrations that have changed. + """ yield from self.iter_all() + def iter_changed_code(self, selection: Iterable[str] = ()) -> Iterable[Integration]: + """ + Iterate over all integrations that have changes that could affect built distributions. + """ + for integration in self.__iter_filtered(selection): + for relative_path in self.repo.git.changed_files: + if integration.requires_changelog_entry(self.repo.path / relative_path): + yield integration + break + def __iter_filtered(self, selection: Iterable[str] = ()) -> Iterable[Integration]: selected = self.__finalize_selection(selection) if selected is None: diff --git a/ddev/src/ddev/utils/git.py b/ddev/src/ddev/utils/git.py index 94d2327aff6d6..3b0636ea3e7ff 100644 --- a/ddev/src/ddev/utils/git.py +++ b/ddev/src/ddev/utils/git.py @@ -47,7 +47,8 @@ def get_current_branch(self) -> str: @cached_property def latest_commit(self) -> GitCommit: - return GitCommit(self.capture('rev-parse', 'HEAD').strip()) + sha, subject = self.capture('log', '-1', '--format=%H%n%s').splitlines() + return GitCommit(sha, subject=subject) def get_latest_commit(self) -> GitCommit: with suppress(AttributeError): diff --git a/ddev/src/ddev/utils/github.py b/ddev/src/ddev/utils/github.py index a8908c641d270..cd3179fb4701e 100644 --- a/ddev/src/ddev/utils/github.py +++ b/ddev/src/ddev/utils/github.py @@ -16,21 +16,26 @@ class PullRequest: def __init__(self, data: dict[str, Any]): - self.__number = str(data['number']) + self.__number = data['number'] self.__title = data['title'] + self.__diff_url = data['pull_request']['diff_url'] # Normalize to remove carriage returns on Windows self.__body = '\n'.join(data['body'].splitlines()) self.__author = data['user']['login'] self.__labels = sorted(label['name'] for label in data['labels']) @property - def number(self) -> str: + def number(self) -> int: return self.__number @property def title(self) -> str: return self.__title + @property + def diff_url(self) -> str: + return self.__diff_url + @property def body(self) -> str: return self.__body @@ -50,6 +55,9 @@ class GitHubManager: # https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-issues-and-pull-requests ISSUE_SEARCH_API = 'https://api.github.com/search/issues' + # https://docs.github.com/en/rest/issues/issues?apiVersion=2022-11-28#list-repository-issues + ISSUE_LIST_API = 'https://api.github.com/repos/{repo_id}/issues' + def __init__(self, repo: Repository, *, user: str, token: str, status: BorrowedStatus): self.__repo = repo self.__auth = (user, token) @@ -83,12 +91,32 @@ def get_pull_request(self, sha: str) -> PullRequest | None: return PullRequest(data['items'][0]) + def get_next_issue_number(self) -> int: + from json import loads + + number = 1 + + response = self.__api_get( + self.ISSUE_LIST_API.format(repo_id=self.repo_id), + params={'state': 'all', 'sort': 'created', 'direction': 'desc', 'per_page': 1}, + ) + data = loads(response.text) + if data: + number += data[0]['number'] + + return number + + def get_diff(self, pr: PullRequest) -> str: + response = self.__api_get(pr.diff_url, follow_redirects=True) + return response.text + def __api_get(self, *args, **kwargs): + from httpx import HTTPError + retry_wait = 2 while True: try: - with self.client as client: - response = client.get(*args, auth=self.__auth, **kwargs) + response = self.client.get(*args, auth=self.__auth, **kwargs) # https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting # https://docs.github.com/en/rest/guides/best-practices-for-integrators?apiVersion=2022-11-28#dealing-with-rate-limits @@ -98,7 +126,7 @@ def __api_get(self, *args, **kwargs): context='GitHub API rate limit reached', ) continue - except Exception as e: + except HTTPError as e: self.__status.wait_for(retry_wait, context=f'GitHub API error: {e}') retry_wait *= 2 continue diff --git a/ddev/src/ddev/utils/scripts/check_pr.py b/ddev/src/ddev/utils/scripts/check_pr.py new file mode 100644 index 0000000000000..03b8704ec1841 --- /dev/null +++ b/ddev/src/ddev/utils/scripts/check_pr.py @@ -0,0 +1,191 @@ +""" +Running this script by itself must not use any external dependencies. +""" +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from typing import Iterator + + +def requires_changelog(target: str, files: Iterator[str]) -> bool: + if target == 'ddev': + source = 'src/ddev/' + else: + if target == 'datadog_checks_base': + directory = 'base' + elif target == 'datadog_checks_dev': + directory = 'dev' + elif target == 'datadog_checks_downloader': + directory = 'downloader' + else: + directory = target.replace('-', '_') + + source = f'datadog_checks/{directory}/' + + return any(f.startswith(source) or f == 'pyproject.toml' for f in files) + + +def git(*args) -> str: + try: + process = subprocess.run( + ['git', *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8', check=True + ) + except subprocess.CalledProcessError as e: + raise RuntimeError(f'{str(e)[:-1]}:\n{e.output}') from None + + return process.stdout + + +def get_added_lines(git_diff: str) -> dict[str, dict[int, str]]: + files: dict[str, dict[int, str]] = {} + for modification in re.split(r'^diff --git ', git_diff, flags=re.MULTILINE): + if not modification: + continue + + # a/file b/file + # new file mode 100644 + # index 0000000000..089fd64579 + # --- a/file + # +++ b/file + metadata, *blocks = re.split(r'^@@ ', modification, flags=re.MULTILINE) + *_, before, after = metadata.strip().splitlines() + + # Binary files /dev/null and b/foo/archive.tar.gz differ + binary_indicator = 'Binary files ' + if after.startswith(binary_indicator): + line = after[len(binary_indicator) :].rsplit(maxsplit=1)[0] + if line.startswith('/dev/null and '): + filename = line.split(maxsplit=2)[-1][2:] + elif line.endswith(' and /dev/null'): + filename = line.split(maxsplit=2)[0][2:] + else: + _, _, filename = line.partition(' and b/') + + files[filename] = {} + continue + + # --- a/file + # +++ /dev/null + before = before.split(maxsplit=1)[1] + after = after.split(maxsplit=1)[1] + filename = before[2:] if after == '/dev/null' else after[2:] + added = files[filename] = {} + + for block in blocks: + # -13,3 +13,8 @@ + info, *lines = block.splitlines() + # third number + start = int(info.split()[1].split(',')[0][1:]) + + removed = 0 + for i, line in enumerate(lines, start): + if line.startswith('+'): + added[i - removed] = line[1:] + elif line.startswith('-'): + removed += 1 + + return files + + +def get_changelog_errors(git_diff: str, suffix: str) -> list[tuple[str, int, str]]: + targets: dict[str, dict[str, dict[int, str]]] = {} + for filename, lines in get_added_lines(git_diff).items(): + target, _, path = filename.partition('/') + if not path: + continue + + targets.setdefault(target, {})[path] = lines + + errors: list[tuple[str, int, str]] = [] + for target, files in sorted(targets.items()): + if not requires_changelog(target, iter(files)): + continue + + changelog_file = 'CHANGELOG.md' + if changelog_file not in files: + errors.append((f'{target}/{changelog_file}', 1, 'Missing changelog entry')) + continue + + added_lines = files[changelog_file] + line_numbers_missing_suffix = [] + lines_with_suffix = 0 + for line_number, line in added_lines.items(): + if not line.startswith('* '): + continue + elif line.endswith(suffix): + lines_with_suffix += 1 + else: + line_numbers_missing_suffix.append(line_number) + + if lines_with_suffix == len(line_numbers_missing_suffix) == 0: + errors.append((f'{target}/{changelog_file}', 1, 'Missing changelog entry')) + elif line_numbers_missing_suffix: + for line_number in line_numbers_missing_suffix: + errors.append( + ( + f'{target}/{changelog_file}', + line_number, + f'The first line of every new changelog entry must ' + f'end with the associated PR number `{suffix}`', + ) + ) + + return errors + + +def changelog_impl(*, ref: str, diff_file: str, pr_number: int) -> None: + on_ci = os.environ.get('GITHUB_ACTIONS') == 'true' + if on_ci: + with open(diff_file, encoding='utf-8') as f: + git_diff = f.read() + else: + git_diff = git('diff', f'{ref}...') + + errors = get_changelog_errors(git_diff, f' (#{pr_number})') + if not errors: + return + elif os.environ.get('GITHUB_ACTIONS') == 'true': + for relative_path, line_number, message in errors: + message = '%0A'.join(message.splitlines()) + print(f'::error file={relative_path},line={line_number}::{message}') + else: + for relative_path, line_number, message in errors: + print(f'{relative_path}, line {line_number}: {message}') + + sys.exit(1) + + +def changelog_command(subparsers) -> None: + parser = subparsers.add_parser('changelog') + parser.add_argument('--ref', default='origin/master') + parser.add_argument('--diff-file') + parser.add_argument('--pr-number', type=int, default=1) + parser.set_defaults(func=changelog_impl) + + +def main(): + parser = argparse.ArgumentParser(prog=__name__, allow_abbrev=False) + subparsers = parser.add_subparsers() + + changelog_command(subparsers) + + kwargs = vars(parser.parse_args()) + try: + # We associate a command function with every subcommand parser. + # This allows us to emulate Click's command group behavior. + command = kwargs.pop('func') + except KeyError: + parser.print_help() + else: + command(**kwargs) + + +if __name__ == '__main__': + main() diff --git a/ddev/tests/cli/release/test_changelog.py b/ddev/tests/cli/release/test_changelog.py new file mode 100644 index 0000000000000..35e68c18ed338 --- /dev/null +++ b/ddev/tests/cli/release/test_changelog.py @@ -0,0 +1,662 @@ +# (C) Datadog, Inc. 2023-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +from ddev.repo.core import Repository + + +class TestFix: + def test_existing_pr(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_existing_pr.yaml') + mocker.patch('ddev.utils.git.GitManager.capture', return_value='cfd8020b628cc24eebadae2ab79a3a1be285885c\nfoo') + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Add changelog enforcement + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + + result = ddev('release', 'changelog', 'fix') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Fixed 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Add changelog enforcement (#15459) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_no_pr(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nfoo', + helpers.dedent( + """ + diff --git a/ddev/CHANGELOG.md b/ddev/CHANGELOG.md + index 4bfffc346413..5603965b33ac 100644 + --- a/ddev/CHANGELOG.md + +++ b/ddev/CHANGELOG.md + @@ -2,6 +2,10 @@ + + ## Unreleased + + +***Added***: + + + +* Add changelog enforcement + + + ## 3.3.0 / 2023-07-20 + + ***Added***: + diff --git a/ddev/pyproject.toml b/ddev/pyproject.toml + index a2d9e8b863..4d93f38bbd 100644 + --- a/ddev/pyproject.toml + +++ b/ddev/pyproject.toml + @@ -111,3 +111,6 @@ ban-relative-imports = "all" + [tool.ruff.per-file-ignores] + #Tests can use assertions and relative imports + "**/tests/**/*" = ["I252"] + + + + + + + """ + ), + ], + ) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Add changelog enforcement + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + + result = ddev('release', 'changelog', 'fix') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Fixed 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Add changelog enforcement (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + +class TestNew: + def test_start(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', 'added') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_append(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', 'added') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_before(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', 'changed') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Changed***: + + * Foo (#15476) + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_after(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', 'fixed') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ***Fixed***: + + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_multiple(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog1 = repository.path / 'ddev' / 'CHANGELOG.md' + changelog1.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + changelog2 = repository.path / 'postgres' / 'CHANGELOG.md' + changelog2.write_text( + helpers.dedent( + """ + # CHANGELOG - postgres + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml\nM postgres/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', 'added') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 2 changelog entries + """ + ) + + assert changelog1.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + assert changelog2.read_text() == helpers.dedent( + """ + # CHANGELOG - postgres + + ## Unreleased + + ***Added***: + + * Over (#9000) + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_explicit_message(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + + result = ddev('release', 'changelog', 'new', 'added', '-m', 'Bar') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Bar (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_prompt_for_entry_type(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value=None) + + result = ddev('release', 'changelog', 'new', input='added') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Entry type? (Removed, Changed, Security, Deprecated, Added, Fixed): added + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Over (#9000) + * Foo (#15476) + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + + def test_edit_entry(self, ddev, repository, helpers, network_replay, mocker): + network_replay('release/changelog/fix_no_pr.yaml') + + repo = Repository(repository.path.name, str(repository.path)) + + changelog = repository.path / 'ddev' / 'CHANGELOG.md' + changelog.write_text( + helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) + ) + repo.git.capture('add', '.') + repo.git.capture('commit', '-m', 'test') + mocker.patch( + 'ddev.utils.git.GitManager.capture', + side_effect=[ + '0000000000000000000000000000000000000000\nFoo', + 'M ddev/pyproject.toml', + '', + '', + ], + ) + mocker.patch('click.edit', return_value='* Foo (#15476)\n\n Bar') + + result = ddev('release', 'changelog', 'new', 'added') + + assert result.exit_code == 0, result.output + assert helpers.remove_trailing_spaces(result.output) == helpers.dedent( + """ + Added 1 changelog entry + """ + ) + + assert changelog.read_text() == helpers.dedent( + """ + # CHANGELOG - ddev + + ## Unreleased + + ***Added***: + + * Foo (#15476) + + Bar + + ## 3.3.0 / 2023-07-20 + + ***Added***: + """ + ) diff --git a/ddev/tests/fixtures/network/release/changelog/fix_existing_pr.yaml b/ddev/tests/fixtures/network/release/changelog/fix_existing_pr.yaml new file mode 100644 index 0000000000000..b8cc013cdaeec --- /dev/null +++ b/ddev/tests/fixtures/network/release/changelog/fix_existing_pr.yaml @@ -0,0 +1,343 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.github.com + user-agent: + - python-httpx/0.24.1 + x-github-api-version: + - '2022-11-28' + method: GET + uri: https://api.github.com/search/issues?q=sha%3Acfd8020b628cc24eebadae2ab79a3a1be285885c%2Brepo%3ADataDog/integrations-core + response: + content: '{"total_count":1,"incomplete_results":false,"items":[{"url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459","repository_url":"https://api.github.com/repos/DataDog/integrations-core","labels_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459/labels{/name}","comments_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459/comments","events_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459/events","html_url":"https://github.com/DataDog/integrations-core/pull/15459","id":1833090960,"node_id":"PR_kwDOAtBC5c5XAVK1","number":15459,"title":"Add + changelog enforcement","user":{"login":"ofek","id":9677399,"node_id":"MDQ6VXNlcjk2NzczOTk=","avatar_url":"https://avatars.githubusercontent.com/u/9677399?v=4","gravatar_id":"","url":"https://api.github.com/users/ofek","html_url":"https://github.com/ofek","followers_url":"https://api.github.com/users/ofek/followers","following_url":"https://api.github.com/users/ofek/following{/other_user}","gists_url":"https://api.github.com/users/ofek/gists{/gist_id}","starred_url":"https://api.github.com/users/ofek/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/ofek/subscriptions","organizations_url":"https://api.github.com/users/ofek/orgs","repos_url":"https://api.github.com/users/ofek/repos","events_url":"https://api.github.com/users/ofek/events{/privacy}","received_events_url":"https://api.github.com/users/ofek/received_events","type":"User","site_admin":false},"labels":[{"id":581315931,"node_id":"MDU6TGFiZWw1ODEzMTU5MzE=","url":"https://api.github.com/repos/DataDog/integrations-core/labels/documentation","name":"documentation","color":"7e1df4","default":true,"description":""},{"id":936341014,"node_id":"MDU6TGFiZWw5MzYzNDEwMTQ=","url":"https://api.github.com/repos/DataDog/integrations-core/labels/dev/testing","name":"dev/testing","color":"6ad86c","default":false,"description":""},{"id":936375010,"node_id":"MDU6TGFiZWw5MzYzNzUwMTA=","url":"https://api.github.com/repos/DataDog/integrations-core/labels/dev/tooling","name":"dev/tooling","color":"6ad86c","default":false,"description":""},{"id":4541502693,"node_id":"LA_kwDOAtBC5c8AAAABDrHU5Q","url":"https://api.github.com/repos/DataDog/integrations-core/labels/ddev","name":"ddev","color":"ededed","default":false,"description":null}],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":1,"created_at":"2023-08-02T12:28:48Z","updated_at":"2023-08-03T07:34:19Z","closed_at":null,"author_association":"MEMBER","active_lock_reason":null,"draft":false,"pull_request":{"url":"https://api.github.com/repos/DataDog/integrations-core/pulls/15459","html_url":"https://github.com/DataDog/integrations-core/pull/15459","diff_url":"https://github.com/DataDog/integrations-core/pull/15459.diff","patch_url":"https://github.com/DataDog/integrations-core/pull/15459.patch","merged_at":null},"body":"### + Motivation\r\n\r\nThis will become our new process\r\n\r\n### Annotations\r\n\r\n![Screenshot + 2023-08-02 084034](https://github.com/DataDog/integrations-core/assets/9677399/49bae755-682f-4d04-a9ad-c47391dd26a7)\r\n\r\n![Screenshot + 2023-08-02 085702](https://github.com/DataDog/integrations-core/assets/9677399/f6f26e12-a71f-4dfd-99ed-6245ac253f45)","reactions":{"url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15459/timeline","performed_via_github_app":null,"state_reason":null,"score":1.0}]}' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, + X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, + X-GitHub-Request-Id, Deprecation, Sunset + Cache-Control: + - no-cache + Content-Encoding: + - gzip + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 03 Aug 2023 22:08:35 GMT + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + Vary: + - Accept, Authorization, Cookie, X-GitHub-OTP + - Accept-Encoding, Accept, X-Requested-With + X-Accepted-OAuth-Scopes: + - '' + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Media-Type: + - github.v3; format=json + X-GitHub-Request-Id: + - EB87:6687:723462:E8E74B:64CC2563 + X-OAuth-Scopes: + - read:org, repo, workflow + X-RateLimit-Limit: + - '30' + X-RateLimit-Remaining: + - '29' + X-RateLimit-Reset: + - '1691100575' + X-RateLimit-Resource: + - search + X-RateLimit-Used: + - '1' + X-XSS-Protection: + - '0' + x-github-api-version-selected: + - '2022-11-28' + http_version: HTTP/1.1 + status_code: 200 +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - github.com + user-agent: + - python-httpx/0.24.1 + x-github-api-version: + - '2022-11-28' + method: GET + uri: https://github.com/DataDog/integrations-core/pull/15459.diff + response: + content: '' + headers: + Cache-Control: + - no-cache + Content-Security-Policy: + - 'default-src ''none''; base-uri ''self''; child-src github.com/assets-cdn/worker/ + gist.github.com/assets-cdn/worker/; connect-src ''self'' uploads.github.com + objects-origin.githubusercontent.com www.githubstatus.com collector.github.com + raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com + github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com + cdn.optimizely.com logx.optimizely.com/v1/events *.actions.githubusercontent.com + productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ + productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ + productionresultssa4.blob.core.windows.net/ wss://*.actions.githubusercontent.com + github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com + insights.github.com wss://alive.github.com; font-src github.githubassets.com; + form-action ''self'' github.com gist.github.com objects-origin.githubusercontent.com; + frame-ancestors ''none''; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com; + img-src ''self'' data: github.githubassets.com media.githubusercontent.com + camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com + github-cloud.s3.amazonaws.com objects.githubusercontent.com objects-origin.githubusercontent.com + secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ + private-user-images.githubusercontent.com opengraph.githubassets.com github-production-user-asset-6210df.s3.amazonaws.com + customer-stories-feed.github.com spotlights-feed.github.com *.githubusercontent.com; + manifest-src ''self''; media-src github.com user-images.githubusercontent.com/ + secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com; + script-src github.githubassets.com; style-src ''unsafe-inline'' github.githubassets.com; + upgrade-insecure-requests; worker-src github.com/assets-cdn/worker/ gist.github.com/assets-cdn/worker/' + Content-Type: + - text/html; charset=utf-8 + Date: + - Thu, 03 Aug 2023 22:08:36 GMT + Location: + - https://patch-diff.githubusercontent.com/raw/DataDog/integrations-core/pull/15459.diff + Referrer-Policy: + - no-referrer-when-downgrade + Server: + - GitHub.com + Set-Cookie: + - logged_in=no; domain=github.com; path=/; expires=Sat, 03 Aug 2024 22:08:36 + GMT; secure; HttpOnly; SameSite=Lax + - _gh_sess=IzTN19nCLZ588uH55a%2BiIj%2Bxj%2B0xrCdRFmf%2FOX4S%2BZ33PNY%2BfWtYJPUZMHlIgqaS4bILySAgrNXq3jpIJt1jygskR1EHkD9adWJR5t4IoIPUXuPbIW0BI1vyoa39xoOxGzkE8I4YZ5Nl96zSqLyO%2FnHZHD9D--ZKPOCMAvO%2F%2BT3MNy--7Pr6ku5aEAglszoUdO4XVw%3D%3D; + path=/; secure; HttpOnly; SameSite=Lax + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + Vary: + - X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame + - Accept-Encoding, Accept, X-Requested-With + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Request-Id: + - EB88:46CB:2A2277:3A48A3:64CC2564 + X-XSS-Protection: + - '0' + http_version: HTTP/1.1 + status_code: 302 +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - patch-diff.githubusercontent.com + user-agent: + - python-httpx/0.24.1 + x-github-api-version: + - '2022-11-28' + method: GET + uri: https://patch-diff.githubusercontent.com/raw/DataDog/integrations-core/pull/15459.diff + response: + content: "diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml\nnew + file mode 100644\nindex 000000000000..82fea5a1b61e\n--- /dev/null\n+++ b/.github/workflows/pr-check.yml\n@@ + -0,0 +1,14 @@\n+name: Check PR\n+\n+on: pull_request\n+\n+concurrency:\n+ group: + ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}\n+ + \ cancel-in-progress: true\n+\n+jobs:\n+ run:\n+ uses: ./.github/workflows/pr-quick-check.yml\n+ + \ with:\n+ repo: core\n+ secrets: inherit\ndiff --git a/.github/workflows/pr-quick-check.yml + b/.github/workflows/pr-quick-check.yml\nnew file mode 100644\nindex 000000000000..821ded203f9d\n--- + /dev/null\n+++ b/.github/workflows/pr-quick-check.yml\n@@ -0,0 +1,50 @@\n+name: + Quick check PR\n+\n+on:\n+ workflow_call:\n+ inputs:\n+ repo:\n+ required: + true\n+ type: string\n+\n+defaults:\n+ run:\n+ shell: bash\n+\n+env:\n+ + \ PYTHON_VERSION: \"3.11\"\n+ CHECK_SCRIPT: \"ddev/src/ddev/utils/scripts/check_pr.py\"\n+\n+jobs:\n+ + \ check:\n+ name: Check PR\n+ runs-on: ubuntu-22.04\n+\n+ steps:\n+ + \ - uses: actions/checkout@v3\n+ if: inputs.repo == 'core'\n+ with:\n+ + \ ref: \"${{ github.event.pull_request.head.sha }}\"\n+\n+ - name: + Set up Python ${{ env.PYTHON_VERSION }}\n+ uses: actions/setup-python@v4\n+ + \ with:\n+ python-version: \"${{ env.PYTHON_VERSION }}\"\n+\n+ - + name: Fetch PR data\n+ env:\n+ GH_TOKEN: \"${{ secrets.GITHUB_TOKEN + }}\"\n+ run: |-\n+ diff_url=$(cat \"$GITHUB_EVENT_PATH\" | jq -r + '.pull_request.diff_url')\n+ curl --header \"Authorization: Bearer $GITHUB_TOKEN\" + -sLo /tmp/diff \"$diff_url\"\n+ cat \"$GITHUB_EVENT_PATH\" | jq -r '.pull_request.number' + > /tmp/number\n+\n+ - name: Fetch script\n+ if: inputs.repo != 'core'\n+ + \ run: |-\n+ mkdir -p $(dirname ${{ env.CHECK_SCRIPT }})\n+ curl + -sLo ${{ env.CHECK_SCRIPT }} https://raw.githubusercontent.com/DataDog/integrations-core/master/${{ + env.CHECK_SCRIPT }}\n+\n+ - name: Check\n+ run: |-\n+ python + ${{ env.CHECK_SCRIPT }} changelog --diff-file /tmp/diff --pr-number $(cat /tmp/number)\ndiff + --git a/ddev/CHANGELOG.md b/ddev/CHANGELOG.md\nindex 4bfffc346413..5603965b33ac + 100644\n--- a/ddev/CHANGELOG.md\n+++ b/ddev/CHANGELOG.md\n@@ -2,6 +2,10 @@\n + \n ## Unreleased\n \n+***Added***:\n+\n+* Add changelog enforcement\n+\n ## + 3.3.0 / 2023-07-20\n \n ***Added***:\ndiff --git a/ddev/src/ddev/utils/scripts/check_pr.py + b/ddev/src/ddev/utils/scripts/check_pr.py\nnew file mode 100644\nindex 000000000000..464a0064a4f5\n--- + /dev/null\n+++ b/ddev/src/ddev/utils/scripts/check_pr.py\n@@ -0,0 +1,189 @@\n+\"\"\"\n+Running + this script by itself must not use any external dependencies.\n+\"\"\"\n+# (C) + Datadog, Inc. 2023-present\n+# All rights reserved\n+# Licensed under a 3-clause + BSD style license (see LICENSE)\n+from __future__ import annotations\n+\n+import + argparse\n+import os\n+import re\n+import subprocess\n+import sys\n+from typing + import Iterator\n+\n+\n+def requires_changelog(target: str, files: Iterator[str]) + -> bool:\n+ if target == 'ddev':\n+ source = 'src/ddev/'\n+ else:\n+ + \ if target == 'datadog_checks_base':\n+ directory = 'base'\n+ + \ elif target == 'datadog_checks_dev':\n+ directory = 'dev'\n+ + \ elif target == 'datadog_checks_downloader':\n+ directory + = 'downloader'\n+ else:\n+ directory = target.replace('-', + '_')\n+\n+ source = f'datadog_checks/{directory}/'\n+\n+ return any(f.startswith(source) + or f == 'pyproject.toml' for f in files)\n+\n+\n+def git(*args) -> str:\n+ try:\n+ + \ process = subprocess.run(\n+ ['git', *args], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, encoding='utf-8', check=True\n+ )\n+ except + subprocess.CalledProcessError as e:\n+ raise OSError(f'{str(e)[:-1]}:\\n{e.output}') + from None\n+\n+ return process.stdout\n+\n+\n+def get_added_lines(git_diff: + str) -> list[str]:\n+ files: dict[str, dict[int, str]] = {}\n+ for modification + in re.split(r'^diff --git ', git_diff, flags=re.MULTILINE):\n+ if not + modification:\n+ continue\n+\n+ # a/file b/file\n+ # + new file mode 100644\n+ # index 0000000000..089fd64579\n+ # --- + a/file\n+ # +++ b/file\n+ metadata, *blocks = re.split(r'^@@ ', + modification, flags=re.MULTILINE)\n+ before, after = metadata.strip().splitlines()[-2:]\n+\n+ + \ # Binary files /dev/null and b/foo/archive.tar.gz differ\n+ binary_indicator + = 'Binary files '\n+ if after.startswith(binary_indicator):\n+ line + = after[len(binary_indicator) :].rsplit(maxsplit=1)[0]\n+ if line.startswith('/dev/null + and '):\n+ filename = line.split(maxsplit=2)[-1][2:]\n+ elif + line.endswith(' and /dev/null'):\n+ filename = line.split(maxsplit=2)[0][2:]\n+ + \ else:\n+ _, _, filename = line.partition(' and b/')\n+\n+ + \ files[filename] = {}\n+ continue\n+\n+ # --- a/file\n+ + \ # +++ /dev/null\n+ before = before.split(maxsplit=1)[1]\n+ after + = after.split(maxsplit=1)[1]\n+ filename = before[2:] if after == '/dev/null' + else after[2:]\n+ added = files[filename] = {}\n+\n+ for block + in blocks:\n+ # -13,3 +13,8 @@\n+ info, *lines = block.splitlines()\n+ + \ # third number\n+ start = int(info.split()[1].split(',')[0][1:])\n+\n+ + \ removed = 0\n+ for i, line in enumerate(lines, start):\n+ + \ if line.startswith('+'):\n+ added[i - removed] + = line[1:]\n+ elif line.startswith('-'):\n+ removed + += 1\n+\n+ return files\n+\n+\n+def get_missing_changelogs(git_diff: str, + suffix: str) -> None:\n+ targets = {}\n+ for filename, lines in get_added_lines(git_diff).items():\n+ + \ target, _, path = filename.partition('/')\n+ if not path:\n+ + \ continue\n+\n+ targets.setdefault(target, {})[path] = lines\n+\n+ + \ errors: tuple[str, int, str] = []\n+ for target, files in sorted(targets.items()):\n+ + \ if not requires_changelog(target, files.keys()):\n+ continue\n+\n+ + \ changelog_file = 'CHANGELOG.md'\n+ if changelog_file not in files:\n+ + \ errors.append((f'{target}/{changelog_file}', 1, 'Missing changelog + entry'))\n+ continue\n+\n+ added_lines = files[changelog_file]\n+ + \ line_numbers_missing_suffix = []\n+ lines_with_suffix = 0\n+ + \ for line_number, line in added_lines.items():\n+ if not line.startswith('* + '):\n+ continue\n+ elif line.endswith(suffix):\n+ + \ lines_with_suffix += 1\n+ else:\n+ line_numbers_missing_suffix.append(line_number)\n+\n+ + \ if lines_with_suffix == len(line_numbers_missing_suffix) == 0:\n+ errors.append((f'{target}/{changelog_file}', + 1, 'Missing changelog entry'))\n+ elif line_numbers_missing_suffix:\n+ + \ for line_number in line_numbers_missing_suffix:\n+ errors.append(\n+ + \ (\n+ f'{target}/{changelog_file}',\n+ + \ line_number,\n+ f'The first line + of every new changelog entry must '\n+ f'end with the + associated PR number `{suffix}`',\n+ )\n+ )\n+\n+ + \ return errors\n+\n+\n+def changelog_impl(*, ref: str, diff_file: str, pr_number: + int) -> None:\n+ on_ci = os.environ.get('GITHUB_ACTIONS') == 'true'\n+ if + on_ci:\n+ with open(diff_file, encoding='utf-8') as f:\n+ git_diff + = f.read()\n+ else:\n+ git_diff = git('diff', f'{ref}...')\n+\n+ errors + = get_missing_changelogs(git_diff, f' (#{pr_number})')\n+ if not errors:\n+ + \ return\n+ elif os.environ.get('GITHUB_ACTIONS') == 'true':\n+ for + relative_path, line_number, message in errors:\n+ message = '%0A'.join(message.splitlines())\n+ + \ print(f'::error file={relative_path},line={line_number}::{message}')\n+ + \ else:\n+ for relative_path, line_number, message in errors:\n+ print(f'{relative_path}, + line {line_number}: {message}')\n+\n+ sys.exit(1)\n+\n+\n+def changelog_command(subparsers) + -> None:\n+ parser = subparsers.add_parser('changelog')\n+ parser.add_argument('--ref', + default='origin/master')\n+ parser.add_argument('--diff-file')\n+ parser.add_argument('--pr-number', + type=int, default=1)\n+ parser.set_defaults(func=changelog_impl)\n+\n+\n+def + main():\n+ parser = argparse.ArgumentParser(prog=__name__, allow_abbrev=False)\n+ + \ subparsers = parser.add_subparsers()\n+\n+ changelog_command(subparsers)\n+\n+ + \ kwargs = vars(parser.parse_args())\n+ try:\n+ command = kwargs.pop('func')\n+ + \ except KeyError:\n+ parser.print_help()\n+ else:\n+ command(**kwargs)\n+\n+\n+if + __name__ == '__main__':\n+ main()\n" + headers: + Cache-Control: + - max-age=0, private, must-revalidate + Content-Encoding: + - gzip + Content-Security-Policy: + - 'default-src ''none''; base-uri ''self''; child-src github.com/assets-cdn/worker/ + gist.github.com/assets-cdn/worker/; connect-src ''self'' uploads.github.com + objects-origin.githubusercontent.com www.githubstatus.com collector.github.com + raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com + github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com + cdn.optimizely.com logx.optimizely.com/v1/events *.actions.githubusercontent.com + productionresultssa0.blob.core.windows.net/ productionresultssa1.blob.core.windows.net/ + productionresultssa2.blob.core.windows.net/ productionresultssa3.blob.core.windows.net/ + productionresultssa4.blob.core.windows.net/ wss://*.actions.githubusercontent.com + github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com + insights.github.com wss://alive.github.com; font-src github.githubassets.com; + form-action ''self'' github.com gist.github.com objects-origin.githubusercontent.com; + frame-ancestors ''none''; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com; + img-src ''self'' data: github.githubassets.com media.githubusercontent.com + camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com + github-cloud.s3.amazonaws.com objects.githubusercontent.com objects-origin.githubusercontent.com + secured-user-images.githubusercontent.com/ user-images.githubusercontent.com/ + private-user-images.githubusercontent.com opengraph.githubassets.com github-production-user-asset-6210df.s3.amazonaws.com + customer-stories-feed.github.com spotlights-feed.github.com *.githubusercontent.com; + manifest-src ''self''; media-src github.com user-images.githubusercontent.com/ + secured-user-images.githubusercontent.com/ private-user-images.githubusercontent.com; + script-src github.githubassets.com; style-src ''unsafe-inline'' github.githubassets.com; + upgrade-insecure-requests; worker-src github.com/assets-cdn/worker/ gist.github.com/assets-cdn/worker/' + Content-Type: + - text/plain; charset=utf-8 + Date: + - Thu, 03 Aug 2023 22:08:36 GMT + ETag: + - W/"a4498e2b0a40022133d9345a4d54e7d3" + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Set-Cookie: + - logged_in=no; domain=github.com; path=/; expires=Sat, 03 Aug 2024 22:08:36 + GMT; secure; HttpOnly; SameSite=Lax + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + Vary: + - X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame + - Accept-Encoding, Accept, X-Requested-With + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Request-Id: + - EB89:4147:ABD300:F40FF2:64CC2564 + X-XSS-Protection: + - '0' + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/ddev/tests/fixtures/network/release/changelog/fix_no_pr.yaml b/ddev/tests/fixtures/network/release/changelog/fix_no_pr.yaml new file mode 100644 index 0000000000000..b1bd5d5022291 --- /dev/null +++ b/ddev/tests/fixtures/network/release/changelog/fix_no_pr.yaml @@ -0,0 +1,172 @@ +interactions: +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.github.com + user-agent: + - python-httpx/0.24.1 + x-github-api-version: + - '2022-11-28' + method: GET + uri: https://api.github.com/search/issues?q=sha%3A0000000000000000000000000000000000000000%2Brepo%3ADataDog/integrations-core + response: + content: '{"total_count":0,"incomplete_results":false,"items":[]}' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, + X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, + X-GitHub-Request-Id, Deprecation, Sunset + Cache-Control: + - no-cache + Content-Encoding: + - gzip + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 03 Aug 2023 22:07:23 GMT + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + Vary: + - Accept, Authorization, Cookie, X-GitHub-OTP + - Accept-Encoding, Accept, X-Requested-With + X-Accepted-OAuth-Scopes: + - '' + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Media-Type: + - github.v3; format=json + X-GitHub-Request-Id: + - EA9F:7C17:707C73:E56B58:64CC251B + X-OAuth-Scopes: + - read:org, repo, workflow + X-RateLimit-Limit: + - '30' + X-RateLimit-Remaining: + - '29' + X-RateLimit-Reset: + - '1691100503' + X-RateLimit-Resource: + - search + X-RateLimit-Used: + - '1' + X-XSS-Protection: + - '0' + x-github-api-version-selected: + - '2022-11-28' + http_version: HTTP/1.1 + status_code: 200 +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + connection: + - keep-alive + host: + - api.github.com + user-agent: + - python-httpx/0.24.1 + x-github-api-version: + - '2022-11-28' + method: GET + uri: https://api.github.com/repos/DataDog/integrations-core/issues?state=all&sort=created&direction=desc&per_page=1 + response: + content: '[{"url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475","repository_url":"https://api.github.com/repos/DataDog/integrations-core","labels_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475/labels{/name}","comments_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475/comments","events_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475/events","html_url":"https://github.com/DataDog/integrations-core/pull/15475","id":1835750235,"node_id":"PR_kwDOAtBC5c5XJVR7","number":15475,"title":"Migrate + `ddev validate licenses` command to ddev","user":{"login":"swang392","id":24441980,"node_id":"MDQ6VXNlcjI0NDQxOTgw","avatar_url":"https://avatars.githubusercontent.com/u/24441980?v=4","gravatar_id":"","url":"https://api.github.com/users/swang392","html_url":"https://github.com/swang392","followers_url":"https://api.github.com/users/swang392/followers","following_url":"https://api.github.com/users/swang392/following{/other_user}","gists_url":"https://api.github.com/users/swang392/gists{/gist_id}","starred_url":"https://api.github.com/users/swang392/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/swang392/subscriptions","organizations_url":"https://api.github.com/users/swang392/orgs","repos_url":"https://api.github.com/users/swang392/repos","events_url":"https://api.github.com/users/swang392/events{/privacy}","received_events_url":"https://api.github.com/users/swang392/received_events","type":"User","site_admin":false},"labels":[{"id":4541502693,"node_id":"LA_kwDOAtBC5c8AAAABDrHU5Q","url":"https://api.github.com/repos/DataDog/integrations-core/labels/ddev","name":"ddev","color":"ededed","default":false,"description":null}],"state":"open","locked":false,"assignee":null,"assignees":[],"milestone":null,"comments":1,"created_at":"2023-08-03T21:19:58Z","updated_at":"2023-08-03T21:31:14Z","closed_at":null,"author_association":"MEMBER","active_lock_reason":null,"draft":false,"pull_request":{"url":"https://api.github.com/repos/DataDog/integrations-core/pulls/15475","html_url":"https://github.com/DataDog/integrations-core/pull/15475","diff_url":"https://github.com/DataDog/integrations-core/pull/15475.diff","patch_url":"https://github.com/DataDog/integrations-core/pull/15475.patch","merged_at":null},"body":"### + What does this PR do?\r\nMigrates `ddev validate licenses` command from datadog_checks_dev + to ddev.\r\n\r\n### Additional Notes\r\nI did not test parts of the code (ex: + checking for the correct format of the 3rd party extra license csv files) because + of an issue (https://github.com/kevin1024/vcrpy/issues/716) with multithreading + HTTPX requests. \r\n\r\n### Review checklist (to be filled by reviewers)\r\n\r\n- + [ ] Feature or bugfix MUST have appropriate tests (unit, integration, e2e)\r\n- + [ ] PR title must be written as a CHANGELOG entry [(see why)](https://github.com/DataDog/integrations-core/blob/master/CONTRIBUTING.md#pull-request-title)\r\n- + [ ] Files changes must correspond to the primary purpose of the PR as described + in the title (small unrelated changes should have their own PR)\r\n- [ ] PR + must have `changelog/` and `integration/` labels attached\r\n- [ ] If the PR + doesn''t need to be tested during QA, please add a `qa/skip-qa` label.","reactions":{"url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"timeline_url":"https://api.github.com/repos/DataDog/integrations-core/issues/15475/timeline","performed_via_github_app":null,"state_reason":null}]' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, + X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, + X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, + X-GitHub-Request-Id, Deprecation, Sunset + Cache-Control: + - private, max-age=60, s-maxage=60 + Content-Encoding: + - gzip + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 03 Aug 2023 22:07:23 GMT + ETag: + - W/"596d12188d97a996b895cc63d248c6bfb95008b7e798932ef90dbddc6c9de9fd" + Link: + - ; + rel="next", ; + rel="last" + Referrer-Policy: + - origin-when-cross-origin, strict-origin-when-cross-origin + Server: + - GitHub.com + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Transfer-Encoding: + - chunked + Vary: + - Accept, Authorization, Cookie, X-GitHub-OTP + - Accept-Encoding, Accept, X-Requested-With + X-Accepted-OAuth-Scopes: + - repo + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-GitHub-Media-Type: + - github.v3; format=json + X-GitHub-Request-Id: + - EA9F:7C17:707C93:E56B95:64CC251B + X-OAuth-Scopes: + - read:org, repo, workflow + X-RateLimit-Limit: + - '5000' + X-RateLimit-Remaining: + - '4991' + X-RateLimit-Reset: + - '1691101239' + X-RateLimit-Resource: + - core + X-RateLimit-Used: + - '9' + X-XSS-Protection: + - '0' + x-github-api-version-selected: + - '2022-11-28' + http_version: HTTP/1.1 + status_code: 200 +version: 1 diff --git a/ddev/tests/utils/test_github.py b/ddev/tests/utils/test_github.py index b2137b40880ec..24a7b8f0de4b9 100644 --- a/ddev/tests/utils/test_github.py +++ b/ddev/tests/utils/test_github.py @@ -29,7 +29,7 @@ def test_found(self, repository, helpers, config_file, network_replay, terminal) ) pr = github.get_pull_request('382cbb0af210897599cbe5fd8d69a38d4017e425') - assert pr.number == '14849' + assert pr.number == 14849 assert pr.title == 'Update formatting for changelogs' assert pr.body == helpers.dedent( """