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
28 changes: 28 additions & 0 deletions ymir/agents/rebuild_consolidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Resolution,
TriageEligibility,
)
from ymir.common.utils import FIXED_IN_BUILD_CUSTOM_FIELD, check_build_in_buildroot
from ymir.common.version_utils import get_fix_version_variants

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -191,6 +192,33 @@ async def find_rebuild_siblings(
)
continue

if target_branch and analysis.dependency_issue and analysis.dependency_component:
try:
dep_details = await run_tool(
"get_jira_details",
available_tools=available_tools,
issue_key=analysis.dependency_issue,
)
fib = dep_details.get("fields", {}).get(FIXED_IN_BUILD_CUSTOM_FIELD)
# fix_version is already normalized by run_triage_analysis
if fib and not await check_build_in_buildroot(
target_branch,
analysis.dependency_component,
fib,
fix_version=rebuild_data.fix_version or "",
):
logger.info(
f"Sibling {candidate_key}: dependency {analysis.dependency_component} "
f"({fib}) not yet in {target_branch} buildroot"
)
summary_lines.append(
f"* {candidate_key} — excluded "
f"({analysis.dependency_component} not yet in buildroot)"
)
continue
except Exception as e:
logger.warning(f"Buildroot check failed for sibling {candidate_key}: {e}")

dep_parts = []
if analysis.dependency_component:
dep_parts.append(analysis.dependency_component)
Expand Down
89 changes: 76 additions & 13 deletions ymir/agents/triage_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,17 @@
from ymir.common.models import (
TriageOutputSchema as OutputSchema,
)
from ymir.common.utils import get_latest_candidate_build
from ymir.common.version_utils import is_older_zstream, normalize_fix_version, parse_rhel_version
from ymir.common.utils import (
FIXED_IN_BUILD_CUSTOM_FIELD,
check_build_in_buildroot,
get_latest_candidate_build,
)
from ymir.common.version_utils import (
construct_internal_branch_name,
is_older_zstream,
normalize_fix_version,
parse_rhel_version,
)
from ymir.tools.unprivileged.commands import RunShellCommandTool

## UpstreamSearchTool is currently unmaintained and disabled.
Expand Down Expand Up @@ -108,14 +117,6 @@ async def determine_target_branch(
return await _map_version_to_branch(triage_data.fix_version, cve_needs_internal_fix, package)


def _construct_internal_branch_name(major_version: str, minor_version: str) -> str:
"""Construct internal RHEL branch name."""
branch = f"rhel-{major_version}.{minor_version}"
if int(major_version) < 10:
branch += ".0"
return branch


async def _map_version_to_branch(
version: str, cve_needs_internal_fix: bool, package: str | None = None
) -> str | None:
Expand Down Expand Up @@ -151,7 +152,7 @@ async def _map_version_to_branch(
# For older z-streams, we want to check if the branch exists like regular bugs
if cve_needs_internal_fix and not older_zstream:
if major_version in y_streams:
branch = _construct_internal_branch_name(major_version, minor_version)
branch = construct_internal_branch_name(major_version, minor_version)
logger.info(f"Mapped {version} -> {branch} (CVE internal fix)")
return branch
# Default to CentOS Stream for CVEs when no Y-stream
Expand All @@ -161,13 +162,13 @@ async def _map_version_to_branch(

# For older Z-Streams, always use internal RHEL branch (it will be created if needed)
if older_zstream:
expected_branch = _construct_internal_branch_name(major_version, minor_version)
expected_branch = construct_internal_branch_name(major_version, minor_version)
logger.info(f"Mapped {version} -> {expected_branch} (older Z-Stream RHEL internal branch)")
return expected_branch

# For latest/upcoming Z-Stream, use internal RHEL branch only if it already exists
if is_zstream and package:
expected_branch = _construct_internal_branch_name(major_version, minor_version)
expected_branch = construct_internal_branch_name(major_version, minor_version)

async with mcp_tools(os.getenv("MCP_GATEWAY_URL")) as gateway_tools:
available_branches = await run_tool(
Expand Down Expand Up @@ -1014,6 +1015,8 @@ async def check_cve_applicability(state):
except Exception as e:
logger.warning(f"Could not prep sources for applicability check: {e}")
state.applicability_check_skipped = True
if state.triage_result.resolution == Resolution.REBUILD:
return "verify_rebuild_buildroot"
return "comment_in_jira"

if not prep_ok:
Expand Down Expand Up @@ -1097,7 +1100,66 @@ async def check_cve_applicability(state):
state.applicability_check_skipped = True

if state.triage_result.resolution == Resolution.REBUILD:
return "verify_rebuild_buildroot"
return "comment_in_jira"

async def verify_rebuild_buildroot(state):
"""Verify the dependency's fixed build is available in the target buildroot."""
data = state.triage_result.data
dep_issue_key = getattr(data, "dependency_issue", None)
dep_component = getattr(data, "dependency_component", None)

if not dep_issue_key or not dep_component or not state.target_branch:
logger.info("Missing dependency info or target branch — skipping buildroot check")
return "consolidate_rebuild_siblings"

dep_details = await run_tool(
"get_jira_details",
available_tools=gateway_tools,
issue_key=dep_issue_key,
)
fixed_in_build = dep_details.get("fields", {}).get(FIXED_IN_BUILD_CUSTOM_FIELD)
if not fixed_in_build:
logger.warning(f"Dependency {dep_issue_key} has no Fixed in Build — skipping buildroot check")
return "consolidate_rebuild_siblings"

# fix_version is already normalized by run_triage_analysis (e.g. rhel-9.8 → rhel-9.8.z)
fix_version = getattr(data, "fix_version", None) or ""

try:
in_buildroot = await check_build_in_buildroot(
state.target_branch,
dep_component,
fixed_in_build,
fix_version=fix_version,
)
except Exception as e:
logger.warning(f"Buildroot check failed for {dep_component} ({fixed_in_build}): {e}")
return "consolidate_rebuild_siblings"

if in_buildroot:
return "consolidate_rebuild_siblings"

logger.info(
f"Dependency {dep_component} ({fixed_in_build}) not in "
f"{state.target_branch} buildroot — postponing {state.jira_issue}"
)
state.triage_result = OutputSchema(
resolution=Resolution.POSTPONED,
data=PostponedData(
summary=(
f"Rebuild of {data.package} waiting for {dep_component} "
f"({fixed_in_build}) to land in {state.target_branch} buildroot"
),
pending_issues=[dep_issue_key],
jira_issue=state.jira_issue,
package=data.package,
fix_version=data.fix_version,
cve_id=data.cve_id,
dependency_issue=dep_issue_key,
dependency_component=dep_component,
),
)
return "comment_in_jira"

async def consolidate_rebuild_siblings(state):
Expand Down Expand Up @@ -1149,6 +1211,7 @@ async def comment_in_jira(state):
workflow.add_step("verify_rebase_author", verify_rebase_author)
workflow.add_step("determine_target_branch", determine_target_branch_step)
workflow.add_step("check_cve_applicability", check_cve_applicability)
workflow.add_step("verify_rebuild_buildroot", verify_rebuild_buildroot)
workflow.add_step("consolidate_rebuild_siblings", consolidate_rebuild_siblings)
workflow.add_step("comment_in_jira", comment_in_jira)

Expand Down
1 change: 1 addition & 0 deletions ymir/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from enum import Enum

BREWHUB_URL = "https://brewhub.engineering.redhat.com/brewhub"
CENTOS_STREAM_KOJIHUB_URL = "https://kojihub.stream.centos.org/kojihub"

JIRA_SEARCH_PATH = "rest/api/3/search/jql"

Expand Down
126 changes: 106 additions & 20 deletions ymir/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
from mcp.types import CallToolResult, TextContent
from specfile.utils import EVR

from ymir.common.constants import BREWHUB_URL
from ymir.common.base_utils import is_cs_branch
from ymir.common.constants import BREWHUB_URL, CENTOS_STREAM_KOJIHUB_URL
from ymir.common.version_utils import construct_internal_branch_name, parse_rhel_version

logger = logging.getLogger(__name__)

FIXED_IN_BUILD_CUSTOM_FIELD = "customfield_10578"


class _MetaInjectingSession:
"""Transparent wrapper around ``ClientSession`` that injects ``meta``
Expand Down Expand Up @@ -149,37 +153,46 @@ async def mcp_tools(
raise


def _evr_from_build(build: dict) -> EVR:
"""Extract an EVR from a Koji build dict."""
return EVR(
epoch=build.get("epoch") or 0,
version=build["version"],
release=build["release"],
)


def _get_latest_koji_build(koji_url: str, tag: str, package: str) -> dict | None:
"""Query a single Koji tag for the latest build of *package*."""
builds = koji.ClientSession(koji_url).listTagged(
package=package,
tag=tag,
latest=True,
inherit=True,
strict=False,
)
return builds[0] if builds else None


def _get_koji_build(koji_url: str, nvr: str) -> dict | None:
"""Look up a build by NVR on the given Koji instance."""
return koji.ClientSession(koji_url).getBuild(nvr)


async def get_latest_candidate_build(package: str, dist_git_branch: str) -> tuple[EVR, str]:
candidate_tags = [
f"{dist_git_branch}-candidate",
f"{dist_git_branch}-z-candidate",
]

def get_latest_build(tag):
builds = koji.ClientSession(BREWHUB_URL).listTagged(
package=package,
tag=tag,
latest=True,
inherit=True,
strict=False,
)
if not builds:
return None
[build] = builds
return build

results = await asyncio.gather(
*(asyncio.to_thread(get_latest_build, tag) for tag in candidate_tags),
*(asyncio.to_thread(_get_latest_koji_build, BREWHUB_URL, tag, package) for tag in candidate_tags),
)
latest = None
for build in results:
if build is None:
continue
evr = EVR(
epoch=build["epoch"] or 0,
version=build["version"],
release=build["release"],
)
evr = _evr_from_build(build)
if latest is None or latest[0] < evr:
latest = (evr, build["build_id"])
if latest is None:
Expand All @@ -189,3 +202,76 @@ def get_latest_build(tag):
metadata = await asyncio.to_thread(session.getBuild, build_id, strict=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It would be great to use _get_koji_build() here, but that would require more changes as the lookup here is by build ID, not NVR.

source_ref = metadata["source"].split("#")[-1]
return evr, source_ref


def _resolve_buildroot_checks(target_branch: str, fix_version: str) -> list[tuple[str, str]]:
"""Return a list of (koji_hub_url, build_tag) pairs to verify.

For CS branches with a Z-stream fix_version, both the CS Koji
buildroot and the Brew Z-stream buildroot are checked (CS-first
approach produces two builds). For internal RHEL branches with
a Z-stream fix_version, only the Brew Z-stream buildroot is checked.
"""
is_zstream = fix_version.lower().endswith(".z")

if is_cs_branch(target_branch):
checks = [(CENTOS_STREAM_KOJIHUB_URL, f"{target_branch}-build")]
if is_zstream and (parsed := parse_rhel_version(fix_version)):
major, minor, _ = parsed
rhel_branch = construct_internal_branch_name(major, minor)
checks.append((BREWHUB_URL, f"{rhel_branch}-z-build"))
return checks

suffix = "-z-build" if is_zstream else "-build"
return [(BREWHUB_URL, f"{target_branch}{suffix}")]


async def check_build_in_buildroot(
target_branch: str,
dep_component: str,
fixed_in_build_nvr: str,
fix_version: str = "",
) -> bool:
"""Check if the dependency's fixed build (or newer) is in all relevant buildroots.

Queries the appropriate Koji instance(s) based on ``target_branch`` and
``fix_version``. For CS Z-stream fixes, both the CS Koji and Brew
Z-stream buildroots are checked.
"""
checks = _resolve_buildroot_checks(target_branch, fix_version)

# Always resolve the fixed build's epoch from Brew — the NVR in
# Jira's "Fixed in Build" is a Brew NVR (e.g. .el9_8) and may
# not exist in CS Koji (which uses .el9).
fixed_build_future = asyncio.to_thread(_get_koji_build, BREWHUB_URL, fixed_in_build_nvr)
tag_futures = [
asyncio.to_thread(_get_latest_koji_build, koji_url, build_tag, dep_component)
for koji_url, build_tag in checks
]
results = await asyncio.gather(fixed_build_future, *tag_futures)
fixed_build = results[0]
tag_results = list(zip([tag for _, tag in checks], results[1:], strict=True))

if not fixed_build:
logger.warning(f"Build {fixed_in_build_nvr} not found in Koji")
return False

fixed_evr = _evr_from_build(fixed_build)

for build_tag, latest in tag_results:
if not latest:
logger.info(f"No builds of {dep_component} found in {build_tag}")
return False

latest_evr = _evr_from_build(latest)

if latest_evr >= fixed_evr:
logger.info(f"{dep_component} in {build_tag}: {latest['nvr']} >= {fixed_in_build_nvr}")
else:
logger.info(
f"{dep_component} in {build_tag}: "
f"{latest['nvr']} < {fixed_in_build_nvr} — not yet in buildroot"
)
return False

return True
8 changes: 8 additions & 0 deletions ymir/common/version_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ def parse_branch_name(branch: str) -> tuple[str, str] | None:
return match.group(1), match.group(2)


def construct_internal_branch_name(major_version: str, minor_version: str) -> str:
"""Construct internal RHEL branch name (e.g. rhel-9.8.0 or rhel-10.3)."""
branch = f"rhel-{major_version}.{minor_version}"
if int(major_version) < 10:
branch += ".0"
return branch


def normalize_fix_version(fix_version: str, rhel_config: dict) -> str:
"""
Normalize a stale Y-stream fixVersion to its Z-stream equivalent.
Expand Down
Loading