diff --git a/ymir/agents/mr_consolidation_agent.py b/ymir/agents/mr_consolidation_agent.py index b908341d9..145becaef 100644 --- a/ymir/agents/mr_consolidation_agent.py +++ b/ymir/agents/mr_consolidation_agent.py @@ -180,6 +180,31 @@ def _extract_cves_from_text(text: str) -> list[str]: return sorted(set(re.findall(r"CVE-\d{4}-\d+", text))) +async def _extract_resolves_from_commit( + commit_sha: str, + spec_name: str, + clone, + env=None, +) -> str | None: + """Extract the Jira key from a commit's own changelog Resolves line. + + Looks at the spec-file diff introduced by *commit_sha* and returns + the first ``RHEL-\\d+`` key found on an added ``Resolves:`` line, + or ``None`` if none is found. + """ + _, diff_output, _ = await run_subprocess( + ["git", "diff", f"{commit_sha}^..{commit_sha}", "--", spec_name], + cwd=clone, + env=env, + ) + for line in (diff_output or "").splitlines(): + if line.startswith("+") and not line.startswith("+++"): + m = re.search(r"Resolves:\s*(RHEL-\d+)", line, re.IGNORECASE) + if m: + return m.group(1).upper() + return None + + _CONSOLIDATION_MR_LABELS = ["ymir_backport", "ymir_rebuild"] _MR_TYPE_PRIORITY = {"backport": 0, "rebuild": 1} @@ -944,10 +969,24 @@ async def per_commit_flow(state): # Use the original commit message for the changelog # summary so the log agent produces a matching entry. summary = original_msg.split("\n")[0] + + # Extract the Jira key from the commit's own + # changelog Resolves line so re-consolidated MRs + # keep their per-CVE keys instead of inheriting + # a single key from the MR description. + commit_jira = await _extract_resolves_from_commit( + commit_sha, + spec_name, + state.local_clone, + env=git_env, + ) + if not commit_jira: + commit_jira = other_jira[0] if other_jira else None + log_prompt = render_template( get_log_prompt(), LogInputSchema( - jira_issue=other_jira[0] if other_jira else None, + jira_issue=commit_jira, changes_summary=summary, ), ) @@ -1258,7 +1297,9 @@ async def run_log_agent(state): log_prompt = render_template( get_log_prompt(), LogInputSchema( - jira_issue=state.jira_issue, + jira_issue=( + ", ".join(state.jira_issues_collected) if state.jira_issues_collected else None + ), changes_summary=changes_summary, ), ) diff --git a/ymir/agents/tests/unit/test_mr_consolidation.py b/ymir/agents/tests/unit/test_mr_consolidation.py index 4b77e97fb..886a4abbf 100644 --- a/ymir/agents/tests/unit/test_mr_consolidation.py +++ b/ymir/agents/tests/unit/test_mr_consolidation.py @@ -7,6 +7,7 @@ from ymir.agents.mr_consolidation_agent import ( _extract_cves_from_text, _extract_jira_issues_from_description, + _extract_resolves_from_commit, _mr_type_from_labels, ) from ymir.agents.tasks import ( @@ -710,3 +711,147 @@ def test_one_backport_one_rebuild_selected(self): assert _mr_type_from_labels(selected[0]) == "backport" assert _mr_type_from_labels(selected[1]) == "rebuild" + + +# -- _extract_resolves_from_commit --------------------------------------------- + + +class TestExtractResolvesFromCommit: + """Tests for extracting Jira keys from a commit's own spec changelog diff.""" + + @pytest.fixture + def git_repo(self, tmp_path): + """Create a minimal git repo with an initial spec file.""" + import subprocess + + repo = tmp_path / "repo" + repo.mkdir() + spec = repo / "test.spec" + spec.write_text( + "Name: test\nVersion: 1.0\nRelease: 1\n\n" + "%changelog\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n" + ) + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo, + check=True, + capture_output=True, + ) + return repo + + def _make_commit(self, repo, spec_content, message="update"): + import subprocess + + spec = repo / "test.spec" + spec.write_text(spec_content) + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", message], + cwd=repo, + check=True, + capture_output=True, + ) + sha = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return sha.stdout.strip() + + @pytest.mark.asyncio + async def test_extracts_resolves_from_commit_diff(self, git_repo): + sha = self._make_commit( + git_repo, + "Name: test\nVersion: 1.0\nRelease: 2\n\n" + "%changelog\n" + "* Mon Jul 20 2026 Ymir - 1.0-2\n" + "- Fix CVE-2026-58014\n" + "- Resolves: RHEL-190609\n" + "\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n", + ) + result = await _extract_resolves_from_commit(sha, "test.spec", git_repo) + assert result == "RHEL-190609" + + @pytest.mark.asyncio + async def test_returns_none_when_no_resolves(self, git_repo): + sha = self._make_commit( + git_repo, + "Name: test\nVersion: 1.0\nRelease: 2\n\n" + "%changelog\n" + "* Mon Jul 20 2026 Ymir - 1.0-2\n" + "- General bugfix\n" + "\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n", + ) + result = await _extract_resolves_from_commit(sha, "test.spec", git_repo) + assert result is None + + @pytest.mark.asyncio + async def test_handles_indented_resolves(self, git_repo): + sha = self._make_commit( + git_repo, + "Name: test\nVersion: 1.0\nRelease: 2\n\n" + "%changelog\n" + "* Mon Jul 20 2026 Ymir - 1.0-2\n" + "- Fix CVE-2026-58014\n" + " Resolves: RHEL-190609\n" + "\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n", + ) + result = await _extract_resolves_from_commit(sha, "test.spec", git_repo) + assert result == "RHEL-190609" + + @pytest.mark.asyncio + async def test_normalizes_lowercase_jira_key(self, git_repo): + sha = self._make_commit( + git_repo, + "Name: test\nVersion: 1.0\nRelease: 2\n\n" + "%changelog\n" + "* Mon Jul 20 2026 Ymir - 1.0-2\n" + "- Fix something\n" + "- Resolves: rhel-190609\n" + "\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n", + ) + result = await _extract_resolves_from_commit(sha, "test.spec", git_repo) + assert result == "RHEL-190609" + + @pytest.mark.asyncio + async def test_returns_first_resolves_when_multiple(self, git_repo): + sha = self._make_commit( + git_repo, + "Name: test\nVersion: 1.0\nRelease: 2\n\n" + "%changelog\n" + "* Mon Jul 20 2026 Ymir - 1.0-2\n" + "- Fix something\n" + "- Resolves: RHEL-190609\n" + "- Related: RHEL-154707\n" + "\n" + "* Thu Dec 23 2021 Dev - 1.0-1\n" + "- Initial build\n", + ) + result = await _extract_resolves_from_commit(sha, "test.spec", git_repo) + assert result == "RHEL-190609"