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/Makefile b/Makefile index a91f4a22..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: @@ -299,6 +300,15 @@ 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) \ + -e ERRATA_ALLOW_STATUS_CHANGES=$(ERRATA_ALLOW_STATUS_CHANGES) \ + 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..e138b4f1 --- /dev/null +++ b/agents_as_skills/errata-workflow/SKILL.md @@ -0,0 +1,116 @@ +--- +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. +--- + +# 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_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_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 + +### 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: 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 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 6) +- 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 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 — the change may be unintentional +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..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 @@ -198,6 +199,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..1f78b081 --- /dev/null +++ b/ymir/agents/errata_workflow_agent.py @@ -0,0 +1,590 @@ +import asyncio +import logging +import os +import sys +import traceback +from datetime import UTC, datetime, timedelta + +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 ( + ErrataStatus, + ErratumBuild, + ErratumBuildMap, + ErratumPushStatus, + 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_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" + + +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})" + + issues = await run_tool( + "search_jira_issues", + available_tools=gateway_tools, + jql=jql, + fields=["key", "summary", "labels"], + max_results=2, + ) + + 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_YMIR_BOT_EMAIL, + assignee_email=JIRA_YMIR_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}"' + ) + + issues = await run_tool( + "search_jira_issues", + available_tools=gateway_tools, + jql=jql, + fields=["key"], + max_results=1, + ) + 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 "route_by_status" + + 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 + 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", + 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 + 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, + ) + 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("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) + + 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..d1adbf29 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,38 @@ 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 +208,633 @@ 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}) + ) + 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( + 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}) + 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"] + 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) + + 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 + + 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 + + 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") + if not rows: + raise RuleParseError("No rows found in tbody") + 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.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") + 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, + 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 (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} (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..545bf6ff 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -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 @@ -1445,3 +1445,109 @@ 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_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: + 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] + 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): + 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: + 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) + 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: + 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}) 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