Skip to content

Commit d54c6b8

Browse files
authored
Fetch commit from origin when not in local repo for ddev port-commit (DataDog#23703)
* Resolve ddev port-commit input by fetching from origin when not local When the commit passed to `ddev release port-commit` is not in the local object database, fall back to `git fetch origin <sha>` before aborting. GitHub allows fetching any reachable commit by SHA, so this lets us port commits that live on branches the local `remote.origin.fetch` refspec does not track (a common configuration in this repo). The fetch is precise — we never need to know which branch the commit is on, since cherry-pick only needs the commit object. * Add changelog entry for DataDog#23703 * Abort early on abbreviated SHAs and clarify test names Address review feedback: - Abbreviated SHAs cannot be fetched from GitHub via uploadpack (allowReachableSHA1InWant only honours full 40-char SHAs), so detect the abbreviated-hex case before attempting the fetch and abort with a clear "pass the full 40-character SHA" message. Avoids the confusing `fatal: couldn't find remote ref` stderr from git on a doomed fetch. - Rename `test_command_aborts_when_commit_does_not_exist` to `test_command_aborts_when_commit_missing_after_fetch`. The original name suggested it covered the "commit truly missing" case, but with the mock setup it actually exercises the edge where the fetch succeeds but rev-parse still fails. The new name reflects that. - Add `test_command_aborts_on_abbreviated_sha_not_local` for the new early-abort path. - Use a full 40-char SHA constant for the fetch-fallback tests so they reach the fetch path under the new early-abort check. * Use contextlib.suppress instead of except: pass * Annotate Application.abort as NoReturn The original `app.abort` calls `ctx.exit` (which raises `SystemExit`), so the method never returns. Mark it with `-> NoReturn` so callers don't need a trailing `raise AssertionError('unreachable')` to satisfy mypy when the abort is the last statement on a code path. Also add an explicit `raise SystemExit(code)` so mypy can see the no-return at the implementation level (the `__exit_func` field is untyped) and as a safety net if a buggy `exit_func` is ever passed in. Removes the two `raise AssertionError('unreachable')` lines from `_resolve_commit_or_fetch` that were only there for type checking. * Type Application.exit_func and abort so callers don't need a trailing raise Annotate the `exit_func` callback as `Callable[[int], NoReturn]` (matching click's `Context.exit`) and mark `Application.abort` as `-> NoReturn`. Mypy now infers that calling `self.__exit_func(code)` doesn't return, so the abort path is correctly recognised as terminal without needing an unreachable `raise SystemExit(code)` inside the method body. Typing the `__init__` body surfaced two pre-existing issues: - `self.__config = {}` needed an explicit `dict[str, Any]` annotation. - `serve_openmetrics_payload.py` was constructing `DockerAgent` with `app.platform` (a `Platform`) where it expects an `Application`. This has been broken since 2024 — `AgentInterface.platform` accesses `self.app.platform`, which would raise `AttributeError` on a Platform instance at runtime. Pass `app` directly. Typing-only imports (`Any`, `Callable`, `NoReturn`) live behind `TYPE_CHECKING` since the file already uses `from __future__ import annotations`. * Skip fetch fallback in --dry-run mode Preserves the dry-run contract: when the commit is not in the local object database, abort with a clear message instead of running `git fetch origin <sha>` during plan resolution. Tells the user to re-run without --dry-run or pre-fetch the commit manually. Adds `test_command_aborts_in_dry_run_when_commit_not_local` covering the new path and asserting no fetch is attempted. * Add PR-or-commit input support to ddev port-commit The command now accepts a PR number, an explicit `PR-<number>` token, or a GitHub PR URL in addition to a commit SHA. Detection is tiered: - `PR-<digits>` or a PR URL is treated as an explicit PR reference. - Pure-digit inputs are tried as a PR first when a GitHub token is configured; a 404 falls back to commit resolution. - Anything else goes straight to commit resolution. When the input resolves to a PR, the command verifies the PR is merged and that its merge commit has a single parent (i.e. the PR was squash-merged); anything else aborts with a clear message instructing the user to pass the specific commit they want to backport. Refactors `_resolve_commit_or_fetch` to raise `_CommitNotResolvable` instead of aborting, so the new orchestrator can wrap the error with a unified "PR or commit" message when pure-digit input also fails commit resolution. The single abort happens at the call site in `resolve_port_plan`. Adds: - `AsyncGitHubClient.get_pull_request` for the GitHub lookup. - `FakeAsyncGitHubClient.get_pull_request` plus a built-in 404 default so tests that don't care about PR lookup auto-fall-through. - New regex constants and helpers. - `[COMMIT_OR_PR]` metavar and updated docstring on the click command. * Add tests and changelog entry for PR-or-commit input * Address round-1 review feedback for PR-or-commit input Apply reviewer findings on top of the PR-or-commit input change: - Wrap `_resolve_commit_or_fetch` inside `_resolve_pr_to_commit` so a non-resolvable merge commit reports `PR #N ...` instead of leaking the raw SHA the user never typed. - Handle `pydantic.ValidationError` from `get_pull_request`, and split 401 / 403 out of the generic HTTP error arm with an actionable hint about `github.token`. - Move the abbreviated-SHA check ahead of the dry-run gate in `_resolve_commit_or_fetch` so abbreviated input gives the real diagnosis on the first run instead of forcing a retry. - Guard the `rev-list --parents` call in `_abort_if_merge_commit` against `OSError`, matching the rest of the module's careful style. - Tighten the token-required abort for explicit PR inputs to flag the `--no-pr` carve-out (the lookup is needed regardless of PR creation). - Drop `_fetch_pr`'s dependency on the full `Application`; only the token is needed. - Annotate `Application.abort` parameters (`text: str`, `code: int`, `**kwargs: Any`) to finish the typing surface. - Strip the raw input in `_resolve_input` so trailing whitespace from a copy-paste no longer drops the user to commit resolution. - Drop `[ ]` from the click metavar; click adds them for optional arguments and they were rendering doubled in --help. - Replace the test helper's `lambda` exit function with a real `NoReturn` callable so it matches the new `Application.__init__` signature. Also corrects the pre-existing `Applicatione` typo. - Add `http://`, fragment, and trailing-whitespace variants to the parametrized PR-input test. - Remove an em-dash from a test comment. * Address round-2 review feedback - Convert `_abort_if_merge_commit`'s try/except to try/else so the parent-count binding is structurally explicit rather than relying on `Application.__exit_func` being `NoReturn` at runtime. The typing contract still holds, but a future caller wiring a non-terminating exit function would now surface as a structural error instead of an `UnboundLocalError`. - Add `test_command_emits_pr_context_when_pure_digit_pr_merge_commit_missing` pinning the path the round-1 f-1 fix was filed for: pure-digit input + PR found + non-resolvable merge commit should abort with a message that names `PR #N`, not the raw SHA the user never typed. * Drop redundant negative assertion in PR-context regression test The positive assertion already validates that the PR-context message fires. The negative assertion was meant to guard against the raw commit-resolution message firing alone, but that message is in fact suffixed onto the PR-context wrap, so any `not in` / `startswith` form either passed trivially (Rich console wrapping) or failed for the wrong reason. Drop it. * Consolidate the PR's changelog into a single entry * Quiet httpx and show a progress line during PR resolution The httpx logger emits an INFO line for every request, which leaks through to the CLI as `INFO: HTTP Request: GET ... "HTTP/1.1 200 OK"` just before our own status messages. Drop httpx to WARNING at command entry so only failures are surfaced. Also print `Resolving PR #N via GitHub...` before the lookup so the user sees something happen when the command pauses for the API call, matching the existing "Commit X not found locally; fetching from origin" pattern. * Force `git add` when resolving `.in-toto/` conflicts `.in-toto/` is gitignored in this repo (and most repos that ship signed packages), so `git add <path>` refuses to stage the file even though the path is already tracked in HEAD. `_resolve_in_toto_conflict` gets here only for paths git itself flagged as `--diff-filter=U`, so forcing the add is safe and matches the rest of the function's "keep target branch's .in-toto" intent. Without this, cherry-picking any commit that touches a `.in-toto/` file (i.e. every release commit) aborts mid-workflow with `Command '['git', 'add', '.in-toto/...']' returned non-zero exit status 1`.
1 parent 9d7c5e9 commit d54c6b8

9 files changed

Lines changed: 541 additions & 19 deletions

File tree

ddev/changelog.d/23703.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

ddev/src/ddev/cli/application.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import os
88
from functools import cached_property
9-
from typing import cast
9+
from typing import TYPE_CHECKING, cast
1010

1111
from ddev.cli.terminal import Terminal
1212
from ddev.config.constants import AppEnvVars, ConfigEnvVars, VerbosityLevels
@@ -16,6 +16,9 @@
1616
from ddev.utils.github import GitHubManager
1717
from ddev.utils.platform import Platform
1818

19+
if TYPE_CHECKING:
20+
from typing import Any, Callable, NoReturn
21+
1922

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

3639

3740
class Application(Terminal):
38-
def __init__(self, exit_func, *args, **kwargs):
41+
def __init__(self, exit_func: Callable[[int], NoReturn], *args, **kwargs):
3942
super().__init__(*args, **kwargs)
4043
self.platform = Platform(self.escaped_output)
4144
self.__exit_func = exit_func
@@ -49,7 +52,7 @@ def __init__(self, exit_func, *args, **kwargs):
4952
self.__github = cast(GitHubManager, None)
5053

5154
# TODO: remove this when the old CLI is gone
52-
self.__config = {}
55+
self.__config: dict[str, Any] = {}
5356

5457
@property
5558
def config(self) -> RootConfig:
@@ -105,7 +108,7 @@ def set_repo(self, core: bool, extras: bool, marketplace: bool, agent: bool, her
105108
self.repo, user=self.config.github.user, token=self.config.github.token, status=self.status
106109
)
107110

108-
def abort(self, text='', code=1, **kwargs):
111+
def abort(self, text: str = '', code: int = 1, **kwargs: Any) -> NoReturn:
109112
if text:
110113
self.display_error(text, **kwargs)
111114
self.__exit_func(code)

ddev/src/ddev/cli/meta/scripts/serve_openmetrics_payload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def serve_openmetrics_payload(
9191
env_data.write_config(check_config)
9292
env_data.write_metadata(metadata)
9393

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

9797
try:

ddev/src/ddev/cli/release/port_commit.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
@click.command(name='port-commit', short_help='Backport a commit onto a target branch')
1515
@click.pass_obj
16-
@click.argument('commit_hash', required=False)
16+
@click.argument('commit_hash', required=False, metavar='COMMIT_OR_PR')
1717
@click.option('-t', '--target-branch', default='master', show_default=True, help='Target branch to port to.')
1818
@click.option('-p', '--branch-prefix', default='port', show_default=True, help='Branch name prefix.')
1919
@click.option('-s', '--branch-suffix', default=None, help='Branch name suffix. Defaults to `to-<target-branch>`.')
@@ -43,23 +43,32 @@ def port_commit(
4343
"""
4444
Backport a commit onto a target branch.
4545
46-
Cherry-picks COMMIT_HASH onto `--target-branch` (default `master`) on a new branch named
46+
Cherry-picks COMMIT_OR_PR onto `--target-branch` (default `master`) on a new branch named
4747
`<github-user>/<prefix>-<sha[:10]>-<suffix>`, preserving `.in-toto` files from the target
4848
branch so package signatures stay intact. Pushes the branch and, unless `--no-pr` is set,
4949
opens a pull request titled `[Backport] <subject>` and labeled with `--pr-labels`.
5050
51-
If COMMIT_HASH is omitted, the current HEAD commit is used after confirmation.
51+
COMMIT_OR_PR accepts: a full 40-character commit SHA, a PR number (e.g. `23703`), an
52+
explicit `PR-<number>` token, or a GitHub PR URL. Pure-digit inputs are tried as a PR
53+
first when a GitHub token is configured, and fall back to commit resolution on 404. If
54+
omitted, the current HEAD commit is used after confirmation.
5255
5356
The GitHub user for the branch prefix is taken from `ddev config` (`github.user`) or the
5457
`DD_GITHUB_USER` / `GITHUB_USER` / `GITHUB_ACTOR` environment variables.
5558
"""
59+
import logging
60+
5661
from ddev.cli.release.port_commit_workflow import (
5762
PortStepError,
5863
build_port_steps,
5964
display_completion_summary,
6065
resolve_port_plan,
6166
)
6267

68+
# httpx logs every request at INFO and clutters the workflow output. The PR-resolution and
69+
# PR-creation steps already print their own status lines; the underlying HTTP traffic is noise.
70+
logging.getLogger('httpx').setLevel(logging.WARNING)
71+
6372
plan = resolve_port_plan(
6473
app,
6574
commit_hash=commit_hash,

ddev/src/ddev/cli/release/port_commit_workflow.py

Lines changed: 180 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
import contextlib
1718
import re
1819
from dataclasses import dataclass
1920
from typing import TYPE_CHECKING
@@ -27,19 +28,33 @@
2728

2829
if TYPE_CHECKING:
2930
from ddev.cli.application import Application
31+
from ddev.utils.github_async.models import PullRequest
3032

3133

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

3845

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

4249

50+
class _CommitNotResolvable(Exception):
51+
"""Raised when a commit input cannot be resolved locally or via a SHA-targeted fetch."""
52+
53+
54+
class _PRNotFound(Exception):
55+
"""Raised when a PR lookup returns 404 so the caller can fall back to commit resolution."""
56+
57+
4358
class PortStep:
4459
"""Single step of the port-commit workflow."""
4560

@@ -342,6 +357,164 @@ async def _create_pr(self) -> None:
342357
)
343358

344359

360+
def _resolve_input(app: Application, raw: str, *, dry_run: bool) -> str:
361+
"""Resolve the raw user input to a full commit SHA.
362+
363+
Handles three input shapes:
364+
- Explicit PR form (`PR-12345` or a GitHub PR URL) -> looks up the PR.
365+
- All-digits (e.g. `12345`) with a GitHub token configured -> tries as a PR; on 404 falls
366+
back to commit resolution. Without a token the PR step is skipped.
367+
- Anything else -> commit resolution.
368+
369+
Raises `_CommitNotResolvable` when nothing matches so the caller can decide how to abort.
370+
"""
371+
raw = raw.strip()
372+
pr_number = _extract_explicit_pr_number(raw)
373+
if pr_number is not None:
374+
return _resolve_pr_to_commit(app, pr_number, dry_run=dry_run)
375+
376+
is_digits = DIGITS_PATTERN.fullmatch(raw) is not None
377+
if is_digits and app.config.github.token:
378+
with contextlib.suppress(_PRNotFound):
379+
return _resolve_pr_to_commit(app, int(raw), dry_run=dry_run)
380+
381+
try:
382+
return _resolve_commit_or_fetch(app, raw, dry_run=dry_run)
383+
except _CommitNotResolvable as exc:
384+
if is_digits:
385+
raise _CommitNotResolvable(
386+
f'Could not resolve `{raw}` as a PR or a commit. '
387+
'Pass the full 40-character SHA, or `PR-xxxxx` / a PR URL to disambiguate.'
388+
) from exc
389+
raise
390+
391+
392+
def _extract_explicit_pr_number(raw: str) -> int | None:
393+
"""Return the PR number when `raw` is a `PR-12345` token or a GitHub PR URL, else None."""
394+
for pattern in (PR_PREFIX_PATTERN, PR_URL_PATTERN):
395+
match = pattern.fullmatch(raw)
396+
if match:
397+
return int(match.group(1))
398+
return None
399+
400+
401+
def _resolve_pr_to_commit(app: Application, pr_number: int, *, dry_run: bool) -> str:
402+
"""Resolve a PR number to the SHA of its merge commit, validating squash-merge.
403+
404+
Raises `_PRNotFound` when GitHub returns 404. Raises `_CommitNotResolvable` (wrapped with PR
405+
context) when the merge commit can't be resolved locally. Aborts on other auth / network /
406+
validation errors so the user gets a clear, contextual message rather than a stack trace.
407+
"""
408+
import asyncio
409+
410+
import httpx
411+
from pydantic import ValidationError
412+
413+
if not app.config.github.token:
414+
app.abort(
415+
'GitHub token required to resolve a PR reference. Set `github.token`, or pass the '
416+
'full commit SHA directly (--no-pr does not skip this lookup).'
417+
)
418+
419+
owner, repo = resolve_owner_repo(app)
420+
app.display_info(f'Resolving PR #{pr_number} via GitHub...')
421+
try:
422+
pr = asyncio.run(_fetch_pr(app.config.github.token, owner, repo, pr_number))
423+
except httpx.HTTPStatusError as exc:
424+
status = exc.response.status_code
425+
if status == 404:
426+
raise _PRNotFound(str(pr_number)) from exc
427+
if status in (401, 403):
428+
app.abort(
429+
f'GitHub denied the request for PR #{pr_number} (HTTP {status}). '
430+
'Check that `github.token` is set and has `repo` scope.'
431+
)
432+
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
433+
except (httpx.HTTPError, ValidationError) as exc:
434+
app.abort(f'Failed to fetch PR #{pr_number} from GitHub: {exc}.')
435+
436+
if not pr.merged:
437+
app.abort(f'PR #{pr_number} is not merged; nothing to backport.')
438+
439+
if not pr.merge_commit_sha:
440+
app.abort(f'PR #{pr_number} has no merge commit SHA available.')
441+
442+
try:
443+
full_sha = _resolve_commit_or_fetch(app, pr.merge_commit_sha, dry_run=dry_run)
444+
except _CommitNotResolvable as exc:
445+
raise _CommitNotResolvable(
446+
f'PR #{pr_number} was found but its merge commit `{pr.merge_commit_sha}` could not be resolved: {exc}'
447+
) from exc
448+
_abort_if_merge_commit(app, pr_number, full_sha)
449+
return full_sha
450+
451+
452+
async def _fetch_pr(token: str, owner: str, repo: str, pr_number: int) -> PullRequest:
453+
from ddev.utils.github_async import async_github_client
454+
455+
async with async_github_client(token=token) as client:
456+
response = await client.get_pull_request(owner=owner, repo=repo, pull_number=pr_number)
457+
return response.data
458+
459+
460+
def _abort_if_merge_commit(app: Application, pr_number: int, full_sha: str) -> None:
461+
"""Abort when `full_sha` is a merge commit (>= 2 parents), which can't be backported as a single commit."""
462+
try:
463+
raw = app.repo.git.capture('rev-list', '--parents', '-n1', full_sha)
464+
except OSError as exc:
465+
app.abort(f'Could not inspect merge parents of `{full_sha}`: {exc}.')
466+
else:
467+
parent_count = max(len(raw.strip().split()) - 1, 0)
468+
if parent_count >= 2:
469+
app.abort(
470+
f"PR #{pr_number} was not squash-merged, so there isn't a single commit to backport "
471+
'the full PR. Run again with the specific commit you want to backport.'
472+
)
473+
474+
475+
def _resolve_commit_or_fetch(app: Application, commit_hash: str, *, dry_run: bool) -> str:
476+
"""Return the full SHA for `commit_hash`, fetching from origin when the commit is not local.
477+
478+
Raises `_CommitNotResolvable` when the commit is neither available locally nor reachable on
479+
origin. Falling back to a SHA-targeted fetch lets the command port commits that live on remote
480+
branches the local repo does not track (the `remote.origin.fetch` refspec is often narrowed in
481+
this repo to avoid pulling thousands of branches).
482+
483+
When `dry_run` is true, the fetch fallback is skipped to preserve the dry-run contract: a
484+
non-local commit raises instead of mutating local state.
485+
"""
486+
git = app.repo.git
487+
with contextlib.suppress(OSError):
488+
return git.capture('rev-parse', '--verify', f'{commit_hash}^{{commit}}').strip()
489+
490+
# Abbreviated SHAs cannot be fetched (GitHub's allowReachableSHA1InWant only honours full
491+
# SHAs), so this is the real diagnosis regardless of dry-run mode. Surface it first.
492+
if HEX_PATTERN.fullmatch(commit_hash) and not FULL_SHA_PATTERN.fullmatch(commit_hash):
493+
raise _CommitNotResolvable(
494+
f'Commit `{commit_hash}` is not in the local repository. '
495+
'Pass the full 40-character SHA so it can be fetched from origin '
496+
'(GitHub does not support SHA-targeted fetches for abbreviated SHAs).'
497+
)
498+
499+
if dry_run:
500+
raise _CommitNotResolvable(
501+
f'Commit `{commit_hash}` is not in the local repository. '
502+
'Re-run without `--dry-run` to fetch it from origin, or pre-fetch the commit manually.'
503+
)
504+
505+
app.display_info(f'Commit `{commit_hash}` not found locally; fetching from origin.')
506+
fetched = False
507+
with contextlib.suppress(OSError):
508+
git.run('fetch', 'origin', commit_hash)
509+
fetched = True
510+
511+
if fetched:
512+
with contextlib.suppress(OSError):
513+
return git.capture('rev-parse', '--verify', f'{commit_hash}^{{commit}}').strip()
514+
515+
raise _CommitNotResolvable(f'Commit `{commit_hash}` does not exist locally or on origin.')
516+
517+
345518
def _path_exists_in_head(git: GitRepository, path: str) -> bool:
346519
try:
347520
git.capture('cat-file', '-e', f'HEAD:{path}')
@@ -355,7 +528,10 @@ def _resolve_in_toto_conflict(git: GitRepository, path: str) -> None:
355528
git.run('rm', '--force', path)
356529
return
357530
git.run('checkout', '--ours', path)
358-
git.run('add', path)
531+
# `.in-toto/` is gitignored in this repo, so `git add` refuses without `--force` even though
532+
# the path is already tracked in HEAD. The force is safe: we only get here for paths that
533+
# came out of `git diff --diff-filter=U`, i.e. files git itself flagged as needing resolution.
534+
git.run('add', '--force', path)
359535

360536

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

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

478654
log_entries = app.repo.git.log(['hash:%H', 'subject:%s'], n=1, source=full_sha)
479655
if not log_entries:

ddev/src/ddev/utils/github_async/client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,31 @@ async def create_issue_comment(
275275
)
276276
return self._parse_response(response, IssueComment)
277277

278+
async def get_pull_request(
279+
self,
280+
owner: str,
281+
repo: str,
282+
pull_number: int,
283+
timeout: float | None = None,
284+
) -> GitHubResponse[PullRequest]:
285+
"""
286+
Calls the GitHub API to get a single pull request.
287+
288+
GitHub API Documentation:
289+
https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
290+
291+
Args:
292+
owner: Repository owner (user or organisation).
293+
repo: Repository name.
294+
pull_number: Pull request number.
295+
timeout: Optional timeout for this specific request. Defaults to the client's default_timeout.
296+
297+
Returns:
298+
GitHubResponse[PullRequest]: The validated pull request data and headers.
299+
"""
300+
response = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{pull_number}", timeout=timeout)
301+
return self._parse_response(response, PullRequest)
302+
278303
async def create_pull_request(
279304
self,
280305
owner: str,

0 commit comments

Comments
 (0)