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
9 changes: 5 additions & 4 deletions otterdog/operations/approve_blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,6 @@ async def execute(
return 1

async with GitHubProvider(credentials) as provider:
rest_api = provider.rest_api

for blueprint in blueprints:
self.printer.print(f"Merging PR #{blueprint.remediation_pr}: ")

Expand All @@ -123,9 +121,12 @@ async def execute(
if repo.allow_rebase_merge is True:
merge_method = "rebase"

result = await rest_api.pull_request.merge_pull_request(
blueprint.id.org_id, blueprint.id.repo_name, f"{blueprint.remediation_pr}", merge_method
pr = provider.pull_request(
blueprint.id.org_id,
blueprint.id.repo_name,
blueprint.remediation_pr,
)
result = await pr.merge_pull_request(merge_method)

if result["merged"] is True:
self.printer.println("[green]merged[/].")
Expand Down
13 changes: 7 additions & 6 deletions otterdog/operations/open_pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@

if TYPE_CHECKING:
from otterdog.config import OrganizationConfig
from otterdog.providers.github.rest import RestApi


class OpenPullRequestOperation(Operation):
Expand Down Expand Up @@ -128,7 +127,7 @@ async def execute(
return 1

pr_number = await self._create_pull_request(
rest_api,
provider,
org_config,
default_branch,
local_configuration,
Expand All @@ -150,11 +149,13 @@ async def execute(

async def _create_pull_request(
self,
rest_api: RestApi,
github: GitHubProvider,
org_config: OrganizationConfig,
default_branch: str,
local_configuration: str,
) -> str:
) -> int:
rest_api = github.rest_api

default_branch_data = await rest_api.reference.get_branch_reference(
org_config.github_id,
org_config.config_repo,
Expand Down Expand Up @@ -183,7 +184,7 @@ async def _create_pull_request(
else:
body = "This PR has been created automatically using the otterdog cli."

pull_request_data = await rest_api.pull_request.create_pull_request(
pull_request_data = await github.create_pull_request(
org_config.github_id,
org_config.config_repo,
self.title,
Expand All @@ -192,4 +193,4 @@ async def _create_pull_request(
body,
)

return pull_request_data["number"]
return pull_request_data.pr_number
55 changes: 55 additions & 0 deletions otterdog/providers/github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from importlib_resources import files

from otterdog import resources
from otterdog.providers.github.exception import GitHubException
from otterdog.providers.github.pull_request import PullRequest
from otterdog.utils import get_logger, is_ghsa_repo, is_set_and_present

if TYPE_CHECKING:
Expand Down Expand Up @@ -47,6 +49,59 @@ def __init__(self, credentials: Credentials | None):
if credentials is not None:
self._init_clients()

def pull_request(self, org_id: str, repo_name: str, pr_number: int) -> PullRequest:
return PullRequest(self.rest_api.requester, org_id, repo_name, pr_number)

async def create_pull_request(
self,
org_id: str,
repo_name: str,
title: str,
head: str,
base: str,
body: str | None = None,
) -> PullRequest:
_logger.debug("creating pull request for repo '%s/%s'", org_id, repo_name)

try:
data = {
"title": title,
"head": head,
"base": base,
}

if body is not None:
data["body"] = body

pr_data = await self.rest_api.requester.request_json(
"POST", f"/repos/{org_id}/{repo_name}/pulls", data=data
)
return PullRequest(self.rest_api.requester, org_id, repo_name, pr_data["number"])
except GitHubException as ex:
raise RuntimeError(f"failed creating pull request:\n{ex}") from ex

# TODO: this should return precached PullRequest objects instead of raw data dicts.
async def get_pull_requests(
self,
org_id: str,
repo_name: str,
state: str = "all",
base_ref: str | None = None,
) -> list[dict[str, Any]]:
_logger.debug("getting pull requests from repo '%s/%s'", org_id, repo_name)

try:
params = {"state": state}

if base_ref is not None:
params.update({"base": base_ref})

return await self.rest_api.requester.request_paged_json(
"GET", f"/repos/{org_id}/{repo_name}/pulls", params=params
)
except GitHubException as ex:
raise RuntimeError(f"failed retrieving pull requests:\n{ex}") from ex

async def __aenter__(self):
return self

Expand Down
153 changes: 153 additions & 0 deletions otterdog/providers/github/pull_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# *******************************************************************************
# Copyright (c) 2024-2025 Eclipse Foundation and others.
# This program and the accompanying materials are made available
# under the terms of the Eclipse Public License 2.0
# which is available at http://www.eclipse.org/legal/epl-v20.html
# SPDX-License-Identifier: EPL-2.0
# *******************************************************************************
import json
from typing import Any

from otterdog.logging import get_logger
from otterdog.providers.github.exception import GitHubException
from otterdog.providers.github.rest.requester import Requester

_logger = get_logger(__name__)


class PullRequest:
def __init__(self, rest_api_requester: Requester, org_id: str, repo_name: str, pr_number: int) -> None:
self.requester = rest_api_requester
self.org_id = org_id
self.repo_name = repo_name
self.pr_number = pr_number

def __str__(self) -> str:
return f"PullRequest(org_id={self.org_id}, repo_name={self.repo_name}, pr_number={self.pr_number})"

def _base_path(self) -> str:
return f"/repos/{self.org_id}/{self.repo_name}/pulls/{self.pr_number}"

async def get_data(
self,
) -> dict[str, Any]:
_logger.debug("getting live data for %s", self)

try:
return await self.requester.request_json("GET", self._base_path())
except GitHubException as ex:
raise RuntimeError(f"failed retrieving {self}") from ex

async def merge_pull_request(
self,
method: str,
) -> dict[str, Any]:
_logger.debug("merging %s", self)

try:
data = {"merge_method": method}
return await self.requester.request_json("PUT", self._base_path() + "/merge", data=data)
except GitHubException as ex:
raise RuntimeError(f"failed merging {self}") from ex

async def get_commits(
self,
) -> list[dict[str, Any]]:
_logger.debug("getting commits for %s", self)

try:
return await self.requester.request_paged_json("GET", self._base_path() + "/commits")
except GitHubException as ex:
raise RuntimeError(f"failed retrieving commits for {self}") from ex

async def get_reviews(
self,
) -> list[dict[str, Any]]:
_logger.debug("getting reviews for %s", self)

try:
return await self.requester.request_paged_json("GET", self._base_path() + "/reviews")
except GitHubException as ex:
raise RuntimeError(f"failed retrieving reviews for {self}") from ex

async def request_reviews(
self,
reviewers: list[str] | None = None,
team_reviewers: list[str] | None = None,
) -> bool:
_logger.debug(
"requesting reviews for %s: %s, %s",
self,
reviewers,
team_reviewers,
)

if (reviewers is None or len(reviewers) == 0) and (team_reviewers is None or len(team_reviewers) == 0):
_logger.error("requesting reviews for %s without any reviewer specified", self)
return False

try:
data = {}

if reviewers is not None:
data["reviewers"] = reviewers

if team_reviewers is not None:
data["team_reviewers"] = team_reviewers

status, body = await self.requester.request_raw(
"POST",
self._base_path() + "/requested_reviewers",
data=json.dumps(data),
)

if status == 201:
return True
elif status == 422:
_logger.warning("failed to request reviews for %s: %s", self, body)
return False
else:
raise RuntimeError(f"failed requesting reviews for {self}\n{status}: {body}")

except GitHubException as ex:
raise RuntimeError(f"failed requesting reviews for {self}") from ex

async def get_files(
self,
) -> list[dict[str, Any]]:
_logger.debug("getting files for %s", self)

try:
return await self.requester.request_paged_json("GET", self._base_path() + "/files")
except GitHubException as ex:
raise RuntimeError(f"failed retrieving files for {self}") from ex

async def merge(
self,
commit_message: str | None = None,
merge_method: str = "squash",
) -> bool:
"""
@param commit_message is only used for "squash" and "merge" merge methods, and ignored for "rebase" method
@param merge_method can be one of "merge", "squash", or "rebase"
"""
# https://docs.github.com/en/enterprise-cloud@latest/rest/pulls/pulls?apiVersion=2022-11-28#merge-a-pull-request

_logger.debug("merging %s", self)

try:
data = {
"merge_method": merge_method,
}

if commit_message is not None:
data.update({"commit_message": commit_message})

response = await self.requester.request_json(
"PUT",
self._base_path() + "/merge",
data=data,
)
return response["merged"]
except GitHubException as ex:
raise RuntimeError(f"failed merging {self}") from ex
6 changes: 0 additions & 6 deletions otterdog/providers/github/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,6 @@ def issue(self):

return IssueClient(self)

@cached_property
def pull_request(self):
from .pull_request_client import PullRequestClient

return PullRequestClient(self)

@cached_property
def reference(self):
from .reference_client import ReferenceClient
Expand Down
Loading