diff --git a/ymir/agents/rebuild_consolidation.py b/ymir/agents/rebuild_consolidation.py index 4f2f13196..3100e8e61 100644 --- a/ymir/agents/rebuild_consolidation.py +++ b/ymir/agents/rebuild_consolidation.py @@ -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__) @@ -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) diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 8c2846bf4..a53c7f17f 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -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. @@ -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: @@ -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 @@ -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( @@ -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: @@ -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): @@ -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) diff --git a/ymir/common/constants.py b/ymir/common/constants.py index 7ef6bbe0d..1ebbf2097 100644 --- a/ymir/common/constants.py +++ b/ymir/common/constants.py @@ -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" diff --git a/ymir/common/utils.py b/ymir/common/utils.py index 11ca15925..78d3daee1 100644 --- a/ymir/common/utils.py +++ b/ymir/common/utils.py @@ -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`` @@ -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: @@ -189,3 +202,76 @@ def get_latest_build(tag): metadata = await asyncio.to_thread(session.getBuild, build_id, strict=True) 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 diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index fcd864335..0130b96d0 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -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.