diff --git a/agents_as_skills/reproducer/SKILL.md b/agents_as_skills/reproducer/SKILL.md index 164ce1549..bb7547de5 100644 --- a/agents_as_skills/reproducer/SKILL.md +++ b/agents_as_skills/reproducer/SKILL.md @@ -119,10 +119,10 @@ Execute the following steps in order. Track state across steps using these varia 8. **Check for existing test or open reproducer MR** before creating a new one: - Clone `https://gitlab.com/redhat/rhel/tests/` to `/git-repos/tests-` (omit branch). - Look for `Security//` (CVE) or `Regression//` (bug), and grep for the issue/CVE ids. - - Call `list_project_merge_requests` on `redhat/rhel/tests/` with `state=opened` and label `ymir_reproducer`; match title/description to this issue/CVE. - - If found: reserve TF for **this** stream and verify the existing test. + - Call `list_project_merge_requests` on `redhat/rhel/tests/` with `state=opened` and label `ymir_reproducer`; match open MRs by **`[{{cve_id}}]` or `[{{jira_issue}}]` in the MR title** (not description). CVE MR titles stay `[CVE-…]` across streams; regression MR titles accumulate `[RHEL-…]` keys when another stream extends the same open MR (e.g. `[RHEL-100, RHEL-200]`). + - If found: reserve TF for **this** stream and verify the existing test from the MR branch. - Works → `success=true`, `test_already_exists=true`, `adapted_existing=false` (no new MR). - - Fails on this stream → adapt the test to be portable across streams, re-verify, set `adapted_existing=true` and `existing_mr_url` so orchestration updates the open MR. + - Fails on this stream → adapt in the **same** test directory (no duplicate paths/MRs), update `main.fmf` by appending `- verifies: https://issues.redhat.com/browse/{{jira_issue}}` to the `link` list (keep existing entries), re-verify, set `adapted_existing=true` and `existing_mr_url` to the open CVE MR so orchestration updates that MR. - If triage says not-affected: still build/verify a test that would catch the issue if present; detection contradicts N/A, non-reproducible supports N/A. ### Step 2: Get Maintainer Rules @@ -377,6 +377,8 @@ tag: - - Security - CVE +link: + - verifies: https://issues.redhat.com/browse/{{jira_issue}} tier: "1" ``` @@ -397,11 +399,15 @@ duration: 10m tag: - - Regression +link: + - verifies: https://issues.redhat.com/browse/{{jira_issue}} tier: "1" ``` Adjust `duration` based on the test complexity. Use `5m` for simple tests, `10m` for standard tests, and `30m` for tests that require compilation, large inputs, or Valgrind. +The `link` field with `verifies` is **required** — it records which Jira issue the test verifies. + ### Step 5: Copy Reproducer to TF Machine, Run, Iterate This is the agentic verification loop — the core of the agent. The goal is to verify that the reproducer actually detects the bug on a real RHEL system. This step iterates: copy the test, run it, analyze the result, fix issues, and try again. diff --git a/ymir/agents/prompts/reproducer/output_format.j2 b/ymir/agents/prompts/reproducer/output_format.j2 index 6d2f67256..15733c2ee 100644 --- a/ymir/agents/prompts/reproducer/output_format.j2 +++ b/ymir/agents/prompts/reproducer/output_format.j2 @@ -89,7 +89,7 @@ Existing test verified on this stream (no changes needed): Existing test adapted for this stream: ```json { - "jira_issue": "RHEL-12345", + "jira_issue": "RHEL-12346", "success": true, "reproducer_type": "cve", "package": "libfoo", @@ -98,7 +98,7 @@ Existing test adapted for this stream: "test_directory": "Security/CVE-2025-12345", "testing_farm_request_id": "tf-request-adapt1", "pass_fail_criteria": "PASS: program exits 0 (fix applied). FAIL: SIGSEGV (bug present).", - "summary": "Existing Security/CVE-2025-12345/ test failed on RHEL 9; adapted package names and assertions so the same test works on RHEL 9 and RHEL 10. Files updated in the clone for orchestration to push to the open MR.", + "summary": "Open MR !12 already had Security/CVE-2025-12345/ from rhel-10.2.z. Adapted the same test for RHEL 9.8 (portable compose checks). Orchestration will push to MR !12's branch — the MR need not be merged first.", "not_reproducible_reason": null, "test_already_exists": true, "existing_mr_url": "https://gitlab.com/redhat/rhel/tests/libfoo/-/merge_requests/12", diff --git a/ymir/agents/prompts/reproducer/prompt.j2 b/ymir/agents/prompts/reproducer/prompt.j2 index 5b565d300..081ebebde 100644 --- a/ymir/agents/prompts/reproducer/prompt.j2 +++ b/ymir/agents/prompts/reproducer/prompt.j2 @@ -87,6 +87,13 @@ Execute the following steps in order. an open MR already carries that test. Do **not** skip verification — reuse and adapt instead of duplicating. + **Cross-stream CVE jobs:** Sibling Jira issues (e.g. RHEL-221017 on rhel-10.2.z + and RHEL-221014 on rhel-9.8.z) share the same `{{ cve_id }}` and one canonical + test directory. An open reproducer MR from the first stream does **not** need + to be merged before you adapt for another stream — orchestration updates that + same MR. Search open MRs primarily by **`{{ cve_id }}`** in title/description, + not only by `{{ jira_issue }}`. + * Clone the RHEL tests repository using `clone_repository`: - URL: `https://gitlab.com/redhat/rhel/tests/` - Do NOT specify a `branch` parameter — omit it so the tool clones the default branch. @@ -112,11 +119,18 @@ Execute the following steps in order. - `project`: `redhat/rhel/tests/` - `state`: `opened` - `labels`: `["ymir_reproducer"]` - Match MRs whose title/description mention `{{ jira_issue }}`{% if cve_id %} or `{{ cve_id }}`{% endif %}. - If an open MR matches, note its URL as `existing_mr_url`. If the MR source - branch has the test but the default branch does not yet, fetch/checkout that - branch into the clone (or re-clone and check out the source branch) so you - can run the MR's test files. + Match MRs whose **title** contains the canonical tag ``[{{ cve_id }}]``{% if cve_id %} + (primary for sibling streams — do not match on description text, which may + mention unrelated CVEs){% endif %}, or ``[{{ jira_issue }}]`` for regression tests. + Orchestration titles new reproducer MRs as ``: [CVE-…] ymir reproducer test`` + (CVE, stable across streams) or ``: [RHEL-…] ymir reproducer test`` + (regression). When a regression test is adapted for another stream, the MR + title gains an additional ``[RHEL-…]`` key (e.g. ``[RHEL-100, RHEL-200]``). + If an open MR matches, note its URL as `existing_mr_url`. **Check out the MR + source branch** in the clone (via `get_merge_request_details` + `fetch_branch` + + `git checkout`) **before** copying/running the test — the test usually lives + only on that branch, not on the default branch yet. The MR does not need to be + merged first. * **If an existing test directory / matching test / open MR is found:** 1. Continue to steps 3–6 to reserve a Testing Farm machine for **this** @@ -128,15 +142,19 @@ Execute the following steps in order. and `test_directory` set to the relative path of the existing test (e.g. `Security/CVE-2025-12345`). Do **not** create new test files or a new MR. 3. If the existing test does **not** work on this stream: - - **Adapt** the test so it works for both the original stream and this one - (avoid stream-hardcoded paths/versions; use portable assertions / - conditionals where needed). + - **Adapt** the test in the **same directory path** already on the open MR + (do not create a parallel directory or a second MR for the same CVE). + - Make the test portable across streams (avoid stream-hardcoded paths/versions; + use portable assertions / conditionals where needed). + - Update `main.fmf`: read the existing `link` list and **append** + `- verifies: https://issues.redhat.com/browse/{{ jira_issue }}` if that + URL is not already present. Do not remove or replace existing link entries. - Re-verify on this compose (still within the iteration limit). - Leave the adapted files in the clone under the same test directory path. - Produce final output with `success`: `true`, `test_already_exists`: `true`, - `adapted_existing`: `true`, `existing_mr_url` when updating an open MR, + `adapted_existing`: `true`, `existing_mr_url` set to the open CVE MR URL, and `test_directory` set to that relative path. - Orchestration will push to the existing MR branch (or open a follow-up). + Orchestration will push to that MR's source branch (even if unmerged). 4. Always cancel the TF reservation (step 6) before returning output. * **If no existing test or open MR is found:** proceed to create a new test (steps 2–6). @@ -416,6 +434,8 @@ Execute the following steps in order. - - Security - CVE + link: + - verifies: https://issues.redhat.com/browse/{{ jira_issue }} tier: "1" ``` @@ -436,11 +456,16 @@ Execute the following steps in order. tag: - - Regression + link: + - verifies: https://issues.redhat.com/browse/{{ jira_issue }} tier: "1" ``` Adjust `duration` based on the test complexity. Use `5m` for simple tests, `10m` for standard tests, and `30m` for tests that require compilation, large inputs, or Valgrind. + The `link` field with `verifies` is **required** — it records which Jira issue + the test verifies (same format as package tests in `redhat/rhel/tests/*`). + 5. **Copy Reproducer to TF Machine, Run, Iterate** This is the agentic verification loop — the core of the agent. The goal is to verify that the reproducer actually detects the bug on a real RHEL system. This step iterates: copy the test, run it, analyze the result, fix issues, and try again. diff --git a/ymir/agents/reproducer_agent.py b/ymir/agents/reproducer_agent.py index 41d3f994e..ddb4202ab 100644 --- a/ymir/agents/reproducer_agent.py +++ b/ymir/agents/reproducer_agent.py @@ -2,6 +2,7 @@ import json import logging import os +import re import shutil import sys import tempfile @@ -54,10 +55,11 @@ ) from ymir.common.reproducer_lock import ( release_reproducer_lock, - reproducer_lock_id, + resolve_reproducer_lock_id, sweep_stale_reproducer_locks, try_acquire_reproducer_lock, ) +from ymir.tools.privileged.jira import fetch_jira_issue_issuelinks from ymir.tools.unprivileged.commands import RunShellCommandTool from ymir.tools.unprivileged.text import CreateTool, SearchTextTool, ViewTool from ymir.tools.unprivileged.version_mapper import VersionMapperTool @@ -246,6 +248,172 @@ def _resolve_test_dir(tests_clone: Path, test_directory: str | None) -> Path | N return None +def _cve_only_needles(cve_id: str | None) -> list[str]: + """CVE id strings used to match sibling-stream reproducer MRs.""" + if not cve_id or not cve_id.strip(): + return [] + return sorted({p.strip().upper() for p in cve_id.replace(";", ",").split(",") if p.strip()}) + + +_REPRODUCER_MR_BRACKET_CVE = re.compile(r"\[(CVE-\d{4}-\d+)\]", re.IGNORECASE) +_REPRODUCER_MR_BRACKET_JIRA = re.compile(r"\[(RHEL-\d+)\]", re.IGNORECASE) + + +def _reproducer_mr_title_tags(title: str) -> tuple[set[str], set[str]]: + """Parse canonical ``[CVE-…]`` / ``[RHEL-…]`` tags from an MR title.""" + cves = {match.upper() for match in _REPRODUCER_MR_BRACKET_CVE.findall(title)} + jiras = {match.upper() for match in _REPRODUCER_MR_BRACKET_JIRA.findall(title)} + return cves, jiras + + +def _build_mr_title( + result: OutputSchema, + input_data: InputSchema, + *, + matched_mr: dict | None = None, +) -> str: + """Build MR title keyed by bracket tags in the title (title-only matching). + + CVE reproducers use a single ``[CVE-…]`` tag (stable across streams). + Regression reproducers accumulate ``[RHEL-…]`` keys when another stream's + job updates the same open MR. + """ + cves = _cve_only_needles(input_data.cve_id) + if result.reproducer_type == "cve" and cves: + tags = " ".join(f"[{cve}]" for cve in cves) + return f"{result.package}: {tags} ymir reproducer test" + + jiras = {result.jira_issue.upper()} + if matched_mr: + _, existing_jiras = _reproducer_mr_title_tags(matched_mr.get("title") or "") + jiras |= existing_jiras + tag = "[" + ", ".join(sorted(jiras)) + "]" + return f"{result.package}: {tag} ymir reproducer test" + + +def _is_reproducer_mr_title(title: str) -> bool: + return "ymir reproducer test" in title.lower() + + +def _match_regression_sibling_mr(mrs: list[dict], jira_issue: str) -> dict | None: + """Find the canonical regression reproducer MR to extend for another stream. + + When the current issue is not yet listed in the title, match a sole open + regression reproducer MR (no ``[CVE-…]`` tag in the title). + """ + wanted = jira_issue.upper() + candidates: list[dict] = [] + for mr in mrs: + title = mr.get("title") or "" + title_cves, title_jiras = _reproducer_mr_title_tags(title) + if title_cves or not title_jiras or not _is_reproducer_mr_title(title): + continue + if wanted in title_jiras: + return mr + candidates.append(mr) + if len(candidates) == 1: + return candidates[0] + return None + + +def _match_open_reproducer_mr( + mrs: list[dict], + *, + cve_ids: list[str] | None = None, + jira_issue: str | None = None, + existing_mr_url: str | None = None, +) -> dict | None: + """Return the open reproducer MR for this CVE or Jira issue. + + Matching uses **MR title only** via ``[CVE-…]`` or ``[RHEL-…]`` bracket + tags (see ``_build_mr_title``). Descriptions are ignored. + """ + if existing_mr_url: + for mr in mrs: + if mr.get("url") == existing_mr_url: + return mr + + wanted_cves = {cve.upper() for cve in cve_ids or [] if cve} + wanted_jira = jira_issue.upper() if jira_issue else None + + for mr in mrs: + title = mr.get("title") or "" + title_cves, title_jiras = _reproducer_mr_title_tags(title) + + if wanted_cves and wanted_cves & title_cves: + return mr + if wanted_jira and wanted_jira in title_jiras: + return mr + + return None + + +async def _list_open_reproducer_mrs(package: str, available_tools: list[Any]) -> list[dict]: + try: + listed = await run_tool( + "list_project_merge_requests", + project=f"redhat/rhel/tests/{package}", + state="opened", + labels=["ymir_reproducer"], + available_tools=available_tools, + ) + except Exception as e: + logger.warning("Failed to list open reproducer MRs for %s: %s", package, e) + return [] + + mrs = json.loads(listed) if isinstance(listed, str) else listed + return mrs if isinstance(mrs, list) else [] + + +async def _resolve_reproducer_mr_target( + result: OutputSchema, + agent_input: InputSchema, + package: str, + available_tools: list[Any], +) -> tuple[str | None, str, dict | None]: + """Resolve MR URL, git branch, and matched MR metadata for create/adapt push. + + When an open ``ymir_reproducer`` MR already exists for the same CVE, sibling + stream jobs must update that MR's source branch — the MR does not need to be + merged first. Regression (non-CVE) jobs accumulate ``[RHEL-…]`` keys in the + MR title when another stream extends the same open MR. + """ + fallback_branch = f"reproducer/{result.jira_issue}" + mrs = await _list_open_reproducer_mrs(package, available_tools) + + cve_needles = _cve_only_needles(agent_input.cve_id) + if cve_needles: + matched = _match_open_reproducer_mr( + mrs, + cve_ids=cve_needles, + existing_mr_url=result.existing_mr_url, + ) + else: + matched = _match_open_reproducer_mr( + mrs, + jira_issue=result.jira_issue, + existing_mr_url=result.existing_mr_url, + ) + if matched is None and result.reproducer_type == "bug": + matched = _match_regression_sibling_mr(mrs, result.jira_issue) + + if matched: + mr_url = matched.get("url") + branch = matched.get("source_branch") or fallback_branch + if mr_url: + result.existing_mr_url = mr_url + result.adapted_existing = True + logger.info( + "Updating existing reproducer MR %s on branch %s for %s", + mr_url, + branch, + result.jira_issue, + ) + return mr_url, branch, matched + + return result.existing_mr_url, fallback_branch, None + + async def _prepare_reproducer_branch( tests_clone: Path, test_dir: Path, @@ -268,7 +436,7 @@ async def _prepare_reproducer_branch( branch = update_branch try: - if adapted_existing and existing_mr_url: + if existing_mr_url: try: details_raw = await run_tool( "get_merge_request_details", @@ -427,43 +595,6 @@ async def run_reproducer_analysis(state): return "create_merge_request" - async def _resolve_update_branch(result: OutputSchema, package: str) -> str: - """Prefer the existing open MR source branch when adapting.""" - fallback = f"reproducer/{result.jira_issue}" - if not result.adapted_existing: - return fallback - - try: - listed = await run_tool( - "list_project_merge_requests", - project=f"redhat/rhel/tests/{package}", - state="opened", - labels=["ymir_reproducer"], - available_tools=gateway_tools, - ) - except Exception as e: - logger.warning("Failed to list open reproducer MRs for %s: %s", package, e) - return fallback - - mrs = json.loads(listed) if isinstance(listed, str) else listed - if not isinstance(mrs, list): - return fallback - - needles = [] - if input_data and input_data.cve_id: - needles.extend(p.strip() for p in input_data.cve_id.replace(";", ",").split(",") if p.strip()) - needles.append(result.jira_issue) - - for mr in mrs: - blob = f"{mr.get('title', '')}\n{mr.get('description', '')}\n{mr.get('url', '')}" - if result.existing_mr_url and mr.get("url") == result.existing_mr_url: - return mr.get("source_branch") or fallback - if any(n and n in blob for n in needles): - result.existing_mr_url = result.existing_mr_url or mr.get("url") - return mr.get("source_branch") or fallback - - return fallback - async def create_merge_request(state): """Fork, push, and open or update a merge request for verified reproducers.""" result = state.result @@ -486,7 +617,11 @@ async def create_merge_request(state): package = result.package agent_input = InputSchema(jira_issue=state.jira_issue) if input_data is None else input_data - lock_id = reproducer_lock_id(agent_input.cve_id, state.jira_issue) + lock_id = await resolve_reproducer_lock_id( + agent_input.cve_id, + state.jira_issue, + fetch_issuelinks=fetch_jira_issue_issuelinks, + ) lock_token: str | None = None if redis_conn is not None: @@ -536,13 +671,18 @@ async def create_merge_request(state): return "handle_results" logger.info("Using test directory %s for MR creation", test_dir) - update_branch = await _resolve_update_branch(result, package) + existing_mr_url, update_branch, matched_mr = await _resolve_reproducer_mr_target( + result, + agent_input, + package, + gateway_tools, + ) update_branch = await _prepare_reproducer_branch( tests_clone, test_dir, update_branch, adapted_existing=bool(result.adapted_existing), - existing_mr_url=result.existing_mr_url, + existing_mr_url=existing_mr_url, available_tools=gateway_tools, ) @@ -569,10 +709,7 @@ async def create_merge_request(state): "fork_repository", repository=repository, available_tools=gateway_tools ) - if result.adapted_existing: - mr_title = f"{package}: adapt {result.reproducer_type} reproducer for {state.jira_issue}" - else: - mr_title = f"{package}: add {result.reproducer_type} reproducer for {state.jira_issue}" + mr_title = _build_mr_title(result, agent_input, matched_mr=matched_mr) mr_description = _build_mr_description(result, agent_input) commit_message = _build_commit_message(result, agent_input) diff --git a/ymir/agents/tests/e2e/reproducer_agent/test_reproducer.py b/ymir/agents/tests/e2e/reproducer_agent/test_reproducer.py index 215bae917..a4902d63c 100644 --- a/ymir/agents/tests/e2e/reproducer_agent/test_reproducer.py +++ b/ymir/agents/tests/e2e/reproducer_agent/test_reproducer.py @@ -510,6 +510,13 @@ def test_reproducer_agent_artifacts(test_case: ReproducerAgentTestCase): main_fmf = test_dir / "main.fmf" assert main_fmf.is_file(), f"{test_case.jira_issue}: missing main.fmf in {test_dir}" + jira_url = f"https://issues.redhat.com/browse/{test_case.jira_issue}" + main_fmf_content = main_fmf.read_text() + assert jira_url in main_fmf_content, ( + f"{test_case.jira_issue}: main.fmf must link to the Jira issue via verifies" + ) + assert "verifies:" in main_fmf_content + # --------------------------------------------------------------------------- # Verification tests — run the agent-created test on unfixed & fixed composes diff --git a/ymir/agents/tests/unit/test_reproducer_agent.py b/ymir/agents/tests/unit/test_reproducer_agent.py index d111be44f..f52c3842c 100644 --- a/ymir/agents/tests/unit/test_reproducer_agent.py +++ b/ymir/agents/tests/unit/test_reproducer_agent.py @@ -7,10 +7,16 @@ import pytest from ymir.agents.reproducer_agent import ( + _build_mr_title, + _cve_only_needles, _determine_comment_resolution, _determine_result_label, + _match_open_reproducer_mr, + _match_regression_sibling_mr, _needs_merge_request, _prepare_reproducer_branch, + _reproducer_mr_title_tags, + _resolve_reproducer_mr_target, _resolve_test_dir, _should_finalize_jira, create_reproducer_agent, @@ -110,6 +116,257 @@ def test_resolve_test_dir_rejects_traversal_and_missing(tmp_path: Path): assert _resolve_test_dir(tmp_path, "Security/CVE-missing") is None +def test_cve_only_needles_splits_and_normalizes(): + assert _cve_only_needles("CVE-2026-56132") == ["CVE-2026-56132"] + assert _cve_only_needles("cve-1; CVE-2") == ["CVE-1", "CVE-2"] + assert _cve_only_needles(None) == [] + + +def test_match_open_reproducer_mr_by_cve_bracket_in_title(): + mrs = [ + { + "url": "https://gitlab.com/redhat/rhel/tests/expat/-/merge_requests/17", + "title": "expat: [CVE-2026-56132] ymir reproducer test", + "description": "Follow-up for CVE-2026-99999 mentioned here only.", + "source_branch": "reproducer/RHEL-221017", + } + ] + matched = _match_open_reproducer_mr(mrs, cve_ids=["CVE-2026-56132"]) + assert matched is not None + assert matched["source_branch"] == "reproducer/RHEL-221017" + + +def test_match_open_reproducer_mr_ignores_unrelated_cve_in_description(): + mrs = [ + { + "url": "https://gitlab.com/redhat/rhel/tests/expat/-/merge_requests/99", + "title": "expat: [CVE-2026-56132] ymir reproducer test", + "description": "Test for CVE-2026-99999 which is unrelated.", + } + ] + assert _match_open_reproducer_mr(mrs, cve_ids=["CVE-2026-99999"]) is None + assert _match_open_reproducer_mr(mrs, cve_ids=["CVE-2026-56132"]) is not None + + +def test_match_open_reproducer_mr_ignores_bare_cve_token_without_brackets(): + mrs = [ + { + "url": "https://gitlab.com/redhat/rhel/tests/expat/-/merge_requests/17", + "title": "expat: add cve reproducer for RHEL-221017 (CVE-2026-56132)", + "description": "Security test for CVE-2026-56132 in expat.", + } + ] + assert _match_open_reproducer_mr(mrs, cve_ids=["CVE-2026-56132"]) is None + + +def test_match_regression_sibling_mr_when_issue_not_yet_in_title(): + mrs = [ + { + "url": "https://gitlab.com/a/1", + "title": "bind: [RHEL-100] ymir reproducer test", + }, + { + "url": "https://gitlab.com/a/2", + "title": "bind: [RHEL-200] ymir reproducer test", + }, + ] + assert _match_regression_sibling_mr(mrs, "RHEL-300") is None + + single = [mrs[0]] + assert _match_regression_sibling_mr(single, "RHEL-200") == single[0] + + +def test_build_mr_title_appends_jira_on_regression_adapt(): + matched_mr = {"title": "bind: [RHEL-100] ymir reproducer test"} + result = _output(package="bind", reproducer_type="bug", jira_issue="RHEL-200") + agent_input = ReproducerInputSchema(jira_issue="RHEL-200", package="bind") + assert ( + _build_mr_title(result, agent_input, matched_mr=matched_mr) + == "bind: [RHEL-100, RHEL-200] ymir reproducer test" + ) + + +def test_build_mr_title_keeps_cve_tag_when_updating_existing_mr(): + matched_mr = {"title": "expat: [CVE-2026-56132] ymir reproducer test"} + result = _output(package="expat", reproducer_type="cve", jira_issue="RHEL-221014") + agent_input = ReproducerInputSchema( + jira_issue="RHEL-221014", + package="expat", + cve_id="CVE-2026-56132", + ) + assert ( + _build_mr_title(result, agent_input, matched_mr=matched_mr) + == "expat: [CVE-2026-56132] ymir reproducer test" + ) + + +def test_match_open_reproducer_mr_by_jira_bracket(): + mrs = [ + { + "url": "https://gitlab.com/a/1", + "title": "bind: [RHEL-99999] ymir reproducer test", + "description": "Also mentions RHEL-88888 in prose.", + } + ] + matched = _match_open_reproducer_mr(mrs, jira_issue="RHEL-99999") + assert matched is not None + assert _match_open_reproducer_mr(mrs, jira_issue="RHEL-88888") is None + + +def test_build_mr_title_uses_cve_bracket_for_security(): + result = _output(package="expat", reproducer_type="cve", jira_issue="RHEL-221014") + agent_input = ReproducerInputSchema( + jira_issue="RHEL-221014", + package="expat", + cve_id="CVE-2026-56132", + ) + assert _build_mr_title(result, agent_input) == "expat: [CVE-2026-56132] ymir reproducer test" + + +def test_build_mr_title_multi_cve_roundtrip(): + """Multi-CVE titles must use separate bracket tags so the regex can parse each one.""" + result = _output(package="expat", reproducer_type="cve", jira_issue="RHEL-221014") + agent_input = ReproducerInputSchema( + jira_issue="RHEL-221014", + package="expat", + cve_id="CVE-2026-56132, CVE-2026-50219", + ) + title = _build_mr_title(result, agent_input) + assert title == "expat: [CVE-2026-50219] [CVE-2026-56132] ymir reproducer test" + + cves, jiras = _reproducer_mr_title_tags(title) + assert cves == {"CVE-2026-50219", "CVE-2026-56132"} + assert jiras == set() + + matched = _match_open_reproducer_mr( + [{"url": "https://gitlab.com/a/1", "title": title}], + cve_ids=["CVE-2026-56132"], + ) + assert matched is not None + + matched2 = _match_open_reproducer_mr( + [{"url": "https://gitlab.com/a/1", "title": title}], + cve_ids=["CVE-2026-50219"], + ) + assert matched2 is not None + + +def test_build_mr_title_uses_jira_bracket_for_regression(): + result = _output(package="bind", reproducer_type="bug", jira_issue="RHEL-12345") + agent_input = ReproducerInputSchema(jira_issue="RHEL-12345", package="bind") + assert _build_mr_title(result, agent_input) == "bind: [RHEL-12345] ymir reproducer test" + + +def test_match_open_reproducer_mr_prefers_existing_url(): + mrs = [ + {"url": "https://gitlab.com/a/1", "title": "other", "description": ""}, + {"url": "https://gitlab.com/a/2", "title": "target", "description": ""}, + ] + matched = _match_open_reproducer_mr( + mrs, + cve_ids=["CVE-1"], + existing_mr_url="https://gitlab.com/a/2", + ) + assert matched["url"] == "https://gitlab.com/a/2" + + +@pytest.mark.asyncio +async def test_resolve_reproducer_mr_target_uses_open_cve_mr(): + result = _output( + jira_issue="RHEL-221014", + success=True, + test_directory="Security/CVE-2026-56132", + package="expat", + reproducer_type="cve", + ) + agent_input = ReproducerInputSchema( + jira_issue="RHEL-221014", + package="expat", + cve_id="CVE-2026-56132", + ) + open_mrs = [ + { + "url": "https://gitlab.com/redhat/rhel/tests/expat/-/merge_requests/17", + "title": "expat: [CVE-2026-56132] ymir reproducer test", + "description": "Security test for CVE-2026-56132 in expat.", + "source_branch": "reproducer/RHEL-221017", + } + ] + + async def fake_run_tool(name, available_tools=None, **kwargs): + if name == "list_project_merge_requests": + return open_mrs + raise AssertionError(name) + + with patch("ymir.agents.reproducer_agent.run_tool", new=AsyncMock(side_effect=fake_run_tool)): + mr_url, branch, matched_mr = await _resolve_reproducer_mr_target(result, agent_input, "expat", []) + + assert mr_url == open_mrs[0]["url"] + assert branch == "reproducer/RHEL-221017" + assert matched_mr == open_mrs[0] + assert result.adapted_existing is True + assert result.existing_mr_url == open_mrs[0]["url"] + + +@pytest.mark.asyncio +async def test_resolve_reproducer_mr_target_extends_regression_mr_for_sibling(): + result = _output( + jira_issue="RHEL-200", + success=True, + test_directory="Regression/RHEL-200", + package="bind", + reproducer_type="bug", + ) + agent_input = ReproducerInputSchema(jira_issue="RHEL-200", package="bind") + open_mrs = [ + { + "url": "https://gitlab.com/redhat/rhel/tests/bind/-/merge_requests/5", + "title": "bind: [RHEL-100] ymir reproducer test", + "source_branch": "reproducer/RHEL-100", + } + ] + + async def fake_run_tool(name, available_tools=None, **kwargs): + if name == "list_project_merge_requests": + return open_mrs + raise AssertionError(name) + + with patch("ymir.agents.reproducer_agent.run_tool", new=AsyncMock(side_effect=fake_run_tool)): + mr_url, branch, matched_mr = await _resolve_reproducer_mr_target(result, agent_input, "bind", []) + + assert mr_url == open_mrs[0]["url"] + assert branch == "reproducer/RHEL-100" + assert matched_mr == open_mrs[0] + assert result.adapted_existing is True + assert ( + _build_mr_title(result, agent_input, matched_mr=matched_mr) + == "bind: [RHEL-100, RHEL-200] ymir reproducer test" + ) + + +@pytest.mark.asyncio +async def test_resolve_reproducer_mr_target_new_branch_without_open_mr(): + result = _output(jira_issue="RHEL-221017", success=True, package="expat", reproducer_type="cve") + agent_input = ReproducerInputSchema( + jira_issue="RHEL-221017", + package="expat", + cve_id="CVE-2026-56132", + ) + + async def fake_run_tool(name, available_tools=None, **kwargs): + if name == "list_project_merge_requests": + return [] + raise AssertionError(name) + + with patch("ymir.agents.reproducer_agent.run_tool", new=AsyncMock(side_effect=fake_run_tool)): + mr_url, branch, matched_mr = await _resolve_reproducer_mr_target(result, agent_input, "expat", []) + + assert mr_url is None + assert branch == "reproducer/RHEL-221017" + assert matched_mr is None + assert result.adapted_existing is False + + def test_reproducer_agent_enables_context_management(): with ( patch("ymir.agents.reproducer_agent.get_chat_model") as mock_get_model, diff --git a/ymir/common/reproducer_lock.py b/ymir/common/reproducer_lock.py index a253f5076..e139d02ce 100644 --- a/ymir/common/reproducer_lock.py +++ b/ymir/common/reproducer_lock.py @@ -3,11 +3,15 @@ Sibling-stream workers (e.g. rhel-10 then rhel-9/rhel-8) serialize on ``package:lock_id`` so only one worker creates or adapts the canonical ``Security//`` or ``Regression//`` test at a time. + +For CVE jobs *lock_id* is the normalized CVE id. For non-CVE bugs it is the +root issue of the Jira Cloners chain (Y-stream root), resolved via issuelinks. """ from __future__ import annotations import logging +from collections.abc import Awaitable, Callable from datetime import UTC, datetime, timedelta from pydantic import BaseModel, Field @@ -49,19 +53,135 @@ class ReproducerLockEntry(BaseModel): activated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) -def reproducer_lock_id(cve_id: str | None, jira_issue: str) -> str: +_CLONERS_LINK_TYPE = "cloners" +_MAX_CLONE_ROOT_DEPTH = 20 + + +def _issue_key(issue: dict | None) -> str | None: + if not issue: + return None + key = issue.get("key") + return key.upper() if key else None + + +def _is_cloners_link(link_type: dict | None) -> bool: + if not link_type: + return False + name = link_type.get("name") + return isinstance(name, str) and name.strip().lower() == _CLONERS_LINK_TYPE + + +def _immediate_clone_parent(current: str, issuelinks: list[dict] | None) -> str | None: + """Return the issue that cloned *current*, if exactly one Cloners parent exists. + + Jira ``Cloners`` links use ``outwardIssue`` as the cloner and ``inwardIssue`` + as the clone ("is cloned by"). + """ + current = current.upper() + if not issuelinks: + return None + + parents: list[str] = [] + for link in issuelinks: + if not _is_cloners_link(link.get("type")): + continue + inward = _issue_key(link.get("inwardIssue")) + outward = _issue_key(link.get("outwardIssue")) + if inward == current and outward: + parents.append(outward) + + if not parents: + return None + if len(parents) == 1: + return parents[0] + logger.warning( + "Issue %s has multiple Cloners parents %s; using %s for reproducer lock", + current, + parents, + sorted(parents)[0], + ) + return sorted(parents)[0] + + +async def resolve_clone_root( + jira_issue: str, + fetch_issuelinks: Callable[[str], Awaitable[list[dict]]], + *, + max_depth: int = _MAX_CLONE_ROOT_DEPTH, +) -> str: + """Walk Cloners links inward until the root issue of a clone chain.""" + current = jira_issue.upper() + visited: set[str] = set() + + for _ in range(max_depth): + if current in visited: + logger.warning("Clone chain cycle detected at %s; stopping walk", current) + break + visited.add(current) + + issuelinks = await fetch_issuelinks(current) + parent = _immediate_clone_parent(current, issuelinks) + if parent is None: + break + current = parent.upper() + + return current + + +def reproducer_lock_id( + cve_id: str | None, + jira_issue: str, + *, + clone_root: str | None = None, +) -> str: """Derive the lock key segment from CVE id(s) or the Jira issue. Multiple CVEs are normalized to a sorted, comma-joined string so all sibling issues that share the same CVE set contend on one lock. + + For non-CVE bugs, pass *clone_root* (the Y-stream root of a clone chain) + so Z-stream clones serialize on the same lock. """ if cve_id and cve_id.strip(): parts = sorted({p.strip().upper() for p in cve_id.replace(";", ",").split(",") if p.strip()}) if parts: return ",".join(parts) + if clone_root and clone_root.strip(): + return clone_root.strip().upper() return jira_issue.upper() +async def resolve_reproducer_lock_id( + cve_id: str | None, + jira_issue: str, + *, + fetch_issuelinks: Callable[[str], Awaitable[list[dict]]] | None = None, +) -> str: + """Resolve the reproducer create/adapt lock id for queue orchestration.""" + if cve_id and cve_id.strip(): + return reproducer_lock_id(cve_id, jira_issue) + + clone_root = jira_issue + if fetch_issuelinks is not None: + try: + clone_root = await resolve_clone_root(jira_issue, fetch_issuelinks) + if clone_root != jira_issue.upper(): + logger.info( + "Using clone root %s for reproducer lock (issue %s)", + clone_root, + jira_issue, + ) + except Exception: + logger.warning( + "Failed to resolve clone root for %s; using issue key for reproducer lock", + jira_issue, + exc_info=True, + ) + clone_root = jira_issue + + return reproducer_lock_id(None, jira_issue, clone_root=clone_root) + + def _active_field(package: str, lock_id: str) -> str: return f"{package}:{lock_id}:active" diff --git a/ymir/common/tests/unit/test_reproducer_lock.py b/ymir/common/tests/unit/test_reproducer_lock.py index 55f0168db..b59c73301 100644 --- a/ymir/common/tests/unit/test_reproducer_lock.py +++ b/ymir/common/tests/unit/test_reproducer_lock.py @@ -8,25 +8,113 @@ from ymir.common.reproducer_lock import ( REPRODUCER_LOCK_HASH, ReproducerLockEntry, + _immediate_clone_parent, release_reproducer_lock, reproducer_lock_id, + resolve_clone_root, + resolve_reproducer_lock_id, sweep_stale_reproducer_locks, try_acquire_reproducer_lock, ) +def _cloners_link(parent: str, clone: str) -> dict: + return { + "type": {"name": "Cloners", "inward": "is cloned by", "outward": "clones"}, + "inwardIssue": {"key": clone}, + "outwardIssue": {"key": parent}, + } + + @pytest.mark.parametrize( - ("cve_id", "jira_issue", "expected"), + ("cve_id", "jira_issue", "clone_root", "expected"), [ - ("CVE-2025-1", "RHEL-1", "CVE-2025-1"), - ("cve-2025-2, CVE-2025-1", "RHEL-1", "CVE-2025-1,CVE-2025-2"), - (None, "RHEL-99", "RHEL-99"), - ("", "rhel-99", "RHEL-99"), - (" ", "RHEL-99", "RHEL-99"), + ("CVE-2025-1", "RHEL-1", None, "CVE-2025-1"), + ("cve-2025-2, CVE-2025-1", "RHEL-1", None, "CVE-2025-1,CVE-2025-2"), + (None, "RHEL-99", None, "RHEL-99"), + ("", "rhel-99", None, "RHEL-99"), + (" ", "RHEL-99", None, "RHEL-99"), + (None, "RHEL-200", "RHEL-100", "RHEL-100"), + ("CVE-2025-1", "RHEL-200", "RHEL-100", "CVE-2025-1"), ], ) -def test_reproducer_lock_id(cve_id, jira_issue, expected): - assert reproducer_lock_id(cve_id, jira_issue) == expected +def test_reproducer_lock_id(cve_id, jira_issue, clone_root, expected): + assert reproducer_lock_id(cve_id, jira_issue, clone_root=clone_root) == expected + + +def test_immediate_clone_parent_finds_cloner(): + links = [_cloners_link("RHEL-100", "RHEL-200")] + assert _immediate_clone_parent("RHEL-200", links) == "RHEL-100" + + +def test_immediate_clone_parent_ignores_outward_clone_direction(): + links = [_cloners_link("RHEL-100", "RHEL-200")] + assert _immediate_clone_parent("RHEL-100", links) is None + + +def test_immediate_clone_parent_picks_smallest_when_multiple(): + links = [ + _cloners_link("RHEL-100", "RHEL-300"), + _cloners_link("RHEL-200", "RHEL-300"), + ] + assert _immediate_clone_parent("RHEL-300", links) == "RHEL-100" + + +@pytest.mark.asyncio +async def test_resolve_clone_root_walks_chain(): + chain = { + "RHEL-100": [], + "RHEL-200": [_cloners_link("RHEL-100", "RHEL-200")], + "RHEL-300": [_cloners_link("RHEL-200", "RHEL-300")], + } + + async def fetch(issue_key: str) -> list[dict]: + return chain[issue_key.upper()] + + assert await resolve_clone_root("RHEL-300", fetch) == "RHEL-100" + assert await resolve_clone_root("RHEL-200", fetch) == "RHEL-100" + assert await resolve_clone_root("RHEL-100", fetch) == "RHEL-100" + + +@pytest.mark.asyncio +async def test_resolve_reproducer_lock_id_uses_clone_root_for_bugs(): + chain = { + "RHEL-100": [], + "RHEL-200": [_cloners_link("RHEL-100", "RHEL-200")], + } + + async def fetch(issue_key: str) -> list[dict]: + return chain[issue_key.upper()] + + lock_id = await resolve_reproducer_lock_id( + None, + "RHEL-200", + fetch_issuelinks=fetch, + ) + assert lock_id == "RHEL-100" + + +@pytest.mark.asyncio +async def test_resolve_reproducer_lock_id_skips_clone_walk_for_cve(): + fetch = AsyncMock() + lock_id = await resolve_reproducer_lock_id( + "CVE-2026-1", + "RHEL-200", + fetch_issuelinks=fetch, + ) + assert lock_id == "CVE-2026-1" + fetch.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_reproducer_lock_id_falls_back_on_fetch_error(): + fetch = AsyncMock(side_effect=RuntimeError("jira down")) + lock_id = await resolve_reproducer_lock_id( + None, + "RHEL-200", + fetch_issuelinks=fetch, + ) + assert lock_id == "RHEL-200" @pytest.mark.asyncio diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index ca7a83ea3..e2af205d7 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -94,6 +94,28 @@ logger = logging.getLogger(__name__) +async def fetch_jira_issue_issuelinks(issue_key: str) -> list[dict[str, Any]]: + """Fetch ``fields.issuelinks`` for a Jira issue (minimal API query).""" + headers = get_jira_auth_headers() + jira_url = urljoin(os.getenv("JIRA_URL"), f"rest/api/3/issue/{issue_key}") + logger.info("Fetching Jira issuelinks for %s", issue_key) + + async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: + with tool_error_context(f"Failed to get issuelinks for issue {issue_key}", url=jira_url): + async with aiohttp_get_with_retries( + session, + jira_url, + params={"fields": "issuelinks"}, + headers=headers, + ) as response: + response.raise_for_status() + issue_data = await response.json() + + fields = issue_data.get("fields") or {} + issuelinks = fields.get("issuelinks") + return issuelinks if isinstance(issuelinks, list) else [] + + def _skip_jira_writes() -> bool: return os.getenv("JIRA_DRY_RUN", "False").lower() == "true"