diff --git a/ddev/changelog.d/24872.added b/ddev/changelog.d/24872.added new file mode 100644 index 0000000000000..e50dcd54f1fc6 --- /dev/null +++ b/ddev/changelog.d/24872.added @@ -0,0 +1 @@ +Include a comment on the source PR listing each failed base when a backport fails. diff --git a/ddev/src/ddev/cli/release/port_commit_workflow.py b/ddev/src/ddev/cli/release/port_commit_workflow.py index be44be5936f63..d1571ff24452e 100644 --- a/ddev/src/ddev/cli/release/port_commit_workflow.py +++ b/ddev/src/ddev/cli/release/port_commit_workflow.py @@ -1011,6 +1011,8 @@ def run_backport_from_pr( options=options, ) _display_backport_summary(app, pr_number, results) + if not options.dry_run: + _comment_on_backport_failures(app, pr_number, results) return all(result.status is not BackportStatus.FAILED for result in results) @@ -1106,3 +1108,48 @@ def _display_backport_summary(app: Application, pr_number: int, results: list[Ba ), stderr=True, ) + + +def _comment_on_backport_failures(app: Application, pr_number: int, results: list[BackportResult]) -> None: + """Post a comment on the source PR when one or more bases failed to backport. + + The pre-`--from-pr` automation commented on the merged PR when a backport failed; without this a + failure is only visible as a red workflow run, never on the PR the developer merged. Posting is + best-effort: a comment failure must not mask the backport failure it is reporting, so any error + here is warned, not raised. + """ + failures = [result for result in results if result.status is BackportStatus.FAILED] + if not failures: + return + token = app.config.github.token + if not token: + app.display_warning(f'No GitHub token configured; skipping backport-failure comment on PR #{pr_number}.') + return + + owner, repo = resolve_owner_repo(app) + lines = [f'⚠️ Automatic backport of this PR failed for {len(failures)} target branch(es):', ''] + for result in failures: + reason = f': {result.detail}' if result.detail else '' + lines.append( + f'- `{result.base}`{reason} — retry manually with ' + f'`ddev release port-commit PR-{pr_number} --target-branch {result.base}`.' + ) + body = '\n'.join(lines) + + try: + _post_issue_comment(token, owner, repo, pr_number, body) + except Exception as e: # noqa: BLE001 - best-effort comment; never mask the backport result + app.display_warning(f'Could not post backport-failure comment on PR #{pr_number}: {e}') + + +def _post_issue_comment(token: str, owner: str, repo: str, issue_number: int, body: str) -> None: + """Create a comment on the given issue or pull request.""" + import asyncio + + from ddev.utils.github_async import async_github_client + + async def _post() -> None: + async with async_github_client(token=token) as client: + await client.create_issue_comment(owner=owner, repo=repo, issue_number=issue_number, body=body) + + asyncio.run(_post()) diff --git a/ddev/tests/cli/release/test_port_commit.py b/ddev/tests/cli/release/test_port_commit.py index 8e77f48c1f6ae..6d89f61eab715 100644 --- a/ddev/tests/cli/release/test_port_commit.py +++ b/ddev/tests/cli/release/test_port_commit.py @@ -1159,6 +1159,55 @@ def test_command_from_pr_aggregates_failures_and_continues( assert bases == ['7.62.x', '7.61.x'] +def test_command_from_pr_comments_on_source_pr_when_a_base_fails( + ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient +) -> None: + """A failed base posts a comment on the merged PR so the failure is visible off the workflow run.""" + import httpx + + _setup_command_mocks(mocker, commit_sha=FULL_SHA_FOR_TESTS) + fake_async_github.mock_response( + 'get_pull_request', + _merged_pr(number=23703, backport_bases=['7.62.x', '7.61.x']), + ) + fake_async_github.mock_response( + 'create_pull_request', + httpx.HTTPStatusError('boom', request=httpx.Request('POST', 'https://x'), response=httpx.Response(500)), + base='7.62.x', + ) + mocker.patch.dict('os.environ', {'DD_GITHUB_USER': 'alice'}) + + result = ddev('release', 'port-commit', '--from-pr', '23703') + + assert result.exit_code == 1, result.output + comment_calls = fake_async_github.calls_to('create_issue_comment') + assert len(comment_calls) == 1 + comment_call = comment_calls[0] + assert comment_call.kwargs['issue_number'] == 23703 + body = comment_call.kwargs['body'] + assert '7.62.x' in body + assert 'retry manually' in body + # Only the failed base is named; the base that ported cleanly is not mentioned. + assert '7.61.x' not in body + + +def test_command_from_pr_does_not_comment_when_all_bases_succeed( + ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient +) -> None: + """The happy path leaves no comment on the source PR.""" + _setup_command_mocks(mocker, commit_sha=FULL_SHA_FOR_TESTS) + fake_async_github.mock_response( + 'get_pull_request', + _merged_pr(number=23703, backport_bases=['7.62.x', '7.61.x']), + ) + mocker.patch.dict('os.environ', {'DD_GITHUB_USER': 'alice'}) + + result = ddev('release', 'port-commit', '--from-pr', '23703') + + assert result.exit_code == 0, result.output + fake_async_github.assert_not_called('create_issue_comment') + + def test_command_from_pr_summary_reports_every_status( ddev: CliRunner, mocker: MockerFixture, fake_async_github: FakeAsyncGitHubClient ) -> None: diff --git a/ddev/tests/helpers/github_async.py b/ddev/tests/helpers/github_async.py index 78d21ad171317..4f123b18d30b7 100644 --- a/ddev/tests/helpers/github_async.py +++ b/ddev/tests/helpers/github_async.py @@ -46,6 +46,7 @@ def test_thing(fake_async_github): from ddev.utils.github_async.models import ( ArtifactsList, CheckRun, + IssueComment, Label, PullRequest, WorkflowDispatchResult, @@ -84,6 +85,10 @@ def _default_response_factories() -> dict[str, Callable[[], Any]]: headers={}, ), 'add_labels_to_issue': lambda: GitHubResponse.model_validate({'data': [], 'headers': {}}), + 'create_issue_comment': lambda: GitHubResponse( + data=IssueComment(id=1, body='', html_url='https://github.com/test/repo/issues/1#issuecomment-1'), + headers={}, + ), # Default to "PR not found" so tests that don't care about PR lookup auto-fall-through # to commit resolution. Tests that need a specific PR register their own mock_response. 'get_pull_request': lambda: httpx.HTTPStatusError( @@ -284,6 +289,23 @@ async def add_labels_to_issue( timeout=timeout, ) + async def create_issue_comment( + self, + owner: str, + repo: str, + issue_number: int, + body: str, + timeout: float | None = None, + ) -> GitHubResponse[IssueComment]: + return self._call( + 'create_issue_comment', + owner=owner, + repo=repo, + issue_number=issue_number, + body=body, + timeout=timeout, + ) + async def create_workflow_dispatch( self, owner: str,