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
6 changes: 3 additions & 3 deletions amazon_app_mesh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Log collection is not supported for this site.
partial -->

<!-- partial
{{< site-region region="us,eu,gov" >}}
{{< site-region region="us,eu,gov,gov2" >}}

To enable log collection, update the Agent's DaemonSet with the dedicated [Kubernetes log collection instructions][1].

Expand Down Expand Up @@ -127,7 +127,7 @@ Log collection is not supported for this site.
partial -->

<!-- partial
{{< site-region region="us,eu,gov" >}}
{{< site-region region="us,eu,gov,gov2" >}}

Enable log collection with the instructions in the [ECS Fargate integration documentation][1].

Expand Down Expand Up @@ -178,7 +178,7 @@ Log collection is not supported for this site.
partial -->

<!-- partial
{{< site-region region="us,eu,gov" >}}
{{< site-region region="us,eu,gov,gov2" >}}

Enable log collection with the instructions in the [ECS integration documentation][1].

Expand Down
1 change: 1 addition & 0 deletions ddev/changelog.d/23703.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Accept a PR number, ``PR-<number>`` token, or GitHub PR URL as input to ``port-commit``, and fetch the target commit from origin when it is not in the local object database.
11 changes: 7 additions & 4 deletions ddev/src/ddev/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
import os
from functools import cached_property
from typing import cast
from typing import TYPE_CHECKING, cast

from ddev.cli.terminal import Terminal
from ddev.config.constants import AppEnvVars, ConfigEnvVars, VerbosityLevels
Expand All @@ -16,6 +16,9 @@
from ddev.utils.github import GitHubManager
from ddev.utils.platform import Platform

if TYPE_CHECKING:
from typing import Any, Callable, NoReturn


class AppLoggingHandler(logging.Handler):
"""Routes Python logging through the Application display methods."""
Expand All @@ -35,7 +38,7 @@ def emit(self, record: logging.LogRecord) -> None:


class Application(Terminal):
def __init__(self, exit_func, *args, **kwargs):
def __init__(self, exit_func: Callable[[int], NoReturn], *args, **kwargs):
super().__init__(*args, **kwargs)
self.platform = Platform(self.escaped_output)
self.__exit_func = exit_func
Expand All @@ -49,7 +52,7 @@ def __init__(self, exit_func, *args, **kwargs):
self.__github = cast(GitHubManager, None)

# TODO: remove this when the old CLI is gone
self.__config = {}
self.__config: dict[str, Any] = {}

@property
def config(self) -> RootConfig:
Expand Down Expand Up @@ -105,7 +108,7 @@ def set_repo(self, core: bool, extras: bool, marketplace: bool, agent: bool, her
self.repo, user=self.config.github.user, token=self.config.github.token, status=self.status
)

def abort(self, text='', code=1, **kwargs):
def abort(self, text: str = '', code: int = 1, **kwargs: Any) -> NoReturn:
if text:
self.display_error(text, **kwargs)
self.__exit_func(code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def serve_openmetrics_payload(
env_data.write_config(check_config)
env_data.write_metadata(metadata)

agent = DockerAgent(app.platform, intg, ENVIRONMENT_NAME, metadata, env_data.config_file)
agent = DockerAgent(app, intg, ENVIRONMENT_NAME, metadata, env_data.config_file)
agent_env_vars = _get_agent_env_vars(app.config.org.config, {}, {}, False)

try:
Expand Down
15 changes: 12 additions & 3 deletions ddev/src/ddev/cli/release/port_commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

@click.command(name='port-commit', short_help='Backport a commit onto a target branch')
@click.pass_obj
@click.argument('commit_hash', required=False)
@click.argument('commit_hash', required=False, metavar='COMMIT_OR_PR')
@click.option('-t', '--target-branch', default='master', show_default=True, help='Target branch to port to.')
@click.option('-p', '--branch-prefix', default='port', show_default=True, help='Branch name prefix.')
@click.option('-s', '--branch-suffix', default=None, help='Branch name suffix. Defaults to `to-<target-branch>`.')
Expand Down Expand Up @@ -43,23 +43,32 @@ def port_commit(
"""
Backport a commit onto a target branch.

Cherry-picks COMMIT_HASH onto `--target-branch` (default `master`) on a new branch named
Cherry-picks COMMIT_OR_PR onto `--target-branch` (default `master`) on a new branch named
`<github-user>/<prefix>-<sha[:10]>-<suffix>`, preserving `.in-toto` files from the target
branch so package signatures stay intact. Pushes the branch and, unless `--no-pr` is set,
opens a pull request titled `[Backport] <subject>` and labeled with `--pr-labels`.

If COMMIT_HASH is omitted, the current HEAD commit is used after confirmation.
COMMIT_OR_PR accepts: a full 40-character commit SHA, a PR number (e.g. `23703`), an
explicit `PR-<number>` token, or a GitHub PR URL. Pure-digit inputs are tried as a PR
first when a GitHub token is configured, and fall back to commit resolution on 404. If
omitted, the current HEAD commit is used after confirmation.

The GitHub user for the branch prefix is taken from `ddev config` (`github.user`) or the
`DD_GITHUB_USER` / `GITHUB_USER` / `GITHUB_ACTOR` environment variables.
"""
import logging

from ddev.cli.release.port_commit_workflow import (
PortStepError,
build_port_steps,
display_completion_summary,
resolve_port_plan,
)

# httpx logs every request at INFO and clutters the workflow output. The PR-resolution and
# PR-creation steps already print their own status lines; the underlying HTTP traffic is noise.
logging.getLogger('httpx').setLevel(logging.WARNING)

plan = resolve_port_plan(
app,
commit_hash=commit_hash,
Expand Down
184 changes: 180 additions & 4 deletions ddev/src/ddev/cli/release/port_commit_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import contextlib
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING
Expand All @@ -27,19 +28,33 @@

if TYPE_CHECKING:
from ddev.cli.application import Application
from ddev.utils.github_async.models import PullRequest


PR_NUMBER_SUFFIX_PATTERN = re.compile(r'\s*\(#(\d+)\)\s*$')
PR_TEMPLATE_RELATIVE_PATH = '.github/PULL_REQUEST_TEMPLATE.md'
PR_TEMPLATE_HEADING = '### What does this PR do?'
IN_TOTO_SUFFIX = '.in-toto'
WORKTREE_BASE = '.worktrees/port-commit'
FULL_SHA_PATTERN = re.compile(r'^[0-9a-fA-F]{40}$')
HEX_PATTERN = re.compile(r'^[0-9a-fA-F]+$')
DIGITS_PATTERN = re.compile(r'^\d+$')
PR_PREFIX_PATTERN = re.compile(r'^PR-(\d+)$', re.IGNORECASE)
PR_URL_PATTERN = re.compile(r'^https?://github\.com/[^/]+/[^/]+/pull/(\d+)(?:[/?#].*)?$', re.IGNORECASE)


class PortStepError(Exception):
"""Raised by a PortStep to signal a clean abort with a user-facing message."""


class _CommitNotResolvable(Exception):
"""Raised when a commit input cannot be resolved locally or via a SHA-targeted fetch."""


class _PRNotFound(Exception):
"""Raised when a PR lookup returns 404 so the caller can fall back to commit resolution."""


class PortStep:
"""Single step of the port-commit workflow."""

Expand Down Expand Up @@ -342,6 +357,164 @@ async def _create_pr(self) -> None:
)


def _resolve_input(app: Application, raw: str, *, dry_run: bool) -> str:
"""Resolve the raw user input to a full commit SHA.

Handles three input shapes:
- Explicit PR form (`PR-12345` or a GitHub PR URL) -> looks up the PR.
- All-digits (e.g. `12345`) with a GitHub token configured -> tries as a PR; on 404 falls
back to commit resolution. Without a token the PR step is skipped.
- Anything else -> commit resolution.

Raises `_CommitNotResolvable` when nothing matches so the caller can decide how to abort.
"""
raw = raw.strip()
pr_number = _extract_explicit_pr_number(raw)
if pr_number is not None:
return _resolve_pr_to_commit(app, pr_number, dry_run=dry_run)

is_digits = DIGITS_PATTERN.fullmatch(raw) is not None
if is_digits and app.config.github.token:
with contextlib.suppress(_PRNotFound):
return _resolve_pr_to_commit(app, int(raw), dry_run=dry_run)

try:
return _resolve_commit_or_fetch(app, raw, dry_run=dry_run)
except _CommitNotResolvable as exc:
if is_digits:
raise _CommitNotResolvable(
f'Could not resolve `{raw}` as a PR or a commit. '
'Pass the full 40-character SHA, or `PR-xxxxx` / a PR URL to disambiguate.'
) from exc
raise


def _extract_explicit_pr_number(raw: str) -> int | None:
"""Return the PR number when `raw` is a `PR-12345` token or a GitHub PR URL, else None."""
for pattern in (PR_PREFIX_PATTERN, PR_URL_PATTERN):
match = pattern.fullmatch(raw)
if match:
return int(match.group(1))
return None


def _resolve_pr_to_commit(app: Application, pr_number: int, *, dry_run: bool) -> str:
"""Resolve a PR number to the SHA of its merge commit, validating squash-merge.

Raises `_PRNotFound` when GitHub returns 404. Raises `_CommitNotResolvable` (wrapped with PR
context) when the merge commit can't be resolved locally. Aborts on other auth / network /
validation errors so the user gets a clear, contextual message rather than a stack trace.
"""
import asyncio

import httpx
from pydantic import ValidationError

if not app.config.github.token:
app.abort(
'GitHub token required to resolve a PR reference. Set `github.token`, or pass the '
'full commit SHA directly (--no-pr does not skip this lookup).'
)

owner, repo = resolve_owner_repo(app)
app.display_info(f'Resolving PR #{pr_number} via GitHub...')
try:
pr = asyncio.run(_fetch_pr(app.config.github.token, owner, repo, pr_number))
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status == 404:
raise _PRNotFound(str(pr_number)) from exc
if status in (401, 403):
app.abort(
f'GitHub denied the request for PR #{pr_number} (HTTP {status}). '
'Check that `github.token` is set and has `repo` scope.'
)
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
except (httpx.HTTPError, ValidationError) as exc:
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')

if not pr.merged:
app.abort(f'PR #{pr_number} is not merged; nothing to backport.')

if not pr.merge_commit_sha:
app.abort(f'PR #{pr_number} has no merge commit SHA available.')

try:
full_sha = _resolve_commit_or_fetch(app, pr.merge_commit_sha, dry_run=dry_run)
except _CommitNotResolvable as exc:
raise _CommitNotResolvable(
f'PR #{pr_number} was found but its merge commit `{pr.merge_commit_sha}` could not be resolved: {exc}'
) from exc
_abort_if_merge_commit(app, pr_number, full_sha)
return full_sha


async def _fetch_pr(token: str, owner: str, repo: str, pr_number: int) -> PullRequest:
from ddev.utils.github_async import async_github_client

async with async_github_client(token=token) as client:
response = await client.get_pull_request(owner=owner, repo=repo, pull_number=pr_number)
return response.data


def _abort_if_merge_commit(app: Application, pr_number: int, full_sha: str) -> None:
"""Abort when `full_sha` is a merge commit (>= 2 parents), which can't be backported as a single commit."""
try:
raw = app.repo.git.capture('rev-list', '--parents', '-n1', full_sha)
except OSError as exc:
app.abort(f'Could not inspect merge parents of `{full_sha}`: {exc}.')
else:
parent_count = max(len(raw.strip().split()) - 1, 0)
if parent_count >= 2:
app.abort(
f"PR #{pr_number} was not squash-merged, so there isn't a single commit to backport "
'the full PR. Run again with the specific commit you want to backport.'
)


def _resolve_commit_or_fetch(app: Application, commit_hash: str, *, dry_run: bool) -> str:
"""Return the full SHA for `commit_hash`, fetching from origin when the commit is not local.

Raises `_CommitNotResolvable` when the commit is neither available locally nor reachable on
origin. Falling back to a SHA-targeted fetch lets the command port commits that live on remote
branches the local repo does not track (the `remote.origin.fetch` refspec is often narrowed in
this repo to avoid pulling thousands of branches).

When `dry_run` is true, the fetch fallback is skipped to preserve the dry-run contract: a
non-local commit raises instead of mutating local state.
"""
git = app.repo.git
with contextlib.suppress(OSError):
return git.capture('rev-parse', '--verify', f'{commit_hash}^{{commit}}').strip()

# Abbreviated SHAs cannot be fetched (GitHub's allowReachableSHA1InWant only honours full
# SHAs), so this is the real diagnosis regardless of dry-run mode. Surface it first.
if HEX_PATTERN.fullmatch(commit_hash) and not FULL_SHA_PATTERN.fullmatch(commit_hash):
raise _CommitNotResolvable(
f'Commit `{commit_hash}` is not in the local repository. '
'Pass the full 40-character SHA so it can be fetched from origin '
'(GitHub does not support SHA-targeted fetches for abbreviated SHAs).'
)

if dry_run:
raise _CommitNotResolvable(
f'Commit `{commit_hash}` is not in the local repository. '
'Re-run without `--dry-run` to fetch it from origin, or pre-fetch the commit manually.'
)

app.display_info(f'Commit `{commit_hash}` not found locally; fetching from origin.')
fetched = False
with contextlib.suppress(OSError):
git.run('fetch', 'origin', commit_hash)
fetched = True

if fetched:
with contextlib.suppress(OSError):
return git.capture('rev-parse', '--verify', f'{commit_hash}^{{commit}}').strip()

raise _CommitNotResolvable(f'Commit `{commit_hash}` does not exist locally or on origin.')


def _path_exists_in_head(git: GitRepository, path: str) -> bool:
try:
git.capture('cat-file', '-e', f'HEAD:{path}')
Expand All @@ -355,7 +528,10 @@ def _resolve_in_toto_conflict(git: GitRepository, path: str) -> None:
git.run('rm', '--force', path)
return
git.run('checkout', '--ours', path)
git.run('add', path)
# `.in-toto/` is gitignored in this repo, so `git add` refuses without `--force` even though
# the path is already tracked in HEAD. The force is safe: we only get here for paths that
# came out of `git diff --diff-filter=U`, i.e. files git itself flagged as needing resolution.
git.run('add', '--force', path)


def _restore_path_from_head(git: GitRepository, path: str) -> None:
Expand Down Expand Up @@ -471,9 +647,9 @@ def resolve_port_plan(
commit_hash = head_commit.sha

try:
full_sha = app.repo.git.capture('rev-parse', '--verify', f'{commit_hash}^{{commit}}').strip()
except OSError:
app.abort(f'Commit `{commit_hash}` does not exist.')
full_sha = _resolve_input(app, commit_hash, dry_run=dry_run)
except _CommitNotResolvable as exc:
app.abort(str(exc))

log_entries = app.repo.git.log(['hash:%H', 'subject:%s'], n=1, source=full_sha)
if not log_entries:
Expand Down
25 changes: 25 additions & 0 deletions ddev/src/ddev/utils/github_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,31 @@ async def create_issue_comment(
)
return self._parse_response(response, IssueComment)

async def get_pull_request(
self,
owner: str,
repo: str,
pull_number: int,
timeout: float | None = None,
) -> GitHubResponse[PullRequest]:
"""
Calls the GitHub API to get a single pull request.

GitHub API Documentation:
https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request

Args:
owner: Repository owner (user or organisation).
repo: Repository name.
pull_number: Pull request number.
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.

Returns:
GitHubResponse[PullRequest]: The validated pull request data and headers.
"""
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{pull_number}", timeout=timeout)
return self._parse_response(response, PullRequest)

async def create_pull_request(
self,
owner: str,
Expand Down
Loading
Loading