Skip to content
Draft
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
1 change: 1 addition & 0 deletions ddev/changelog.d/24872.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Include a comment on the source PR listing each failed base when a backport fails.
47 changes: 47 additions & 0 deletions ddev/src/ddev/cli/release/port_commit_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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())
49 changes: 49 additions & 0 deletions ddev/tests/cli/release/test_port_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions ddev/tests/helpers/github_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def test_thing(fake_async_github):
from ddev.utils.github_async.models import (
ArtifactsList,
CheckRun,
IssueComment,
Label,
PullRequest,
WorkflowDispatchResult,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading