From a6e1b254bacc6c2069f2f4cd7f0651bd5c1a62c0 Mon Sep 17 00:00:00 2001 From: Anton Bobrov Date: Fri, 3 Apr 2026 15:43:34 +0200 Subject: [PATCH] Add support for backporting to RHEL z-streams --- agents/backport_agent.py | 453 +++++++++++++++++++++--- agents/merge_request_agent.py | 8 +- agents/rebase_agent.py | 11 +- agents/tests/unit/test_backporting.py | 20 -- agents/tests/unit/test_tools.py | 36 ++ agents/tools/specfile.py | 2 +- agents/triage_agent.py | 239 +++++++++++-- mcp_server/lookaside_tools.py | 60 +++- mcp_server/tests/unit/test_lookaside.py | 70 +++- 9 files changed, 783 insertions(+), 116 deletions(-) delete mode 100644 agents/tests/unit/test_backporting.py diff --git a/agents/backport_agent.py b/agents/backport_agent.py index dd580b22..57a1559b 100644 --- a/agents/backport_agent.py +++ b/agents/backport_agent.py @@ -3,12 +3,10 @@ import logging import os import sys -import re import traceback from pathlib import Path from typing import Any -import aiohttp from pydantic import BaseModel, Field from beeai_framework.agents.requirement import RequirementAgent @@ -40,7 +38,7 @@ BackportData, ErrorData, ) -from common.utils import redis_client, fix_await +from common.utils import redis_client, fix_await, is_cs_branch from constants import I_AM_JOTNAR, CAREFULLY_REVIEW_CHANGES from observability import setup_observability from tools.commands import RunShellCommandTool @@ -78,14 +76,15 @@ get_tool_call_checker_config, mcp_tools, render_prompt, + run_tool, ) +from common.version_utils import is_older_zstream from specfile import Specfile logger = logging.getLogger(__name__) -def get_instructions() -> str: - return """ +BACKPORT_INSTRUCTIONS = """ You are an expert on backporting upstream patches to packages in RHEL ecosystem. To backport upstream patches to package in dist-git branch , do the following: @@ -292,6 +291,199 @@ def get_instructions() -> str: - Never abort the existing git am session. """ +BACKPORT_INSTRUCTIONS_ZSTREAM = """ + You are an expert on backporting upstream patches to packages in RHEL ecosystem. + + To backport upstream patches to package in dist-git branch , do the following: + + CRITICAL: Do NOT modify, delete, or touch any existing patches in the dist-git repository. + Only add new patches for the current backport. Existing patches are there for a reason + and must remain unchanged. + + 1. Knowing Jira issue , CVE ID or both, use the `git_log_search` tool to check + in the dist-git repository whether the issue/CVE has already been resolved. If it has, + end the process with `success=True` and `status="Backport already applied"`. + + 2. Use the `git_prepare_package_sources` tool to prepare package sources in directory + for application of the upstream patch. + + 3. Determine which backport approach to use: + + A. CHERRY-PICK WORKFLOW (Preferred - try this first): + + IMPORTANT: This workflow uses TWO separate git repositories: + - : Git repository (from Step 2) containing unpacked and committed upstream sources + - : A temporary upstream repository clone (created in step 3c with -upstream suffix) + + When to use this workflow: + - is a list of commit or pull request URLs + - This includes URLs with .patch suffix (e.g., https://github.com/.../commit/abc123.patch) + - If URL extraction fails, fall back to approach B + + 3a. Extract upstream repository information: + - Use `extract_upstream_repository` tool with the upstream fix URL + - This extracts the repository URL and commit hash + - If extraction fails, fall back to approach B + + 3b. Get package information from dist-git: + - Use `get_package_info` tool with the spec file path from + - This provides the package version and list of existing patch filenames + + 3c. Clone the upstream repository to a SEPARATE directory: + - Use `clone_upstream_repository` tool with: + * repository_url: from step 3a + * clone_directory: current working directory (the dist-git repository root) + * The tool automatically creates a directory with -upstream suffix as + - Steps 3d-3g work in , NOT in + + 3d. Find and checkout the base version in upstream: + - Use `find_base_commit` tool with path and package version from 3b + - IMPORTANT: Save this base version commit hash using `run_shell_command`: + `git -C rev-parse HEAD` - store this as UPSTREAM_BASE + - If no matching tag found, try to find the base commit manually using `view` and `run_shell_command` tools + - Look for any tags or commits that might correspond to the package version + - Only fall back to approach B if you cannot find any reasonable base commit + + 3e. Apply existing patches from dist-git to upstream: + - Use `apply_downstream_patches` tool with: + * repo_path: (where to apply) + * patches_directory: current working directory (dist-git root where patch files are located) + * patch_files: list from step 3b + - This recreates the current package state in + - IMPORTANT: Save the current commit hash after applying patches using `run_shell_command`: + `git -C rev-parse HEAD` - store this as PATCHED_BASE for patch generation + - If any patch fails to apply, immediately fall back to approach B + + 3f. Cherry-pick the fix in upstream: + FOR PULL REQUESTS (if is_pr is True from step 3a): + * Download the PR patch to see all commits: `curl -L -o /tmp/pr.patch` + * Parse the patch file to extract commit hashes (lines starting with "From ") + Each commit appears as "From Mon Sep DD ..." and has "[PATCH XX/YY]" in subject + * You now have the exact list of commits that are part of the PR + * Fetch PR branch: `git -C fetch origin pull//head:pr-branch` + * Cherry-pick each commit from the list, starting from the first (oldest) + * When conflicts occur (EXPECTED when backporting to older version): + - Understand what the commit is trying to do and why it conflicts + - Examine what's different between old and current version + - Identify if the commit depends on changes that aren't in the dist-git version: + * Missing helper functions, types, or macros + * API changes that happened between versions + * Structural changes to the codebase + * Test file reorganization (tests split/merged into different files) + - If prerequisites are missing, you have options: + * Cherry-pick the prerequisite commits first (from upstream history between dist-git version and PR) + * Or adapt the code to work without them (rewrite to use older APIs) + * Or manually backport just the needed helper functions + - For test file conflicts due to reorganization: + * NEVER SKIP TEST COMMITS - tests validate that your fix actually works! + * Check if test files exist in different locations in the old version + * Use git log in upstream repo to trace test file movements: `git -C log --follow --all -- path/to/test_file` + * Merge test changes into existing test files that match the old structure + * Adapt test code to work with older test frameworks or patterns + * Don't skip tests just because file paths don't match - adapt them! + * For CVE fixes: tests often demonstrate the vulnerability - they're CRITICAL + - If adding NEW test files, ensure they're integrated into the build system: + check Makefile/CMakeLists.txt/meson.build and add to test lists if needed, + or verify they follow auto-discovery naming conventions (test_*.py, *_test.c) + - Intelligently adapt the changes to make them work with the older codebase + * Continue until all PR commits are successfully cherry-picked and adapted + + FOR SINGLE COMMITS (if is_pr is False): + * Use commit_hash from step 3a + * Cherry-pick this single commit + + CHERRY-PICKING PROCESS (ONE commit at a time - NEVER multiple at once): + 1. Cherry-pick ONE commit: `cherry_pick_commit` tool with ONE commit hash + 2. If conflicts occur (NORMAL for backporting): + a. View conflicting files to understand what's needed + b. Intelligently resolve by editing files with `str_replace`: + - Understand what the commit does + - Adapt to older codebase + - Add missing helpers if needed + - Rewrite to use older APIs if needed + - Prioritize preserving the patch's original logic. The final backport must still fix the original bug. + c. Stage ALL resolved files: `git -C add ` for each file + d. Complete cherry-pick: `cherry_pick_continue` tool + 3. CRITICAL: Only move to next commit after current one is FULLY COMPLETE + 4. NEVER try to cherry-pick multiple commits at once + 5. Do NOT fall back to approach B - keep cherry-picking through all PR commits + 6. NEVER skip any commits - all commits must be adapted and cherry-picked + + 3g. Generate the final patch file from upstream: + - Use `generate_patch_from_commit` tool on + - Specify output_directory as current working directory (the dist-git repository root) + - Use a descriptive name like .patch (e.g., if JIRA is RHEL-114639, use RHEL-114639.patch) + - CRITICAL: Provide base_commit parameter with the PATCHED_BASE from step 3e + This ensures the patch includes ALL cherry-picked commits, not just the last one + - IMPORTANT: Only create NEW patch files. Do NOT modify existing patches in the dist-git repository + - This patch file is now ready to be added to the spec file + + 3h. The cherry-pick workflow is complete! The generated patch file contains the cleanly + cherry-picked fix. Continue with steps 4-6 below to add this patch to the spec file, + verify it with ` prep`, and build the SRPM. + + Note: You do NOT need to apply this patch to . The patch file + will be automatically applied during the RPM build process when you run ` prep`. + + B. GIT AM WORKFLOW (Fallback approach): + + Note: For this workflow, use the pre-downloaded patch files in the current working directory. + They are called `-.patch` where is a 0-based index. For example, + for a `RHEL-12345` Jira issue the first patch would be called `RHEL-12345-0.patch`. + + Backport all patches individually using the steps 3a and 3b below. + + 3a. Backport one patch at a time using the following steps: + - Use the `git_patch_apply` tool with the patch file: -.patch + - Resolve all conflicts and leave the repository in a dirty state. Delete all *.rej files. + - Use the `git_apply_finish` tool to finish the patch application. + + 3b. Once there are no more conflicts, use the `git_patch_create` tool with the patch file path + -.patch to update the patch file. + + 4. Update the spec file. Add a new `Patch` tag for every patch in . + Add the new `Patch` tag after all existing `Patch` tags and, if `Patch` tags are numbered, + make sure it has the highest number. Make sure the patch is applied in the "%prep" section + and the `-p` argument is correct. Add an upstream URL as a comment above + the `Patch:` tag - this URL references the related upstream commit or a pull/merge request. + Include every patch defined in list. + IMPORTANT: Only ADD new patches. Do NOT modify existing Patch tags or their order. Do NOT + add or change any changelog entries. Do NOT change the Release field. + + 5. Run ` --name= --namespace=rpms --release= prep` to see if the new patch + applies cleanly. When `prep` command finishes with "exit 0", it's a success. Ignore errors from + libtoolize that warn about newer files: "use '--force' to overwrite". + Note: is `centpkg` for CentOS Stream branches (c9s, c10s) and `rhpkg` for RHEL branches. + + 6. Generate a SRPM using ` --name= --namespace=rpms --release= srpm`. + + + General instructions: + + - If necessary, you can run `git checkout -- ` to revert any changes done to . + - Never change anything in the spec file changelog. + - Never change the Release field in the spec file. + - Preserve existing formatting and style conventions in spec files and patch headers. + - Prefer native tools, if available, the `run_shell_command` tool should be the last resort. + - Ignore all changes that cause conflicts in the following kinds of files: .github/ workflows, .gitignore, news, changes, and internal documentation. + - Apply all changes that modify the core library of the package, and all binaries, manpages, and user-facing documentation. + - For more information how the package is being built, inspect the RPM spec file and read sections `%prep` and `%build`. + - If there is a complex conflict, you are required to properly resolve it by applying the core functionality of the proposed patch. + - When a tool explicitly says "Abort cherry-pick approach, use git am workflow", immediately switch to approach B. + - When using the cherry-pick workflow, you have access to (the cloned upstream repository). + You can explore it to find clues for resolving conflicts: examine commit history, related changes, + documentation, test files, or similar fixes that might help understand the proper resolution. + - Never apply the patches yourself, always use the `git_patch_apply` tool. + - Never run `git am --skip`, always use the `git_apply_finish` tool instead. + - Never abort the existing git am session. + """ + + +async def get_instructions(fix_version: str | None = None) -> str: + if fix_version and await is_older_zstream(fix_version): + return BACKPORT_INSTRUCTIONS_ZSTREAM + return BACKPORT_INSTRUCTIONS + def get_prompt() -> str: return """ @@ -316,8 +508,7 @@ def get_prompt() -> str: """ -def get_fix_build_error_prompt() -> str: - return """ +BACKPORT_FIX_BUILD_ERROR_PROMPT = """ Your working directory is {{local_clone}}, a clone of dist-git repository of package {{package}}. {{dist_git_branch}} dist-git branch has been checked out. You are working on Jira issue {{jira_issue}} {{#cve_id}}(a.k.a. {{.}}){{/cve_id}}. @@ -447,9 +638,147 @@ def get_fix_build_error_prompt() -> str: The upstream repository at {{local_clone}}-upstream is your playground - explore it freely! """ +BACKPORT_FIX_BUILD_ERROR_PROMPT_ZSTREAM = """ + Your working directory is {{local_clone}}, a clone of dist-git repository of package {{package}}. + {{dist_git_branch}} dist-git branch has been checked out. You are working on Jira issue {{jira_issue}} + {{#cve_id}}(a.k.a. {{.}}){{/cve_id}}. + + Upstream patches that were backported: + {{#upstream_patches}} + - {{.}} + {{/upstream_patches}} + + The backport of upstream patches was initially successful using the cherry-pick workflow, + but the build failed with the following error: + + {{build_error}} + + CRITICAL: The upstream repository ({{local_clone}}-upstream) still exists with all your previous work intact. + DO NOT clone it again. DO NOT reset to base commit. DO NOT modify anything in {{local_clone}} dist-git repository. + Your cherry-picked commits are still there in {{local_clone}}-upstream. + + The package built successfully before your patches were added - the spec file and build configuration are correct. + Your task is to fix this build error by improving the patches - NOT by modifying the spec file. + This includes BOTH compilation errors AND test failures during the check section. + Make ONE attempt to fix the issue - you will be called again if the build still fails. + + Follow these steps: + + STEP 1: Analyze the build error + - Identify if it's a compilation error (undefined symbols, headers) or test failure (in check section) + - Identify what's missing: undefined functions, types, macros, symbols, headers, or API changes + - Look for patterns like "undefined reference", "implicit declaration", "undeclared identifier", etc. + - Note the specific names of missing symbols + + STEP 2: Explore the upstream repository for solutions + You have FULL ACCESS to the upstream repository ({{local_clone}}-upstream) as a reference: + + - Examine the history between versions: + * `git -C {{local_clone}}-upstream log --oneline ..` + + - Search for how missing symbols are implemented: + * Search in commit messages: `git -C {{local_clone}}-upstream log --all --grep="function_name" --oneline` + * Search in code changes: `git -C {{local_clone}}-upstream log --all -S"function_name" --oneline` + * Show commit details: `git -C {{local_clone}}-upstream show ` + + - Look at current implementation in newer versions: + * View files to see how things work: `view` tool on files in {{local_clone}}-upstream + * Understand the context and dependencies + * See how the code evolved over time + + - Explore related changes: + * Check header files, documentation, tests + * Look for API changes, refactorings, helper functions + * Understand the bigger picture + + STEP 3: Choose the best fix approach + You have TWO options for fixing the issue: + + OPTION A: Cherry-pick prerequisite commits + - If you find clean, self-contained commits that add what's missing + - Use `cherry_pick_commit` tool ONE commit at a time (chronological order, oldest first) + - Resolve conflicts using `str_replace` tool + - Stage resolved files: `git -C {{local_clone}}-upstream add ` + - Complete cherry-pick: use `cherry_pick_continue` tool + + OPTION B: Manually adapt the code + - If cherry-picking would pull in too many dependencies + - If the commit doesn't apply cleanly and needs significant adaptation + - If you need to backport just a small piece of functionality + - Directly edit files in {{local_clone}}-upstream using `str_replace` or `insert` tools + - Make minimal changes to fix the specific build error + - Commit your changes: `git -C {{local_clone}}-upstream add ` then + `git -C {{local_clone}}-upstream commit -m "Manually backport: "` + + You can MIX both approaches: + - Cherry-pick some commits, then manually adapt code where needed + - Use the upstream repo as a reference while writing your own backport + + SPECIAL CONSIDERATIONS FOR TEST FAILURES: + - Tests validate the fix - they MUST pass + - If tests use missing functions/helpers: backport ONLY the minimal necessary test helpers + (search upstream history for test utility commits and cherry-pick or manually add them) + - If tests fail due to API changes: adapt test code to work with older APIs + - NEVER skip or disable tests - fix them instead + + STEP 4: Regenerate the patch + - After making your fixes (cherry-picked or manual), regenerate the patch file + - First, delete the old patch file: `rm {{local_clone}}/{{jira_issue}}.patch` + - Use `generate_patch_from_commit` tool with the PATCHED_BASE commit + - This creates a single patch with all changes: original commits + prerequisites/fixes + - Save it as {{jira_issue}}.patch in {{local_clone}} + - This improved patch now includes all missing dependencies needed for a successful build + + STEP 5: Test the build + - The spec file should already reference {{jira_issue}}.patch + - Run `{{pkg_tool}} --name={{package}} --namespace=rpms --release={{dist_git_branch}} prep` to verify patch applies + - Run `{{pkg_tool}} --name={{package}} --namespace=rpms --release={{dist_git_branch}} srpm` to generate SRPM + - Test if the SRPM builds successfully using the `build_package` tool: + * Call build_package with the SRPM path, dist_git_branch, and jira_issue + * Wait for build results + * If build PASSES: Report success=true with the SRPM path + * If build FAILS: Use `download_artifacts` to get build logs if available + * Extract the new error message from the logs: + - IMPORTANT: Before viewing log files, check their size using `wc -l` command + - If a log file has more than 2000 lines, use the view tool with offset and limit + parameters to read only the LAST 1000 lines (calculate offset as total_lines - 1000, limit as 1000) + - Build failures are almost always at the end of logs, avoiding context overflow + - Alternatively, use the `search_text` tool to search for error patterns (e.g., "ERROR", "FAILED", "error:", "fatal:") + and then use the view tool to read targeted sections around the matching line numbers + - Combine strategies as needed to understand the failure without reading the entire file + * Report success=false with the extracted error + + Report your results: + - If build passes → Report success=true with the SRPM path + - If build fails → Report success=false with the extracted error message + - If you can't find a fix → Report success=false explaining why + + IMPORTANT RULES: + - Work in the EXISTING {{local_clone}}-upstream directory (don't clone again) + - NEVER modify the spec file - build failures are caused by incomplete patches, not spec issues + - The ONLY dist-git file you can modify is {{jira_issue}}.patch (by regenerating it from upstream repo) + - Fix build errors (compilation AND test failures) by adding missing prerequisites/dependencies to your patches in upstream repo + - For test failures: backport minimal necessary test helpers/functions to make tests pass + - You can freely explore, edit, cherry-pick, and commit in the upstream repo - it's your workspace + - Use the upstream repo as a rich source of information and examples + - Be creative and pragmatic - the goal is a working build with passing tests, not perfect git history + - Make ONE solid attempt to fix the issue - if the build fails, report the error clearly + - Your work will persist in the upstream repo for the next attempt if needed + + Remember: Unpacked upstream sources are in {{unpacked_sources}}. + The upstream repository at {{local_clone}}-upstream is your playground - explore it freely! + """ + + +async def get_fix_build_error_prompt(fix_version: str | None = None) -> str: + if fix_version and await is_older_zstream(fix_version): + return BACKPORT_FIX_BUILD_ERROR_PROMPT_ZSTREAM + return BACKPORT_FIX_BUILD_ERROR_PROMPT + -def create_backport_agent( - mcp_tools: list[Tool], local_tool_options: dict[str, Any], include_build_tools: bool = False +async def create_backport_agent( + mcp_tools: list[Tool], local_tool_options: dict[str, Any], + include_build_tools: bool = False, fix_version: str | None = None, ) -> RequirementAgent: """ Create a backport agent. @@ -459,6 +788,7 @@ def create_backport_agent( local_tool_options: Options for local tools include_build_tools: If True, include build_package and download_artifacts tools for iterative build testing during error fixing + fix_version: Fix version string for z-stream instruction selection """ base_tools = [ ThinkTool(), @@ -510,7 +840,7 @@ def create_backport_agent( ], middlewares=[GlobalTrajectoryMiddleware(pretty=True)], role="Red Hat Enterprise Linux developer", - instructions=get_instructions(), + instructions=await get_instructions(fix_version), # role and instructions above set defaults for the system prompt input # but the `RequirementAgentSystemPrompt` instance is shared so the defaults # affect all requirement agents - use our own copy to prevent that @@ -518,26 +848,6 @@ def create_backport_agent( ) -def get_unpacked_sources(local_clone: Path, package: str) -> Path: - """ - Get a path to the root of extracted archive directory tree (referenced as TLD - in RPM documentation) for a given package. - - That's the place where we'll initiate the backporting process. - """ - with Specfile(local_clone / f"{package}.spec") as spec: - buildsubdir = spec.expand("%{buildsubdir}") - if "/" in buildsubdir: - # Sooner or later we'll run into a package where this will break. Sorry. - # More details: https://github.com/packit/jotnar/issues/217 - buildsubdir = buildsubdir.split("/")[0] - sources_dir = local_clone / buildsubdir - - if not sources_dir.exists(): - raise ValueError(f"Unpacked source directory does not exist: {sources_dir}") - - return sources_dir - async def main() -> None: logging.basicConfig(level=logging.INFO) @@ -561,14 +871,17 @@ class State(PackageUpdateState): attempts_remaining: int = Field(default=max_build_attempts) used_cherry_pick_workflow: bool = Field(default=False) # Track if cherry-pick was used incremental_fix_attempts: int = Field(default=0) # Track how many times we tried incremental fix + fix_version: str | None = Field(default=None) async def run_workflow( - package, dist_git_branch, upstream_patches, jira_issue, cve_id, redis_conn=None + package, dist_git_branch, upstream_patches, jira_issue, cve_id, + fix_version=None, redis_conn=None, ): local_tool_options["working_directory"] = None async with mcp_tools(os.environ["MCP_GATEWAY_URL"]) as gateway_tools: - backport_agent = create_backport_agent(gateway_tools, local_tool_options) + backport_agent = await create_backport_agent( + gateway_tools, local_tool_options, fix_version=fix_version) build_agent = create_build_agent(gateway_tools, local_tool_options) log_agent = create_log_agent(gateway_tools, local_tool_options) @@ -600,23 +913,31 @@ async def fork_and_prepare_dist_git(state): available_tools=gateway_tools, ) local_tool_options["working_directory"] = state.local_clone - centpkg_cmd = ["centpkg", f"--name={state.package}", "--namespace=rpms", f"--release={state.dist_git_branch}"] - await check_subprocess(centpkg_cmd + ["sources"], cwd=state.local_clone) - await check_subprocess(centpkg_cmd + ["prep"], cwd=state.local_clone) - state.unpacked_sources = get_unpacked_sources(state.local_clone, state.package) - timeout = aiohttp.ClientTimeout(total=30) - async with aiohttp.ClientSession(timeout=timeout) as session: - for idx, upstream_patch in enumerate(state.upstream_patches): - # should we guess the patch name with log agent? - patch_name = f"{state.jira_issue}-{idx}.patch" - async with session.get(upstream_patch) as response: - if response.status < 400: - (state.local_clone / patch_name).write_text(await response.text()) - else: - raise ValueError(f"Failed to fetch upstream patch: {response.status}") + await run_tool( + "download_sources", available_tools=gateway_tools, + dist_git_path=str(state.local_clone), + package=state.package, + dist_git_branch=state.dist_git_branch, + ) + state.unpacked_sources = Path(await run_tool( + "prep_sources", available_tools=gateway_tools, + dist_git_path=str(state.local_clone), + package=state.package, + dist_git_branch=state.dist_git_branch, + )) + for idx, upstream_patch in enumerate(state.upstream_patches): + # should we guess the patch name with log agent? + patch_name = f"{state.jira_issue}-{idx}.patch" + patch_content = await run_tool( + "get_patch_from_url", + available_tools=gateway_tools, + patch_url=upstream_patch, + ) + (state.local_clone / patch_name).write_text(patch_content) return "run_backport_agent" async def run_backport_agent(state): + pkg_tool = "centpkg" if is_cs_branch(state.dist_git_branch) else "rhpkg" response = await backport_agent.run( render_prompt( template=get_prompt(), @@ -629,6 +950,7 @@ async def run_backport_agent(state): cve_id=state.cve_id, upstream_patches=state.upstream_patches, build_error=state.build_error, + pkg_tool=pkg_tool, ), ), expected_output=BackportOutputSchema, @@ -674,12 +996,15 @@ async def fix_build_error(state): try: # Create a fresh backport agent with build tools enabled for iterative testing - fix_agent = create_backport_agent(gateway_tools, local_tool_options, include_build_tools=True) + fix_agent = await create_backport_agent( + gateway_tools, local_tool_options, + include_build_tools=True, fix_version=state.fix_version) # Give the agent the current build error and let it try to fix it + pkg_tool = "centpkg" if is_cs_branch(state.dist_git_branch) else "rhpkg" response = await fix_agent.run( render_prompt( - template=get_fix_build_error_prompt(), + template=await get_fix_build_error_prompt(fix_version=state.fix_version), input=BackportInputSchema( local_clone=state.local_clone, unpacked_sources=state.unpacked_sources, @@ -689,6 +1014,7 @@ async def fix_build_error(state): cve_id=state.cve_id, upstream_patches=state.upstream_patches, build_error=state.build_error, + pkg_tool=pkg_tool, ), ), expected_output=BackportOutputSchema, @@ -800,18 +1126,24 @@ async def update_release(state): async def stage_changes(state): try: - # Find patch files to stage based on workflow type - if state.used_cherry_pick_workflow: - # Cherry-pick workflow: only stage the single consolidated patch - # This avoids staging the pre-downloaded input patches (JIRA-12345-0.patch, etc.) - patch_file = state.local_clone / f"{state.jira_issue}.patch" - patch_files = [patch_file] if patch_file.exists() else [] - else: - # Git am workflow: stage all numbered patches (JIRA-12345-0.patch, JIRA-12345-1.patch, etc.) - patch_files = list(state.local_clone.glob(f"{state.jira_issue}-*.patch")) + # Use the spec file as the source of truth for which patches + # to commit. The agent may create temporary or pre-downloaded + # .patch files in the working directory that should NOT be + # committed; only patches referenced by Patch tags in the spec + # belong in the final commit. + spec_path = state.local_clone / f"{state.package}.spec" + with Specfile(spec_path) as spec: + with spec.patches() as patches: + patch_files = [p.expanded_location for p in patches if p.expanded_location] + + if not patch_files: + raise RuntimeError( + f"Backport completed but no Patch tags found " + f"in {spec_path}" + ) - files_to_git_add = [f"{state.package}.spec"] + [p.name for p in patch_files] - logger.info(f"Staging files (cherry_pick={state.used_cherry_pick_workflow}): {files_to_git_add}") + files_to_git_add = [f"{state.package}.spec"] + patch_files + logger.info(f"Staging files: {files_to_git_add}") await tasks.stage_changes( local_clone=state.local_clone, @@ -945,6 +1277,7 @@ async def comment_in_jira(state): upstream_patches=upstream_patches, jira_issue=jira_issue, cve_id=cve_id, + fix_version=fix_version, ), ) return response.state @@ -963,6 +1296,7 @@ async def comment_in_jira(state): upstream_patches=upstream_patches, jira_issue=jira_issue, cve_id=os.getenv("CVE_ID", None), + fix_version=branch, redis_conn=None, ) logger.info(f"Direct run completed: {state.backport_result.model_dump_json(indent=4)}") @@ -1025,6 +1359,7 @@ async def retry(task, error): upstream_patches=backport_data.patch_urls, jira_issue=backport_data.jira_issue, cve_id=backport_data.cve_id, + fix_version=backport_data.fix_version, redis_conn=redis, ) logger.info( diff --git a/agents/merge_request_agent.py b/agents/merge_request_agent.py index 4d75ec70..18aaf8cf 100644 --- a/agents/merge_request_agent.py +++ b/agents/merge_request_agent.py @@ -33,6 +33,7 @@ MergeRequestInputSchema, MergeRequestOutputSchema, ) +from common.utils import is_cs_branch from constants import I_AM_JOTNAR from observability import setup_observability from tools.commands import RunShellCommandTool @@ -62,14 +63,15 @@ def get_instructions() -> str: 2. If you updated the spec file, use `rpmlint .spec` to validate your changes and fix any new issues. - 3. Verify any changes to patches by running `centpkg --name= --namespace=rpms --release= prep`. + 3. Verify any changes to patches by running ` --name= --namespace=rpms --release= prep`. Repeat as necessary. Do not remove any patches unless all their hunks have been already applied to the upstream sources. + Note: is `centpkg` for CentOS Stream branches (c9s, c10s) and `rhpkg` for RHEL branches. 4. If you removed any patch file references from the spec file (e.g. because they were already applied upstream), you must remove all the corresponding patch files from the repository as well. - 5. Generate a SRPM using `centpkg --name= --namespace=rpms --release= srpm`. + 5. Generate a SRPM using ` --name= --namespace=rpms --release= srpm`. 6. In your output, provide a "files_to_git_add" list containing all files that have been modified, added or removed. This typically includes the updated spec file and any new/modified/deleted patch files or other files you've changed @@ -245,6 +247,7 @@ async def prepare_dist_git_from_mr(state): return "run_merge_request_agent" async def run_merge_request_agent(state): + pkg_tool = "centpkg" if is_cs_branch(state.dist_git_branch) else "rhpkg" response = await merge_request_agent.run( render_prompt( template=get_prompt(), @@ -259,6 +262,7 @@ async def run_merge_request_agent(state): comments=state.merge_request_comments, fedora_clone=state.fedora_clone, build_error=state.build_error, + pkg_tool=pkg_tool, ), ), expected_output=MergeRequestOutputSchema, diff --git a/agents/rebase_agent.py b/agents/rebase_agent.py index 77d0b475..e3a78efd 100644 --- a/agents/rebase_agent.py +++ b/agents/rebase_agent.py @@ -37,7 +37,7 @@ RebaseOutputSchema, Task, ) -from common.utils import redis_client, fix_await +from common.utils import redis_client, fix_await, is_cs_branch from constants import I_AM_JOTNAR, CAREFULLY_REVIEW_CHANGES from observability import setup_observability from tools.commands import RunShellCommandTool @@ -79,13 +79,14 @@ def get_instructions() -> str: 4. Use `rpmlint .spec` to validate your changes and fix any new issues. 5. Download upstream sources using `spectool -g -S .spec`. - Run `centpkg --name= --namespace=rpms --release= prep` + Run ` --name= --namespace=rpms --release= prep` to see if everything is in order. It is possible that some *.patch files will fail to apply now that the spec file has been updated. Don't jump to conclusions - if one patch fails to apply, it doesn't mean all other patches fail to apply as well. Go through the errors one by one, fix them and verify the changes - by running `centpkg --name= --namespace=rpms --release= prep` again. + by running ` --name= --namespace=rpms --release= prep` again. Repeat as necessary. Do not remove any patches unless all their hunks have been already applied to the upstream sources. + Note: is `centpkg` for CentOS Stream branches (c9s, c10s) and `rhpkg` for RHEL branches. 6. Upload new upstream sources (files that the `spectool` command downloaded in the previous step) to lookaside cache using the `upload_sources` tool. @@ -93,7 +94,7 @@ def get_instructions() -> str: 7. If you removed any patch file references from the spec file (e.g. because they were already applied upstream), you must remove all the corresponding patch files from the repository as well. - 8. Generate a SRPM using `centpkg --name= --namespace=rpms --release= srpm`. + 8. Generate a SRPM using ` --name= --namespace=rpms --release= srpm`. 9. In your output, provide a "files_to_git_add" list containing all files that should be git added for this rebase. This typically includes the updated spec file and any new/modified/deleted patch files or other files you've changed @@ -241,6 +242,7 @@ async def fork_and_prepare_dist_git(state): async def run_rebase_agent(state): package_instructions = await get_package_instructions(state.package, "rebase") + pkg_tool = "centpkg" if is_cs_branch(state.dist_git_branch) else "rhpkg" response = await rebase_agent.run( render_prompt( template=get_prompt(), @@ -253,6 +255,7 @@ async def run_rebase_agent(state): jira_issue=state.jira_issue, build_error=state.build_error, package_instructions=package_instructions, + pkg_tool=pkg_tool, ), ), expected_output=RebaseOutputSchema, diff --git a/agents/tests/unit/test_backporting.py b/agents/tests/unit/test_backporting.py deleted file mode 100644 index 041dfafb..00000000 --- a/agents/tests/unit/test_backporting.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest -from flexmock import flexmock -from specfile import Specfile - -from agents.backport_agent import get_unpacked_sources - -@pytest.mark.parametrize( - "buildsubdir", - [ - "minimal", - "minimal/baz", - ], -) -def test_get_unpacked_sources(tmp_path, buildsubdir, minimal_spec): - assert minimal_spec.exists() - flexmock(Specfile).should_receive("expand").and_return(buildsubdir) - (tmp_path / buildsubdir).mkdir(parents=True) - result = get_unpacked_sources(tmp_path, "minimal") - - assert result == tmp_path / "minimal" diff --git a/agents/tests/unit/test_tools.py b/agents/tests/unit/test_tools.py index bc69c961..3bee2644 100644 --- a/agents/tests/unit/test_tools.py +++ b/agents/tests/unit/test_tools.py @@ -140,6 +140,9 @@ async def test_add_changelog_entry(minimal_spec): "fix-memory-leak.patch", "update-documentation.patch" ]), + ("spec_with_macro_patches", "3.5.3", [ + "mypackage-3.5.3-Fix-CVE-2026-4111.patch", + ]), ("minimal_spec", "0.1", []), ], ) @@ -216,6 +219,39 @@ def spec_with_patches(tmp_path): return spec +@pytest.fixture +def spec_with_macro_patches(tmp_path): + spec = tmp_path / "macro_patches.spec" + source_file = tmp_path / "source.tar.gz" + source_file.touch() + spec.write_text( + dedent( + """ + Name: mypackage + Version: 3.5.3 + Release: 1%{?dist} + Summary: Test package + + License: MIT + + Source0: source.tar.gz + Patch0: %{name}-%{version}-Fix-CVE-2026-4111.patch + + %description + Test package with macro-containing patch names + + %prep + %autosetup -p1 + + %changelog + * Thu Jan 13 3770 Test User - 3.5.3-1 + - first version + """ + ) + ) + return spec + + @pytest.mark.parametrize( "rebase", [False, True], diff --git a/agents/tools/specfile.py b/agents/tools/specfile.py index 6097a538..f8c1011f 100644 --- a/agents/tools/specfile.py +++ b/agents/tools/specfile.py @@ -64,7 +64,7 @@ async def _run( with Specfile(spec_path) as spec: version = spec.version with spec.patches() as patches: - patch_files = [p.location for p in patches if p.location] + patch_files = [p.expanded_location for p in patches if p.expanded_location] return GetPackageInfoToolOutput( result=PackageInfo( diff --git a/agents/triage_agent.py b/agents/triage_agent.py index d24a1eec..2c80179c 100644 --- a/agents/triage_agent.py +++ b/agents/triage_agent.py @@ -1,7 +1,6 @@ import asyncio import logging import os -import re import sys import traceback from textwrap import dedent @@ -23,6 +22,7 @@ import agents.tasks as tasks from common.config import load_rhel_config +from common.version_utils import parse_rhel_version, is_older_zstream from common.models import ( Task, TriageInputSchema as InputSchema, @@ -40,6 +40,7 @@ from agents.tools.patch_validator import PatchValidatorTool from agents.tools.version_mapper import VersionMapperTool from agents.tools.upstream_search import UpstreamSearchTool +from agents.tools.zstream_search import ZStreamSearchTool from agents.utils import get_agent_execution_config, get_chat_model, get_tool_call_checker_config, mcp_tools, run_tool logger = logging.getLogger(__name__) @@ -86,21 +87,26 @@ async def _map_version_to_branch(version: str, cve_needs_internal_fix: bool, pac - RHEL internal fix: rhel-{major}.{minor}.0 (for RHEL 10, without .0 suffix) - CentOS Stream: c{major}s """ - version_match = re.match(r"^rhel-(\d+)\.(\d+)(\.z)?", version.lower()) - if not version_match: + parsed = parse_rhel_version(version) + if not parsed: logger.warning(f"Failed to parse version: {version}") return None - major_version = version_match.group(1) - minor_version = version_match.group(2) - is_zstream = version_match.group(3) is not None + major_version, minor_version, is_zstream = parsed # Load rhel-config to check which major versions have Y-stream mappings config = await load_rhel_config() y_streams = config.get("current_y_streams", {}) + current_z_streams = config.get("current_z_streams", {}) + # Check if this is an older z-stream than the current one + older_zstream = await is_older_zstream(version, current_z_streams) + if older_zstream: + logger.info(f"Detected older z-stream: {version}") - if cve_needs_internal_fix: + # Only apply special CVE handling if NOT targeting an older z-stream + # 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) logger.info(f"Mapped {version} -> {branch} (CVE internal fix)") @@ -110,24 +116,27 @@ async def _map_version_to_branch(version: str, cve_needs_internal_fix: bool, pac logger.info(f"Mapped {version} -> {branch} (CentOS Stream)") return branch - # For Z-stream bugs, check if internal RHEL branch exists - if is_zstream and package: + # For Z-stream bugs, always use internal RHEL branch + # Check if branch exists, but use it anyway since it will be created later if needed + if is_zstream or older_zstream: expected_branch = _construct_internal_branch_name(major_version, minor_version) - try: - async with mcp_tools(os.getenv("MCP_GATEWAY_URL")) as gateway_tools: - available_branches = await run_tool( - "get_internal_rhel_branches", - available_tools=gateway_tools, - package=package - ) + if package: + try: + async with mcp_tools(os.getenv("MCP_GATEWAY_URL")) as gateway_tools: + available_branches = await run_tool( + "get_internal_rhel_branches", + available_tools=gateway_tools, + package=package + ) + + if expected_branch not in available_branches: + logger.info(f"Branch {expected_branch} does not exist for package {package}") + except Exception as e: + logger.warning(f"Failed to check internal branches for package {package}: {e}") - if expected_branch in available_branches: - logger.info(f"Mapped {version} -> {expected_branch} (Z-stream with internal branch)") - return expected_branch - logger.info(f"Internal branch {expected_branch} not found for package {package}, falling back to Stream") - except Exception as e: - logger.warning(f"Failed to check internal branches for package {package}: {e}, falling back to Stream") + logger.info(f"Mapped {version} -> {expected_branch} (Z-stream RHEL internal branch)") + return expected_branch # Default to CentOS Stream branch = f"c{major_version}s" @@ -138,8 +147,7 @@ async def _map_version_to_branch(version: str, cve_needs_internal_fix: bool, pac # All schemas are now imported from common.models -def render_prompt(input: InputSchema) -> str: - template = """ +TRIAGE_PROMPT = """ You are an agent tasked to analyze Jira issues for RHEL and identify the most efficient path to resolution, whether through a version rebase, a patch backport, or by requesting clarification when blocked. @@ -286,6 +294,168 @@ def render_prompt(input: InputSchema) -> str: * Severity: default to 'moderate', for important issues use 'important', for most critical use 'critical' (privilege escalation, RCE, data loss) * Fix Version: use the appropriate stream version determined from map_version tool result """ + +TRIAGE_PROMPT_ZSTREAM = """ + You are an agent tasked to analyze Jira issues for RHEL and identify the most efficient path to resolution, + whether through a version rebase, a patch backport, or by requesting clarification when blocked. + + **Important**: Focus on bugs, CVEs, and technical defects that need code fixes. + QE tasks, feature requests, refactoring, documentation, and other non-bug issues should be marked as "no-action". + + Goal: Analyze the given issue to determine the correct course of action. + + **Initial Analysis Steps** + + 1. Open the {{issue}} Jira issue and thoroughly analyze it: + * Extract key details from the title, description, fields, and comments + * Identify the Fix Version using the map_version tool and check if it is an older z-stream. + An older z-stream is a z-stream version with a minor number lower than the current + z-stream for the same major version. + * If the Fix Version is an older z-stream use the zstream_search tool to locate the fix. + Provide the following from the Jira issue to the tool: + - The component name. + - The full issue summary text as-is. + - The fix_version string. + If the tool returns 'found', use the returned commit URLs as your patch candidates. + * Pay special attention to comments as they often contain crucial information such as: + - Additional context about the problem + - Links to upstream fixes or patches + - Clarifications from reporters or developers + * Look for keywords indicating the root cause of the problem + * Identify specific error messages, log snippets, or CVE identifiers + * Note any functions, files, or methods mentioned + * Pay attention to any direct links to fixes provided in the issue + * Do not use upstream patches for older z-streams. + + 2. Identify the package name that must be updated: + * Determine the name of the package from the issue details (usually component name) + * Confirm the package repository exists by running + `GIT_TERMINAL_PROMPT=0 git ls-remote https://gitlab.com/redhat/centos-stream/rpms/` + * A successful command (exit code 0) confirms the package exists + * If the package does not exist, re-examine the Jira issue for the correct package name and if it is not found, + return error and explicitly state the reason + + 3. Proceed to decision making process described below. + + **Decision Guidelines & Investigation Steps** + + You must decide between one of 5 actions. Follow these guidelines to make your decision: + + 1. **Rebase** + * A Rebase is only to be chosen when the issue explicitly instructs you to "rebase" or "update" + to a newer/specific upstream version. Do not infer this. + * Identify the the package should be updated or rebased to. + * Set the Jira fields as per the instructions below. + + 2. **Backport a Patch OR Request Clarification** + This path is for issues that represent a clear bug or CVE that needs a targeted fix. + + 2.1. Deep Analysis of the Issue + * Use the details extracted from your initial analysis + * Focus on keywords and root cause identification + * If the Jira issue already provides a direct link to the fix, use that as your primary lead + (e.g. in the commit hash field or comment) unless backporting to an older z-stream. + + 2.2. Systematic Source Investigation + * Identify the official upstream project from two sources: + * Links from the Jira issue (if any direct upstream links are provided) + * Package spec file (.spec) in the GitLab repository: check the URL field or Source0 field for upstream project location + + * Even if the Jira issue provides a direct link to a fix, you need to validate it + * When no direct link is provided, you must proactively search for fixes - do not give up easily + * Try to use upstream_search tool to find out commits related to the issue. + - The description you will use should be 1-2 sentences long and include implementation + details, keywords, function names or any other helpful information. + - The description should be like a command for example `Fix`, `Add` etc. + - If the tool gives you list of URLs use them without any change. + - Use release date of upstream version used in RHEL if you know it. + - If the tool says it can not be used for this project, or it encounters internal error, + do not try to use it again and proceed with different approach. + - If you run out of commits to check, use different approach, do not give up. Inability + of the tool to find proper fix does not mean it does not exist, search bug trackers + and version control system. + * Using the details from your analysis, search these sources: + - Bug Trackers (for fixed bugs matching the issue summary and description) + - Git / Version Control (for commit messages, using keywords, CVE IDs, function names, etc.) + * Be thorough in your search - try multiple search terms and approaches based on the issue details + * Advanced investigation techniques: + - If you can identify specific files, functions, or code sections mentioned in the issue, + locate them in the source code + - Use git history (git log, git blame) to examine changes to those specific code areas + - Look for commits that modify the problematic code, especially those with relevant keywords in commit messages + - Check git tags and releases around the time when the issue was likely fixed + - Search for commits by date ranges when you know approximately when the issue was resolved + - Utilize dates strategically in your search if needed, using the version/release date of the package + currently used in RHEL + - Focus on fixes that came after the RHEL package version date, as earlier fixes would already be included + - For CVEs, use the CVE publication date to narrow down the timeframe for fixes + - Check upstream release notes and changelogs after the RHEL package version date + + 2.3. Validate the Fix and URL + * Use the PatchValidator tool to fetch content from any patch/commit URL you intend to use + * The tool will verify the URL is accessible and not an issue reference, then return the content + * Once you have the content, you must validate two things: + 1. **Is it a patch/diff?** Look for diff indicators like: + - `diff --git` headers + - `--- a/file +++ b/file` unified diff headers + - `@@...@@` hunk headers + - `+` and `-` lines showing changes + 2. **Does it fix the issue?** Examine the actual code changes to verify: + - The fix directly addresses the root cause identified in your analysis + - The code changes align with the symptoms described in the Jira issue + - The modified functions/files match those mentioned in the issue + * Only proceed with URLs that contain valid patch content AND address the specific issue + * If the content is not a proper patch or doesn't fix the issue, continue searching for other fixes + + 2.4. Decide the Outcome + * If your investigation successfully identifies a specific fix that passes both validations in step 2.3, your decision is backport + * You must be able to justify why the patch is correct and how it addresses the issue + * If your investigation confirms a valid bug/CVE but fails to locate a specific fix, your decision + is clarification-needed + * This is the correct choice when you are sure a problem exists but cannot find the solution yourself + + 2.5 Set the Jira fields as per the instructions below. + + 3. **No Action** + A No Action decision is appropriate for issues that are NOT bugs or CVEs requiring code fixes: + * QE tasks, testing, or validation work + * Feature requests or enhancements + * Refactoring or code restructuring without fixing bugs + * Documentation, build system, or process changes + * Vague requests or insufficient information to identify a bug + * Note: This is not for valid bugs where you simply can't find the patch + + 4. **Error** + An Error decision is appropriate when there are processing issues that prevent proper analysis, e.g.: + * The package mentioned in the issue cannot be found or identified + * The issue cannot be accessed + + **Final Step: Set JIRA Fields (for Rebase and Backport decisions only)** + + If your decision is rebase or backport, use set_jira_fields tool to update JIRA fields (Severity, Fix Version): + 1. Check all of the mentioned fields in the JIRA issue and don't modify those that are already set + 2. Extract the affected RHEL major version from the JIRA issue (look in Affects Version/s field or issue description) + 3. If the Fix Version field is set, do not change it and use its value in the output. + 4. If the Fix Version field is not set, use the map_version tool with the major version to get available streams + and determine appropriate Fix Version: + * The tool will return both Y-stream and Z-stream versions (if available) and indicate if it's a maintenance version + * For maintenance versions (no Y-stream available): + - Critical issues should be fixed (privilege escalation, remote code execution, data loss/corruption, system compromise, regressions, moderate and higher severity CVEs) + - Non-critical issues should be marked as no-action with appropriate reasoning + * For non-maintenance versions (Y-stream available): + - Most critical issues (privilege escalation, RCE, data loss, regressions) should use Z-stream + - Other issues should use Y-stream (e.g. performance, usability issues) + 5. Set non-empty JIRA fields: + * Severity: default to 'moderate', for important issues use 'important', for most critical use 'critical' (privilege escalation, RCE, data loss) + * Fix Version: use the appropriate stream version determined from map_version tool result + """ + + +async def render_prompt(input: InputSchema, fix_version: str | None = None) -> str: + if fix_version and await is_older_zstream(fix_version): + template = TRIAGE_PROMPT_ZSTREAM + else: + template = TRIAGE_PROMPT return PromptTemplate(schema=InputSchema, template=template).render(input) @@ -302,7 +472,7 @@ def create_triage_agent(gateway_tools): llm=get_chat_model(), tool_call_checker=get_tool_call_checker_config(), tools=[ThinkTool(), RunShellCommandTool(), PatchValidatorTool(), - VersionMapperTool(), UpstreamSearchTool()] + VersionMapperTool(), UpstreamSearchTool(), ZStreamSearchTool()] + [t for t in gateway_tools if t.name in ["get_jira_details", "set_jira_fields"]], memory=UnconstrainedMemory(), requirements=[ @@ -315,6 +485,7 @@ def create_triage_agent(gateway_tools): ), ConditionalRequirement("get_jira_details", min_invocations=1), ConditionalRequirement(UpstreamSearchTool, only_after="get_jira_details"), + ConditionalRequirement(ZStreamSearchTool, only_after="get_jira_details"), ConditionalRequirement(RunShellCommandTool, only_after="get_jira_details"), ConditionalRequirement(PatchValidatorTool, only_after="get_jira_details"), ConditionalRequirement("set_jira_fields", only_after="get_jira_details"), @@ -322,7 +493,6 @@ def create_triage_agent(gateway_tools): middlewares=[GlobalTrajectoryMiddleware(pretty=True)], role="Red Hat Enterprise Linux developer", instructions=[ - "Use the `think` tool to reason through complex decisions and document your approach.", "Be proactive in your search for fixes and do not give up easily.", "For any patch URL that you are proposing for backport, you need to validate it using PatchValidator tool.", "Do not modify the patch URL in your final answer after it has been validated with the PatchValidator tool.", @@ -377,6 +547,21 @@ async def check_cve_eligibility(state): async def run_triage_analysis(state): """Run the main triage analysis""" logger.info(f"Running triage analysis for {state.jira_issue}") + + # Pre-fetch JIRA fix version to determine z-stream prompt variant + fix_version_name = None + try: + jira_details = await run_tool( + "get_jira_details", + available_tools=gateway_tools, + issue_key=state.jira_issue + ) + fix_versions = jira_details.get("fields", {}).get("fixVersions", []) + if fix_versions: + fix_version_name = fix_versions[0].get("name", "") + except Exception as e: + logger.warning(f"Failed to pre-fetch fix version for prompt selection: {e}") + input_data = InputSchema(issue=state.jira_issue) output_schema_json = to_json( OutputSchema.model_json_schema(mode="validation"), @@ -384,7 +569,7 @@ async def run_triage_analysis(state): sort_keys=False, ) response = await triage_agent.run( - render_prompt(input_data), + await render_prompt(input_data, fix_version=fix_version_name), # `OutputSchema` alone is not enough here, some models (cough cough, Claude Sonnet 4.5) # really stuggle with the nesting, let's provide some more hints expected_output=dedent( diff --git a/mcp_server/lookaside_tools.py b/mcp_server/lookaside_tools.py index c3f763b3..652043b4 100644 --- a/mcp_server/lookaside_tools.py +++ b/mcp_server/lookaside_tools.py @@ -1,6 +1,9 @@ import asyncio import logging import os +import re +import rpm +from pathlib import Path from typing import Annotated from fastmcp.exceptions import ToolError @@ -43,6 +46,60 @@ async def download_sources( return "Successfully downloaded sources from lookaside cache" +def _get_unpacked_sources(dist_git_path: str | Path, package: str) -> str: + """ + Get the path to the root of the extracted archive directory tree + after rpmbuild prep has been run. + + Reads the spec file to extract Name, Version, macro definitions, and the + %setup/%autosetup -n argument. Uses rpm.expandMacro() to resolve any macros + without requiring full spec parsing (which can fail on specs with deprecated + syntax like %patchN). + + RPM 4.20+ creates a per-build directory named %{NAME}-%{VERSION}-build + under _builddir (hardcoded in librpmbuild) and unpacks sources inside it. + """ + spec_path = Path(dist_git_path) / f"{package}.spec" + spec_text = spec_path.read_text() + + rpm.reloadConfig() + have_name = False + have_version = False + for line in spec_text.splitlines(): + line = line.strip() + # Stop at %prep — everything after is build instructions, not metadata + if line.startswith("%prep"): + break + m = re.match(r"^%(define|global)\s+(\S+)\s+(.*)", line) + if m: + rpm.addMacro(m.group(2), m.group(3)) + elif not have_name and (m := re.match(r"^Name:\s*(\S+)", line)): + rpm.addMacro("name", m.group(1)) + have_name = True + elif not have_version and (m := re.match(r"^Version:\s*(\S+)", line)): + rpm.addMacro("version", m.group(1)) + have_version = True + + # Determine buildsubdir: use -n argument from %setup/%autosetup if present, + # otherwise default to %{name}-%{version} + setup_match = re.search( + r"^%(?:auto)?setup\b.*?-n\s+(\S+)", spec_text, re.MULTILINE + ) + if setup_match: + buildsubdir = rpm.expandMacro(setup_match.group(1)) + else: + buildsubdir = rpm.expandMacro("%{name}-%{version}") + + name = rpm.expandMacro("%{name}") + version = rpm.expandMacro("%{version}") + per_build_dir = Path(dist_git_path) / f"{name}-{version}-build" + + sources_dir = per_build_dir / buildsubdir + if sources_dir.is_dir(): + return str(sources_dir) + raise ToolError(f"Unpacked source directory does not exist: {sources_dir}") + + async def prep_sources( dist_git_path: Annotated[AbsolutePath, Field(description="Absolute path to cloned dist-git repository")], package: Annotated[str, Field(description="Package name")], @@ -50,6 +107,7 @@ async def prep_sources( ) -> str: """ Runs rpmbuild prep on the package to unpack and patch sources. + Returns the absolute path to the unpacked source directory. """ await _try_init_kerberos() proc = await asyncio.create_subprocess_exec( @@ -59,7 +117,7 @@ async def prep_sources( ) if await proc.wait(): raise ToolError("Failed to prep sources") - return "Successfully prepped sources" + return _get_unpacked_sources(dist_git_path, package) async def upload_sources( diff --git a/mcp_server/tests/unit/test_lookaside.py b/mcp_server/tests/unit/test_lookaside.py index a39488b8..e457c8b4 100644 --- a/mcp_server/tests/unit/test_lookaside.py +++ b/mcp_server/tests/unit/test_lookaside.py @@ -1,10 +1,12 @@ import asyncio +from pathlib import Path +from textwrap import dedent import pytest from flexmock import flexmock import lookaside_tools -from lookaside_tools import download_sources, prep_sources, upload_sources +from lookaside_tools import _get_unpacked_sources, download_sources, prep_sources, upload_sources async def _noop(): @@ -51,8 +53,72 @@ async def wait(): _mock_kerberos() flexmock(asyncio).should_receive("create_subprocess_exec").replace_with(create_subprocess_exec) + flexmock(lookaside_tools).should_receive("_get_unpacked_sources").with_args( + ".", package + ).and_return("/some/path").once() result = await prep_sources(dist_git_path=".", package=package, dist_git_branch=branch) - assert result.startswith("Successfully") + assert result == "/some/path" + + +def test_get_unpacked_sources_default_buildsubdir(tmp_path): + """Default buildsubdir is %{name}-%{version}.""" + sources_dir = tmp_path / "pkg-1.0-build" / "pkg-1.0" + sources_dir.mkdir(parents=True) + + spec = tmp_path / "pkg.spec" + spec.write_text(dedent("""\ + Name: pkg + Version: 1.0 + Release: 1 + Summary: t + License: MIT + %description + t + %prep + %autosetup + """)) + assert _get_unpacked_sources(tmp_path, "pkg") == str(sources_dir) + + +def test_get_unpacked_sources_custom_name(tmp_path): + """Handles %setup -n with a custom directory name.""" + sources_dir = tmp_path / "pkg-1.0-build" / "custom-name" + sources_dir.mkdir(parents=True) + + spec = tmp_path / "pkg.spec" + spec.write_text(dedent("""\ + Name: pkg + Version: 1.0 + Release: 1 + Summary: t + License: MIT + %description + t + %prep + %setup -q -n custom-name + """)) + assert _get_unpacked_sources(tmp_path, "pkg") == str(sources_dir) + + +def test_get_unpacked_sources_multi_dir_buildsubdir(tmp_path): + """Handles %setup -n with multi-directory buildsubdir containing macros.""" + sources_dir = tmp_path / "expat-2.6.4-build" / "libexpat-R_2_6_4" / "expat" + sources_dir.mkdir(parents=True) + + spec = tmp_path / "expat.spec" + spec.write_text(dedent("""\ + Name: expat + Version: 2.6.4 + Release: 1 + Summary: t + License: MIT + %define unversion %(echo %{version} | tr . _) + %description + t + %prep + %setup -q -n libexpat-R_%{unversion}/expat + """)) + assert _get_unpacked_sources(tmp_path, "expat") == str(sources_dir) @pytest.mark.parametrize(