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
24 changes: 21 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A GitHub Action that monitors and updates RHDH plugins in your RHDH GitOps repos
## Features

- 🔄 **Automatic Plugin Updates**: Monitors RHDH plugin versions from the GitHub Container Registry
- 🎯 **Flexible PR Strategies**: Create `separate` PRs per plugin or a single `joint` PR for all updates
- 🎯 **Flexible PR Strategies**: Create `separate` PRs per plugin, a single `joint` PR for all updates, or `grouped` PRs for matching frontend/backend packages
- 🔒 **Safe Updates**: Preserves YAML formatting and validates plugin definitions
- 🚀 **GitOps Integration**: Works seamlessly with GitOps-based RHDH deployments
- ⚙️ **Configurable**: Customize PR limits, config paths, and update strategies
Expand Down Expand Up @@ -74,6 +74,23 @@ Create a single pull request containing all plugin updates:
update-pr-strategy: "joint"
```

### Grouped PRs for Frontend and Backend Updates

Changing the `update-pr-strategy` field from `separate` to `grouped` results in creating one PR for an exact frontend/backend package pair, such as `pluginabc` and `pluginabc-backend`, when both have updates in the same run and use the same tag prefix. Each package keeps its own target version; the versions can differ. Packages without a matching update get individual PRs.

```yaml

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so from what I can tell in the code grouping is just done by default

not following what this chunk of yaml has to do with the grouping ...I'm not connecting the dots between this yaml and the text that precedes and follows it

can you elaborate ? Or was this included incorrectly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a snippet of the update-pr-strategy when consuming as a GH action, you can see our current one in the gitops repo here: https://github.com/redhat-ai-dev/ai-rolling-demo-gitops/blob/main/.github/workflows/plugins-updater.yaml

- name: Update RHDH plugins
uses: thepetk/rhdh-plugin-gitops-updater@v1
with:
config-path: "charts/rhdh/values.yaml"
github-token: ${{ secrets.GITHUB_TOKEN }}
update-pr-strategy: "grouped"
```

Grouping removes only a terminal `-backend` from the full OCI package name and collects updates with the same resulting name and tag channel. Namespaces are preserved; backend modules and packages in different tag channels remain separate. An existing individual update branch is skipped before grouping, so a partner that arrives later gets its own PR without duplicating the original update. Existing PRs are not refreshed; newer target versions can create new PRs, following the separate strategy.

The `pr-creation-limit` counts newly created PRs per run, and a pair counts as one. Existing branches and failed creation attempts do not consume the limit. PR creation failures are logged and other groups continue, matching the separate strategy. Grouped mode completes discovery before creating PRs: a lookup exception aborts the run without creating any grouped PRs, while a package with no available versions is skipped normally.

### Custom Configuration Location

If your dynamic plugins configuration is at a different YAML path:
Expand Down Expand Up @@ -137,8 +154,8 @@ Specify custom tag prefixes to filter plugin versions:
| `config-path` | Path to the dynamic plugins config YAML file (e.g., `charts/rhdh/values.yaml`) | Yes | - |
| `config-location` | Location of the dynamic plugins config inside the YAML file | No | `redhat-developer-hub.dynamicPlugins.plugins` |
| `github-token` | GitHub token for API access and PR creation | Yes | - |
| `update-pr-strategy` | PR creation strategy: `separate` or `joint` | No | `separate` |
| `pr-creation-limit` | Maximum number of PRs to create (0 for unlimited, only applies with `separate` strategy) | No | `0` |
| `update-pr-strategy` | PR creation strategy: `separate`, `joint`, or `grouped` | No | `separate` |
| `pr-creation-limit` | Maximum new PRs per run (0 for unlimited, applies to `separate` and `grouped`; a pair counts as one PR) | No | `0` |
| `tag-prefixes` | Tag prefixes to consider when checking for plugin updates (newline-separated list) | No | `next__` |
| `base-branch` | Base branch for pull requests (overrides the branch the workflow runs on) | No | `main` |
| `verbose` | Enable verbose logging (0 = normal, 1 = debug) | No | `0` |
Expand All @@ -151,6 +168,7 @@ Specify custom tag prefixes to filter plugin versions:
4. **PR Creation**: Creates pull requests based on your chosen strategy:
- **Separate**: One PR per plugin update
- **Joint**: Single PR with all updates
- **Grouped**: One PR per matching frontend/backend pair in the same tag channel; unmatched updates get individual PRs
5. **Formatting Preservation**: Updates are made via string replacement to preserve your YAML formatting

## Supported Plugin Format
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ inputs:
description: 'GitHub token for API access and PR creation'
required: true
update-pr-strategy:
description: 'PR creation strategy: "separate" for individual PRs per plugin, "joint" for one PR with all updates'
description: 'PR creation strategy: "separate" per plugin, "joint" for all updates, "grouped" for matching frontend/backend packages in the same tag channel'
required: false
default: 'separate'
pr-creation-limit:
description: 'Maximum number of PRs to create (0 for unlimited, only applies when update-pr-strategy is "separate")'
description: 'Maximum new PRs per run (0 for unlimited, applies to "separate" and "grouped"; a grouped pair counts as one PR)'
required: false
default: '0'
verbose:
Expand Down
207 changes: 171 additions & 36 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#!/usr/bin/env python3
import hashlib
import json
import sys

from src.constants import (
Expand All @@ -19,7 +21,152 @@
RHDHPluginUpdaterConfig,
)
from src.updater import RHDHPluginConfigUpdater
from src.utils import build_version_string, rhdh_plugin_needs_update
from src.utils import (
build_version_string,
group_plugin_updates,
rhdh_plugin_needs_update,
)


def _single_plugin_pr_info(
plugin_name: "str", current_version: "str", latest_version: "str"
) -> "tuple[str, str, str]":
values = {
"plugin_name": plugin_name,
"current_version": current_version,
"latest_version": latest_version,
}
return (
RHDHPluginUpdaterConfig.GH_PR_BRANCH_NAME_BASE.format(**values),
RHDHPluginUpdaterConfig.GH_PR_TITLE_BASE.format(**values),
RHDHPluginUpdaterConfig.GH_PR_BODY_BASE.format(**values),
)


def _plugin_update_details(updates: "list[RHDHPluginUpdate]") -> "str":
body = ""
for update in updates:
plugin = update.rhdh_plugin
current = build_version_string(
plugin.current_version, plugin.current_second_version
)
latest = build_version_string(update.new_version, update.new_second_version)
body += f"- **{plugin.plugin_name}**: `{current}` → `{latest}`\n"
return body + (
"\n🤖 Generated with [RHDH Plugin GitOps Updater]"
"(https://github.com/thepetk/rhdh-plugin-gitops-updater)\n"
)


def _grouped_update_hash(
updates: "list[RHDHPluginUpdate]", base_branch: "str", config_path: "str"
) -> "str":
identities: "list[dict[str, str]]" = [
{
"package_name": update.rhdh_plugin.package_name,
"plugin_name": update.rhdh_plugin.plugin_name,
"tag_prefix": update.rhdh_plugin.current_tag_prefix or "",
"new_version": str(update.new_version),
"new_second_version": (
str(update.new_second_version) if update.new_second_version else ""
),
}
for update in updates
]
identity = {
"updates": sorted(identities, key=lambda item: tuple(item.values())),
"base_branch": base_branch,
"config_path": config_path,
}
encoded_identity = json.dumps(
identity, sort_keys=True, separators=(",", ":")
).encode("utf-8")
return hashlib.sha256(encoded_identity).hexdigest()[:10]


def _create_grouped_pull_requests(
gh_api_client: "GithubAPIClient",
rhdh_config_updater: "RHDHPluginConfigUpdater",
plugin_updates: "list[RHDHPluginUpdate]",
trimmed_file_path: "str",
) -> "int":
repo = gh_api_client.client.get_repo(GITHUB_REPOSITORY)
eligible_updates: "list[RHDHPluginUpdate]" = []
for update in plugin_updates:
plugin = update.rhdh_plugin
latest_version_string = build_version_string(
update.new_version, update.new_second_version
)
separate_branch_name = RHDHPluginUpdaterConfig.GH_PR_BRANCH_NAME_BASE.format(
plugin_name=plugin.plugin_name,
latest_version=latest_version_string,
)
# Only a confirmed 404 means absent here: lookup errors must not duplicate
# an existing individual update in a new group.
if not gh_api_client._branch_exists(repo, separate_branch_name, strict=True):
eligible_updates.append(update)

groups = group_plugin_updates(eligible_updates)
prs_created = 0
for group in groups:
if PR_CREATION_LIMIT > 0 and prs_created >= PR_CREATION_LIMIT:
logger.warning(
f"reached the PR creation limit of {PR_CREATION_LIMIT}, stopping..."
)
break

try:
updated_yaml = rhdh_config_updater.bulk_update_rhdh_plugins(group)

if len(group) == 1:
update = group[0]
plugin = update.rhdh_plugin
current_version_string = build_version_string(
plugin.current_version, plugin.current_second_version
)
latest_version_string = build_version_string(
update.new_version, update.new_second_version
)
branch_name, pr_title, pr_body = _single_plugin_pr_info(
plugin.plugin_name, current_version_string, latest_version_string
)
else:
package_name = group[0].rhdh_plugin.package_name
family_name = package_name.removesuffix("-backend").rsplit("/", 1)[-1]
update_hash = _grouped_update_hash(group, GITHUB_REF, trimmed_file_path)
branch_name = (
RHDHPluginUpdaterConfig.GH_GROUPED_PR_BRANCH_NAME_BASE.format(
plugin_name=family_name,
update_hash=update_hash,
)
)
pr_title = RHDHPluginUpdaterConfig.GH_GROUPED_PR_TITLE_BASE.format(
plugin_name=family_name,
plugin_updates_count=len(group),
)
pr_body = (
"## Grouped Plugin Update\n\n"
f"This PR updates {len(group)} related RHDH plugin packages "
f"for `{family_name}`:\n\n"
)
pr_body += _plugin_update_details(group)

pr_url = gh_api_client.create_pull_request(
repo_full_name=GITHUB_REPOSITORY,
file_path=trimmed_file_path,
new_content=updated_yaml,
branch_name=branch_name,
pr_title=pr_title,
pr_body=pr_body,
base_branch=GITHUB_REF,
)
prs_created += 1
logger.info(f"✓ Created grouped PR: {pr_url}")

except GithubPRFailedException as e:
logger.warning(f"✗ Failed to create grouped PR: {e}")

return prs_created


def main():
Expand All @@ -41,7 +188,7 @@ def main():

logger.info(f"found {len(rhdh_plugins)} RHDH plugins to check for updates")

# list to cache all updates in case of joint strategy
# list to cache updates for joint and grouped strategies
plugin_updates: "list[RHDHPluginUpdate]" = []
prs_created = 0

Expand Down Expand Up @@ -88,8 +235,11 @@ def main():
f"{latest_version_string} (current: {current_version_string})"
)

if UPDATE_PR_STRATEGY == GithubPullRequestStrategy.JOINT:
logger.debug("caching plugin update for joint PR...")
if UPDATE_PR_STRATEGY in (
GithubPullRequestStrategy.JOINT,
GithubPullRequestStrategy.GROUPED,
):
logger.debug("caching plugin update for deferred PR creation...")
plugin_updates.append(
RHDHPluginUpdate(
rhdh_plugin=plugin,
Expand All @@ -109,24 +259,17 @@ def main():
updated_yaml = rhdh_config_updater.update_rhdh_plugin(
plugin, latest_version, latest_second_version
)
branch_name, pr_title, pr_body = _single_plugin_pr_info(
plugin.plugin_name, current_version_string, latest_version_string
)

pr_url = gh_api_client.create_pull_request(
repo_full_name=GITHUB_REPOSITORY,
file_path=trimmed_file_path,
new_content=updated_yaml,
branch_name=RHDHPluginUpdaterConfig.GH_PR_BRANCH_NAME_BASE.format(
plugin_name=plugin.plugin_name,
latest_version=latest_version_string,
),
pr_title=RHDHPluginUpdaterConfig.GH_PR_TITLE_BASE.format(
plugin_name=plugin.plugin_name,
latest_version=latest_version_string,
),
pr_body=RHDHPluginUpdaterConfig.GH_PR_BODY_BASE.format(
plugin_name=plugin.plugin_name,
current_version=current_version_string,
latest_version=latest_version_string,
),
branch_name=branch_name,
pr_title=pr_title,
pr_body=pr_body,
base_branch=GITHUB_REF,
)
logger.info(f"✓ Created PR: {pr_url}")
Expand All @@ -135,32 +278,24 @@ def main():
except GithubPRFailedException as e:
logger.warning(f"✗ Failed to create PR for {plugin.plugin_name}: {e}")

if plugin_updates:
if UPDATE_PR_STRATEGY == GithubPullRequestStrategy.GROUPED and plugin_updates:
logger.info(f"creating grouped PRs for {len(plugin_updates)} plugin updates...")
prs_created += _create_grouped_pull_requests(
gh_api_client,
rhdh_config_updater,
plugin_updates,
trimmed_file_path,
)

elif plugin_updates:
logger.info(f"creating joint PR for {len(plugin_updates)} plugin updates...")
try:
updated_yaml = rhdh_config_updater.bulk_update_rhdh_plugins(plugin_updates)

pr_body = RHDHPluginUpdaterConfig.GH_BULK_PR_BODY_BASE.format(
plugin_updates_count=len(plugin_updates)
)
for update in plugin_updates:
update_current = build_version_string(
update.rhdh_plugin.current_version,
update.rhdh_plugin.current_second_version,
)
update_new = build_version_string(
update.new_version, update.new_second_version
)
pr_body += (
f"- **{update.rhdh_plugin.plugin_name}**: "
f"`{update_current}` → "
f"`{update_new}`\n"
)

pr_body += (
"\n🤖 Generated with [RHDH Plugin GitOps Updater]"
"(https://github.com/thepetk/rhdh-plugin-gitops-updater)\n"
)
pr_body += _plugin_update_details(plugin_updates)

pr_url = gh_api_client.create_pull_request(
repo_full_name=GITHUB_REPOSITORY,
Expand Down
4 changes: 2 additions & 2 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")

# UPDATE_PR_STRATEGY: is the strategy to use when creating
# the github pull requests. It can be either "separate" or "joint".
# the github pull requests. It can be "separate", "joint", or "grouped".
UPDATE_PR_STRATEGY = os.getenv("UPDATE_PR_STRATEGY", "separate")

# PR_CREATION_LIMIT: is the maximum number of pull requests to create.
# PR_CREATION_LIMIT: maximum new PRs per run for separate or grouped strategies.
PR_CREATION_LIMIT = int(os.getenv("PR_CREATION_LIMIT", "0"))

# GITHUB_REPOSITORY: is the Github repository where PRs will be
Expand Down
19 changes: 14 additions & 5 deletions src/github_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from urllib.parse import quote

import requests
from github import Auth, Github
from github import Auth, Github, GithubException
from github.ContentFile import ContentFile
from github.Repository import Repository
from requests import Response
Expand Down Expand Up @@ -163,15 +163,21 @@ def fetch_package(
package_name, raw_versions, tag_prefix_filter
)

def _branch_exists(self, repo: "Repository", branch_name: "str") -> "bool":
def _branch_exists(
self, repo: "Repository", branch_name: "str", *, strict: "bool" = False
) -> "bool":
"""
checks if a branch exists in the given repository
"""
try:
repo.get_git_ref(f"heads/{branch_name}")
logger.debug(f"branch {branch_name} already exists")
return True
except Exception:
except Exception as error:
if strict and (
not isinstance(error, GithubException) or error.status != 404
):
raise
logger.debug(f"branch {branch_name} does not exist, will create it")
return False

Expand Down Expand Up @@ -209,8 +215,11 @@ def create_pull_request(
base_sha = base_ref.object.sha

if self._branch_exists(repo, branch_name):
# skip for separate pr strategy
if UPDATE_PR_STRATEGY == GithubPullRequestStrategy.SEPARATE:
# skip for separate and grouped PR strategies
if UPDATE_PR_STRATEGY in (
GithubPullRequestStrategy.SEPARATE,
GithubPullRequestStrategy.GROUPED,
):
logger.debug(f"checking for existing open PR for branch {branch_name}")
raise GithubPRFailedException(f"Branch {branch_name} already exists")

Expand Down
Loading
Loading