Skip to content

Commit

Permalink
feat: add list-blueprints and approve-blueprints operations
Browse files Browse the repository at this point in the history
  • Loading branch information
netomi committed Jan 27, 2025
1 parent 2e2b586 commit ce7f479
Show file tree
Hide file tree
Showing 8 changed files with 399 additions and 32 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- Added operations `list-blueprints` and `approve-blueprints` to list and approve remediation PRs created for specific organizations.
- Added support for teams.
- Use asyncer to speed up retrieval of live settings. ([#209](https://github.com/eclipse-csi/otterdog/issues/209))

Expand Down
81 changes: 78 additions & 3 deletions otterdog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,59 @@ def invoke(self, ctx: click.Context) -> Any:
return super().invoke(ctx)


class SingletonCommand(click.Command):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.context_settings = _CONTEXT_SETTINGS
self.params.insert(
0,
click.Option(
["-v", "--verbose"],
count=True,
help="enable verbose output (-vvv for more verbose output)",
),
)

self.params.insert(
0,
click.Option(
["-c", "--config"],
default=_CONFIG_FILE,
show_default=True,
type=click.Path(True, True, False),
help="configuration file to use",
),
)

self.params.insert(
0,
click.Option(
["--local"],
is_flag=True,
default=False,
show_default=True,
help="work in local mode, not updating the referenced default config",
),
)

def invoke(self, ctx: click.Context) -> Any:
global _CONFIG

verbose = ctx.params.pop("verbose")
init_logging(verbose)

config_file = ctx.params.pop("config")
local_mode = ctx.params.pop("local")

try:
_CONFIG = OtterdogConfig.from_file(config_file, local_mode)
except Exception as exc:
print_exception(exc)
sys.exit(2)

return super().invoke(ctx)


@click.group(context_settings=_CONTEXT_SETTINGS)
@click.version_option(version=__version__, prog_name="otterdog.sh")
def cli():
Expand Down Expand Up @@ -297,14 +350,36 @@ def list_members(organizations: list[str], two_factor_disabled: bool):
_execute_operation(organizations, ListMembersOperation(two_factor_disabled))


@cli.command(cls=StdCommand)
def list_projects(organizations: list[str]):
@cli.command(cls=SingletonCommand)
def list_projects():
"""
Lists all configured projects and their corresponding GitHub id.
"""
from otterdog.operations.list_projects import ListProjectsOperation

_execute_operation(organizations, ListProjectsOperation())
_execute_operation([], ListProjectsOperation())


@cli.command(cls=StdCommand)
@click.option("-b", "--blueprint-id", required=False, help="blueprint id")
def list_blueprints(organizations: list[str], blueprint_id):
"""
List blueprints.
"""
from otterdog.operations.list_blueprints import ListBlueprintsOperation

_execute_operation(organizations, ListBlueprintsOperation(blueprint_id))


@cli.command(cls=StdCommand)
@click.option("-b", "--blueprint-id", required=False, help="blueprint id")
def approve_blueprints(organizations: list[str], blueprint_id):
"""
Approved remediation PRs for blueprints.
"""
from otterdog.operations.approve_blueprints import ApproveBlueprintsOperation

_execute_operation(organizations, ApproveBlueprintsOperation(blueprint_id))


@cli.command(cls=StdCommand, name="import")
Expand Down
8 changes: 7 additions & 1 deletion otterdog/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# *******************************************************************************
# Copyright (c) 2023-2024 Eclipse Foundation and others.
# Copyright (c) 2023-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
Expand Down Expand Up @@ -190,6 +190,7 @@ class OtterdogConfig:
local_mode: bool
working_dir: str

_base_url: str = dataclasses.field(init=False)
_jsonnet_config: Mapping[str, Any] = dataclasses.field(init=False)
_github_config: Mapping[str, Any] = dataclasses.field(init=False)
_default_credential_provider: str = dataclasses.field(init=False)
Expand All @@ -199,6 +200,7 @@ class OtterdogConfig:
_organizations: list[OrganizationConfig] = dataclasses.field(init=False, default_factory=list)

def __post_init__(self):
object.__setattr__(self, "_base_url", query_json("defaults.base_url", self.configuration) or None)
object.__setattr__(self, "_jsonnet_config", query_json("defaults.jsonnet", self.configuration) or {})
object.__setattr__(self, "_github_config", query_json("defaults.github", self.configuration) or {})
object.__setattr__(
Expand All @@ -220,6 +222,10 @@ def __post_init__(self):
self._organizations_map[org_config.name.lower()] = org_config
self._organizations_map[org_config.github_id.lower()] = org_config

@property
def base_url(self) -> str:
return self._base_url

@property
def jsonnet_base_dir(self) -> str:
return self._jsonnet_base_dir
Expand Down
14 changes: 6 additions & 8 deletions otterdog/models/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -1089,36 +1089,34 @@ def generate_live_patch(
)
)

parent_repo = coerced_object if current_object is None else current_object

RepositoryWebhook.generate_live_patch_of_list(
coerced_object.webhooks,
current_object.webhooks if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)

RepositorySecret.generate_live_patch_of_list(
coerced_object.secrets,
current_object.secrets if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)

RepositoryVariable.generate_live_patch_of_list(
coerced_object.variables,
current_object.variables if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)

Environment.generate_live_patch_of_list(
coerced_object.environments,
current_object.environments if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)
Expand All @@ -1130,15 +1128,15 @@ def generate_live_patch(
BranchProtectionRule.generate_live_patch_of_list(
coerced_object.branch_protection_rules,
current_object.branch_protection_rules if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)

RepositoryRuleset.generate_live_patch_of_list(
coerced_object.rulesets,
current_object.rulesets if current_object is not None else [],
parent_repo,
coerced_object,
context,
handler,
)
Expand Down
138 changes: 138 additions & 0 deletions otterdog/operations/approve_blueprints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# *******************************************************************************
# Copyright (c) 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
# *******************************************************************************

from __future__ import annotations

from typing import TYPE_CHECKING

from otterdog.models.github_organization import GitHubOrganization
from otterdog.providers.github import GitHubProvider
from otterdog.utils import unwrap
from otterdog.webapp.db.models import BlueprintStatusModel

from . import Operation

if TYPE_CHECKING:
from otterdog.config import OrganizationConfig


class ApproveBlueprintsOperation(Operation):
"""
Approves and merges remediation PRs for blueprints.
"""

def __init__(self, blueprint_id: str | None):
super().__init__()
self._blueprint_id = blueprint_id
self._blueprints_by_org: dict[str, list[BlueprintStatusModel]] = {}

@property
def blueprint_id(self) -> str | None:
return self._blueprint_id

def pre_execute(self) -> None:
import requests

items = []
params = {
"pageIndex": 1,
"pageSize": 50,
}

base_url = unwrap(self._config).base_url
if base_url is None:
raise RuntimeError("no base_url set which is required when using operation 'approve-blueprints'")

while True:
response = requests.get(f"{base_url}/api/blueprints/remediations", params=params, timeout=10)
response_json = response.json()
items.extend(response_json["data"])
if len(items) >= response_json["itemsCount"]:
break
else:
params["pageIndex"] = params["pageIndex"] + 1

for item in items:
blueprint_status = BlueprintStatusModel.model_validate(item)

if self._skip_blueprint(blueprint_status):
continue

blueprint_list = self._blueprints_by_org.setdefault(blueprint_status.id.org_id, [])
blueprint_list.append(blueprint_status)

def _skip_blueprint(self, blueprint: BlueprintStatusModel) -> bool:
if self.blueprint_id is not None and blueprint.id.blueprint_id != self.blueprint_id:
return True

return False

async def execute(
self,
org_config: OrganizationConfig,
org_index: int | None = None,
org_count: int | None = None,
) -> int:
blueprints = self._blueprints_by_org.get(org_config.github_id, [])

if len(blueprints) > 0:
github_id = org_config.github_id
jsonnet_config = org_config.jsonnet_config
await jsonnet_config.init_template()

self._print_project_header(org_config, org_index, org_count)
self.printer.level_up()

try:
org_file_name = jsonnet_config.org_config_file
if not await self.check_config_file_exists(org_file_name):
return 1

try:
organization = GitHubOrganization.load_from_file(github_id, org_file_name)
except RuntimeError as ex:
self.printer.print_error(f"failed to load configuration: {ex!s}")
return 1

try:
credentials = self.get_credentials(org_config, only_token=True)
except RuntimeError as e:
self.printer.print_error(f"invalid credentials\n{e!s}")
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}: ")

repo = organization.get_repository(blueprint.id.repo_name)
if repo is None:
self.printer.println("no repo found.")
continue

if repo.allow_merge_commit is True:
merge_method = "merge"
if repo.allow_squash_merge is True:
merge_method = "squash"
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
)

if result["merged"] is True:
self.printer.println("[green]merged[/].")
else:
self.printer.println(f"[red]failed[/]: {result['message']}.")

finally:
self.printer.level_down()

return 0
Loading

0 comments on commit ce7f479

Please sign in to comment.