From 02a3298a2121d25e2c7795da6ea66acf61ead7ed Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Wed, 10 Jun 2026 20:43:46 +0200 Subject: [PATCH 01/19] Add standalone errata-workflow agent with MCP tools Convert the supervisor's ErratumHandler into a standalone BeeAI workflow agent that communicates exclusively through MCP tools. This includes: - Errata models in ymir/common/models.py (ErratumBuild, TransitionRuleSet, RHELVersion, ErratumPushDetails, etc.) - 9 new errata MCP tools in ymir/tools/privileged/errata.py (transition rules, build maps, stage push, state changes, ownership, comments) - CreateJiraIssueTool in ymir/tools/privileged/jira.py - All tools registered in gateway.py - errata_workflow_agent.py with full workflow: fetch erratum, check attention flags, verify ownership, route by status, advance through states, and verify product listings - compose.yaml service and Makefile target for standalone execution - SKILL.md documenting the complete workflow Co-Authored-By: Claude Opus 4.6 --- Makefile | 8 + agents_as_skills/errata_workflow/SKILL.md | 130 +++++ compose.yaml | 7 + ymir/agents/errata_workflow_agent.py | 637 ++++++++++++++++++++ ymir/common/models.py | 150 ++++- ymir/tools/privileged/errata.py | 676 +++++++++++++++++++++- ymir/tools/privileged/gateway.py | 25 +- ymir/tools/privileged/jira.py | 120 +++- 8 files changed, 1744 insertions(+), 9 deletions(-) create mode 100644 agents_as_skills/errata_workflow/SKILL.md create mode 100644 ymir/agents/errata_workflow_agent.py diff --git a/Makefile b/Makefile index a91f4a22..d7acf897 100644 --- a/Makefile +++ b/Makefile @@ -299,6 +299,14 @@ run-issue-verification-agent-standalone: -e IGNORE_NEEDS_ATTENTION=$(IGNORE_NEEDS_ATTENTION) \ issue-verification-agent +.PHONY: run-errata-workflow-agent-standalone +run-errata-workflow-agent-standalone: + $(COMPOSE_AGENTS) run --rm \ + -e ERRATUM_ID=$(ERRATUM_ID) \ + -e DRY_RUN=$(DRY_RUN) \ + -e IGNORE_NEEDS_ATTENTION=$(IGNORE_NEEDS_ATTENTION) \ + errata-workflow-agent + .PHONY: run-preliminary-testing-agent-standalone run-preliminary-testing-agent-standalone: $(COMPOSE_AGENTS) run --rm \ diff --git a/agents_as_skills/errata_workflow/SKILL.md b/agents_as_skills/errata_workflow/SKILL.md new file mode 100644 index 00000000..4cb7dbd2 --- /dev/null +++ b/agents_as_skills/errata_workflow/SKILL.md @@ -0,0 +1,130 @@ +--- +description: > + Runs the Errata Workflow for an erratum, advancing it through states + (NEW_FILES -> QE -> REL_PREP), handling stage pushes, CAT test timeouts, + product listing verification, and flagging for human attention. +arguments: + - name: erratum_id + description: "Erratum ID or advisory URL (e.g. '12345' or full URL)" + required: true + - name: dry_run + description: "If true, skip all Errata Tool and JIRA modifications. Default: false" + required: false + - name: ignore_needs_attention + description: "If true, process the erratum even if it already has a ymir_needs_attention RHELMISC issue. Default: false" + required: false +--- + +# Errata Workflow Skill + +You are the errata workflow agent for Project Ymir. Your task is to manage the lifecycle of an erratum — advancing it through states, handling stage pushes, verifying product listings, and flagging errata that need human attention. + +## Input Arguments + +- `erratum_id`: {{erratum_id}} — The erratum ID or advisory URL to process +- `dry_run`: {{dry_run}} — When true, skip all modifications +- `ignore_needs_attention`: {{ignore_needs_attention}} — When true, process even if already flagged + +## Tools + +This skill uses the following MCP tools: + +**Errata Tools:** +- `get_erratum` — Fetch erratum details (basic or full with comments) +- `get_erratum_transition_rules` — Scrape HTML for state transition guard rules +- `get_erratum_build_map` — Get build NVR + package file lists for an erratum +- `get_previous_erratum` — Search RHEL version inheritance chain for previous erratum +- `get_erratum_stage_push_details` — Get latest stage push status and timestamp +- `erratum_push_to_stage` — Push erratum to CDN stage (respects DRY_RUN) +- `erratum_change_state` — Change erratum state (respects DRY_RUN) +- `erratum_change_ownership` — Change erratum ownership (respects DRY_RUN) +- `erratum_add_comment` — Add comment to erratum (respects DRY_RUN) +- `erratum_refresh_security_alerts` — Refresh security alerts (respects DRY_RUN) + +**JIRA Tools:** +- `get_jira_details` — Fetch full details of a JIRA issue +- `search_jira_issues` — Search JIRA with JQL +- `edit_jira_labels` — Add or remove labels on a JIRA issue +- `create_jira_issue` — Create a new JIRA issue (for RHELMISC attention tracking) + +## Constants + +- `WAIT_DELAY`: 20 minutes (1200 seconds) — delay between reschedule checks +- `POST_PUSH_TESTING_TIMEOUT`: 3 hours — timeout for CAT tests after stage push +- `ERRATA_JOTNAR_BOT_EMAIL`: jotnar-bot@IPA.REDHAT.COM — Ymir's Errata Tool identity +- `JIRA_JOTNAR_BOT_EMAIL`: jotnar+bot@redhat.com — Ymir's JIRA identity +- `JIRA_JOTNAR_TEAM`: rhel-jotnar — Ymir's assigned team name + +## Workflow Steps + +### Step 1: Fetch Erratum +Fetch erratum details using `get_erratum`. Extract status, jira_issues, assigned_to_email, package_owner_email, and other fields. + +### Step 2: Check Needs Attention +Unless `ignore_needs_attention` is true, search for an existing RHELMISC issue with a YmirTag matching this erratum AND the `ymir_needs_attention` label. If found, stop processing — the erratum is already flagged for human attention. + +### Step 3: Fetch Related Issues +For each JIRA issue key in the erratum's `jira_issues` list, fetch full issue details using `get_jira_details`. Store all issue data for later checks. + +### Step 4: Check Ownership +Verify the erratum is owned by the Ymir bot (`jotnar-bot@IPA.REDHAT.COM`). If not: +- Check if all related JIRA issues have `AssignedTeam = rhel-jotnar` +- If yes: change erratum ownership to the bot and reschedule immediately +- If no: flag for human attention — erratum has issues not owned by Project Ymir + +### Step 5: Route by Status +Based on erratum status: +- **NEW_FILES**: Target advancing to QE +- **QE**: Check if all related JIRA issues are in "Release Pending" status. If yes, target advancing to REL_PREP. If not, stop. +- **Other statuses**: No action needed, stop. + +### Step 6: Try to Advance +Get transition rules using `get_erratum_transition_rules`. Handle outcomes: + +**All rules OK:** +- For REL_PREP target: proceed to product listing verification (Step 7) +- For other targets: change state immediately + +**Stagepush blocking:** +- If no push or previous push completed: initiate new stage push, reschedule in 20 minutes +- If push failed: flag for human attention +- If push in progress: reschedule in 20 minutes + +**Cat (CAT tests) blocking:** +- Get stage push details to check completion time +- If push not complete: reschedule in 20 minutes +- If push completed but within 3-hour timeout: reschedule in 20 minutes +- If push completed and timeout exceeded: flag for human attention + +**Securityalert blocking:** +- Refresh security alerts and reschedule in 20 minutes + +**Unknown blocking rules:** +- Flag for human attention with details of blocking rules + +### Step 7: Verify Product Listings (REL_PREP only) +For each package in the erratum build map: +1. Check if already verified (magic string `ymir-product-listings-checked(NVR)` or `jotnar-product-listings-checked(NVR)` in erratum comments) +2. Find the previous erratum using `get_previous_erratum` (RHEL version inheritance search) +3. Compare package file lists between current and previous builds +4. Add verification comment to erratum +5. If mismatches found: flag for human attention +6. If all match (or no previous erratum): advance to REL_PREP + +## Flagging for Human Attention + +When an erratum needs human attention: +1. Search JIRA for an existing RHELMISC issue with a YmirTag matching this erratum +2. If found: add `ymir_needs_attention` label to the existing issue +3. If not found: create a new RHELMISC issue with: + - Summary: `{advisory} ({synopsis}) needs attention` + - Description: YmirTag + erratum URL + reason + - Reporter/Assignee: jotnar+bot@redhat.com + - Labels: `ymir_needs_attention` + - Component: `jotnar-package-automation` + +## Output Schema + +The workflow returns a `WorkflowResult` with: +- `status`: A message describing what happened and why +- `reschedule_in`: Delay in seconds (-1 = don't reschedule, 0 = immediate, 1200 = 20 minutes) diff --git a/compose.yaml b/compose.yaml index 27e2c305..d584a51e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -198,6 +198,13 @@ services: command: ["python", "-m", "ymir.agents.issue_verification_agent"] profiles: ["agents"] + errata-workflow-agent: + <<: *beeai-agent-c10s + environment: + <<: *beeai-env + command: ["python", "-m", "ymir.agents.errata_workflow_agent"] + profiles: ["agents"] + triage-agent-e2e-tests: <<: *beeai-agent-c10s environment: diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py new file mode 100644 index 00000000..64e21682 --- /dev/null +++ b/ymir/agents/errata_workflow_agent.py @@ -0,0 +1,637 @@ +"""Errata Workflow Agent — standalone BeeAI agent for erratum lifecycle management. + +Advances errata through states (NEW_FILES → QE → REL_PREP), handles stage pushes, +CAT test timeouts, product listing verification, and flagging for human attention. +Communicates exclusively through MCP tools. +""" + +import asyncio +import logging +import os +import sys +import traceback +from datetime import UTC, datetime, timedelta +from typing import Any + +from beeai_framework.errors import FrameworkError +from beeai_framework.workflows import Workflow +from pydantic import BaseModel, Field + +from ymir.agents.observability import setup_observability +from ymir.agents.utils import mcp_tools, run_tool +from ymir.common.constants import JiraLabels +from ymir.common.logging_setup import configure_logging +from ymir.common.models import ( + ErratumBuild, + ErratumBuildMap, + ErratumPushStatus, + ErrataStatus, + TransitionRuleOutcome, + TransitionRuleSet, + WorkflowResult, + YmirTag, +) + +logger = logging.getLogger(__name__) + +# Constants +WAIT_DELAY = 20 * 60 # 20 minutes +POST_PUSH_TESTING_TIMEOUT = timedelta(hours=3) +POST_PUSH_TESTING_TIMEOUT_STR = "3 hours" +ERRATA_JOTNAR_BOT_EMAIL = "jotnar-bot@IPA.REDHAT.COM" +JIRA_JOTNAR_BOT_EMAIL = "jotnar+bot@redhat.com" +JIRA_JOTNAR_TEAM = "rhel-jotnar" +ET_URL = "https://errata.engineering.redhat.com" + + +class ErrataWorkflowState(BaseModel): + erratum_id: str + dry_run: bool = False + ignore_needs_attention: bool = False + + erratum: dict | None = Field(default=None) + related_issues: list[dict] | None = Field(default=None) + target_status: str | None = Field(default=None) + result: WorkflowResult | None = Field(default=None) + + +def _needs_attention_tag(erratum_id: int) -> YmirTag: + return YmirTag(type="needs_attention", resource="erratum", id=str(erratum_id)) + + +def _get_erratum_advisory_url(erratum_id: int | str) -> str: + return f"{ET_URL}/advisory/{erratum_id}" + + +def compare_file_lists( + current_build: ErratumBuild, + previous_build: ErratumBuild, + previous_erratum_id: str | int, +) -> tuple[bool, str]: + is_matched = current_build.package_file_list == previous_build.package_file_list + + comment = ( + f"ymir-product-listings-checked({current_build.nvr})\n\n" + f"Compared the file lists for {current_build.nvr} to the file lists for\n" + f"{previous_build.nvr} in {_get_erratum_advisory_url(previous_erratum_id)} -\n" + ) + + if is_matched: + comment += "the same subpackages are shipped to each variant. Proceeding with the errata workflow." + else: + comment += ( + "differences were found.\n\n" + "Old file list:\n" + f"{previous_build.model_dump_json(indent=2)}\n\n" + "New file list:\n" + f"{current_build.model_dump_json(indent=2)}\n\n" + "Flagging for human attention." + ) + return is_matched, comment + + +async def run_errata_workflow( + erratum_id: str, + dry_run: bool = False, + ignore_needs_attention: bool = False, +) -> WorkflowResult: + async with mcp_tools(os.getenv("MCP_GATEWAY_URL")) as gateway_tools: + workflow = Workflow(ErrataWorkflowState, name="ErrataWorkflow") + + # -- Helper closures over gateway_tools -- + + async def _flag_attention( + state: ErrataWorkflowState, + why: str, + ) -> WorkflowResult: + """Search for existing RHELMISC issue by YmirTag JQL, create or add label.""" + erratum = state.erratum + tag = _needs_attention_tag(erratum["id"]) + + # Search for existing issue with this tag + description_filter = " OR ".join( + f'description ~ "\\"{t}\\""' for t in tag.all_formats() + ) + jql = ( + f'project = RHELMISC AND status NOT IN (Done, Closed) AND ({description_filter})' + ) + + search_result = await run_tool( + "search_jira_issues", + available_tools=gateway_tools, + jql=jql, + fields=["key", "summary", "labels"], + max_results=2, + ) + issues = search_result.get("issues", []) + + if issues: + if len(issues) > 1: + logger.warning("Multiple open issues found with YmirTag %s", tag) + existing_key = issues[0]["key"] + await run_tool( + "edit_jira_labels", + available_tools=gateway_tools, + issue_key=existing_key, + labels_to_add=[JiraLabels.NEEDS_ATTENTION.value], + ) + else: + summary = f"{erratum['full_advisory']} ({erratum['synopsis']}) needs attention" + description = f"{tag}\n\nErratum: {erratum['url']}\n\n{why}" + await run_tool( + "create_jira_issue", + available_tools=gateway_tools, + project="RHELMISC", + summary=summary, + description=description, + reporter_email=JIRA_JOTNAR_BOT_EMAIL, + assignee_email=JIRA_JOTNAR_BOT_EMAIL, + labels=[JiraLabels.NEEDS_ATTENTION.value], + components=["jotnar-package-automation"], + ) + + return WorkflowResult(status=why, reschedule_in=-1) + + async def _erratum_has_magic_string_in_comments( + erratum_id: str | int, magic_string: str + ) -> bool: + """Fetch full erratum and search comments client-side.""" + full_erratum = await run_tool( + "get_erratum", + available_tools=gateway_tools, + erratum_id=str(erratum_id), + full=True, + ) + comments = full_erratum.get("comments") or [] + return any(magic_string in c.get("body", "") for c in comments) + + # -- Workflow steps -- + + async def fetch_erratum(state: ErrataWorkflowState): + """Fetch erratum details.""" + logger.info("Fetching erratum %s", state.erratum_id) + state.erratum = await run_tool( + "get_erratum", + available_tools=gateway_tools, + erratum_id=state.erratum_id, + ) + logger.info( + "Erratum %s (%s) status=%s", + state.erratum["url"], + state.erratum["full_advisory"], + state.erratum["status"], + ) + return "check_needs_attention" + + async def check_needs_attention(state: ErrataWorkflowState): + """Check if erratum is already flagged for human attention.""" + if state.ignore_needs_attention: + return "fetch_related_issues" + + erratum_id = state.erratum["id"] + tag = _needs_attention_tag(erratum_id) + + description_filter = " OR ".join( + f'description ~ "\\"{t}\\""' for t in tag.all_formats() + ) + jql = ( + f"project = RHELMISC AND status NOT IN (Done, Closed) " + f"AND ({description_filter}) " + f'AND labels = "{JiraLabels.NEEDS_ATTENTION.value}"' + ) + + search_result = await run_tool( + "search_jira_issues", + available_tools=gateway_tools, + jql=jql, + fields=["key"], + max_results=1, + ) + issues = search_result.get("issues", []) + if issues: + logger.info("Erratum %s already flagged for human attention", erratum_id) + state.result = WorkflowResult( + status="Erratum already flagged for human attention", + reschedule_in=-1, + ) + return Workflow.END + + return "fetch_related_issues" + + async def fetch_related_issues(state: ErrataWorkflowState): + """Fetch JIRA issue details for each issue linked to the erratum.""" + jira_issues = state.erratum.get("jira_issues", []) + logger.info("Fetching %d related JIRA issues", len(jira_issues)) + state.related_issues = [] + for issue_key in jira_issues: + try: + issue_data = await run_tool( + "get_jira_details", + available_tools=gateway_tools, + issue_key=issue_key, + ) + state.related_issues.append(issue_data) + except Exception as e: + logger.warning("Failed to fetch issue %s: %s", issue_key, e) + + return "check_ownership" + + async def check_ownership(state: ErrataWorkflowState): + """Verify erratum is owned by Ymir bot, change ownership if needed.""" + erratum = state.erratum + assigned_to = erratum.get("assigned_to_email", "") + package_owner = erratum.get("package_owner_email", "") + + if ( + assigned_to == ERRATA_JOTNAR_BOT_EMAIL + and package_owner == ERRATA_JOTNAR_BOT_EMAIL + ): + return "route_by_status" + + # Check if Ymir owns all related issues + all_owned = all( + _get_assigned_team(issue) == JIRA_JOTNAR_TEAM + for issue in (state.related_issues or []) + ) + + if all_owned: + await run_tool( + "erratum_change_ownership", + available_tools=gateway_tools, + erratum_id=str(erratum["id"]), + new_owner_email=ERRATA_JOTNAR_BOT_EMAIL, + ) + state.result = WorkflowResult( + status=f"Changed ownership of erratum {erratum['id']} to Ymir bot, re-processing", + reschedule_in=0, + ) + return Workflow.END + + state.result = await _flag_attention( + state, + "Erratum has issues not owned by Project Ymir. Please coordinate with QA Contact for these " + "issues to move those issues to Release Pending or change the Assigned Team for the issue " + "to rhel-jotnar. No further action will be taken on the erratum until ymir_needs_attention " + "is cleared on this issue.", + ) + return Workflow.END + + async def route_by_status(state: ErrataWorkflowState): + """Route to appropriate handler based on erratum status.""" + status = state.erratum["status"] + + match status: + case "NEW_FILES": + state.target_status = "QE" + return "try_to_advance" + case "QE": + if not _all_issues_release_pending(state.related_issues or []): + state.result = WorkflowResult( + status="Not all issues are release pending", + reschedule_in=-1, + ) + return Workflow.END + state.target_status = "REL_PREP" + return "try_to_advance" + case _: + state.result = WorkflowResult( + status=f"status is {status}", + reschedule_in=-1, + ) + return Workflow.END + + async def try_to_advance(state: ErrataWorkflowState): + """Get transition rules and try to advance the erratum.""" + erratum_id = str(state.erratum["id"]) + new_status = state.target_status + + rule_set_data = await run_tool( + "get_erratum_transition_rules", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + rule_set = TransitionRuleSet.model_validate(rule_set_data) + + if rule_set.to_status != new_status: + state.result = await _flag_attention( + state, + f"Next state is {rule_set.to_status} instead of {new_status}", + ) + return Workflow.END + + if rule_set.all_ok: + if new_status == ErrataStatus.REL_PREP: + # Verify product listings before advancing + return "verify_product_listings" + + # Change state + await run_tool( + "erratum_change_state", + available_tools=gateway_tools, + erratum_id=erratum_id, + new_state=new_status, + ) + reschedule_delay = ( + 0 if new_status in (ErrataStatus.NEW_FILES, ErrataStatus.QE) else -1 + ) + state.result = WorkflowResult( + status=f"Moving to {new_status}, since all rules are OK", + reschedule_in=reschedule_delay, + ) + return Workflow.END + + # Handle blocking rules + blocking_outcomes = [ + r.name for r in rule_set.rules if r.outcome != TransitionRuleOutcome.OK + ] + + if "Stagepush" in blocking_outcomes: + push_details = await run_tool( + "get_erratum_stage_push_details", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + existing = push_details.get("status") + + if existing in (None, ErratumPushStatus.COMPLETE): + await run_tool( + "erratum_push_to_stage", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + state.result = WorkflowResult( + status=f"Stage-pushing erratum {erratum_id} before moving to {new_status}", + reschedule_in=WAIT_DELAY, + ) + return Workflow.END + + if existing == ErratumPushStatus.FAILED: + state.result = await _flag_attention( + state, + f"Stage-push previously FAILED for erratum {erratum_id}," + f" needs manual intervention before moving to {new_status}", + ) + return Workflow.END + + state.result = WorkflowResult( + status=( + f"Stage-push already in progress ({existing}) for erratum {erratum_id}," + f" waiting for completion before moving to {new_status}" + ), + reschedule_in=WAIT_DELAY, + ) + return Workflow.END + + if "Cat" in blocking_outcomes: + state.result = await _handle_cat_tests(state, new_status) + return Workflow.END + + if "Securityalert" in blocking_outcomes: + await run_tool( + "erratum_refresh_security_alerts", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + state.result = WorkflowResult( + status=f"Refreshing security alerts for erratum {erratum_id} before moving to {new_status}", + reschedule_in=WAIT_DELAY, + ) + return Workflow.END + + # Unknown blocking rules + blocking_rules_details = "\n".join( + f"{r.name}: {r.details}" + for r in rule_set.rules + if r.outcome == TransitionRuleOutcome.BLOCK + ) + state.result = await _flag_attention( + state, + f"Transition to {new_status} is blocked by:\n" + blocking_rules_details, + ) + return Workflow.END + + async def _handle_cat_tests( + state: ErrataWorkflowState, new_status: str + ) -> WorkflowResult: + """Handle CAT test blocking rule with timeout.""" + erratum_id = str(state.erratum["id"]) + push_details = await run_tool( + "get_erratum_stage_push_details", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + + push_status = push_details.get("status") + if push_status != ErratumPushStatus.COMPLETE: + return WorkflowResult( + status=( + f"Stage push status is {push_status} for erratum {erratum_id}," + f" waiting for push to complete before moving to {new_status}" + ), + reschedule_in=WAIT_DELAY, + ) + + updated_at_str = push_details.get("updated_at") + if updated_at_str is None: + return await _flag_attention( + state, + "Cannot determine stage push completion time (no log timestamps available).", + ) + + if isinstance(updated_at_str, str): + updated_at = datetime.fromisoformat(updated_at_str) + else: + updated_at = updated_at_str + + cur_time = datetime.now(tz=UTC) + time_elapsed = cur_time - updated_at + + if time_elapsed > POST_PUSH_TESTING_TIMEOUT: + return await _flag_attention( + state, + f"CAT tests didn't complete successfully after {POST_PUSH_TESTING_TIMEOUT_STR}", + ) + + return WorkflowResult( + status=( + f"Stage push completed for erratum {erratum_id}," + f" waiting for CAT tests to complete before moving to {new_status}" + ), + reschedule_in=WAIT_DELAY, + ) + + async def verify_product_listings(state: ErrataWorkflowState): + """REL_PREP-specific: compare build maps with previous erratum.""" + erratum_id = str(state.erratum["id"]) + new_status = state.target_status + + build_map_data = await run_tool( + "get_erratum_build_map", + available_tools=gateway_tools, + erratum_id=erratum_id, + ) + cur_build_map = ErratumBuildMap.model_validate(build_map_data) + + mismatch_packages = [] + for package, cur_build in cur_build_map.root.items(): + nvr = cur_build.nvr + + # Check if already verified + already_checked = await _erratum_has_magic_string_in_comments( + erratum_id, f"ymir-product-listings-checked({nvr})" + ) or await _erratum_has_magic_string_in_comments( + erratum_id, f"jotnar-product-listings-checked({nvr})" + ) + + if already_checked: + continue + + prev_result = await run_tool( + "get_previous_erratum", + available_tools=gateway_tools, + erratum_id=erratum_id, + package_name=package, + ) + prev_erratum_id = prev_result.get("id") + + if prev_erratum_id: + other_build_map_data = await run_tool( + "get_erratum_build_map", + available_tools=gateway_tools, + erratum_id=str(prev_erratum_id), + ) + other_build_map = ErratumBuildMap.model_validate(other_build_map_data) + prev_build = other_build_map.root[package] + + is_matched, comment = compare_file_lists( + cur_build, prev_build, prev_erratum_id + ) + + if not is_matched: + mismatch_packages.append(package) + + await run_tool( + "erratum_add_comment", + available_tools=gateway_tools, + erratum_id=erratum_id, + comment=comment, + ) + else: + await run_tool( + "erratum_add_comment", + available_tools=gateway_tools, + erratum_id=erratum_id, + comment=( + f"ymir-product-listings-checked({nvr})\n\n" + "No previous erratum for this package - " + "no need to check package file list change." + ), + ) + + if mismatch_packages: + state.result = await _flag_attention( + state, + "The package file lists of this build don't match all " + f"of their previous builds - mismatch packages: {mismatch_packages}.\n" + "See erratum comments for details.", + ) + return Workflow.END + + # All clear, advance to REL_PREP + await run_tool( + "erratum_change_state", + available_tools=gateway_tools, + erratum_id=erratum_id, + new_state=new_status, + ) + state.result = WorkflowResult( + status=f"Moving to {new_status}, since all rules are OK", + reschedule_in=-1, + ) + return Workflow.END + + # Register workflow steps + workflow.add_step("fetch_erratum", fetch_erratum) + workflow.add_step("check_needs_attention", check_needs_attention) + workflow.add_step("fetch_related_issues", fetch_related_issues) + workflow.add_step("check_ownership", check_ownership) + workflow.add_step("route_by_status", route_by_status) + workflow.add_step("try_to_advance", try_to_advance) + workflow.add_step("verify_product_listings", verify_product_listings) + + response = await workflow.run( + ErrataWorkflowState( + erratum_id=erratum_id, + dry_run=dry_run, + ignore_needs_attention=ignore_needs_attention, + ) + ) + + return response.state.result + + +ASSIGNED_TEAM_CUSTOM_FIELD = "customfield_10371" + + +def _get_assigned_team(issue_data: dict) -> str | None: + """Extract assigned team from JIRA issue data.""" + fields = issue_data.get("fields", {}) + assigned_team = fields.get(ASSIGNED_TEAM_CUSTOM_FIELD) + if isinstance(assigned_team, dict): + return assigned_team.get("value") + return None + + +def _all_issues_release_pending(related_issues: list[dict]) -> bool: + """Check if all issues are in Release Pending status.""" + for issue_data in related_issues: + fields = issue_data.get("fields", {}) + status = fields.get("status", {}).get("name", "") + if status != "Release Pending": + return False + return True + + +async def main() -> None: + configure_logging(level=logging.INFO) + + span_processor = setup_observability(os.environ["COLLECTOR_ENDPOINT"]) + + dry_run = os.getenv("DRY_RUN", "False").lower() == "true" + ignore_needs_attention = os.getenv("IGNORE_NEEDS_ATTENTION", "false").lower() == "true" + + erratum_id = os.getenv("ERRATUM_ID") + if not erratum_id: + logger.error("ERRATUM_ID environment variable is required") + sys.exit(1) + + # Handle URL input — extract the ID from the end + if "/" in erratum_id: + erratum_id = erratum_id.rstrip("/").split("/")[-1] + + logger.info( + "Running errata workflow for erratum %s (dry_run=%s, ignore_needs_attention=%s)", + erratum_id, + dry_run, + ignore_needs_attention, + ) + + result = await run_errata_workflow( + erratum_id, + dry_run=dry_run, + ignore_needs_attention=ignore_needs_attention, + ) + + separator = "=" * 60 + print(f"\n{separator}") + print(f" STATUS: {result.status}") + print(f" RESCHEDULE_IN: {result.reschedule_in}") + print(f"{separator}\n") + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except FrameworkError as e: + traceback.print_exc() + sys.exit(e.explain()) diff --git a/ymir/common/models.py b/ymir/common/models.py index c0bb1f7a..03caa8b9 100644 --- a/ymir/common/models.py +++ b/ymir/common/models.py @@ -5,7 +5,8 @@ and components to ensure consistency and type safety. """ -from datetime import datetime +import re +from datetime import UTC, datetime from enum import Enum, StrEnum from pathlib import Path from typing import Any, Literal @@ -949,6 +950,153 @@ class TestingState(StrEnum): WAIVED = "tests-waived" +# ============================================================================ +# Errata Workflow Models +# ============================================================================ + + +class ErratumPackageFileList(RootModel): + """Map variant and architecture to a set of subpackage names shipped for that architecture. + + Example:: + + { + "AppStream": { + "SRPMS": {"libtiff"}, + "aarch64": {"libtiff", "libtiff-devel", ...} + } + } + """ + + root: dict[str, dict[str, set[str]]] + + +class ErratumBuild(BaseModel): + """A single erratum build: NVR + package file list.""" + + nvr: str + package_file_list: ErratumPackageFileList + + +class ErratumBuildMap(RootModel): + """Map package name to ErratumBuild.""" + + root: dict[str, ErratumBuild] + + +class TransitionRuleOutcome(StrEnum): + BLOCK = "BLOCK" + OK = "OK" + UNKNOWN = "UNKNOWN" + + +class TransitionRule(BaseModel): + name: str + outcome: TransitionRuleOutcome + details: str + + +class TransitionRuleSet(BaseModel): + from_status: ErrataStatus + to_status: ErrataStatus + rules: list[TransitionRule] + + @property + def all_ok(self) -> bool: + return all(rule.outcome == TransitionRuleOutcome.OK for rule in self.rules) + + +class ErratumPushStatus(StrEnum): + QUEUED = "QUEUED" + READY = "READY" + RUNNING = "RUNNING" + WAITING_ON_PUB = "WAITING_ON_PUB" + POST_PUSH_PROCESSING = "POST_PUSH_PROCESSING" + COMPLETE = "COMPLETE" + FAILED = "FAILED" + + +class ErratumPushDetails(BaseModel): + status: ErratumPushStatus | None + updated_at: datetime | None + + +class RHELVersion(BaseModel): + major: int + minor: int + micro: int | None + stream: str + + def __str__(self): + if self.micro is not None: + return f"RHEL-{self.major}.{self.minor}.{self.micro}.{self.stream}" + return f"RHEL-{self.major}.{self.minor}.{self.stream}" + + @property + def parent(self) -> "RHELVersion | None": + """The release that the release inherits builds from.""" + if self.stream != "GA": + return RHELVersion( + major=self.major, + minor=self.minor, + micro=self.micro, + stream="GA", + ) + + if self.minor > 0: + one_minor_version_up = self.minor - 1 + match self.major: + case 10: + return RHELVersion( + major=self.major, + minor=one_minor_version_up, + micro=self.micro, + stream="Z", + ) + case 9 | 8: + if one_minor_version_up % 2 == 1: + return RHELVersion( + major=self.major, + minor=one_minor_version_up, + micro=self.micro, + stream="Z.MAIN", + ) + return RHELVersion( + major=self.major, + minor=one_minor_version_up, + micro=self.micro, + stream="Z.MAIN+EUS", + ) + + return None + + @staticmethod + def from_str(version_string: str) -> "RHELVersion | None": + version_string = version_string.strip().upper() + pattern = r"RHEL-(\d+)\.(\d+)(?:\.(\d+))?\.([^\d].*)$" + match = re.match(pattern, version_string) + if match is not None: + version = RHELVersion( + major=int(match.group(1)), + minor=int(match.group(2)), + micro=int(match.group(3)) if match.group(3) else None, + stream=match.group(4), + ) + if version_string != str(version): + raise ValueError(f"round-trip mismatch: {version_string!r} != {str(version)!r}") + return version + return None + + +class RHELRelease(BaseModel): + version: str + ship_date: datetime | None # None means already shipped + + @property + def shipped(self): + return self.ship_date is None or self.ship_date < datetime.now(tz=UTC) + + class WorkflowResult(BaseModel): """Represents the result of running a workflow once.""" diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 14ae9963..86359184 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -1,6 +1,8 @@ import asyncio import logging import os +import re +from collections import defaultdict from datetime import UTC, datetime from functools import cache from typing import Any @@ -8,16 +10,37 @@ import requests from beeai_framework.context import RunContext from beeai_framework.emitter import Emitter -from beeai_framework.tools import JSONToolOutput, Tool, ToolError, ToolRunOptions +from beeai_framework.tools import JSONToolOutput, StringToolOutput, Tool, ToolError, ToolRunOptions +from bs4 import BeautifulSoup, Tag # type: ignore from pydantic import BaseModel, Field from requests_gssapi import HTTPSPNEGOAuth -from ymir.common.models import ErrataComment, ErrataStatus, Erratum, FullErratum - +from ymir.common.models import ( + ErrataComment, + ErrataStatus, + Erratum, + ErratumBuild, + ErratumBuildMap, + ErratumPackageFileList, + ErratumPushDetails, + ErratumPushStatus, + FullErratum, + RHELRelease, + RHELVersion, + TransitionRule, + TransitionRuleOutcome, + TransitionRuleSet, +) logger = logging.getLogger(__name__) ET_URL = "https://errata.engineering.redhat.com" +# regex pattern for extracting timestamps from push logs +_TIMESTAMP_PATTERN = re.compile(r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \+0000") + +# Compares correctly - all our dates are tz-aware +_DATETIME_MIN_UTC = datetime.min.replace(tzinfo=UTC) + @cache def _et_verify() -> bool | str: @@ -184,3 +207,650 @@ async def _run( raise ToolError(f"Failed to get build NVR for {package_name} in erratum {erratum_id}: {e}") from e return JSONToolOutput(result=None) + + +# -- Private helpers for new tools -- + + +def _skip_writes() -> bool: + return os.getenv("DRY_RUN", "False").lower() == "true" + + +def _et_api_post(path: str, data: dict[str, Any]) -> Any | None: + response = requests.post( + f"{ET_URL}/api/v1/{path}", + data=data, + auth=HTTPSPNEGOAuth(opportunistic_auth=True), + verify=_et_verify(), + timeout=30, + ) + response.raise_for_status() + return None + + +def _et_api_put(path: str, data: dict[str, Any]) -> None: + response = requests.put( + f"{ET_URL}/api/v1/{path}", + data=data, + auth=HTTPSPNEGOAuth(opportunistic_auth=True), + verify=_et_verify(), + timeout=30, + ) + response.raise_for_status() + + +def _et_get_html(path: str) -> str: + response = requests.get( + f"{ET_URL}/{path}", + auth=HTTPSPNEGOAuth(opportunistic_auth=True), + verify=_et_verify(), + timeout=30, + ) + response.raise_for_status() + return response.text + + +def _variant_to_base_variant(variant: str) -> str: + return variant.split("-")[0] + + +def _nvr_to_package_name(nvr: str) -> str: + return nvr.rsplit("-", 2)[0] + + +def _get_erratum_build_map(erratum_id: int | str) -> ErratumBuildMap: + data = _et_api_get(f"erratum/{erratum_id}/builds_list") + + if len(data) != 1: + raise ValueError("Expected JSON object to have a single product version key.") + + detail = next(iter(data.values())) + builds = detail.get("builds", []) + build_map = {} + + for build in builds: + if len(build) != 1: + raise ValueError("Expected build to have a single NVR key.") + + (nvr, build_detail) = next(iter(build.items())) + variant_arch = build_detail["variant_arch"] + + package_file_map = { + _variant_to_base_variant(variant): { + arch: { + _nvr_to_package_name( + rpm["filename"] if not isinstance(rpm, str) else rpm + ) + for rpm in rpms + } + for arch, rpms in arches.items() + } + for variant, arches in variant_arch.items() + } + + build_map[_nvr_to_package_name(nvr)] = ErratumBuild( + nvr=nvr, package_file_list=ErratumPackageFileList(root=package_file_map) + ) + + return ErratumBuildMap(root=build_map) + + +def _get_RHEL_release(param: int | str) -> RHELRelease: + response = ( + _et_api_get("releases", params={"filter[id]": param}) + if isinstance(param, int) + else _et_api_get("releases", params={"filter[name]": param}) + ) + release_data = response["data"][0] + + ship_date_string = release_data["attributes"]["ship_date"] + ship_date = _get_utc_timestamp_from_str(ship_date_string) if ship_date_string is not None else None + + return RHELRelease( + version=release_data["attributes"]["name"], + ship_date=ship_date, + ) + + +def _get_erratum_build_nvr(erratum_id: str | int, package_name: str) -> str | None: + builds_by_release = _et_api_get(f"erratum/{erratum_id}/builds_list") + for release_info in builds_by_release.values(): + for builds_map in release_info["builds"]: + for build_nvr in builds_map: + if build_nvr.rsplit("-", 2)[0] == package_name: + return build_nvr + return None + + +def _get_rel_prep_lookup(package_name: str) -> defaultdict[str, list[Erratum]]: + rel_prep_lookup: defaultdict[str, list[Erratum]] = defaultdict(list) + package_data = _et_api_get("packages", params={"name": package_name}) + related_errata = package_data["data"]["relationships"]["errata"] + if not isinstance(related_errata, list): + raise TypeError(f"expected list of errata, got {type(related_errata)}") + for erratum_info in related_errata: + if erratum_info["status"] != ErrataStatus.REL_PREP: + continue + + id = erratum_info["id"] + cur_erratum = _get_erratum(id) + cur_release = _get_RHEL_release(cur_erratum.release_id) + + rel_prep_lookup[cur_release.version].append(cur_erratum) + + return rel_prep_lookup + + +def _get_previous_erratum( + current_erratum_id: str | int, package_name: str +) -> tuple[None, None] | tuple[None, str] | tuple[int, str]: + erratum = _get_erratum(current_erratum_id) + + target_release = _get_RHEL_release(erratum.release_id) + target_version = RHELVersion.from_str(target_release.version) + if target_version is None: + logger.info(f"Unknown RHEL release format: {target_release.version}") + return (None, None) + + def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): + if erratum_version == target_version: + return True + if target_release.shipped: + return False + if target_release.ship_date is None: + raise ValueError("target_release.ship_date must be set for unshipped releases") + return erratum.publish_date is not None and erratum.publish_date <= target_release.ship_date + + rel_prep_lookup = _get_rel_prep_lookup(package_name) + cur_version = target_version + while cur_version: + rel_prep_errata = rel_prep_lookup[str(cur_version)] + rel_prep = [e for e in rel_prep_errata if is_previous_erratum_applicable(str(cur_version), e)] + + if rel_prep: + latest_erratum = max( + rel_prep, + key=lambda e: e.publish_date if e.publish_date else _DATETIME_MIN_UTC, + ) + + nvr = _get_erratum_build_nvr(latest_erratum.id, package_name) + + if nvr is None: + raise RuntimeError( + f"{latest_erratum.id}, returned by Errata tool as an errata " + f"for {package_name}, does not have a build for {package_name}" + ) + + return (latest_erratum.id, nvr) + + release = _get_RHEL_release(str(cur_version)) + if release.shipped: + released_build = _et_api_get( + f"product_versions/{release.version}/released_builds/{package_name}" + ) + + erratum_id_from_released_build: int | None = released_build["errata_id"] + nvr: str | None = released_build["build"] + + if nvr is None: + return (None, None) + if erratum_id_from_released_build is None: + return (None, nvr) + return (erratum_id_from_released_build, nvr) + + cur_version = cur_version.parent + + return (None, None) + + +class RuleParseError(Exception): + pass + + +def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: + html = _et_get_html(f"/workflow_rules/for_advisory/{erratum_id}") + soup = BeautifulSoup(html, "lxml") + + tbody = soup.tbody + if tbody is None: + raise RuleParseError("No tbody found") + + rows = tbody.find_all("tr") + transition_row = rows[0] + if not isinstance(transition_row, Tag): + raise RuleParseError("Expected a Tag for transition row") + + spans = transition_row.find_all("span") + states = [ + span.text + for span in spans + if isinstance(span, Tag) and "state_indicator" in span.attrs.get("class", "") + ] + if len(states) != 2: + raise RuleParseError("Couldn't find from and to states") + + def text_to_status(text: str) -> ErrataStatus: + text = text.strip().upper().replace(" ", "_") + if text == "SHIPPED": + return ErrataStatus.SHIPPED_LIVE + return ErrataStatus(text) + + from_status = text_to_status(states[0]) + to_status = text_to_status(states[1]) + + res: list[TransitionRule] = [] + + for row in rows[1:]: + if not isinstance(row, Tag): + continue + + tds = row.find_all("td") + if len(tds) != 3: + raise RuleParseError("Invalid number of columns") + + guard_type, test_type, status = tds + if not isinstance(guard_type, Tag) or not isinstance(test_type, Tag) or not isinstance(status, Tag): + raise RuleParseError("Expected Tag elements for columns") + + if guard_type.text != "Block": + continue + name = test_type.text + span = status.span + if span is None: + raise RuleParseError("No found in rule status element") + className = span.attrs.get("class", "") + if "step-status-block" in className: + outcome = TransitionRuleOutcome.BLOCK + elif "step-status-ok" in className: + outcome = TransitionRuleOutcome.OK + else: + outcome = TransitionRuleOutcome.UNKNOWN + + res.append(TransitionRule(name=name, outcome=outcome, details=status.text.strip())) + + return TransitionRuleSet( + from_status=from_status, + to_status=to_status, + rules=res, + ) + + +def _get_erratum_stage_push_details(erratum_id: int | str) -> ErratumPushDetails: + pushes = _et_api_get(f"erratum/{erratum_id}/push") + + highest_push_id = 0 + status = None + log = None + for push in pushes: + if push["target"]["name"] == "cdn_stage" and push["id"] > highest_push_id: + highest_push_id = push["id"] + status = push["status"] + log = push.get("log", "") + + updated_at = None + if log: + timestamps = _TIMESTAMP_PATTERN.findall(log) + if timestamps: + last_timestamp = timestamps[-1] + updated_at = datetime.strptime(last_timestamp, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC) + + return ErratumPushDetails(status=ErratumPushStatus(status) if status else None, updated_at=updated_at) + + +# -- New MCP Tools -- + + +class GetErratumTransitionRulesToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + + +class GetErratumTransitionRulesTool( + Tool[GetErratumTransitionRulesToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "get_erratum_transition_rules" + description = """ + Scrape the Errata Tool HTML to get state transition guard rules for an erratum. + Returns the from/to status and list of blocking rules with their outcomes. + """ + input_schema = GetErratumTransitionRulesToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: GetErratumTransitionRulesToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + erratum_id = tool_input.erratum_id + logger.info("Getting transition rules for erratum %s", erratum_id) + try: + rule_set = await asyncio.to_thread(_get_erratum_transition_rules, erratum_id) + except Exception as e: + raise ToolError(f"Failed to get transition rules for erratum {erratum_id}: {e}") from e + return JSONToolOutput(result=rule_set.model_dump(mode="json")) + + +class GetErratumBuildMapToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + + +class GetErratumBuildMapTool( + Tool[GetErratumBuildMapToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "get_erratum_build_map" + description = """ + Get the build map for an erratum: maps package names to NVR + package file lists. + """ + input_schema = GetErratumBuildMapToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: GetErratumBuildMapToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + erratum_id = tool_input.erratum_id + logger.info("Getting build map for erratum %s", erratum_id) + try: + build_map = await asyncio.to_thread(_get_erratum_build_map, erratum_id) + except Exception as e: + raise ToolError(f"Failed to get build map for erratum {erratum_id}: {e}") from e + return JSONToolOutput(result=build_map.model_dump(mode="json")) + + +class GetPreviousErratumToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + package_name: str = Field(description="Package name") + + +class GetPreviousErratumTool( + Tool[GetPreviousErratumToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "get_previous_erratum" + description = """ + Search backwards through RHEL release versions to find the previous erratum + for a given package, following the RHEL version inheritance chain. + Returns dict with 'id' (int or null) and 'nvr' (str or null). + """ + input_schema = GetPreviousErratumToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: GetPreviousErratumToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + erratum_id = tool_input.erratum_id + package_name = tool_input.package_name + logger.info("Getting previous erratum for %s in erratum %s", package_name, erratum_id) + try: + prev_id, prev_nvr = await asyncio.to_thread( + _get_previous_erratum, erratum_id, package_name + ) + except Exception as e: + raise ToolError( + f"Failed to get previous erratum for {package_name} in {erratum_id}: {e}" + ) from e + return JSONToolOutput(result={"id": prev_id, "nvr": prev_nvr}) + + +class GetErratumStagePushDetailsToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + + +class GetErratumStagePushDetailsTool( + Tool[GetErratumStagePushDetailsToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "get_erratum_stage_push_details" + description = """ + Get the latest stage push status and timestamp for an erratum. + """ + input_schema = GetErratumStagePushDetailsToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: GetErratumStagePushDetailsToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + erratum_id = tool_input.erratum_id + logger.info("Getting stage push details for erratum %s", erratum_id) + try: + details = await asyncio.to_thread(_get_erratum_stage_push_details, erratum_id) + except Exception as e: + raise ToolError( + f"Failed to get stage push details for erratum {erratum_id}: {e}" + ) from e + return JSONToolOutput(result=details.model_dump(mode="json")) + + +class ErratumPushToStageToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + + +class ErratumPushToStageTool( + Tool[ErratumPushToStageToolInput, ToolRunOptions, StringToolOutput] +): + name = "erratum_push_to_stage" + description = """ + Push an erratum to the CDN stage environment. Respects DRY_RUN. + """ + input_schema = ErratumPushToStageToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: ErratumPushToStageToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + erratum_id = tool_input.erratum_id + if _skip_writes(): + return StringToolOutput( + result=f"Dry run, not pushing erratum {erratum_id} to stage " + f"(this is expected, not an error)" + ) + logger.info("Pushing erratum %s to stage", erratum_id) + try: + await asyncio.to_thread( + _et_api_post, f"erratum/{erratum_id}/push", {"defaults": "stage"} + ) + except Exception as e: + raise ToolError(f"Failed to push erratum {erratum_id} to stage: {e}") from e + return StringToolOutput(result=f"Successfully pushed erratum {erratum_id} to stage") + + +class ErratumChangeStateToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + new_state: str = Field(description="New state (e.g. 'QE', 'REL_PREP')") + + +class ErratumChangeStateTool( + Tool[ErratumChangeStateToolInput, ToolRunOptions, StringToolOutput] +): + name = "erratum_change_state" + description = """ + Change the state of an erratum. Respects DRY_RUN. + """ + input_schema = ErratumChangeStateToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: ErratumChangeStateToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + erratum_id = tool_input.erratum_id + new_state = tool_input.new_state + if _skip_writes(): + return StringToolOutput( + result=f"Dry run, not changing state of erratum {erratum_id} to {new_state} " + f"(this is expected, not an error)" + ) + logger.info("Changing state of erratum %s to %s", erratum_id, new_state) + try: + await asyncio.to_thread( + _et_api_post, + f"erratum/{erratum_id}/change_state", + {"new_state": new_state}, + ) + except Exception as e: + raise ToolError( + f"Failed to change state of erratum {erratum_id} to {new_state}: {e}" + ) from e + return StringToolOutput( + result=f"Successfully changed state of erratum {erratum_id} to {new_state}" + ) + + +class ErratumChangeOwnershipToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + new_owner_email: str = Field(description="New owner email address") + + +class ErratumChangeOwnershipTool( + Tool[ErratumChangeOwnershipToolInput, ToolRunOptions, StringToolOutput] +): + name = "erratum_change_ownership" + description = """ + Change the ownership (assigned_to and package_owner) of an erratum. Respects DRY_RUN. + """ + input_schema = ErratumChangeOwnershipToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: ErratumChangeOwnershipToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + erratum_id = tool_input.erratum_id + new_owner_email = tool_input.new_owner_email + if _skip_writes(): + return StringToolOutput( + result=f"Dry run, not changing ownership of erratum {erratum_id} " + f"(this is expected, not an error)" + ) + logger.info("Changing ownership of erratum %s to %s", erratum_id, new_owner_email) + try: + await asyncio.to_thread( + _et_api_put, + f"erratum/{erratum_id}", + { + "advisory[assigned_to_email]": new_owner_email, + "advisory[package_owner_email]": new_owner_email, + }, + ) + except Exception as e: + raise ToolError( + f"Failed to change ownership of erratum {erratum_id}: {e}" + ) from e + return StringToolOutput( + result=f"Successfully changed ownership of erratum {erratum_id} to {new_owner_email}" + ) + + +class ErratumAddCommentToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + comment: str = Field(description="Comment text") + + +class ErratumAddCommentTool( + Tool[ErratumAddCommentToolInput, ToolRunOptions, StringToolOutput] +): + name = "erratum_add_comment" + description = """ + Add a comment to an erratum. Respects DRY_RUN. + """ + input_schema = ErratumAddCommentToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: ErratumAddCommentToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + erratum_id = tool_input.erratum_id + comment = tool_input.comment + if _skip_writes(): + return StringToolOutput( + result=f"Dry run, not adding comment to erratum {erratum_id} " + f"(this is expected, not an error)" + ) + logger.info("Adding comment to erratum %s", erratum_id) + try: + await asyncio.to_thread( + _et_api_post, + f"erratum/{erratum_id}/add_comment", + {"comment": comment}, + ) + except Exception as e: + raise ToolError(f"Failed to add comment to erratum {erratum_id}: {e}") from e + return StringToolOutput( + result=f"Successfully added comment to erratum {erratum_id}" + ) + + +class ErratumRefreshSecurityAlertsToolInput(BaseModel): + erratum_id: str = Field(description="Erratum ID") + + +class ErratumRefreshSecurityAlertsTool( + Tool[ErratumRefreshSecurityAlertsToolInput, ToolRunOptions, StringToolOutput] +): + name = "erratum_refresh_security_alerts" + description = """ + Refresh security alerts for an erratum. Respects DRY_RUN. + """ + input_schema = ErratumRefreshSecurityAlertsToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "errata", self.name], creator=self) + + async def _run( + self, + tool_input: ErratumRefreshSecurityAlertsToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + erratum_id = tool_input.erratum_id + if _skip_writes(): + return StringToolOutput( + result=f"Dry run, not refreshing security alerts for erratum {erratum_id} " + f"(this is expected, not an error)" + ) + logger.info("Refreshing security alerts for erratum %s", erratum_id) + try: + await asyncio.to_thread( + _et_api_post, + f"erratum/{erratum_id}/security_alerts/refresh", + {}, + ) + except Exception as e: + raise ToolError( + f"Failed to refresh security alerts for erratum {erratum_id}: {e}" + ) from e + return StringToolOutput( + result=f"Successfully refreshed security alerts for erratum {erratum_id}" + ) diff --git a/ymir/tools/privileged/gateway.py b/ymir/tools/privileged/gateway.py index 0984d906..976e7364 100644 --- a/ymir/tools/privileged/gateway.py +++ b/ymir/tools/privileged/gateway.py @@ -14,7 +14,19 @@ from ymir.tools.gateway_utils import setup_logging from ymir.tools.privileged.copr import BuildPackageTool, DownloadArtifactsTool from ymir.tools.privileged.distgit import CreateZstreamBranchTool -from ymir.tools.privileged.errata import GetErratumBuildNvrTool, GetErratumTool +from ymir.tools.privileged.errata import ( + ErratumAddCommentTool, + ErratumChangeOwnershipTool, + ErratumChangeStateTool, + ErratumPushToStageTool, + ErratumRefreshSecurityAlertsTool, + GetErratumBuildMapTool, + GetErratumBuildNvrTool, + GetErratumStagePushDetailsTool, + GetErratumTool, + GetErratumTransitionRulesTool, + GetPreviousErratumTool, +) from ymir.tools.privileged.gitlab import ( AddBlockingMergeRequestCommentTool, AddMergeRequestCommentTool, @@ -37,6 +49,7 @@ AddJiraCommentTool, ChangeJiraStatusTool, CheckCveTriageEligibilityTool, + CreateJiraIssueTool, EditJiraLabelsTool, GetJiraAttachmentTool, GetJiraDetailsTool, @@ -140,6 +153,15 @@ def main(): SearchGitlabProjectMrsTool(options=tool_options), GetErratumTool(options=tool_options), GetErratumBuildNvrTool(options=tool_options), + GetErratumTransitionRulesTool(options=tool_options), + GetErratumBuildMapTool(options=tool_options), + GetPreviousErratumTool(options=tool_options), + GetErratumStagePushDetailsTool(options=tool_options), + ErratumPushToStageTool(options=tool_options), + ErratumChangeStateTool(options=tool_options), + ErratumChangeOwnershipTool(options=tool_options), + ErratumAddCommentTool(options=tool_options), + ErratumRefreshSecurityAlertsTool(options=tool_options), GetTestingFarmRequestTool(options=tool_options), ReproduceTestingFarmRequestTool(options=tool_options), AddJiraAttachmentsTool(options=tool_options), @@ -156,6 +178,7 @@ def main(): SetPreliminaryTestingTool(options=tool_options), UpdateJiraCommentTool(options=tool_options), VerifyIssueAuthorTool(options=tool_options), + CreateJiraIssueTool(options=tool_options), DownloadSourcesTool(options=tool_options), PrepSourcesTool(options=tool_options), UploadSourcesTool(options=tool_options), diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 4d26b249..f2ae8265 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -400,7 +400,7 @@ async def _check_zstream_clones_shipped( output = await tool.run( input={"jql": jql, "fields": ["fixVersions", "status", "resolution"], "max_results": 50} ) - issues = output.result + issues = output.result.get("issues", []) if not issues: logger.info(f"No clones found for {cve_id} in component {component}, proceeding with triage") @@ -946,7 +946,7 @@ class SearchJiraIssuesToolInput(BaseModel): class SearchJiraIssuesTool( - Tool[SearchJiraIssuesToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] + Tool[SearchJiraIssuesToolInput, ToolRunOptions, JSONToolOutput[dict[str, list[dict[str, Any]]]]] ): name = "search_jira_issues" description = """ @@ -966,7 +966,7 @@ async def _run( tool_input: SearchJiraIssuesToolInput, options: ToolRunOptions | None, context: RunContext, - ) -> JSONToolOutput[list[dict[str, Any]]]: + ) -> JSONToolOutput[dict[str, list[dict[str, Any]]]]: jql = tool_input.jql fields = tool_input.fields if tool_input.fields is not None else ["key", "summary", "fixVersions"] max_results = tool_input.max_results @@ -1004,7 +1004,7 @@ async def _run( } for issue in issues ] - return JSONToolOutput(result=out) + return JSONToolOutput(result={"issues": out}) async def _fetch_dev_status_details( @@ -1445,3 +1445,115 @@ async def _run( return StringToolOutput(result=f"Failed to decode attachment {filename} as UTF-8") return StringToolOutput(result=text) + + +# -- Helpers for CreateJiraIssueTool -- + + +async def _get_user_account_id(session: Any, headers: dict, email: str) -> str: + """Resolve a user email to a Jira account ID / name.""" + jira_base = os.getenv("JIRA_URL") + url = urljoin(jira_base, "rest/api/3/user/search") + try: + async with session.get(url, params={"query": email}, headers=headers) as response: + response.raise_for_status() + users = await response.json() + except aiohttp.ClientError as e: + raise ToolError(f"Failed to search for user {email}: {e}") from e + + matches = [u for u in users if u.get("emailAddress") == email] + if len(matches) == 0: + raise ToolError(f"No JIRA user with email {email}") + if len(matches) > 1: + raise ToolError(f"Multiple JIRA users with email {email}") + + user = matches[0] + return user.get("name") or user["accountId"] or user.get("displayName") + + +class CreateJiraIssueToolInput(BaseModel): + project: str = Field(description="Jira project key (e.g. 'RHELMISC')") + summary: str = Field(description="Issue summary") + description: str = Field(description="Issue description") + assignee_email: str | None = Field(default=None, description="Assignee email address") + reporter_email: str | None = Field(default=None, description="Reporter email address") + components: list[str] | None = Field(default=None, description="List of component names") + labels: list[str] | None = Field(default=None, description="List of labels") + + +class CreateJiraIssueTool( + Tool[CreateJiraIssueToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "create_jira_issue" + description = """ + Creates a new Jira issue. Respects DRY_RUN and JIRA_DRY_RUN. + Returns a dict with 'key' (e.g. 'RHELMISC-12345'). + """ + input_schema = CreateJiraIssueToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child( + namespace=["tool", "jira", self.name], + creator=self, + ) + + async def _run( + self, + tool_input: CreateJiraIssueToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + if os.getenv("DRY_RUN", "False").lower() == "true": + return JSONToolOutput( + result={"key": None, "dry_run": True, "message": "Dry run, not creating issue"} + ) + if _skip_jira_writes(): + return JSONToolOutput( + result={"key": None, "dry_run": True, "message": "JIRA_DRY_RUN is set, not creating issue"} + ) + + headers = get_jira_auth_headers() + jira_base = os.getenv("JIRA_URL") + + fields: dict[str, Any] = { + "project": {"key": tool_input.project}, + "summary": tool_input.summary, + "description": tool_input.description, + "issuetype": {"name": "Task"}, + } + + async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: + if tool_input.assignee_email: + account_id = await _get_user_account_id( + session, headers, tool_input.assignee_email + ) + fields["assignee"] = {"accountId": account_id} + + if tool_input.reporter_email: + account_id = await _get_user_account_id( + session, headers, tool_input.reporter_email + ) + fields["reporter"] = {"accountId": account_id} + + if tool_input.components: + fields["components"] = [{"name": c} for c in tool_input.components] + + if tool_input.labels: + fields["labels"] = tool_input.labels + + url = urljoin(jira_base, "rest/api/2/issue") + logger.info("Creating Jira issue in project %s", tool_input.project) + try: + async with session.post( + url, + json={"fields": fields}, + headers=headers, + ) as response: + response.raise_for_status() + data = await response.json() + except aiohttp.ClientError as e: + raise ToolError(f"Failed to create Jira issue: {e}") from e + + key = data["key"] + logger.info("Created Jira issue %s", key) + return JSONToolOutput(result={"key": key}) From b1c12eb0ce88d1433344139d44d89283f17de700 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:46:23 +0000 Subject: [PATCH 02/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/errata_workflow_agent.py | 47 +++++------------- ymir/tools/privileged/errata.py | 72 ++++++++-------------------- ymir/tools/privileged/jira.py | 12 ++--- 3 files changed, 34 insertions(+), 97 deletions(-) diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py index 64e21682..cc4d7664 100644 --- a/ymir/agents/errata_workflow_agent.py +++ b/ymir/agents/errata_workflow_agent.py @@ -11,7 +11,6 @@ import sys import traceback from datetime import UTC, datetime, timedelta -from typing import Any from beeai_framework.errors import FrameworkError from beeai_framework.workflows import Workflow @@ -22,10 +21,10 @@ from ymir.common.constants import JiraLabels from ymir.common.logging_setup import configure_logging from ymir.common.models import ( + ErrataStatus, ErratumBuild, ErratumBuildMap, ErratumPushStatus, - ErrataStatus, TransitionRuleOutcome, TransitionRuleSet, WorkflowResult, @@ -109,12 +108,8 @@ async def _flag_attention( tag = _needs_attention_tag(erratum["id"]) # Search for existing issue with this tag - description_filter = " OR ".join( - f'description ~ "\\"{t}\\""' for t in tag.all_formats() - ) - jql = ( - f'project = RHELMISC AND status NOT IN (Done, Closed) AND ({description_filter})' - ) + description_filter = " OR ".join(f'description ~ "\\"{t}\\""' for t in tag.all_formats()) + jql = f"project = RHELMISC AND status NOT IN (Done, Closed) AND ({description_filter})" search_result = await run_tool( "search_jira_issues", @@ -152,9 +147,7 @@ async def _flag_attention( return WorkflowResult(status=why, reschedule_in=-1) - async def _erratum_has_magic_string_in_comments( - erratum_id: str | int, magic_string: str - ) -> bool: + async def _erratum_has_magic_string_in_comments(erratum_id: str | int, magic_string: str) -> bool: """Fetch full erratum and search comments client-side.""" full_erratum = await run_tool( "get_erratum", @@ -191,9 +184,7 @@ async def check_needs_attention(state: ErrataWorkflowState): erratum_id = state.erratum["id"] tag = _needs_attention_tag(erratum_id) - description_filter = " OR ".join( - f'description ~ "\\"{t}\\""' for t in tag.all_formats() - ) + description_filter = " OR ".join(f'description ~ "\\"{t}\\""' for t in tag.all_formats()) jql = ( f"project = RHELMISC AND status NOT IN (Done, Closed) " f"AND ({description_filter}) " @@ -242,16 +233,12 @@ async def check_ownership(state: ErrataWorkflowState): assigned_to = erratum.get("assigned_to_email", "") package_owner = erratum.get("package_owner_email", "") - if ( - assigned_to == ERRATA_JOTNAR_BOT_EMAIL - and package_owner == ERRATA_JOTNAR_BOT_EMAIL - ): + if assigned_to == ERRATA_JOTNAR_BOT_EMAIL and package_owner == ERRATA_JOTNAR_BOT_EMAIL: return "route_by_status" # Check if Ymir owns all related issues all_owned = all( - _get_assigned_team(issue) == JIRA_JOTNAR_TEAM - for issue in (state.related_issues or []) + _get_assigned_team(issue) == JIRA_JOTNAR_TEAM for issue in (state.related_issues or []) ) if all_owned: @@ -331,9 +318,7 @@ async def try_to_advance(state: ErrataWorkflowState): erratum_id=erratum_id, new_state=new_status, ) - reschedule_delay = ( - 0 if new_status in (ErrataStatus.NEW_FILES, ErrataStatus.QE) else -1 - ) + reschedule_delay = 0 if new_status in (ErrataStatus.NEW_FILES, ErrataStatus.QE) else -1 state.result = WorkflowResult( status=f"Moving to {new_status}, since all rules are OK", reschedule_in=reschedule_delay, @@ -341,9 +326,7 @@ async def try_to_advance(state: ErrataWorkflowState): return Workflow.END # Handle blocking rules - blocking_outcomes = [ - r.name for r in rule_set.rules if r.outcome != TransitionRuleOutcome.OK - ] + blocking_outcomes = [r.name for r in rule_set.rules if r.outcome != TransitionRuleOutcome.OK] if "Stagepush" in blocking_outcomes: push_details = await run_tool( @@ -400,9 +383,7 @@ async def try_to_advance(state: ErrataWorkflowState): # Unknown blocking rules blocking_rules_details = "\n".join( - f"{r.name}: {r.details}" - for r in rule_set.rules - if r.outcome == TransitionRuleOutcome.BLOCK + f"{r.name}: {r.details}" for r in rule_set.rules if r.outcome == TransitionRuleOutcome.BLOCK ) state.result = await _flag_attention( state, @@ -410,9 +391,7 @@ async def try_to_advance(state: ErrataWorkflowState): ) return Workflow.END - async def _handle_cat_tests( - state: ErrataWorkflowState, new_status: str - ) -> WorkflowResult: + async def _handle_cat_tests(state: ErrataWorkflowState, new_status: str) -> WorkflowResult: """Handle CAT test blocking rule with timeout.""" erratum_id = str(state.erratum["id"]) push_details = await run_tool( @@ -503,9 +482,7 @@ async def verify_product_listings(state: ErrataWorkflowState): other_build_map = ErratumBuildMap.model_validate(other_build_map_data) prev_build = other_build_map.root[package] - is_matched, comment = compare_file_lists( - cur_build, prev_build, prev_erratum_id - ) + is_matched, comment = compare_file_lists(cur_build, prev_build, prev_erratum_id) if not is_matched: mismatch_packages.append(package) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 86359184..890589cc 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -31,6 +31,7 @@ TransitionRuleOutcome, TransitionRuleSet, ) + logger = logging.getLogger(__name__) ET_URL = "https://errata.engineering.redhat.com" @@ -278,10 +279,7 @@ def _get_erratum_build_map(erratum_id: int | str) -> ErratumBuildMap: package_file_map = { _variant_to_base_variant(variant): { arch: { - _nvr_to_package_name( - rpm["filename"] if not isinstance(rpm, str) else rpm - ) - for rpm in rpms + _nvr_to_package_name(rpm["filename"] if not isinstance(rpm, str) else rpm) for rpm in rpms } for arch, rpms in arches.items() } @@ -385,9 +383,7 @@ def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): release = _get_RHEL_release(str(cur_version)) if release.shipped: - released_build = _et_api_get( - f"product_versions/{release.version}/released_builds/{package_name}" - ) + released_build = _et_api_get(f"product_versions/{release.version}/released_builds/{package_name}") erratum_id_from_released_build: int | None = released_build["errata_id"] nvr: str | None = released_build["build"] @@ -592,13 +588,9 @@ async def _run( package_name = tool_input.package_name logger.info("Getting previous erratum for %s in erratum %s", package_name, erratum_id) try: - prev_id, prev_nvr = await asyncio.to_thread( - _get_previous_erratum, erratum_id, package_name - ) + prev_id, prev_nvr = await asyncio.to_thread(_get_previous_erratum, erratum_id, package_name) except Exception as e: - raise ToolError( - f"Failed to get previous erratum for {package_name} in {erratum_id}: {e}" - ) from e + raise ToolError(f"Failed to get previous erratum for {package_name} in {erratum_id}: {e}") from e return JSONToolOutput(result={"id": prev_id, "nvr": prev_nvr}) @@ -629,9 +621,7 @@ async def _run( try: details = await asyncio.to_thread(_get_erratum_stage_push_details, erratum_id) except Exception as e: - raise ToolError( - f"Failed to get stage push details for erratum {erratum_id}: {e}" - ) from e + raise ToolError(f"Failed to get stage push details for erratum {erratum_id}: {e}") from e return JSONToolOutput(result=details.model_dump(mode="json")) @@ -639,9 +629,7 @@ class ErratumPushToStageToolInput(BaseModel): erratum_id: str = Field(description="Erratum ID") -class ErratumPushToStageTool( - Tool[ErratumPushToStageToolInput, ToolRunOptions, StringToolOutput] -): +class ErratumPushToStageTool(Tool[ErratumPushToStageToolInput, ToolRunOptions, StringToolOutput]): name = "erratum_push_to_stage" description = """ Push an erratum to the CDN stage environment. Respects DRY_RUN. @@ -660,14 +648,11 @@ async def _run( erratum_id = tool_input.erratum_id if _skip_writes(): return StringToolOutput( - result=f"Dry run, not pushing erratum {erratum_id} to stage " - f"(this is expected, not an error)" + result=f"Dry run, not pushing erratum {erratum_id} to stage (this is expected, not an error)" ) logger.info("Pushing erratum %s to stage", erratum_id) try: - await asyncio.to_thread( - _et_api_post, f"erratum/{erratum_id}/push", {"defaults": "stage"} - ) + await asyncio.to_thread(_et_api_post, f"erratum/{erratum_id}/push", {"defaults": "stage"}) except Exception as e: raise ToolError(f"Failed to push erratum {erratum_id} to stage: {e}") from e return StringToolOutput(result=f"Successfully pushed erratum {erratum_id} to stage") @@ -678,9 +663,7 @@ class ErratumChangeStateToolInput(BaseModel): new_state: str = Field(description="New state (e.g. 'QE', 'REL_PREP')") -class ErratumChangeStateTool( - Tool[ErratumChangeStateToolInput, ToolRunOptions, StringToolOutput] -): +class ErratumChangeStateTool(Tool[ErratumChangeStateToolInput, ToolRunOptions, StringToolOutput]): name = "erratum_change_state" description = """ Change the state of an erratum. Respects DRY_RUN. @@ -711,12 +694,8 @@ async def _run( {"new_state": new_state}, ) except Exception as e: - raise ToolError( - f"Failed to change state of erratum {erratum_id} to {new_state}: {e}" - ) from e - return StringToolOutput( - result=f"Successfully changed state of erratum {erratum_id} to {new_state}" - ) + raise ToolError(f"Failed to change state of erratum {erratum_id} to {new_state}: {e}") from e + return StringToolOutput(result=f"Successfully changed state of erratum {erratum_id} to {new_state}") class ErratumChangeOwnershipToolInput(BaseModel): @@ -724,9 +703,7 @@ class ErratumChangeOwnershipToolInput(BaseModel): new_owner_email: str = Field(description="New owner email address") -class ErratumChangeOwnershipTool( - Tool[ErratumChangeOwnershipToolInput, ToolRunOptions, StringToolOutput] -): +class ErratumChangeOwnershipTool(Tool[ErratumChangeOwnershipToolInput, ToolRunOptions, StringToolOutput]): name = "erratum_change_ownership" description = """ Change the ownership (assigned_to and package_owner) of an erratum. Respects DRY_RUN. @@ -760,9 +737,7 @@ async def _run( }, ) except Exception as e: - raise ToolError( - f"Failed to change ownership of erratum {erratum_id}: {e}" - ) from e + raise ToolError(f"Failed to change ownership of erratum {erratum_id}: {e}") from e return StringToolOutput( result=f"Successfully changed ownership of erratum {erratum_id} to {new_owner_email}" ) @@ -773,9 +748,7 @@ class ErratumAddCommentToolInput(BaseModel): comment: str = Field(description="Comment text") -class ErratumAddCommentTool( - Tool[ErratumAddCommentToolInput, ToolRunOptions, StringToolOutput] -): +class ErratumAddCommentTool(Tool[ErratumAddCommentToolInput, ToolRunOptions, StringToolOutput]): name = "erratum_add_comment" description = """ Add a comment to an erratum. Respects DRY_RUN. @@ -795,8 +768,7 @@ async def _run( comment = tool_input.comment if _skip_writes(): return StringToolOutput( - result=f"Dry run, not adding comment to erratum {erratum_id} " - f"(this is expected, not an error)" + result=f"Dry run, not adding comment to erratum {erratum_id} (this is expected, not an error)" ) logger.info("Adding comment to erratum %s", erratum_id) try: @@ -807,9 +779,7 @@ async def _run( ) except Exception as e: raise ToolError(f"Failed to add comment to erratum {erratum_id}: {e}") from e - return StringToolOutput( - result=f"Successfully added comment to erratum {erratum_id}" - ) + return StringToolOutput(result=f"Successfully added comment to erratum {erratum_id}") class ErratumRefreshSecurityAlertsToolInput(BaseModel): @@ -848,9 +818,5 @@ async def _run( {}, ) except Exception as e: - raise ToolError( - f"Failed to refresh security alerts for erratum {erratum_id}: {e}" - ) from e - return StringToolOutput( - result=f"Successfully refreshed security alerts for erratum {erratum_id}" - ) + raise ToolError(f"Failed to refresh security alerts for erratum {erratum_id}: {e}") from e + return StringToolOutput(result=f"Successfully refreshed security alerts for erratum {erratum_id}") diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index f2ae8265..b935b727 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1481,9 +1481,7 @@ class CreateJiraIssueToolInput(BaseModel): labels: list[str] | None = Field(default=None, description="List of labels") -class CreateJiraIssueTool( - Tool[CreateJiraIssueToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] -): +class CreateJiraIssueTool(Tool[CreateJiraIssueToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]]): name = "create_jira_issue" description = """ Creates a new Jira issue. Respects DRY_RUN and JIRA_DRY_RUN. @@ -1524,15 +1522,11 @@ async def _run( async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: if tool_input.assignee_email: - account_id = await _get_user_account_id( - session, headers, tool_input.assignee_email - ) + account_id = await _get_user_account_id(session, headers, tool_input.assignee_email) fields["assignee"] = {"accountId": account_id} if tool_input.reporter_email: - account_id = await _get_user_account_id( - session, headers, tool_input.reporter_email - ) + account_id = await _get_user_account_id(session, headers, tool_input.reporter_email) fields["reporter"] = {"accountId": account_id} if tool_input.components: From 33c2cc91b37cfbf64d9de093ac2d9f9f291ab4dd Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:28:41 +0200 Subject: [PATCH 03/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 890589cc..db4f97ab 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -301,7 +301,9 @@ def _get_RHEL_release(param: int | str) -> RHELRelease: ) release_data = response["data"][0] - ship_date_string = release_data["attributes"]["ship_date"] + if not response.get("data"): + raise ValueError(f"Release not found for parameter: {param}") + release_data = response["data"][0] ship_date = _get_utc_timestamp_from_str(ship_date_string) if ship_date_string is not None else None return RHELRelease( From 21138a7ba153302a0d73be77346bb28c4f99bde5 Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:29:27 +0200 Subject: [PATCH 04/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index db4f97ab..758cb6de 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -460,11 +460,11 @@ def text_to_status(text: str) -> ErrataStatus: if "step-status-block" in className: outcome = TransitionRuleOutcome.BLOCK elif "step-status-ok" in className: + classes = span.get("class", []) + if "step-status-block" in classes: + outcome = TransitionRuleOutcome.BLOCK + elif "step-status-ok" in classes: outcome = TransitionRuleOutcome.OK - else: - outcome = TransitionRuleOutcome.UNKNOWN - - res.append(TransitionRule(name=name, outcome=outcome, details=status.text.strip())) return TransitionRuleSet( from_status=from_status, From 929a90d7c8d80be2d885a65af7be911806add10e Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:30:06 +0200 Subject: [PATCH 05/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 758cb6de..d98ed303 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -423,11 +423,11 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: span.text for span in spans if isinstance(span, Tag) and "state_indicator" in span.attrs.get("class", "") + states = [ + span.text + for span in spans + if isinstance(span, Tag) and "state_indicator" in span.get("class", []) ] - if len(states) != 2: - raise RuleParseError("Couldn't find from and to states") - - def text_to_status(text: str) -> ErrataStatus: text = text.strip().upper().replace(" ", "_") if text == "SHIPPED": return ErrataStatus.SHIPPED_LIVE From 439ef60aefb84b1c7a8f638f4b9e808b9c8fb600 Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:30:32 +0200 Subject: [PATCH 06/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index d98ed303..4eb75d46 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -417,8 +417,10 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: transition_row = rows[0] if not isinstance(transition_row, Tag): raise RuleParseError("Expected a Tag for transition row") - - spans = transition_row.find_all("span") + rows = tbody.find_all("tr") + if not rows: + raise RuleParseError("No rows found in tbody") + transition_row = rows[0] states = [ span.text for span in spans From 035f2e3fb985db4ca5d667f2666bdaafa37843c8 Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:32:43 +0200 Subject: [PATCH 07/19] Update ymir/tools/privileged/jira.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/jira.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index b935b727..665f8583 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1523,18 +1523,17 @@ async def _run( async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: if tool_input.assignee_email: account_id = await _get_user_account_id(session, headers, tool_input.assignee_email) - fields["assignee"] = {"accountId": account_id} + if tool_input.assignee_email: + id_type, id_val = await _get_user_identifier( + session, headers, tool_input.assignee_email + ) + fields["assignee"] = {id_type: id_val} if tool_input.reporter_email: - account_id = await _get_user_account_id(session, headers, tool_input.reporter_email) - fields["reporter"] = {"accountId": account_id} - - if tool_input.components: - fields["components"] = [{"name": c} for c in tool_input.components] - - if tool_input.labels: - fields["labels"] = tool_input.labels - + id_type, id_val = await _get_user_identifier( + session, headers, tool_input.reporter_email + ) + fields["reporter"] = {id_type: id_val} url = urljoin(jira_base, "rest/api/2/issue") logger.info("Creating Jira issue in project %s", tool_input.project) try: From 6ec98fe663c0fce269a424af4910f9d35d55d6bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:33:00 +0000 Subject: [PATCH 08/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/tools/privileged/jira.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 665f8583..26092b84 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1524,15 +1524,11 @@ async def _run( if tool_input.assignee_email: account_id = await _get_user_account_id(session, headers, tool_input.assignee_email) if tool_input.assignee_email: - id_type, id_val = await _get_user_identifier( - session, headers, tool_input.assignee_email - ) + id_type, id_val = await _get_user_identifier(session, headers, tool_input.assignee_email) fields["assignee"] = {id_type: id_val} if tool_input.reporter_email: - id_type, id_val = await _get_user_identifier( - session, headers, tool_input.reporter_email - ) + id_type, id_val = await _get_user_identifier(session, headers, tool_input.reporter_email) fields["reporter"] = {id_type: id_val} url = urljoin(jira_base, "rest/api/2/issue") logger.info("Creating Jira issue in project %s", tool_input.project) From a36ae881b80dca53a5b3b17d27b17173f7208994 Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:35:52 +0200 Subject: [PATCH 09/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 4eb75d46..623aebf8 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -386,11 +386,22 @@ def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): release = _get_RHEL_release(str(cur_version)) if release.shipped: released_build = _et_api_get(f"product_versions/{release.version}/released_builds/{package_name}") + try: + release = _get_RHEL_release(str(cur_version)) + except Exception as e: + logger.warning(f"Release {cur_version} not found or failed to fetch: {e}") + cur_version = cur_version.parent + continue - erratum_id_from_released_build: int | None = released_build["errata_id"] - nvr: str | None = released_build["build"] - - if nvr is None: + if release.shipped: + try: + released_build = _et_api_get( + f"product_versions/{release.version}/released_builds/{package_name}" + ) + except Exception as e: + logger.warning(f"Failed to get released build for {package_name} in {release.version}: {e}") + cur_version = cur_version.parent + continue return (None, None) if erratum_id_from_released_build is None: return (None, nvr) From a396df0dcb5fcb9d40795dce3f621920686f3eb5 Mon Sep 17 00:00:00 2001 From: martinky82 Date: Thu, 11 Jun 2026 15:36:22 +0200 Subject: [PATCH 10/19] Update ymir/tools/privileged/errata.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- ymir/tools/privileged/errata.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 623aebf8..5d2e2e54 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -326,10 +326,13 @@ def _get_rel_prep_lookup(package_name: str) -> defaultdict[str, list[Erratum]]: rel_prep_lookup: defaultdict[str, list[Erratum]] = defaultdict(list) package_data = _et_api_get("packages", params={"name": package_name}) related_errata = package_data["data"]["relationships"]["errata"] + if not isinstance(related_errata, list): + if not package_data.get("data") or not isinstance(package_data["data"], list): + return rel_prep_lookup + package_resource = package_data["data"][0] + related_errata = package_resource.get("relationships", {}).get("errata", []) if not isinstance(related_errata, list): raise TypeError(f"expected list of errata, got {type(related_errata)}") - for erratum_info in related_errata: - if erratum_info["status"] != ErrataStatus.REL_PREP: continue id = erratum_info["id"] From 912e035ff913ca8ec9487eb4c620d1e83f6d0f3d Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Thu, 11 Jun 2026 18:06:28 +0200 Subject: [PATCH 11/19] Add ERRATA_ALLOW_STATUS_CHANGES guard to errata workflow agent Mirrors the JIRA_ALLOW_STATUS_CHANGES pattern from issue verification agent. Erratum state changes are skipped unless the env var is explicitly set to true, preventing accidental state transitions in production. Co-Authored-By: Claude Opus 4.6 --- Makefile | 6 ++-- compose.yaml | 1 + ymir/agents/errata_workflow_agent.py | 48 +++++++++++++++++++++------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index d7acf897..cc6d802f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ DRY_RUN ?= false MOCK_JIRA ?= false JIRA_DRY_RUN ?= false JIRA_ALLOW_STATUS_CHANGES ?= false +ERRATA_ALLOW_STATUS_CHANGES ?= false AUTO_CHAIN ?= true FORCE_CVE_TRIAGE ?= false RUN_LLM_JUDGE ?= true @@ -201,11 +202,11 @@ build-jira-issue-fetcher: .PHONY: start start: - DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) JIRA_DRY_RUN=$(JIRA_DRY_RUN) JIRA_ALLOW_STATUS_CHANGES=$(JIRA_ALLOW_STATUS_CHANGES) AUTO_CHAIN=$(AUTO_CHAIN) $(COMPOSE_AGENTS) up + DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) JIRA_DRY_RUN=$(JIRA_DRY_RUN) JIRA_ALLOW_STATUS_CHANGES=$(JIRA_ALLOW_STATUS_CHANGES) ERRATA_ALLOW_STATUS_CHANGES=$(ERRATA_ALLOW_STATUS_CHANGES) AUTO_CHAIN=$(AUTO_CHAIN) $(COMPOSE_AGENTS) up .PHONY: start-detached start-detached: - DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) JIRA_DRY_RUN=$(JIRA_DRY_RUN) JIRA_ALLOW_STATUS_CHANGES=$(JIRA_ALLOW_STATUS_CHANGES) AUTO_CHAIN=$(AUTO_CHAIN) $(COMPOSE_AGENTS) up -d + DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) JIRA_DRY_RUN=$(JIRA_DRY_RUN) JIRA_ALLOW_STATUS_CHANGES=$(JIRA_ALLOW_STATUS_CHANGES) ERRATA_ALLOW_STATUS_CHANGES=$(ERRATA_ALLOW_STATUS_CHANGES) AUTO_CHAIN=$(AUTO_CHAIN) $(COMPOSE_AGENTS) up -d .PHONY: stop stop: @@ -305,6 +306,7 @@ run-errata-workflow-agent-standalone: -e ERRATUM_ID=$(ERRATUM_ID) \ -e DRY_RUN=$(DRY_RUN) \ -e IGNORE_NEEDS_ATTENTION=$(IGNORE_NEEDS_ATTENTION) \ + -e ERRATA_ALLOW_STATUS_CHANGES=$(ERRATA_ALLOW_STATUS_CHANGES) \ errata-workflow-agent .PHONY: run-preliminary-testing-agent-standalone diff --git a/compose.yaml b/compose.yaml index d584a51e..2aade5dc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,6 +10,7 @@ x-beeai-env: &beeai-env DRY_RUN: ${DRY_RUN:-false} JIRA_DRY_RUN: ${JIRA_DRY_RUN:-false} JIRA_ALLOW_STATUS_CHANGES: ${JIRA_ALLOW_STATUS_CHANGES:-false} + ERRATA_ALLOW_STATUS_CHANGES: ${ERRATA_ALLOW_STATUS_CHANGES:-false} AUTO_CHAIN: ${AUTO_CHAIN:-true} REQUESTS_CA_BUNDLE: /etc/pki/tls/certs/ca-bundle.crt diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py index cc4d7664..e8914d00 100644 --- a/ymir/agents/errata_workflow_agent.py +++ b/ymir/agents/errata_workflow_agent.py @@ -312,12 +312,24 @@ async def try_to_advance(state: ErrataWorkflowState): return "verify_product_listings" # Change state - await run_tool( - "erratum_change_state", - available_tools=gateway_tools, - erratum_id=erratum_id, - new_state=new_status, - ) + status_changes_allowed = os.getenv( + "ERRATA_ALLOW_STATUS_CHANGES", "false" + ).lower() == "true" + if state.dry_run or not status_changes_allowed: + reason = "dry run" if state.dry_run else "ERRATA_ALLOW_STATUS_CHANGES is not set" + logger.info( + "Skipping erratum state change of %s to %s (%s)", + erratum_id, + new_status, + reason, + ) + else: + await run_tool( + "erratum_change_state", + available_tools=gateway_tools, + erratum_id=erratum_id, + new_state=new_status, + ) reschedule_delay = 0 if new_status in (ErrataStatus.NEW_FILES, ErrataStatus.QE) else -1 state.result = WorkflowResult( status=f"Moving to {new_status}, since all rules are OK", @@ -515,12 +527,24 @@ async def verify_product_listings(state: ErrataWorkflowState): return Workflow.END # All clear, advance to REL_PREP - await run_tool( - "erratum_change_state", - available_tools=gateway_tools, - erratum_id=erratum_id, - new_state=new_status, - ) + status_changes_allowed = os.getenv( + "ERRATA_ALLOW_STATUS_CHANGES", "false" + ).lower() == "true" + if state.dry_run or not status_changes_allowed: + reason = "dry run" if state.dry_run else "ERRATA_ALLOW_STATUS_CHANGES is not set" + logger.info( + "Skipping erratum state change of %s to %s (%s)", + erratum_id, + new_status, + reason, + ) + else: + await run_tool( + "erratum_change_state", + available_tools=gateway_tools, + erratum_id=erratum_id, + new_state=new_status, + ) state.result = WorkflowResult( status=f"Moving to {new_status}, since all rules are OK", reschedule_in=-1, From 14ec933288079f79699a0646d0f978f0ddc22c94 Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Mon, 15 Jun 2026 09:48:54 +0200 Subject: [PATCH 12/19] Fix syntax errors from interleaved old/new code in errata conversion The errata.py updates left old code alongside new robustness improvements, causing ruff and Python syntax failures. Also fix line-too-long and unused variable in errata_workflow_agent.py. Co-Authored-By: Claude Opus 4.6 --- ymir/agents/errata_workflow_agent.py | 7 ++-- ymir/tools/privileged/errata.py | 50 ++++++++++++++++------------ 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py index e8914d00..9e1dae57 100644 --- a/ymir/agents/errata_workflow_agent.py +++ b/ymir/agents/errata_workflow_agent.py @@ -388,7 +388,10 @@ async def try_to_advance(state: ErrataWorkflowState): erratum_id=erratum_id, ) state.result = WorkflowResult( - status=f"Refreshing security alerts for erratum {erratum_id} before moving to {new_status}", + status=( + f"Refreshing security alerts for erratum {erratum_id}" + f" before moving to {new_status}" + ), reschedule_in=WAIT_DELAY, ) return Workflow.END @@ -596,7 +599,7 @@ def _all_issues_release_pending(related_issues: list[dict]) -> bool: async def main() -> None: configure_logging(level=logging.INFO) - span_processor = setup_observability(os.environ["COLLECTOR_ENDPOINT"]) + setup_observability(os.environ["COLLECTOR_ENDPOINT"]) dry_run = os.getenv("DRY_RUN", "False").lower() == "true" ignore_needs_attention = os.getenv("IGNORE_NEEDS_ATTENTION", "false").lower() == "true" diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 5d2e2e54..9768ac6c 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -299,11 +299,11 @@ def _get_RHEL_release(param: int | str) -> RHELRelease: if isinstance(param, int) else _et_api_get("releases", params={"filter[name]": param}) ) - release_data = response["data"][0] - if not response.get("data"): raise ValueError(f"Release not found for parameter: {param}") release_data = response["data"][0] + + ship_date_string = release_data["attributes"]["ship_date"] ship_date = _get_utc_timestamp_from_str(ship_date_string) if ship_date_string is not None else None return RHELRelease( @@ -325,14 +325,14 @@ def _get_erratum_build_nvr(erratum_id: str | int, package_name: str) -> str | No def _get_rel_prep_lookup(package_name: str) -> defaultdict[str, list[Erratum]]: rel_prep_lookup: defaultdict[str, list[Erratum]] = defaultdict(list) package_data = _et_api_get("packages", params={"name": package_name}) - related_errata = package_data["data"]["relationships"]["errata"] - if not isinstance(related_errata, list): if not package_data.get("data") or not isinstance(package_data["data"], list): return rel_prep_lookup package_resource = package_data["data"][0] related_errata = package_resource.get("relationships", {}).get("errata", []) if not isinstance(related_errata, list): raise TypeError(f"expected list of errata, got {type(related_errata)}") + for erratum_info in related_errata: + if erratum_info["status"] != ErrataStatus.REL_PREP: continue id = erratum_info["id"] @@ -386,9 +386,6 @@ def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): return (latest_erratum.id, nvr) - release = _get_RHEL_release(str(cur_version)) - if release.shipped: - released_build = _et_api_get(f"product_versions/{release.version}/released_builds/{package_name}") try: release = _get_RHEL_release(str(cur_version)) except Exception as e: @@ -402,9 +399,16 @@ def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): f"product_versions/{release.version}/released_builds/{package_name}" ) except Exception as e: - logger.warning(f"Failed to get released build for {package_name} in {release.version}: {e}") + logger.warning( + f"Failed to get released build for {package_name} in {release.version}: {e}" + ) cur_version = cur_version.parent continue + + erratum_id_from_released_build: int | None = released_build["errata_id"] + nvr: str | None = released_build["build"] + + if nvr is None: return (None, None) if erratum_id_from_released_build is None: return (None, nvr) @@ -428,22 +432,22 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: raise RuleParseError("No tbody found") rows = tbody.find_all("tr") - transition_row = rows[0] - if not isinstance(transition_row, Tag): - raise RuleParseError("Expected a Tag for transition row") - rows = tbody.find_all("tr") if not rows: raise RuleParseError("No rows found in tbody") transition_row = rows[0] - states = [ - span.text - for span in spans - if isinstance(span, Tag) and "state_indicator" in span.attrs.get("class", "") + if not isinstance(transition_row, Tag): + raise RuleParseError("Expected a Tag for transition row") + + spans = transition_row.find_all("span") states = [ span.text for span in spans if isinstance(span, Tag) and "state_indicator" in span.get("class", []) ] + if len(states) != 2: + raise RuleParseError("Couldn't find from and to states") + + def text_to_status(text: str) -> ErrataStatus: text = text.strip().upper().replace(" ", "_") if text == "SHIPPED": return ErrataStatus.SHIPPED_LIVE @@ -463,7 +467,11 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: raise RuleParseError("Invalid number of columns") guard_type, test_type, status = tds - if not isinstance(guard_type, Tag) or not isinstance(test_type, Tag) or not isinstance(status, Tag): + if ( + not isinstance(guard_type, Tag) + or not isinstance(test_type, Tag) + or not isinstance(status, Tag) + ): raise RuleParseError("Expected Tag elements for columns") if guard_type.text != "Block": @@ -472,15 +480,15 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: span = status.span if span is None: raise RuleParseError("No found in rule status element") - className = span.attrs.get("class", "") - if "step-status-block" in className: - outcome = TransitionRuleOutcome.BLOCK - elif "step-status-ok" in className: classes = span.get("class", []) if "step-status-block" in classes: outcome = TransitionRuleOutcome.BLOCK elif "step-status-ok" in classes: outcome = TransitionRuleOutcome.OK + else: + outcome = TransitionRuleOutcome.UNKNOWN + + res.append(TransitionRule(name=name, outcome=outcome, details=status.text.strip())) return TransitionRuleSet( from_status=from_status, From ec54adcc052cf1f18fc4b697694da6117d6ee69a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 07:49:22 +0000 Subject: [PATCH 13/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/errata_workflow_agent.py | 11 +++-------- ymir/tools/privileged/errata.py | 14 +++----------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py index 9e1dae57..0fab6930 100644 --- a/ymir/agents/errata_workflow_agent.py +++ b/ymir/agents/errata_workflow_agent.py @@ -312,9 +312,7 @@ async def try_to_advance(state: ErrataWorkflowState): return "verify_product_listings" # Change state - status_changes_allowed = os.getenv( - "ERRATA_ALLOW_STATUS_CHANGES", "false" - ).lower() == "true" + status_changes_allowed = os.getenv("ERRATA_ALLOW_STATUS_CHANGES", "false").lower() == "true" if state.dry_run or not status_changes_allowed: reason = "dry run" if state.dry_run else "ERRATA_ALLOW_STATUS_CHANGES is not set" logger.info( @@ -389,8 +387,7 @@ async def try_to_advance(state: ErrataWorkflowState): ) state.result = WorkflowResult( status=( - f"Refreshing security alerts for erratum {erratum_id}" - f" before moving to {new_status}" + f"Refreshing security alerts for erratum {erratum_id} before moving to {new_status}" ), reschedule_in=WAIT_DELAY, ) @@ -530,9 +527,7 @@ async def verify_product_listings(state: ErrataWorkflowState): return Workflow.END # All clear, advance to REL_PREP - status_changes_allowed = os.getenv( - "ERRATA_ALLOW_STATUS_CHANGES", "false" - ).lower() == "true" + status_changes_allowed = os.getenv("ERRATA_ALLOW_STATUS_CHANGES", "false").lower() == "true" if state.dry_run or not status_changes_allowed: reason = "dry run" if state.dry_run else "ERRATA_ALLOW_STATUS_CHANGES is not set" logger.info( diff --git a/ymir/tools/privileged/errata.py b/ymir/tools/privileged/errata.py index 9768ac6c..d1adbf29 100644 --- a/ymir/tools/privileged/errata.py +++ b/ymir/tools/privileged/errata.py @@ -399,9 +399,7 @@ def is_previous_erratum_applicable(erratum_version: str, erratum: Erratum): f"product_versions/{release.version}/released_builds/{package_name}" ) except Exception as e: - logger.warning( - f"Failed to get released build for {package_name} in {release.version}: {e}" - ) + logger.warning(f"Failed to get released build for {package_name} in {release.version}: {e}") cur_version = cur_version.parent continue @@ -440,9 +438,7 @@ def _get_erratum_transition_rules(erratum_id: int | str) -> TransitionRuleSet: spans = transition_row.find_all("span") states = [ - span.text - for span in spans - if isinstance(span, Tag) and "state_indicator" in span.get("class", []) + span.text for span in spans if isinstance(span, Tag) and "state_indicator" in span.get("class", []) ] if len(states) != 2: raise RuleParseError("Couldn't find from and to states") @@ -467,11 +463,7 @@ def text_to_status(text: str) -> ErrataStatus: raise RuleParseError("Invalid number of columns") guard_type, test_type, status = tds - if ( - not isinstance(guard_type, Tag) - or not isinstance(test_type, Tag) - or not isinstance(status, Tag) - ): + if not isinstance(guard_type, Tag) or not isinstance(test_type, Tag) or not isinstance(status, Tag): raise RuleParseError("Expected Tag elements for columns") if guard_type.text != "Block": From 7a92cd33eeda9fc0d9c68370e424ff82a81a08ed Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Mon, 15 Jun 2026 09:56:20 +0200 Subject: [PATCH 14/19] Fix interleaved old/new user lookup code in jira.py Replace _get_user_account_id with _get_user_identifier that returns a (field_key, value) tuple to properly support both Jira Server and Cloud. Remove leftover duplicate assignee block. Co-Authored-By: Claude Opus 4.6 --- ymir/tools/privileged/jira.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 26092b84..b6c22725 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1450,8 +1450,11 @@ async def _run( # -- Helpers for CreateJiraIssueTool -- -async def _get_user_account_id(session: Any, headers: dict, email: str) -> str: - """Resolve a user email to a Jira account ID / name.""" +async def _get_user_identifier(session: Any, headers: dict, email: str) -> tuple[str, str]: + """Resolve a user email to a Jira (field_key, value) tuple. + + Returns ("name", username) for Jira Server or ("accountId", id) for Cloud. + """ jira_base = os.getenv("JIRA_URL") url = urljoin(jira_base, "rest/api/3/user/search") try: @@ -1468,7 +1471,11 @@ async def _get_user_account_id(session: Any, headers: dict, email: str) -> str: raise ToolError(f"Multiple JIRA users with email {email}") user = matches[0] - return user.get("name") or user["accountId"] or user.get("displayName") + if user.get("name"): + return ("name", user["name"]) + if user.get("accountId"): + return ("accountId", user["accountId"]) + raise ToolError(f"User {email} has neither name nor accountId") class CreateJiraIssueToolInput(BaseModel): @@ -1521,8 +1528,6 @@ async def _run( } async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: - if tool_input.assignee_email: - account_id = await _get_user_account_id(session, headers, tool_input.assignee_email) if tool_input.assignee_email: id_type, id_val = await _get_user_identifier(session, headers, tool_input.assignee_email) fields["assignee"] = {id_type: id_val} From 0afea52ca6498b514010b69b081ddc8a7b77d16d Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Mon, 15 Jun 2026 10:41:29 +0200 Subject: [PATCH 15/19] Add missing dependencies to ymir-tools package beautifulsoup4, lxml, and requests-gssapi are imported by errata.py but were only listed in the root requirements.txt, not in the ymir-tools sub-package requirements used by CI. Co-Authored-By: Claude Opus 4.6 --- ymir/tools/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ymir/tools/requirements.txt b/ymir/tools/requirements.txt index 65373790..ed459f8b 100644 --- a/ymir/tools/requirements.txt +++ b/ymir/tools/requirements.txt @@ -1,6 +1,9 @@ # Dependencies specific to ymir-tools ymir-common>=0.1.0 aiohttp>=3.12.15 +beautifulsoup4>=4.13.4 +lxml>=5.4.0 +requests-gssapi>=1.3.0 copr>=1.129 flexmock>=0.12.2 GitPython>=3.1.0 From ff89f6465788be9187d83c9b2fa4b2c56edc37cf Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Mon, 15 Jun 2026 10:45:30 +0200 Subject: [PATCH 16/19] Fix _check_zstream_clones_shipped to use output.result directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchJiraIssuesTool returns JSONToolOutput(result=list), so output.result is already the issues list — no .get("issues") needed. Co-Authored-By: Claude Opus 4.6 --- ymir/tools/privileged/jira.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index b6c22725..76b20a98 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -400,7 +400,7 @@ async def _check_zstream_clones_shipped( output = await tool.run( input={"jql": jql, "fields": ["fixVersions", "status", "resolution"], "max_results": 50} ) - issues = output.result.get("issues", []) + issues = output.result if not issues: logger.info(f"No clones found for {cve_id} in component {component}, proceeding with triage") From da5004b41971ffe8eb3eb6fed8d6019c3469b032 Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Wed, 24 Jun 2026 19:50:56 +0200 Subject: [PATCH 17/19] Address PR #594 review comments - Remove ownership check step (agent should not change erratum ownership) - Clarify product listing verification purpose in SKILL.md - Remove module docstring (not used elsewhere in the project) - Rename JOTNAR constants to YMIR - Revert accidental SearchJiraIssuesTool return value change Co-Authored-By: Claude Opus 4.6 --- agents_as_skills/errata_workflow/SKILL.md | 25 ++++----- ymir/agents/errata_workflow_agent.py | 62 +++-------------------- ymir/tools/privileged/jira.py | 2 +- 3 files changed, 19 insertions(+), 70 deletions(-) diff --git a/agents_as_skills/errata_workflow/SKILL.md b/agents_as_skills/errata_workflow/SKILL.md index 4cb7dbd2..7939899e 100644 --- a/agents_as_skills/errata_workflow/SKILL.md +++ b/agents_as_skills/errata_workflow/SKILL.md @@ -37,7 +37,6 @@ This skill uses the following MCP tools: - `get_erratum_stage_push_details` — Get latest stage push status and timestamp - `erratum_push_to_stage` — Push erratum to CDN stage (respects DRY_RUN) - `erratum_change_state` — Change erratum state (respects DRY_RUN) -- `erratum_change_ownership` — Change erratum ownership (respects DRY_RUN) - `erratum_add_comment` — Add comment to erratum (respects DRY_RUN) - `erratum_refresh_security_alerts` — Refresh security alerts (respects DRY_RUN) @@ -51,9 +50,9 @@ This skill uses the following MCP tools: - `WAIT_DELAY`: 20 minutes (1200 seconds) — delay between reschedule checks - `POST_PUSH_TESTING_TIMEOUT`: 3 hours — timeout for CAT tests after stage push -- `ERRATA_JOTNAR_BOT_EMAIL`: jotnar-bot@IPA.REDHAT.COM — Ymir's Errata Tool identity -- `JIRA_JOTNAR_BOT_EMAIL`: jotnar+bot@redhat.com — Ymir's JIRA identity -- `JIRA_JOTNAR_TEAM`: rhel-jotnar — Ymir's assigned team name +- `ERRATA_YMIR_BOT_EMAIL`: jotnar-bot@IPA.REDHAT.COM — Ymir's Errata Tool identity +- `JIRA_YMIR_BOT_EMAIL`: jotnar+bot@redhat.com — Ymir's JIRA identity +- `JIRA_YMIR_TEAM`: rhel-jotnar — Ymir's assigned team name ## Workflow Steps @@ -66,23 +65,17 @@ Unless `ignore_needs_attention` is true, search for an existing RHELMISC issue w ### Step 3: Fetch Related Issues For each JIRA issue key in the erratum's `jira_issues` list, fetch full issue details using `get_jira_details`. Store all issue data for later checks. -### Step 4: Check Ownership -Verify the erratum is owned by the Ymir bot (`jotnar-bot@IPA.REDHAT.COM`). If not: -- Check if all related JIRA issues have `AssignedTeam = rhel-jotnar` -- If yes: change erratum ownership to the bot and reschedule immediately -- If no: flag for human attention — erratum has issues not owned by Project Ymir - -### Step 5: Route by Status +### Step 4: Route by Status Based on erratum status: - **NEW_FILES**: Target advancing to QE - **QE**: Check if all related JIRA issues are in "Release Pending" status. If yes, target advancing to REL_PREP. If not, stop. - **Other statuses**: No action needed, stop. -### Step 6: Try to Advance +### Step 5: Try to Advance Get transition rules using `get_erratum_transition_rules`. Handle outcomes: **All rules OK:** -- For REL_PREP target: proceed to product listing verification (Step 7) +- For REL_PREP target: proceed to product listing verification (Step 6) - For other targets: change state immediately **Stagepush blocking:** @@ -102,13 +95,15 @@ Get transition rules using `get_erratum_transition_rules`. Handle outcomes: **Unknown blocking rules:** - Flag for human attention with details of blocking rules -### Step 7: Verify Product Listings (REL_PREP only) +### Step 6: Verify Product Listings (REL_PREP only) +Sanity check before advancing to REL_PREP: compare the package file lists of the current builds against previous erratum builds to catch unintentional changes. A mismatch could mean shipping unwanted packages or dropping packages that should be shipped. + For each package in the erratum build map: 1. Check if already verified (magic string `ymir-product-listings-checked(NVR)` or `jotnar-product-listings-checked(NVR)` in erratum comments) 2. Find the previous erratum using `get_previous_erratum` (RHEL version inheritance search) 3. Compare package file lists between current and previous builds 4. Add verification comment to erratum -5. If mismatches found: flag for human attention +5. If mismatches found: flag for human attention — the change may be unintentional 6. If all match (or no previous erratum): advance to REL_PREP ## Flagging for Human Attention diff --git a/ymir/agents/errata_workflow_agent.py b/ymir/agents/errata_workflow_agent.py index 0fab6930..1f78b081 100644 --- a/ymir/agents/errata_workflow_agent.py +++ b/ymir/agents/errata_workflow_agent.py @@ -1,10 +1,3 @@ -"""Errata Workflow Agent — standalone BeeAI agent for erratum lifecycle management. - -Advances errata through states (NEW_FILES → QE → REL_PREP), handles stage pushes, -CAT test timeouts, product listing verification, and flagging for human attention. -Communicates exclusively through MCP tools. -""" - import asyncio import logging import os @@ -37,9 +30,9 @@ WAIT_DELAY = 20 * 60 # 20 minutes POST_PUSH_TESTING_TIMEOUT = timedelta(hours=3) POST_PUSH_TESTING_TIMEOUT_STR = "3 hours" -ERRATA_JOTNAR_BOT_EMAIL = "jotnar-bot@IPA.REDHAT.COM" -JIRA_JOTNAR_BOT_EMAIL = "jotnar+bot@redhat.com" -JIRA_JOTNAR_TEAM = "rhel-jotnar" +ERRATA_YMIR_BOT_EMAIL = "jotnar-bot@IPA.REDHAT.COM" +JIRA_YMIR_BOT_EMAIL = "jotnar+bot@redhat.com" +JIRA_YMIR_TEAM = "rhel-jotnar" ET_URL = "https://errata.engineering.redhat.com" @@ -111,14 +104,13 @@ async def _flag_attention( description_filter = " OR ".join(f'description ~ "\\"{t}\\""' for t in tag.all_formats()) jql = f"project = RHELMISC AND status NOT IN (Done, Closed) AND ({description_filter})" - search_result = await run_tool( + issues = await run_tool( "search_jira_issues", available_tools=gateway_tools, jql=jql, fields=["key", "summary", "labels"], max_results=2, ) - issues = search_result.get("issues", []) if issues: if len(issues) > 1: @@ -139,8 +131,8 @@ async def _flag_attention( project="RHELMISC", summary=summary, description=description, - reporter_email=JIRA_JOTNAR_BOT_EMAIL, - assignee_email=JIRA_JOTNAR_BOT_EMAIL, + reporter_email=JIRA_YMIR_BOT_EMAIL, + assignee_email=JIRA_YMIR_BOT_EMAIL, labels=[JiraLabels.NEEDS_ATTENTION.value], components=["jotnar-package-automation"], ) @@ -191,14 +183,13 @@ async def check_needs_attention(state: ErrataWorkflowState): f'AND labels = "{JiraLabels.NEEDS_ATTENTION.value}"' ) - search_result = await run_tool( + issues = await run_tool( "search_jira_issues", available_tools=gateway_tools, jql=jql, fields=["key"], max_results=1, ) - issues = search_result.get("issues", []) if issues: logger.info("Erratum %s already flagged for human attention", erratum_id) state.result = WorkflowResult( @@ -225,43 +216,7 @@ async def fetch_related_issues(state: ErrataWorkflowState): except Exception as e: logger.warning("Failed to fetch issue %s: %s", issue_key, e) - return "check_ownership" - - async def check_ownership(state: ErrataWorkflowState): - """Verify erratum is owned by Ymir bot, change ownership if needed.""" - erratum = state.erratum - assigned_to = erratum.get("assigned_to_email", "") - package_owner = erratum.get("package_owner_email", "") - - if assigned_to == ERRATA_JOTNAR_BOT_EMAIL and package_owner == ERRATA_JOTNAR_BOT_EMAIL: - return "route_by_status" - - # Check if Ymir owns all related issues - all_owned = all( - _get_assigned_team(issue) == JIRA_JOTNAR_TEAM for issue in (state.related_issues or []) - ) - - if all_owned: - await run_tool( - "erratum_change_ownership", - available_tools=gateway_tools, - erratum_id=str(erratum["id"]), - new_owner_email=ERRATA_JOTNAR_BOT_EMAIL, - ) - state.result = WorkflowResult( - status=f"Changed ownership of erratum {erratum['id']} to Ymir bot, re-processing", - reschedule_in=0, - ) - return Workflow.END - - state.result = await _flag_attention( - state, - "Erratum has issues not owned by Project Ymir. Please coordinate with QA Contact for these " - "issues to move those issues to Release Pending or change the Assigned Team for the issue " - "to rhel-jotnar. No further action will be taken on the erratum until ymir_needs_attention " - "is cleared on this issue.", - ) - return Workflow.END + return "route_by_status" async def route_by_status(state: ErrataWorkflowState): """Route to appropriate handler based on erratum status.""" @@ -553,7 +508,6 @@ async def verify_product_listings(state: ErrataWorkflowState): workflow.add_step("fetch_erratum", fetch_erratum) workflow.add_step("check_needs_attention", check_needs_attention) workflow.add_step("fetch_related_issues", fetch_related_issues) - workflow.add_step("check_ownership", check_ownership) workflow.add_step("route_by_status", route_by_status) workflow.add_step("try_to_advance", try_to_advance) workflow.add_step("verify_product_listings", verify_product_listings) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 76b20a98..545bf6ff 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1004,7 +1004,7 @@ async def _run( } for issue in issues ] - return JSONToolOutput(result={"issues": out}) + return JSONToolOutput(result=out) async def _fetch_dev_status_details( From 2bd298ae2dc9bea00db641ed53329880f0713758 Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Wed, 24 Jun 2026 20:33:10 +0200 Subject: [PATCH 18/19] Fix CI failures: skill frontmatter and missing bs4 dependency - Add name field and remove unsupported arguments from SKILL.md frontmatter - Add beautifulsoup4 and lxml to test container dependencies Co-Authored-By: Claude Opus 4.6 --- Containerfile.tests | 2 ++ agents_as_skills/errata_workflow/SKILL.md | 11 +---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Containerfile.tests b/Containerfile.tests index 1ee281df..9d994de0 100644 --- a/Containerfile.tests +++ b/Containerfile.tests @@ -38,6 +38,8 @@ RUN pip install --no-cache-dir \ flexmock \ koji \ ogr \ + beautifulsoup4 \ + lxml \ pytest \ pytest-asyncio \ rpm \ diff --git a/agents_as_skills/errata_workflow/SKILL.md b/agents_as_skills/errata_workflow/SKILL.md index 7939899e..3b605dee 100644 --- a/agents_as_skills/errata_workflow/SKILL.md +++ b/agents_as_skills/errata_workflow/SKILL.md @@ -1,18 +1,9 @@ --- +name: errata_workflow description: > Runs the Errata Workflow for an erratum, advancing it through states (NEW_FILES -> QE -> REL_PREP), handling stage pushes, CAT test timeouts, product listing verification, and flagging for human attention. -arguments: - - name: erratum_id - description: "Erratum ID or advisory URL (e.g. '12345' or full URL)" - required: true - - name: dry_run - description: "If true, skip all Errata Tool and JIRA modifications. Default: false" - required: false - - name: ignore_needs_attention - description: "If true, process the erratum even if it already has a ymir_needs_attention RHELMISC issue. Default: false" - required: false --- # Errata Workflow Skill From 1d7d5dce618d39de62cbfecaa7313dcd544b5b2e Mon Sep 17 00:00:00 2001 From: Martin Kyral Date: Wed, 24 Jun 2026 20:35:27 +0200 Subject: [PATCH 19/19] renamed errata_wrokflow to errata-workflow underscore is not allowed in skill names --- agents_as_skills/{errata_workflow => errata-workflow}/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename agents_as_skills/{errata_workflow => errata-workflow}/SKILL.md (99%) diff --git a/agents_as_skills/errata_workflow/SKILL.md b/agents_as_skills/errata-workflow/SKILL.md similarity index 99% rename from agents_as_skills/errata_workflow/SKILL.md rename to agents_as_skills/errata-workflow/SKILL.md index 3b605dee..e138b4f1 100644 --- a/agents_as_skills/errata_workflow/SKILL.md +++ b/agents_as_skills/errata-workflow/SKILL.md @@ -1,5 +1,5 @@ --- -name: errata_workflow +name: errata-workflow description: > Runs the Errata Workflow for an erratum, advancing it through states (NEW_FILES -> QE -> REL_PREP), handling stage pushes, CAT test timeouts,