diff --git a/ymir/agents/backport_agent.py b/ymir/agents/backport_agent.py index 41085ec33..7b4128d45 100644 --- a/ymir/agents/backport_agent.py +++ b/ymir/agents/backport_agent.py @@ -1449,6 +1449,7 @@ async def submit_consolidation_job(state): state.dist_git_branch, gateway_tools, redis_conn, + jira_issue=state.jira_issue, ) except InvalidConsolidationConfigError as e: logger.warning("Invalid consolidation config for %s: %s", state.package, e) diff --git a/ymir/agents/mr_consolidation_agent.py b/ymir/agents/mr_consolidation_agent.py index f052bcd55..0d5a12aec 100644 --- a/ymir/agents/mr_consolidation_agent.py +++ b/ymir/agents/mr_consolidation_agent.py @@ -188,6 +188,19 @@ def _extract_cves_from_cve_footer_lines(text: str) -> list[str]: return sorted(set(cves)) +def _extract_jira_from_mr_descriptions(mrs: list[dict]) -> list[str]: + """Extract Jira issue keys from MR description Resolves:/Related: footers. + + Used in auto mode to seed jira_issues_collected before the git clone is + available, so early-exit paths can still post Jira comments. + """ + issues: list[str] = [] + for mr in mrs: + desc = mr.get("description") or "" + issues.extend(_extract_jira_issues_from_resolves_footer_lines(desc)) + return sorted(set(issues)) + + def _extract_jira_issues_from_resolves_footer_lines(text: str) -> list[str]: """Extract RHEL keys only from ``Resolves:`` / ``Related:`` lines. @@ -299,7 +312,7 @@ def _mr_type_from_labels(mr: dict) -> str: async def _resolve_source_issues( - state, + state: ConsolidationState, project_path: str, issue_keys: list[str], gateway_tools: list, @@ -349,10 +362,14 @@ async def _resolve_source_issues( ) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status=f"Could not find an open MR for {issue_key}", + status="mr_not_found", + status_detail=f"Could not find an open MR for {issue_key}", error=f"No open MR matching {issue_key} in {project_path}", ) - return Workflow.END + # When have no collected issues post updates under those from issue_keys + if not state.jira_issues_collected: + state.jira_issues_collected = issue_keys + return "update_jira_issues" matched_mrs.append(mr) logger.info( @@ -375,8 +392,12 @@ async def _resolve_source_issues( logger.info("Fewer than 2 unique MRs resolved, nothing to consolidate") state.consolidation_result = MRConsolidationOutputSchema( success=True, - status="Fewer than 2 unique MRs resolved; nothing to do.", + status="nothing_to_consolidate", + status_detail="Fewer than 2 unique MRs resolved; nothing to do.", ) + # When have no collected issues post updates under those from issue_keys + if not state.jira_issues_collected: + state.jira_issues_collected = issue_keys return Workflow.END state.all_open_mrs = matched_mrs @@ -444,7 +465,7 @@ async def run_workflow( workflow = Workflow(ConsolidationState, name="MRConsolidationWorkflow") - async def list_open_mrs(state): + async def list_open_mrs(state: ConsolidationState): """List open backport and rebuild MRs for the package/branch.""" if state.mr_branches: logger.info( @@ -496,14 +517,17 @@ async def list_open_mrs(state): ) state.consolidation_result = MRConsolidationOutputSchema( success=True, - status="Fewer than 2 MRs to consolidate; nothing to do.", + status="nothing_to_consolidate", + status_detail="Fewer than 2 MRs to consolidate; nothing to do.", ) + if not state.jira_issues_collected and all_mrs: + state.jira_issues_collected = _extract_jira_from_mr_descriptions(all_mrs) return Workflow.END state.all_open_mrs = all_mrs return "fork_and_prepare_dist_git" - async def fork_and_prepare_dist_git(state): + async def fork_and_prepare_dist_git(state: ConsolidationState): working_id = f"consolidation-{package}-{dist_git_branch}-{int(time.time())}" ( state.local_clone, @@ -622,8 +646,11 @@ async def fork_and_prepare_dist_git(state): ) state.consolidation_result = MRConsolidationOutputSchema( success=True, - status="Fewer than 2 MRs based on current HEAD; nothing to do.", + status="nothing_to_consolidate", + status_detail="Fewer than 2 MRs based on current HEAD; nothing to do.", ) + if not state.jira_issues_collected and state.all_open_mrs: + state.jira_issues_collected = _extract_jira_from_mr_descriptions(state.all_open_mrs) return Workflow.END # Sort by type priority (backport first, then rebuild) @@ -641,10 +668,13 @@ async def fork_and_prepare_dist_git(state): ) state.consolidation_result = MRConsolidationOutputSchema( success=True, - status="No backport MR on current HEAD; " + status="nothing_to_consolidate", + status_detail="No backport MR on current HEAD; " "consolidation without a backport is not supported.", ) - return Workflow.END + if not state.jira_issues_collected and state.all_open_mrs: + state.jira_issues_collected = _extract_jira_from_mr_descriptions(state.all_open_mrs) + return "update_jira_issues" selected = sorted_mrs[:2] @@ -679,7 +709,8 @@ async def fork_and_prepare_dist_git(state): ) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to diff branch", + status="failed", + status_detail="Failed to diff branch", error=f"git diff {dist_git_branch}...{branch_name} " f"failed (exit {exit_code}): {err_msg}", ) @@ -702,7 +733,8 @@ async def fork_and_prepare_dist_git(state): logger.error("Failed to collect commit footers: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to collect commit footers", + status="failed", + status_detail="Failed to collect commit footers", error=str(e), ) return "handle_failure" @@ -723,7 +755,7 @@ async def fork_and_prepare_dist_git(state): return "per_commit_flow" if release_strategy == "per_commit" else "run_consolidation_agent" - async def run_consolidation_agent(state): + async def run_consolidation_agent(state: ConsolidationState): prompt = render_template( "mr_consolidation/prompt.j2", MRConsolidationInputSchema( @@ -751,14 +783,16 @@ async def run_consolidation_agent(state): logger.error("Consolidation agent error: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Agent error", + status="failed", + status_detail="Agent error", error=str(e), ) except Exception as e: logger.error("Unexpected consolidation error: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Unexpected error", + status="failed", + status_detail="Unexpected error", error=str(e), ) @@ -766,7 +800,7 @@ async def run_consolidation_agent(state): return "handle_failure" return "run_build_agent" - async def run_build_agent(state): + async def run_build_agent(state: ConsolidationState): if not state.consolidation_result or not state.consolidation_result.srpm_path: logger.warning("No SRPM generated, skipping build verification") return "stage_changes" @@ -781,7 +815,7 @@ async def run_build_agent(state): ), ) - def _retry_step_for_build(state): + def _retry_step_for_build(state: ConsolidationState): """Determine which flow to retry on build failure.""" has_rebuild_other = any(t == "rebuild" for t in state.mr_types.values()) if has_rebuild_other and len(state.mr_types) > 1: @@ -802,6 +836,12 @@ def _retry_step_for_build(state): state.attempts_remaining -= 1 if state.attempts_remaining > 0: return _retry_step_for_build(state) + state.consolidation_result = MRConsolidationOutputSchema( + success=False, + status="failed", + status_detail="Package build failed", + error=build_output.error, + ) return "handle_failure" except Exception as e: logger.error("Build verification error: %s", e) @@ -809,6 +849,12 @@ def _retry_step_for_build(state): state.attempts_remaining -= 1 if state.attempts_remaining > 0: return _retry_step_for_build(state) + state.consolidation_result = MRConsolidationOutputSchema( + success=False, + status="failed", + status_detail="Unexpected error", + error=str(e), + ) return "handle_failure" if release_strategy == "per_commit": @@ -860,7 +906,7 @@ async def _run_prep(clone) -> tuple[bool, str]: return False, output return True, output - async def per_commit_flow(state): + async def per_commit_flow(state: ConsolidationState): """Cherry-pick base branch, then incrementally adapt commits from the other branch. 1. Choose the branch with larger patches as the "base" — cherry-pick @@ -1114,10 +1160,14 @@ async def per_commit_flow(state): ), ) output = srpm_result.result - srpm_path = output.strip() if "FAILED" not in output else None + output_stripped = output.strip() + srpm_path = output_stripped if output_stripped and "FAILED" not in output else None state.consolidation_result = MRConsolidationOutputSchema( success=srpm_path is not None, - status="per_commit consolidation complete", + status="consolidation_complete" if srpm_path else "failed", + status_detail="per_commit consolidation complete" + if srpm_path + else "per_commit flow failed", srpm_path=srpm_path, ) if not srpm_path: @@ -1128,14 +1178,15 @@ async def per_commit_flow(state): logger.error("per_commit flow error: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="per_commit flow failed", + status="failed", + status_detail="per_commit flow failed", error=str(e), ) return "handle_failure" return "run_build_agent" - async def rebuild_append_flow(state): + async def rebuild_append_flow(state: ConsolidationState): """Append rebuild ticket(s) to the backport MR without cherry-picking. The backport branch is cherry-picked as base (it has patches + Release @@ -1300,10 +1351,14 @@ async def rebuild_append_flow(state): ), ) output = srpm_result.result - srpm_path = output.strip() if "FAILED" not in output else None + output_stripped = output.strip() + srpm_path = output_stripped if output_stripped and "FAILED" not in output else None state.consolidation_result = MRConsolidationOutputSchema( success=srpm_path is not None, - status="rebuild_append consolidation complete", + status="consolidation_complete" if srpm_path else "failed", + status_detail="rebuild_append consolidation complete" + if srpm_path + else "rebuild_append flow failed", srpm_path=srpm_path, ) if not srpm_path: @@ -1314,14 +1369,15 @@ async def rebuild_append_flow(state): logger.error("rebuild_append flow error: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="rebuild_append flow failed", + status="failed", + status_detail="rebuild_append flow failed", error=str(e), ) return "handle_failure" return "run_build_agent" - async def update_release(state): + async def update_release(state: ConsolidationState): try: await tasks.update_release( local_clone=state.local_clone, @@ -1334,13 +1390,14 @@ async def update_release(state): logger.error("Error updating release: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to update release", + status="failed", + status_detail="Failed to update release", error=str(e), ) return "handle_failure" return "stage_changes" - async def stage_changes(state): + async def stage_changes(state: ConsolidationState): try: files_to_stage = _files_to_stage_for_patches(state.local_clone, package) logger.info("Staging files: %s", files_to_stage) @@ -1352,7 +1409,8 @@ async def stage_changes(state): logger.error("Error staging changes: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to stage changes", + status="failed", + status_detail="Failed to stage changes", error=str(e), ) return "handle_failure" @@ -1360,15 +1418,15 @@ async def stage_changes(state): return "commit_push_and_open_mr" return "run_log_agent" - async def run_log_agent(state): + async def run_log_agent(state: ConsolidationState): summary_parts = [ f"Consolidated {len(state.mr_branches)} backport branches " f"for {package} on {dist_git_branch}.", ] if state.mr_titles: summary_parts.extend(f" - {title}" for title in state.mr_titles) - if state.consolidation_result and state.consolidation_result.status: - summary_parts.append(f"Result: {state.consolidation_result.status}") + if state.consolidation_result and state.consolidation_result.status_detail: + summary_parts.append(f"Result: {state.consolidation_result.status_detail}") changes_summary = "\n".join(summary_parts) log_prompt = render_template( @@ -1395,7 +1453,7 @@ async def run_log_agent(state): ) return "stage_changes" - async def commit_push_and_open_mr(state): + async def commit_push_and_open_mr(state: ConsolidationState): """Squash all changes into a single commit (merged strategy), then push.""" if state.log_result: commit_message = f"{state.log_result.title}\n\n{state.log_result.description}" @@ -1450,14 +1508,15 @@ async def commit_push_and_open_mr(state): logger.error("Failed to finalize commit: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to create consolidated commit", + status="failed", + status_detail="Failed to create consolidated commit", error=str(e), ) - return Workflow.END + return "handle_failure" return "push_and_open_mr" - async def push_and_open_mr(state): + async def push_and_open_mr(state: ConsolidationState): """Push commits and open the consolidated MR on GitLab.""" has_rebuild = any(t == "rebuild" for t in state.mr_types.values()) combined_description = _build_consolidated_description( @@ -1534,14 +1593,15 @@ async def push_and_open_mr(state): logger.error("Failed to create consolidated MR: %s", e) state.consolidation_result = MRConsolidationOutputSchema( success=False, - status="Failed to create consolidated MR", + status="failed", + status_detail="Failed to create consolidated MR", error=str(e), ) - return Workflow.END + return "handle_failure" return "mark_original_mrs" - async def mark_original_mrs(state): + async def mark_original_mrs(state: ConsolidationState): """Label original MRs as consolidated so they are excluded from future runs.""" if dry_run: logger.info( @@ -1565,26 +1625,80 @@ async def mark_original_mrs(state): return "update_jira_issues" - async def update_jira_issues(state): - if not state.jira_issues_collected or not state.merge_request_url: + async def update_jira_issues(state: ConsolidationState): + """Post status updates to Jira issues based on consolidation outcome.""" + if not state.jira_issues_collected: + return "requeue_if_needed" + + # Determine if we should post a comment based on the status + if not state.consolidation_result: + return "requeue_if_needed" + + status = state.consolidation_result.status + + # Skip comment posting only when there's genuinely nothing to report + # (i.e., no failure, no success, and no informational status) + if not state.merge_request_url and status == "consolidation_complete": + logger.warning( + "Consolidation marked complete for %s/%s but no MR URL available - possible workflow bug", + package, + dist_git_branch, + ) return "requeue_if_needed" has_rebuild = any(t == "rebuild" for t in state.mr_types.values()) for issue_key in state.jira_issues_collected: - if has_rebuild: + comment = None + + if status == "failed": + # Report failure details from consolidation_result + error_detail = state.consolidation_result.error or "unknown" + comment = f"MR consolidation failed for {package}/{dist_git_branch}: {error_detail}" + elif status == "consolidation_complete": + # Successfully consolidated and created an MR + if has_rebuild: + comment = ( + f"Your MR has been consolidated (backport + rebuild) " + f"into a single MR: {state.merge_request_url}" + ) + else: + comment = ( + f"Your backport MR has been consolidated with other fixes " + f"into a single MR: {state.merge_request_url}" + ) + elif status == "nothing_to_consolidate": + # Informational: not enough MRs to consolidate + detail = ( + state.consolidation_result.status_detail or "Nothing to consolidate at this time." + ) comment = ( - f"Your MR has been consolidated (backport + rebuild) " - f"into a single MR: {state.merge_request_url}" + f"MR consolidation check completed for {package}/{dist_git_branch}.\n\n" + f"{detail}\n\n" + f"Your MR will be evaluated again when more MRs are available for consolidation." ) - else: + elif status == "mr_not_found": + # Could not find MR for the specified issue + detail = state.consolidation_result.status_detail or "Could not find MR for this issue." comment = ( - f"Your backport MR has been consolidated with other fixes " - f"into a single MR: {state.merge_request_url}" + f"MR consolidation could not proceed for {package}/{dist_git_branch}.\n\n{detail}" + ) + + if not comment: + logger.warning( + ( + "No comment generated for issue %s with status '%s' " + "- unhandled ConsolidationStatus value" + ), + issue_key, + status, ) + continue + if dry_run: logger.info( - "Dry run: would post consolidation comment on %s", + "Dry run: would post consolidation comment on %s: %s", issue_key, + comment, ) continue try: @@ -1603,9 +1717,13 @@ async def update_jira_issues(state): e, ) + if status in ("failed", "nothing_to_consolidate", "mr_not_found"): + # End the workflow if we have experienced failure, there is nothing to do or MR was not found + return Workflow.END + return "requeue_if_needed" - async def requeue_if_needed(state): + async def requeue_if_needed(state: ConsolidationState): current_count = state.current_mrs_count remaining = current_count - 2 if remaining < 1: @@ -1627,14 +1745,19 @@ async def requeue_if_needed(state): ) return Workflow.END - async def handle_failure(state): + async def handle_failure(state: ConsolidationState): + """Log failure and request update of all linked Jira items.""" logger.error( "MR consolidation failed for %s/%s: %s", package, dist_git_branch, state.consolidation_result.error if state.consolidation_result else "unknown", ) - return Workflow.END + # In auto mode, jira_issues_collected may not be populated yet + # (failure before footer collection). Fall back to MR descriptions. + if not state.jira_issues_collected and state.all_open_mrs: + state.jira_issues_collected = _extract_jira_from_mr_descriptions(state.all_open_mrs) + return "update_jira_issues" workflow.add_step("list_open_mrs", list_open_mrs) workflow.add_step("fork_and_prepare_dist_git", fork_and_prepare_dist_git) @@ -1666,8 +1789,39 @@ async def handle_failure(state): # CVE / Jira lists are collected from commit footers after branches # are fetched — do not seed from branch metadata. - response = await workflow.run(initial_state) - return response.state + try: + response = await workflow.run(initial_state) + return response.state + except Exception: + logger.exception( + "Unhandled error in consolidation workflow for %s/%s", + package, + dist_git_branch, + ) + # Notify all known Jira issues — prefer keys collected from commit + # footers/MR descriptions; fall back to the source_issues seed. + issue_keys_to_notify = initial_state.jira_issues_collected or list(source_issues or []) + if issue_keys_to_notify and not dry_run: + msg = ( + f"MR consolidation failed for {package}/{dist_git_branch} " + f"with an unexpected error. Please check the agent logs." + ) + for issue_key in issue_keys_to_notify: + try: + await run_tool( + "add_jira_comment", + issue_key=issue_key, + comment=msg, + private=True, + available_tools=gateway_tools, + ) + except Exception as e: + logger.warning( + "Failed to post unhandled-failure comment on %s: %s", + issue_key, + e, + ) + raise _CONSOLIDATED_MARKER = "## Consolidated Backport MR" diff --git a/ymir/agents/rebuild_agent.py b/ymir/agents/rebuild_agent.py index 3f8570729..643d93d69 100644 --- a/ymir/agents/rebuild_agent.py +++ b/ymir/agents/rebuild_agent.py @@ -295,6 +295,7 @@ async def submit_consolidation_job(state): state.dist_git_branch, gateway_tools, redis_conn, + jira_issue=state.jira_issue, ) except InvalidConsolidationConfigError as e: logger.warning("Invalid consolidation config for %s: %s", state.package, e) diff --git a/ymir/agents/tasks.py b/ymir/agents/tasks.py index 26d84f4d2..018a329bd 100644 --- a/ymir/agents/tasks.py +++ b/ymir/agents/tasks.py @@ -1075,11 +1075,19 @@ async def try_submit_consolidation_job( dist_git_branch: str, gateway_tools: list, redis_conn, + jira_issue: str | None = None, ) -> None: """Fetch consolidation config and submit a job if enabled. Shared logic used by both the backport and rebuild agents after - creating an MR. + creating an MR. Posts a Jira comment when consolidation is triggered. + + Args: + package: The package name + dist_git_branch: The dist-git branch + gateway_tools: List of available MCP tools + redis_conn: Redis connection for job submission + jira_issue: Optional Jira issue key to post consolidation notification Raises: InvalidConsolidationConfigError: When ymir.yaml exists but the diff --git a/ymir/agents/tests/unit/test_tasks.py b/ymir/agents/tests/unit/test_tasks.py index 7eb196ed3..b1c57d45b 100644 --- a/ymir/agents/tests/unit/test_tasks.py +++ b/ymir/agents/tests/unit/test_tasks.py @@ -18,9 +18,10 @@ post_user_ack_once, push_changes, request_mr_qe_reviews, + try_submit_consolidation_job, ) from ymir.common.constants import JiraLabels, RedisQueues -from ymir.common.models import Task +from ymir.common.models import PackageConsolidationConfig, Task @asynccontextmanager @@ -812,3 +813,141 @@ async def test_fetch_release_bumping_config_returns_default_when_no_release_bump assert config.abandon_autorelease is False assert config.treat_maintenance_rhel_as_zstream is False assert config.disregard_zstream_nvr_policy is False + + +# -- try_submit_consolidation_job --------------------------------------------- + + +def _consolidation_config(merge_mrs: bool = True) -> PackageConsolidationConfig: + return PackageConsolidationConfig(merge_mrs=merge_mrs) + + +@pytest.mark.asyncio +async def test_try_submit_no_jira_comment_when_issue_is_none(): + """When jira_issue=None no Jira comment is posted, even if the job was submitted.""" + with ( + patch( + "ymir.agents.tasks.fetch_consolidation_config", + new_callable=AsyncMock, + return_value=_consolidation_config(), + ), + patch( + "ymir.agents.tasks.submit_merge_job", + new_callable=AsyncMock, + return_value=True, + ), + patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool, + ): + await try_submit_consolidation_job( + package="bash", + dist_git_branch="c10s", + gateway_tools=[], + redis_conn=AsyncMock(), + jira_issue=None, + ) + + mock_run_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_try_submit_no_jira_comment_when_already_queued(): + """When the job is already queued (submit_merge_job returns False) no comment is posted.""" + with ( + patch( + "ymir.agents.tasks.fetch_consolidation_config", + new_callable=AsyncMock, + return_value=_consolidation_config(), + ), + patch( + "ymir.agents.tasks.submit_merge_job", + new_callable=AsyncMock, + return_value=False, + ), + patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool, + ): + await try_submit_consolidation_job( + package="bash", + dist_git_branch="c10s", + gateway_tools=[], + redis_conn=AsyncMock(), + jira_issue="RHEL-12345", + ) + + mock_run_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_try_submit_jira_comment_failure_is_swallowed(): + """A failure posting the Jira comment must not propagate — it is logged and ignored.""" + with ( + patch( + "ymir.agents.tasks.fetch_consolidation_config", + new_callable=AsyncMock, + return_value=_consolidation_config(), + ), + patch( + "ymir.agents.tasks.submit_merge_job", + new_callable=AsyncMock, + return_value=True, + ), + patch( + "ymir.agents.tasks.run_tool", + new_callable=AsyncMock, + side_effect=RuntimeError("Jira down"), + ), + ): + await try_submit_consolidation_job( + package="bash", + dist_git_branch="c10s", + gateway_tools=[], + redis_conn=AsyncMock(), + jira_issue="RHEL-12345", + ) + + +@pytest.mark.asyncio +async def test_try_submit_skips_when_consolidation_disabled(): + """When merge_mrs=False no job is submitted and no Jira comment is posted.""" + with ( + patch( + "ymir.agents.tasks.fetch_consolidation_config", + new_callable=AsyncMock, + return_value=_consolidation_config(merge_mrs=False), + ), + patch("ymir.agents.tasks.submit_merge_job", new_callable=AsyncMock) as mock_submit, + patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool, + ): + await try_submit_consolidation_job( + package="bash", + dist_git_branch="c10s", + gateway_tools=[], + redis_conn=AsyncMock(), + jira_issue="RHEL-12345", + ) + + mock_submit.assert_not_awaited() + mock_run_tool.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_try_submit_skips_when_redis_is_none(): + """Without a Redis connection (direct mode) no job is submitted and no comment is posted.""" + with ( + patch( + "ymir.agents.tasks.fetch_consolidation_config", + new_callable=AsyncMock, + return_value=_consolidation_config(), + ), + patch("ymir.agents.tasks.submit_merge_job", new_callable=AsyncMock) as mock_submit, + patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool, + ): + await try_submit_consolidation_job( + package="bash", + dist_git_branch="c10s", + gateway_tools=[], + redis_conn=None, + jira_issue="RHEL-12345", + ) + + mock_submit.assert_not_awaited() + mock_run_tool.assert_not_awaited() diff --git a/ymir/common/models.py b/ymir/common/models.py index 8e3a80a70..6f54df8ce 100644 --- a/ymir/common/models.py +++ b/ymir/common/models.py @@ -899,11 +899,23 @@ class MRConsolidationInputSchema(BaseModel): build_error: str | None = Field(default=None, description="Error encountered during package build") +ConsolidationStatus = Literal[ + "nothing_to_consolidate", # Fewer than 2 MRs, or no backport MR found + "mr_not_found", # Could not find MR for specified issue + "consolidation_complete", # Successfully consolidated and created MR + "failed", # Consolidation failed (see error field for details) +] + + class MRConsolidationOutputSchema(BaseModel): """Output schema for the MR consolidation agent.""" success: bool = Field(description="Whether the consolidation was successfully completed") - status: str = Field(description="Consolidation status with details of how the merge was performed") + status: ConsolidationStatus = Field(description="Consolidation status indicating the outcome type") + status_detail: str | None = Field( + default=None, + description="Human-readable details about the status", + ) srpm_path: Path | None = Field(default=None, description="Absolute path to generated SRPM") error: str | None = Field(default=None, description="Specific details about an error") files_to_git_add: list[str] | None = Field( diff --git a/ymir/common/tests/unit/test_models.py b/ymir/common/tests/unit/test_models.py index c0d4e1fd1..80158f08d 100644 --- a/ymir/common/tests/unit/test_models.py +++ b/ymir/common/tests/unit/test_models.py @@ -1,3 +1,6 @@ +import pytest +from pydantic import ValidationError + from ymir.common.models import ( AUTOMATED_RESOLUTION_NOT_SUPPORTED, TRIAGE_DISCLAIMER, @@ -6,6 +9,7 @@ ClarificationNeededData, ConsolidatedIssue, ErrorData, + MRConsolidationOutputSchema, NotAffectedData, OpenEndedAnalysisData, PostponedData, @@ -670,3 +674,70 @@ def test_reproducer_output_retryable_error(): assert data.test_already_exists is False restored = ReproducerOutputSchema.model_validate_json(data.model_dump_json()) assert restored.retryable_error is True + + +# --- MRConsolidationOutputSchema tests --- + + +class TestMRConsolidationOutputSchema: + @pytest.mark.parametrize( + "status", + [ + "nothing_to_consolidate", + "mr_not_found", + "consolidation_complete", + "failed", + ], + ) + def test_valid_status_values_accepted(self, status): + schema = MRConsolidationOutputSchema(success=True, status=status) + assert schema.status == status + + def test_invalid_status_raises_validation_error(self): + with pytest.raises(ValidationError): + MRConsolidationOutputSchema(success=True, status="in_progress") + + def test_status_detail_defaults_to_none(self): + schema = MRConsolidationOutputSchema(success=True, status="consolidation_complete") + assert schema.status_detail is None + + def test_status_detail_is_set(self): + schema = MRConsolidationOutputSchema( + success=False, + status="failed", + status_detail="Agent error during consolidation", + ) + assert schema.status_detail == "Agent error during consolidation" + + def test_failed_status_with_error_field(self): + schema = MRConsolidationOutputSchema( + success=False, + status="failed", + status_detail="Unexpected error", + error="Traceback (most recent call last): ...", + ) + assert schema.success is False + assert schema.status == "failed" + assert schema.status_detail == "Unexpected error" + assert schema.error is not None + + def test_nothing_to_consolidate_is_successful(self): + """A nothing_to_consolidate result is a success (not a failure).""" + schema = MRConsolidationOutputSchema( + success=True, + status="nothing_to_consolidate", + status_detail="Fewer than 2 unique MRs resolved; nothing to do.", + ) + assert schema.success is True + assert schema.error is None + + def test_mr_not_found_with_error(self): + schema = MRConsolidationOutputSchema( + success=False, + status="mr_not_found", + status_detail="Could not find an open MR for RHEL-99999", + error="No open MR matching RHEL-99999 in rpms/bash", + ) + assert schema.status == "mr_not_found" + assert "RHEL-99999" in schema.status_detail + assert "RHEL-99999" in schema.error