From 8eb6173ca830095b5bb97664afab537e6957a257 Mon Sep 17 00:00:00 2001 From: Kashyap Ekbote Date: Tue, 25 Aug 2026 20:58:01 +0530 Subject: [PATCH 1/2] feat: add pipeline triage and ship-mr automation to issue verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Issue Verification Agent finds an open MR (ymir_backported/ ymir_rebased label present but ymir_merged absent), it now checks the MR pipeline status inline instead of waiting 3 hours: - Pipeline running → reschedule in 20 minutes - Pipeline failed → post structured triage comment on the MR (new regressions vs waived, build/check blockers) - Pipeline passed → approve + set auto-merge (opt-in via PIPELINE_AUTO_SHIP=true, default false) New MCP tools added to the privileged gateway: - GitLab: pipeline status with child pipeline traversal, MR diff, discussions, reply/resolve threads, approve, auto-merge, deep job log analysis, cross-MR test failure comparison, inline comments - Jira: update_test_coverage New Claude Code skills added to agents_as_skills/: - triage-pipeline: analyze failed jobs, categorize regressions vs waived failures, post comment - ship-mr: full review-to-merge workflow (code review, Jira cross-check, pipeline triage, approve, auto-merge) Fixes: https://github.com/packit/ai-workflows/issues/777 --- agents_as_skills/ship-mr/SKILL.md | 238 ++++++++ agents_as_skills/triage-pipeline/SKILL.md | 97 ++++ ymir/agents/issue_verification_agent.py | 336 ++++++++++- ymir/tools/privileged/gateway.py | 25 +- ymir/tools/privileged/gitlab.py | 666 ++++++++++++++++++++++ ymir/tools/privileged/jira.py | 56 ++ 6 files changed, 1412 insertions(+), 6 deletions(-) create mode 100644 agents_as_skills/ship-mr/SKILL.md create mode 100644 agents_as_skills/triage-pipeline/SKILL.md diff --git a/agents_as_skills/ship-mr/SKILL.md b/agents_as_skills/ship-mr/SKILL.md new file mode 100644 index 000000000..8ebe612c4 --- /dev/null +++ b/agents_as_skills/ship-mr/SKILL.md @@ -0,0 +1,238 @@ +--- +name: ship-mr +description: Full review-to-merge workflow — review code, cross-check Jira, triage pipeline, post comments, approve and auto-merge a GitLab merge request. +--- + +# Ship MR — Full Review-to-Merge Workflow + +You are running the complete merge request workflow: review, triage, comment, and (if clean) approve + auto-merge. Use the Ymir privileged MCP gateway tools throughout. + +## Parse arguments + +Extract from the user's input: +- `project` — the GitLab project path (e.g. `redhat/centos-stream/rpms/bash`) — **required** +- `mr_iid` — the merge request IID (the number after `!`) — **required** +- `--jira ` — optional Jira issue key (e.g. `RHEL-184006`) + +If `project` or `mr_iid` is missing, ask the user before proceeding. + +If `--jira` is not provided, try to detect the Jira issue key from the MR title, description, or branch name (pattern: `RHEL-\d+`). + +--- + +## GLOBAL COMMENT RULE + +Every comment you post via `reply_to_mr_discussion` or `add_merge_request_comment` must end with: + +``` +🤖 Generated by Ymir +``` + +--- + +## PHASE 1: GATHER + +Call these tools **in parallel** to minimize latency: + +1. `get_merge_request_details` with the MR URL — title, description, source/target branch, merge status +2. `get_mr_changes` — full diff of all changed files (also returns `diff_refs`) +3. `get_mr_discussions` — existing discussion threads (to avoid duplicate comments) +4. `get_mr_pipelines_with_status` — all pipelines and their statuses +5. `get_jira_details` — **if Jira key available:** fetch full issue details +6. `get_jira_pull_requests` — **if Jira key available:** fetch linked MRs + +Save the `diff_refs` from `get_mr_changes` — you need `base_sha`, `start_sha`, `head_sha` for inline comments. + +**Report to user:** +``` +Reviewing MR !: "" -> <target_branch> +Linked Jira: <issue_key> — "<jira_summary>" [<jira_status>] +``` + +--- + +## PHASE 2: ANALYZE CODE + +Review every changed file in the diff for: + +- **Critical/Major bugs** — logic errors, null dereferences, race conditions, data loss, missing error handling on critical paths +- **Security issues** — injection, hardcoded secrets/tokens, insecure defaults +- **Performance** — N+1 queries, unbounded loops, missing indexes +- **Minor/Nit** — dead code, naming issues, style inconsistencies + +**Skip** auto-generated files, lock files, vendored dependencies, and changelog entries. + +Classify each finding: `critical`, `major`, `minor`, or `nit`. + +Track: +- `blockers` = count of critical + major findings +- `total_findings` = count of all findings + +--- + +## PHASE 2b: CROSS-CHECK WITH JIRA (if Jira issue available) + +- Verify the MR changes address what the Jira issue requires +- Flag if the MR only partially implements requirements or adds scope not in Jira +- Note: `jira_completeness` (full/partial/unclear), `jira_scope_creep` (true/false) + +--- + +## PHASE 3: ANALYZE PIPELINE + +From the pipelines gathered in Phase 1, identify the **latest pipeline**. + +**IMPORTANT:** A parent pipeline may show `success` even when child pipelines have failed. Always check `child_pipelines`. If ANY child pipeline has `status == "failed"`, treat as FAILED. + +### If pipeline PASSED (parent and all children): +- `pipeline_ok = true`, `new_regressions = 0` + +### If pipeline FAILED: + +First check `get_mr_discussions` for developer-posted waivers (waiver language: "waiv", "known issue", "pre-existing", "expected failure"). + +Then call **in parallel**: +1. `get_pipeline_failed_jobs_deep` with the pipeline ID — get failed jobs, stages, and log tails +2. `compare_mr_test_failures` — separate new regressions from pre-existing failures *(skip if all failures are already waived by developer comments)* + +Categorize: +- **Check/build stage failures** — ALWAYS a blocker, never waivable +- **New test regressions** — blocker +- **Pre-existing test failures** — not a blocker (waived) +- **Infrastructure failures** — not a blocker (suggest retry) + +Set: +- `pipeline_ok = false` if ANY check/build failure OR new test regression +- `pipeline_ok = true` ONLY if no check/build failures AND all test failures are pre-existing/waived +- `new_regressions` = count of genuinely new failures + +### If pipeline RUNNING: +- `pipeline_ok = "running"` — auto-merge can still be set + +--- + +## PHASE 4: POST COMMENTS + +### 4a. Post pipeline analysis (if pipeline had failures) — ALWAYS FIRST + +From `get_mr_discussions`, find the active (unresolved) pipeline results thread (look for `<!--RESULTS_COMMENT-->` or pipeline ID/URL in the body). + +Use `reply_to_mr_discussion` to reply to that thread. Only fall back to `add_merge_request_comment` if no pipeline thread exists. + +Format: +``` +### Pipeline Analysis — Pipeline #<id> + +**New Regressions:** N +**Pre-existing (waived):** N +**Infrastructure (retriable):** N + +<details for each new regression: job name, error, suggested fix> +``` + +### 4b. Post inline code review comments + +For each **major or critical** finding, use `create_mr_inline_comment` at the exact file and line. Use `diff_refs` from Phase 1. + +Before posting, check existing discussions to avoid duplicates. + +Format: +``` +**[SEVERITY]** <one-line summary> + +<detailed explanation> + +**Suggestion:** <how to fix> +``` + +### 4c. Post minor/nit findings + Jira cross-check as ONE combined note + +Use `add_merge_request_comment` with a single combined note: + +``` +### Code Review — Minor Findings + +| # | File | Line | Severity | Issue | +|---|------|------|----------|-------| +| 1 | src/foo.py | 42 | minor | Description | + +--- + +### Jira Cross-Check — <issue_key>: <jira_summary> + +| Check | Result | +|-------|--------| +| Jira Status | <status> | +| Priority | <priority> | +| Completeness | full / partial / unclear | +| Scope Creep | none / yes | +``` + +--- + +## PHASE 5: DECISION GATE + +Evaluate: + +| Condition | Check | +|-----------|-------| +| No critical/major code findings | `blockers == 0` | +| Pipeline clean | `pipeline_ok == true` OR `pipeline_ok == "running"` | +| No merge conflicts | MR has no conflicts | +| Not a draft | Title doesn't start with "Draft:" or "WIP:" | + +### If ALL conditions pass — SHIP IT + +Apply a final confidence check. If there is ANY residual doubt (unverified infrastructure failures, large number of waivers, AI-generated patches), **block instead**. + +If confident: + +1. **Resolve all unresolved discussions** — call `resolve_mr_discussion` in parallel for each unresolved thread +2. **Post approval comment** via `add_merge_request_comment`: + ``` + Review Complete — Approved + + Code Review: <total_findings> findings (0 blockers) + Pipeline: <passing / all waived / running> + Jira: <issue_key> — <completeness> | <priority> + Verdict: LGTM — approving and setting auto-merge. + ``` +3. **Approve** via `approve_merge_request` +4. **Set auto-merge** via `set_mr_auto_merge` + +### If ANY condition fails — BLOCK + +Post via `add_merge_request_comment`: +``` +Review Complete — Changes Requested + +Code Review: <blockers> blocker(s) found +<list each: file:line — summary> + +Pipeline: <N> new regression(s) +<list each: job — error> + +Other Blockers: +<if conflicts: "Merge conflicts detected — please rebase"> +<if draft: "MR is in draft state"> +``` + +Do NOT approve or set auto-merge. + +--- + +## OUTPUT SUMMARY + +End with a dashboard: + +``` +## /ship-mr Summary: !<iid> "<title>" + +| Phase | Result | +|------------------|-------------------------------------------| +| Code Review | N findings (N blockers) | +| Jira Cross-Check | <issue_key>: <completeness> / <priority> | +| Pipeline | passed / N new regressions / waived | +| Comments Posted | N inline + N summary | +| Verdict | Approved + Auto-merge / Blocked | +``` diff --git a/agents_as_skills/triage-pipeline/SKILL.md b/agents_as_skills/triage-pipeline/SKILL.md new file mode 100644 index 000000000..8d62ef066 --- /dev/null +++ b/agents_as_skills/triage-pipeline/SKILL.md @@ -0,0 +1,97 @@ +--- +name: triage-pipeline +description: Triage CI/CD pipeline failures for a GitLab merge request — analyze failed jobs, compare with previous MRs, categorize regressions vs waived failures, and optionally post a comment. +--- + +# Triage Pipeline Failures + +You are triaging CI/CD pipeline failures for a GitLab merge request using the Ymir privileged MCP gateway tools. + +## Parse arguments + +Extract from the user's input: +- `project` — the GitLab project path (e.g. `redhat/centos-stream/rpms/bash`) +- `mr_iid` — the merge request IID (the number after `!`) +- `--report` flag — if present, generate a detailed text report +- `--deep` flag — if present, include full log analysis for each failed job +- `--comment` flag — if present, post the triage summary as a reply on the MR + +If any required argument is missing, ask the user. + +## GLOBAL COMMENT RULE + +Every comment body you post via `reply_to_mr_discussion` or `add_merge_request_comment` must end with: + +``` +🤖 Generated by Ymir +``` + +--- + +## Step 1: Get pipeline overview + +1. Call `get_mr_pipelines_with_status` with `project` and `mr_iid` +2. Identify the latest pipeline (highest ID) +3. If the latest pipeline status is `success` **and all child pipelines are also successful**, report that and stop. + - **IMPORTANT:** A parent pipeline may show `success` even when child pipelines have failed. Always check `child_pipelines` in the result. If ANY child pipeline has `status == "failed"`, treat the overall pipeline as FAILED and continue. + +## Step 2: Get failed job details + +Call `get_pipeline_failed_jobs_deep` with the failed pipeline ID to get: +- Job names, stages, and failure reasons +- Log tail output from each failed job +- Any child/downstream pipeline failures + +## Step 3: Check for developer-posted waivers + +Call `get_mr_discussions` to fetch all discussion threads. Scan for **non-bot human comments** containing waiver language: "waiv", "known issue", "pre-existing", "skip this failure", "ignoring", "expected failure", or similar. + +- If **all** failures are covered by developer waivers → treat as waived, skip `compare_mr_test_failures` +- If **some** → treat those as waived, call `compare_mr_test_failures` for the rest +- If **none** → proceed with the full comparison + +## Step 4: Compare with previous MR + +Call `compare_mr_test_failures` to identify: +- **new_failures** — regressions introduced by this MR (need attention) +- **waived_failures** — failures also present in the previous merged MR (pre-existing, can be waived) +- **fixed** — failures that were present before but now pass + +## Step 5: Categorize failures + +Group failures into: +- **Check/build failures** (`pre_build`, `check`, `build` stage) — **never waivable**, always a blocker +- **Test failures** — waivable if pre-existing in the previous MR +- **Infrastructure failures** — timeout, runner issues — suggest retry + +## Output + +Present a structured summary: + +``` +## Pipeline #<id> — <status> + +### New Regressions (action required) +- Job: <name> | Stage: <stage> | Reason: <reason> + Key error: <extracted from log> + Suggested fix: <recommendation> + +### Pre-existing / Waived Failures +- Job: <name> — also failed in previous MR (waived) + +### Summary +- Total failed jobs: N +- New regressions: N (need fixing) +- Pre-existing: N (waived) +- Infrastructure: N (retry may help) +``` + +End with actionable recommendations: which jobs to retry, which failures need code fixes, and whether the MR is likely safe to merge despite failures. + +## Step 6: Post comment on MR (if --comment flag) + +If the user passed `--comment`: +1. From `get_mr_discussions`, find the unresolved discussion thread referencing the pipeline failure (look for `<!--RESULTS_COMMENT-->` or pipeline ID in the body) +2. Use `reply_to_mr_discussion` to reply to that thread with the triage summary +3. If no pipeline results thread exists, fall back to `add_merge_request_comment` +4. Check for duplicate — if a triage reply for the same pipeline ID already exists, skip posting diff --git a/ymir/agents/issue_verification_agent.py b/ymir/agents/issue_verification_agent.py index a30abd897..de918eb67 100644 --- a/ymir/agents/issue_verification_agent.py +++ b/ymir/agents/issue_verification_agent.py @@ -48,6 +48,7 @@ WAIT_DELAY = 20 * 60 # 20 minutes MERGE_CHECK_DELAY = 3 * 60 * 60 # 3 hours ERRATA_WAIT_DELAY = 60 * 60 # 1 hour +PIPELINE_CHECK_DELAY = 20 * 60 # 20 minutes — how often to recheck a running pipeline ATTENTION_TEMPLATE = ( "{{panel:title=Project Ymir: ATTENTION NEEDED|" @@ -396,6 +397,311 @@ async def _get_latest_merged_timestamp(issue: FullIssue, tools: list) -> datetim ) +_PIPELINE_RUNNING_STATUSES = {"running", "pending", "created", "waiting_for_resource", "preparing"} +_PIPELINE_SUCCESS_STATUSES = {"success"} +_PIPELINE_FAILED_STATUSES = {"failed"} + + +async def _find_open_mr(component: str, issue_key: str, tools: list) -> GitlabMergeRequest | None: + """Find the first open MR for this issue across all GitLab groups.""" + for group in GITLAB_GROUPS: + project = f"redhat/{group}/{component}" + try: + mrs_data = await run_tool( + "search_gitlab_project_mrs", + available_tools=tools, + project=project, + search=issue_key, + state=GitlabMergeRequestState.OPEN, + ) + if mrs_data: + return GitlabMergeRequest(**mrs_data[0]) + except Exception as e: + logger.warning("Error searching open MRs in %s: %s", project, e) + return None + + +async def _check_and_post_pipeline_triage( + open_mr: GitlabMergeRequest, + issue_key: str, + tools: list, + dry_run: bool, +) -> WorkflowResult: + """Check the pipeline status of an open MR and act on it. + + - running/pending → reschedule in PIPELINE_CHECK_DELAY + - failed → fetch failed jobs + comparison, post triage comment on MR + - success → optionally run ship-mr (if PIPELINE_AUTO_SHIP=true) + """ + project = open_mr.project + mr_iid = open_mr.iid + + try: + pipelines = await run_tool( + "get_mr_pipelines_with_status", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + except Exception as e: + logger.warning("Failed to get pipelines for MR !%d in %s: %s", mr_iid, project, e) + return WorkflowResult( + status=f"Failed to get pipelines for !{mr_iid}: {e}. Reschedule in {PIPELINE_CHECK_DELAY}s", + reschedule_in=PIPELINE_CHECK_DELAY, + ) + + if not pipelines: + logger.info("No pipelines yet for MR !%d in %s", mr_iid, project) + return WorkflowResult( + status=f"No pipelines found for !{mr_iid} — reschedule in {PIPELINE_CHECK_DELAY}s", + reschedule_in=PIPELINE_CHECK_DELAY, + ) + + latest = pipelines[0] + pipeline_id = latest["id"] + pipeline_status = latest.get("status", "") + + # A parent pipeline showing 'success' may have failed child pipelines + child_statuses = [c.get("status", "") for c in latest.get("child_pipelines", [])] + if any(s in _PIPELINE_FAILED_STATUSES for s in child_statuses): + pipeline_status = "failed" + + logger.info( + "Pipeline #%d for MR !%d in %s: status=%s", pipeline_id, mr_iid, project, pipeline_status + ) + + if pipeline_status in _PIPELINE_RUNNING_STATUSES: + return WorkflowResult( + status=f"Pipeline #{pipeline_id} is {pipeline_status} — reschedule in {PIPELINE_CHECK_DELAY}s", + reschedule_in=PIPELINE_CHECK_DELAY, + ) + + if pipeline_status in _PIPELINE_FAILED_STATUSES: + if not dry_run: + await _post_pipeline_triage_comment(project, mr_iid, pipeline_id, tools) + else: + logger.info( + "Dry run: would post pipeline triage comment for !%d pipeline #%d", + mr_iid, + pipeline_id, + ) + return WorkflowResult( + status=( + f"Pipeline #{pipeline_id} failed — triage comment posted on !{mr_iid}. " + f"Reschedule in {PIPELINE_CHECK_DELAY}s to recheck." + ), + reschedule_in=PIPELINE_CHECK_DELAY, + ) + + if pipeline_status in _PIPELINE_SUCCESS_STATUSES: + auto_ship = os.getenv("PIPELINE_AUTO_SHIP", "false").lower() == "true" + if auto_ship and not dry_run: + await _post_ship_mr_comment(project, mr_iid, issue_key, tools) + else: + logger.info( + "Pipeline #%d passed for !%d — PIPELINE_AUTO_SHIP=%s, dry_run=%s. " + "Manual QE review required.", + pipeline_id, + mr_iid, + auto_ship, + dry_run, + ) + return WorkflowResult( + status=( + f"Pipeline #{pipeline_id} passed — " + + ("ship-mr review posted." if auto_ship and not dry_run else "manual QE review required.") + + f" Reschedule in {MERGE_CHECK_DELAY}s." + ), + reschedule_in=MERGE_CHECK_DELAY, + ) + + # Unknown/canceled/skipped — wait and recheck + return WorkflowResult( + status=f"Pipeline #{pipeline_id} status: {pipeline_status} — reschedule in {PIPELINE_CHECK_DELAY}s", + reschedule_in=PIPELINE_CHECK_DELAY, + ) + + +async def _post_pipeline_triage_comment( + project: str, mr_iid: int, pipeline_id: int, tools: list +) -> None: + """Fetch failed jobs and comparison data, then post a structured triage comment on the MR.""" + failed_jobs: list[dict] = [] + comparison: dict = {} + + try: + failed_jobs = await run_tool( + "get_pipeline_failed_jobs_deep", + available_tools=tools, + project=project, + pipeline_id=pipeline_id, + log_lines=50, + ) + except Exception as e: + logger.warning("Failed to get failed jobs for pipeline #%d: %s", pipeline_id, e) + + try: + comparison = await run_tool( + "compare_mr_test_failures", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + except Exception as e: + logger.warning("Failed to compare test failures for !%d: %s", mr_iid, e) + + # Categorize + build_check_stages = {"build", "pre_build", "check"} + new_failures = comparison.get("new_failures", []) + waived_failures = comparison.get("waived_failures", []) + + blocker_jobs = [j for j in failed_jobs if j.get("stage", "") in build_check_stages] + new_regression_jobs = [j for j in failed_jobs if j.get("name", "") in new_failures] + waived_jobs = [j for j in failed_jobs if j.get("name", "") in waived_failures] + + lines = [f"### Pipeline Triage — Pipeline #{pipeline_id}", ""] + + if blocker_jobs: + lines.append("#### Build/Check Failures (blockers — not waivable)") + for j in blocker_jobs: + lines.append(f"- **{j['name']}** (stage: {j['stage']}) — {j.get('failure_reason', 'N/A')}") + lines.append(f" [Job URL]({j['web_url']})") + lines.append("") + + if new_regression_jobs: + lines.append("#### New Regressions (action required)") + for j in new_regression_jobs: + lines.append(f"- **{j['name']}** (stage: {j['stage']}) — {j.get('failure_reason', 'N/A')}") + lines.append(f" [Job URL]({j['web_url']})") + lines.append("") + + if waived_jobs: + lines.append("#### Pre-existing / Waived Failures") + prev_iid = comparison.get("previous_mr_iid", "N/A") + for j in waived_jobs: + lines.append(f"- **{j['name']}** — also failed in previous MR !{prev_iid} (waived)") + lines.append("") + + total = len(failed_jobs) + new_count = len(blocker_jobs) + len(new_regression_jobs) + lines.append("#### Summary") + lines.append(f"- Total failed jobs: {total}") + lines.append(f"- Blockers (build/check): {len(blocker_jobs)}") + lines.append(f"- New regressions: {len(new_regression_jobs)}") + lines.append(f"- Pre-existing (waived): {len(waived_jobs)}") + lines.append("") + if new_count == 0: + lines.append("All failures are pre-existing — no new regressions. MR may be safe to merge.") + else: + lines.append(f"{new_count} blocking failure(s) need attention before this MR can merge.") + lines.append("") + lines.append("🤖 Generated by Ymir") + + comment_body = "\n".join(lines) + + try: + # Try to find the pipeline results discussion thread and reply to it + discussions = await run_tool( + "get_mr_discussions", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + pipeline_discussion_id = None + for disc in discussions: + notes = disc.get("notes", []) + if notes: + first_body = notes[0].get("body", "") + if "<!--RESULTS_COMMENT-->" in first_body or f"#{pipeline_id}" in first_body: + if not disc.get("resolved", False): + pipeline_discussion_id = disc.get("discussion_id") + break + + if pipeline_discussion_id: + await run_tool( + "reply_to_mr_discussion", + available_tools=tools, + project=project, + mr_iid=mr_iid, + discussion_id=pipeline_discussion_id, + body=comment_body, + ) + logger.info("Posted pipeline triage reply to discussion %s on !%d", pipeline_discussion_id, mr_iid) + else: + await run_tool( + "add_merge_request_comment", + available_tools=tools, + merge_request_url=f"https://gitlab.com/{project}/-/merge_requests/{mr_iid}", + comment=comment_body, + ) + logger.info("Posted pipeline triage comment on !%d (no pipeline thread found)", mr_iid) + except Exception as e: + logger.error("Failed to post pipeline triage comment on !%d: %s", mr_iid, e) + + +async def _post_ship_mr_comment( + project: str, mr_iid: int, issue_key: str, tools: list +) -> None: + """Post a ship-mr readiness summary when the pipeline passes.""" + try: + comparison = await run_tool( + "compare_mr_test_failures", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + waived = comparison.get("waived_failures", []) + new_failures = comparison.get("new_failures", []) + except Exception as e: + logger.warning("Failed to compare test failures for !%d: %s", mr_iid, e) + waived = [] + new_failures = [] + + if new_failures: + logger.info( + "Pipeline passed for !%d but compare_mr_test_failures found %d new failures — " + "not auto-approving.", + mr_iid, + len(new_failures), + ) + return + + comment_lines = [ + f"### Pipeline Passed — Ship MR Check for !{mr_iid}", + "", + f"**Pipeline:** passed", + f"**Jira:** {issue_key}", + f"**Pre-existing failures waived:** {len(waived)}", + "", + "Pipeline is clean. Proceeding to approve and enable auto-merge.", + "", + "🤖 Generated by Ymir", + ] + + try: + await run_tool( + "add_merge_request_comment", + available_tools=tools, + merge_request_url=f"https://gitlab.com/{project}/-/merge_requests/{mr_iid}", + comment="\n".join(comment_lines), + ) + await run_tool( + "approve_merge_request", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + await run_tool( + "set_mr_auto_merge", + available_tools=tools, + project=project, + mr_iid=mr_iid, + ) + logger.info("Approved and set auto-merge on !%d in %s", mr_iid, project) + except Exception as e: + logger.error("Failed to approve/auto-merge !%d: %s", mr_iid, e) + + async def run_issue_verification( jira_issue: str, dry_run: bool = False, @@ -466,11 +772,8 @@ async def run_before_errata(state: IssueVerificationWorkflowState): await _label_merge_if_needed(issue, gateway_tools, state.dry_run) if JiraLabels.MERGED.value not in issue.labels: - state.result = WorkflowResult( - status=f"No merged MR found, reschedule in {MERGE_CHECK_DELAY}s", - reschedule_in=MERGE_CHECK_DELAY, - ) - return Workflow.END + # Check the pipeline on the open MR before waiting + return "check_open_mr_pipeline" latest_merged_timestamp = await _get_latest_merged_timestamp(issue, gateway_tools) cur_time = datetime.now(tz=UTC) @@ -745,9 +1048,32 @@ async def check_reproduction(state: IssueVerificationWorkflowState): ) return Workflow.END + async def check_open_mr_pipeline(state: IssueVerificationWorkflowState): + """Check the pipeline status of the open MR and triage or ship accordingly.""" + issue = state.issue + component = issue.components[0] + + open_mr = await _find_open_mr(component, issue.key, gateway_tools) + if open_mr is None: + logger.info("No open MR found for %s — waiting %ds", issue.key, MERGE_CHECK_DELAY) + state.result = WorkflowResult( + status=f"No open or merged MR found for {issue.key} — reschedule in {MERGE_CHECK_DELAY}s", + reschedule_in=MERGE_CHECK_DELAY, + ) + return Workflow.END + + state.result = await _check_and_post_pipeline_triage( + open_mr=open_mr, + issue_key=issue.key, + tools=gateway_tools, + dry_run=state.dry_run, + ) + return Workflow.END + workflow.add_step("fetch_and_validate_issue", fetch_and_validate_issue) workflow.add_step("check_errata_status", check_errata_status) workflow.add_step("run_before_errata", run_before_errata) + workflow.add_step("check_open_mr_pipeline", check_open_mr_pipeline) workflow.add_step("run_after_errata", run_after_errata) workflow.add_step("analyze_testing", analyze_testing) workflow.add_step("check_reproduction", check_reproduction) diff --git a/ymir/tools/privileged/gateway.py b/ymir/tools/privileged/gateway.py index 59e36ffba..b51247143 100644 --- a/ymir/tools/privileged/gateway.py +++ b/ymir/tools/privileged/gateway.py @@ -28,7 +28,10 @@ AddBlockingMergeRequestCommentTool, AddMergeRequestCommentTool, AddMergeRequestLabelsTool, + ApproveMergeRequestTool, CloneRepositoryTool, + CompareMrTestFailuresTool, + CreateMrInlineCommentTool, FetchBranchTool, FetchGitlabMrNotesTool, ForkRepositoryTool, @@ -36,15 +39,22 @@ GetFailedPipelineJobsFromMergeRequestTool, GetInternalRhelBranchesTool, GetMergeRequestDetailsTool, + GetMrChangesTool, + GetMrDiscussionsTool, + GetMrPipelinesWithStatusTool, GetPatchFromUrlTool, + GetPipelineFailedJobsDeepTool, ListProjectMergeRequestsTool, OpenMergeRequestTool, PushToRemoteRepositoryTool, ResolveQeReviewersTool, + ReplyToMrDiscussionTool, ResolveReviewersTool, + ResolveMrDiscussionTool, RetryPipelineJobTool, SearchGitlabProjectMrsTool, SetMergeRequestReviewersTool, + SetMrAutoMergeTool, ) from ymir.tools.privileged.jira import ( AddJiraAttachmentsTool, @@ -61,6 +71,7 @@ SetJiraFieldsTool, SetPreliminaryTestingTool, UpdateJiraCommentTool, + UpdateTestCoverageTool, VerifyIssueAuthorTool, ) from ymir.tools.privileged.lookaside import ( @@ -94,7 +105,7 @@ async def _async_main(): config = MCPServerConfig(**config_kwargs) setup_logging() - apply_zstream_override_from_env() +apply_zstream_override_from_env() tool_options: dict = {"working_directory": None} mcp = MCPServer(config=config) @@ -132,6 +143,17 @@ async def _async_main(): ResolveReviewersTool(options=tool_options), ResolveQeReviewersTool(options=tool_options), SetMergeRequestReviewersTool(options=tool_options), + # Review / QE tools + GetMrPipelinesWithStatusTool(options=tool_options), + GetMrChangesTool(options=tool_options), + GetMrDiscussionsTool(options=tool_options), + ReplyToMrDiscussionTool(options=tool_options), + ResolveMrDiscussionTool(options=tool_options), + ApproveMergeRequestTool(options=tool_options), + SetMrAutoMergeTool(options=tool_options), + GetPipelineFailedJobsDeepTool(options=tool_options), + CompareMrTestFailuresTool(options=tool_options), + CreateMrInlineCommentTool(options=tool_options), GetErratumTool(options=tool_options), GetErratumBuildNvrTool(options=tool_options), GetErratumTransitionRulesTool(options=tool_options), @@ -163,6 +185,7 @@ async def _async_main(): SetJiraFieldsTool(options=tool_options), SetPreliminaryTestingTool(options=tool_options), UpdateJiraCommentTool(options=tool_options), + UpdateTestCoverageTool(options=tool_options), VerifyIssueAuthorTool(options=tool_options), CreateJiraIssueTool(options=tool_options), DownloadSourcesTool(options=tool_options), diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index f6636cb1d..da24b71e8 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -1628,3 +1628,669 @@ async def _run( except Exception as e: raise ToolError(f"Failed to list MRs for {tool_input.project}: {e}") from e + + +# --------------------------------------------------------------------------- +# Review / QE tools — pipeline triage and ship-mr support +# --------------------------------------------------------------------------- + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x07|\x1b\\)") + + +def _strip_ansi(text: str) -> str: + text = _ANSI_RE.sub("", text) + return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text) + + +async def _gitlab_api_get(path: str, params: dict | None = None) -> Any: + """GET from the GitLab API, authenticating with GITLAB_TOKEN.""" + token = os.getenv("GITLAB_TOKEN", "") + base = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/") + url = f"{base}/api/v4{path}" + headers: dict[str, str] = {"User-Agent": YMIR_USER_AGENT} + if token: + headers["PRIVATE-TOKEN"] = token + async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: + async with aiohttp_get_with_retries(session, url, headers=headers, params=params or {}) as resp: + resp.raise_for_status() + return await resp.json() + + +async def _gitlab_api_post(path: str, body: dict) -> Any: + token = os.getenv("GITLAB_TOKEN", "") + base = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/") + url = f"{base}/api/v4{path}" + headers = {"User-Agent": YMIR_USER_AGENT, "Content-Type": "application/json"} + if token: + headers["PRIVATE-TOKEN"] = token + async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: + async with session.post(url, json=body, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + +async def _gitlab_api_put(path: str, body: dict) -> Any: + token = os.getenv("GITLAB_TOKEN", "") + base = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/") + url = f"{base}/api/v4{path}" + headers = {"User-Agent": YMIR_USER_AGENT, "Content-Type": "application/json"} + if token: + headers["PRIVATE-TOKEN"] = token + async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: + async with session.put(url, json=body, headers=headers) as resp: + resp.raise_for_status() + return await resp.json() + + +async def _get_job_trace(project_enc: str, job_id: int) -> str: + token = os.getenv("GITLAB_TOKEN", "") + base = os.getenv("GITLAB_URL", "https://gitlab.com").rstrip("/") + url = f"{base}/api/v4/projects/{project_enc}/jobs/{job_id}/trace" + headers: dict[str, str] = {"User-Agent": YMIR_USER_AGENT} + if token: + headers["PRIVATE-TOKEN"] = token + try: + async with aiohttp.ClientSession(timeout=AIOHTTP_TIMEOUT) as session: + async with session.get(url, headers=headers) as resp: + if resp.status >= 400: + return "" + return _strip_ansi(await resp.text()) + except Exception: + return "" + + +async def _collect_failed_jobs_recursive( + project_enc: str, pipeline_id: int, log_lines: int, depth: int = 0 +) -> list[dict]: + if depth > 3: + return [] + output: list[dict] = [] + try: + jobs = await _gitlab_api_get( + f"/projects/{project_enc}/pipelines/{pipeline_id}/jobs", + params={"scope[]": "failed", "per_page": "100"}, + ) + except Exception: + jobs = [] + for job in jobs: + trace = await _get_job_trace(project_enc, job["id"]) + trace_lines = trace.splitlines() + tail = "\n".join(trace_lines[-log_lines:]) if len(trace_lines) > log_lines else trace + output.append({ + "job_id": job["id"], + "name": job.get("name", ""), + "stage": job.get("stage", ""), + "status": job.get("status", ""), + "failure_reason": job.get("failure_reason", ""), + "web_url": job.get("web_url", ""), + "log_tail": tail, + "pipeline_id": pipeline_id, + }) + # Recurse into child pipelines via bridges + try: + bridges = await _gitlab_api_get( + f"/projects/{project_enc}/pipelines/{pipeline_id}/bridges", + params={"per_page": "50"}, + ) + except Exception: + bridges = [] + for bridge in bridges: + downstream = bridge.get("downstream_pipeline") + if not downstream: + continue + child_proj = str(downstream.get("project_id", "")) or project_enc + child_output = await _collect_failed_jobs_recursive(child_proj, downstream["id"], log_lines, depth + 1) + output.extend(child_output) + return output + + +# -- GetMrPipelinesWithStatusTool -- + +class GetMrPipelinesWithStatusToolInput(BaseModel): + project: str = Field(description="GitLab project path (e.g. 'redhat/centos-stream/rpms/bash')") + mr_iid: int = Field(description="Merge request IID (the number after !)") + + +class GetMrPipelinesWithStatusTool( + Tool[GetMrPipelinesWithStatusToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] +): + name = "get_mr_pipelines_with_status" + timeout = 120 + description = """ + Lists all pipelines for a merge request, including child/downstream pipelines + triggered via bridge jobs. Returns status, ref, SHA, web_url, and child pipeline info. + Use this to detect whether the latest pipeline passed, failed, or is still running. + """ + input_schema = GetMrPipelinesWithStatusToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: GetMrPipelinesWithStatusToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[list[dict[str, Any]]]: + project_enc = quote(tool_input.project, safe="") + try: + pipelines = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/pipelines", + params={"per_page": "20"}, + ) + except Exception as e: + raise ToolError(f"Failed to get pipelines for !{tool_input.mr_iid}: {e}") from e + + results = [] + for p in pipelines: + entry: dict[str, Any] = { + "id": p["id"], + "status": p.get("status", ""), + "ref": p.get("ref", ""), + "sha": p.get("sha", "")[:8], + "web_url": p.get("web_url", ""), + "created_at": p.get("created_at", ""), + "child_pipelines": [], + } + try: + bridges = await _gitlab_api_get( + f"/projects/{project_enc}/pipelines/{p['id']}/bridges", + params={"per_page": "50"}, + ) + for bridge in bridges: + downstream = bridge.get("downstream_pipeline") + if downstream: + entry["child_pipelines"].append({ + "id": downstream["id"], + "status": downstream.get("status", ""), + "web_url": downstream.get("web_url", ""), + "triggered_by": bridge.get("name", ""), + }) + except Exception: + pass + results.append(entry) + return JSONToolOutput(result=results) + + +# -- GetMrChangesTool -- + +class GetMrChangesToolInput(BaseModel): + project: str = Field(description="GitLab project path (e.g. 'redhat/centos-stream/rpms/bash')") + mr_iid: int = Field(description="Merge request IID") + + +class GetMrChangesTool(Tool[GetMrChangesToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] +): + name = "get_mr_changes" + timeout = 120 + description = """ + Returns the full diff of all changed files in a merge request. + Each entry includes old_path, new_path, and the unified diff text. + Also returns diff_refs (base_sha, start_sha, head_sha) needed for inline comments. + """ + input_schema = GetMrChangesToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: GetMrChangesToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[list[dict[str, Any]]]: + project_enc = quote(tool_input.project, safe="") + try: + data = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/changes" + ) + except Exception as e: + raise ToolError(f"Failed to get MR changes for !{tool_input.mr_iid}: {e}") from e + + diff_refs = data.get("diff_refs", {}) + changes = [ + { + "old_path": c.get("old_path", ""), + "new_path": c.get("new_path", ""), + "new_file": c.get("new_file", False), + "deleted_file": c.get("deleted_file", False), + "renamed_file": c.get("renamed_file", False), + "diff": c.get("diff", ""), + "diff_refs": diff_refs, + } + for c in data.get("changes", []) + ] + return JSONToolOutput(result=changes) + + +# -- GetMrDiscussionsTool -- + +class GetMrDiscussionsToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + + +class GetMrDiscussionsTool( + Tool[GetMrDiscussionsToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] +): + name = "get_mr_discussions" + timeout = 120 + description = """ + Returns all discussion threads and comments on a merge request. + Each thread includes its discussion_id, resolved state, position (for inline comments), + and all notes (author, body, created_at). + Use this to find pipeline result threads, existing review comments, or waiver messages. + """ + input_schema = GetMrDiscussionsToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: GetMrDiscussionsToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[list[dict[str, Any]]]: + project_enc = quote(tool_input.project, safe="") + try: + discussions = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/discussions", + params={"per_page": "100"}, + ) + except Exception as e: + raise ToolError(f"Failed to get discussions for !{tool_input.mr_iid}: {e}") from e + + results = [] + for disc in discussions: + notes = disc.get("notes", []) + if not notes: + continue + first = notes[0] + position = first.get("position") + results.append({ + "discussion_id": disc.get("id", ""), + "resolved": first.get("resolved", False), + "position": { + "new_path": position.get("new_path", "") if position else "", + "new_line": position.get("new_line") if position else None, + } if position else None, + "notes": [ + { + "author": n.get("author", {}).get("username", ""), + "body": n.get("body", ""), + "created_at": n.get("created_at", ""), + "system": n.get("system", False), + } + for n in notes + ], + }) + return JSONToolOutput(result=results) + + +# -- ReplyToMrDiscussionTool -- + +class ReplyToMrDiscussionToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + discussion_id: str = Field(description="ID of the discussion thread to reply to") + body: str = Field(description="Reply text (supports GitLab Markdown)") + + +class ReplyToMrDiscussionTool( + Tool[ReplyToMrDiscussionToolInput, ToolRunOptions, StringToolOutput] +): + name = "reply_to_mr_discussion" + timeout = 120 + description = """ + Replies to an existing discussion thread on a merge request. + Use get_mr_discussions first to find the discussion_id. + Use this to post pipeline triage results in-context on the pipeline failure thread. + """ + input_schema = ReplyToMrDiscussionToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: ReplyToMrDiscussionToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + project_enc = quote(tool_input.project, safe="") + try: + result = await _gitlab_api_post( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" + f"/discussions/{tool_input.discussion_id}/notes", + body={"body": tool_input.body}, + ) + except Exception as e: + raise ToolError(f"Failed to reply to discussion {tool_input.discussion_id}: {e}") from e + return StringToolOutput(result=f"Reply posted (note ID: {result.get('id', '?')})") + + +# -- ResolveMrDiscussionTool -- + +class ResolveMrDiscussionToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + discussion_id: str = Field(description="ID of the discussion thread to resolve") + resolved: bool = Field(default=True, description="True to resolve, False to unresolve") + + +class ResolveMrDiscussionTool( + Tool[ResolveMrDiscussionToolInput, ToolRunOptions, StringToolOutput] +): + name = "resolve_mr_discussion" + timeout = 120 + description = """ + Resolves or unresolves a discussion thread on a merge request. + Use get_mr_discussions to find discussion_id values. + Resolving all open discussions is required before approving a merge request. + """ + input_schema = ResolveMrDiscussionToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: ResolveMrDiscussionToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + project_enc = quote(tool_input.project, safe="") + try: + await _gitlab_api_put( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" + f"/discussions/{tool_input.discussion_id}", + body={"resolved": tool_input.resolved}, + ) + except Exception as e: + raise ToolError(f"Failed to resolve discussion {tool_input.discussion_id}: {e}") from e + action = "Resolved" if tool_input.resolved else "Unresolved" + return StringToolOutput(result=f"{action} discussion {tool_input.discussion_id}") + + +# -- ApproveMergeRequestTool -- + +class ApproveMergeRequestToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + + +class ApproveMergeRequestTool( + Tool[ApproveMergeRequestToolInput, ToolRunOptions, StringToolOutput] +): + name = "approve_merge_request" + timeout = 120 + description = """ + Approves a merge request. Requires your GITLAB_TOKEN to have appropriate permissions. + Call this only after all review checks pass and all discussions are resolved. + """ + input_schema = ApproveMergeRequestToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: ApproveMergeRequestToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + project_enc = quote(tool_input.project, safe="") + try: + await _gitlab_api_post( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/approve", + body={}, + ) + except Exception as e: + raise ToolError(f"Failed to approve MR !{tool_input.mr_iid}: {e}") from e + return StringToolOutput(result=f"MR !{tool_input.mr_iid} approved successfully.") + + +# -- SetMrAutoMergeTool -- + +class SetMrAutoMergeToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + + +class SetMrAutoMergeTool( + Tool[SetMrAutoMergeToolInput, ToolRunOptions, StringToolOutput] +): + name = "set_mr_auto_merge" + timeout = 120 + description = """ + Enables auto-merge (merge when pipeline succeeds) on a merge request. + The MR must have an active pipeline and all required approvals. + Call this after approving the MR when the pipeline is still running. + """ + input_schema = SetMrAutoMergeToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: SetMrAutoMergeToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + project_enc = quote(tool_input.project, safe="") + try: + await _gitlab_api_put( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/merge", + body={"merge_when_pipeline_succeeds": True}, + ) + except Exception as e: + raise ToolError(f"Failed to set auto-merge on !{tool_input.mr_iid}: {e}") from e + return StringToolOutput( + result=f"Auto-merge enabled on !{tool_input.mr_iid}. It will merge when the pipeline succeeds." + ) + + +# -- GetPipelineFailedJobsDeepTool -- + +class GetPipelineFailedJobsDeepToolInput(BaseModel): + project: str = Field(description="GitLab project path") + pipeline_id: int = Field(description="Pipeline ID (numeric)") + log_lines: int = Field(default=100, description="Number of log tail lines to include per failed job") + + +class GetPipelineFailedJobsDeepTool( + Tool[GetPipelineFailedJobsDeepToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] +): + name = "get_pipeline_failed_jobs_deep" + timeout = 180 + description = """ + Gets failed job details and log output for a pipeline, including child/downstream + pipelines triggered via bridge jobs. Returns job name, stage, failure reason, + web_url, and the last N lines of the job log for each failed job. + Use get_mr_pipelines_with_status first to find the pipeline ID. + """ + input_schema = GetPipelineFailedJobsDeepToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: GetPipelineFailedJobsDeepToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[list[dict[str, Any]]]: + project_enc = quote(tool_input.project, safe="") + try: + failed = await _collect_failed_jobs_recursive( + project_enc, tool_input.pipeline_id, tool_input.log_lines + ) + except Exception as e: + raise ToolError(f"Failed to get failed jobs for pipeline #{tool_input.pipeline_id}: {e}") from e + return JSONToolOutput(result=failed) + + +# -- CompareMrTestFailuresTool -- + +class CompareMrTestFailuresToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + + +class CompareMrTestFailuresTool( + Tool[CompareMrTestFailuresToolInput, ToolRunOptions, JSONToolOutput[dict[str, Any]]] +): + name = "compare_mr_test_failures" + timeout = 180 + description = """ + Compares pipeline test failures for this MR against the previous merged MR on the + same target branch. Returns: + - new_failures: job names that failed in this MR but NOT the previous one (regressions) + - waived_failures: job names that failed in BOTH this and the previous MR (pre-existing) + - fixed: job names that failed previously but pass now + Use this to determine whether failures are new regressions or pre-existing issues. + """ + input_schema = CompareMrTestFailuresToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: CompareMrTestFailuresToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> JSONToolOutput[dict[str, Any]]: + project_enc = quote(tool_input.project, safe="") + try: + mr = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" + ) + target_branch = mr.get("target_branch", "") + + # Get current MR pipelines and collect failed job names + current_pipelines = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/pipelines", + params={"per_page": "5"}, + ) + current_failed: set[str] = set() + for p in current_pipelines[:1]: # only latest pipeline + jobs = await _collect_failed_jobs_recursive(project_enc, p["id"], log_lines=10) + current_failed.update(j["name"] for j in jobs) + + # Find previous merged MR on same target branch + prev_mrs = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests", + params={ + "state": "merged", + "target_branch": target_branch, + "order_by": "merged_at", + "sort": "desc", + "per_page": "5", + }, + ) + prev_mr = next((m for m in prev_mrs if m["iid"] != tool_input.mr_iid), None) + + prev_failed: set[str] = set() + prev_iid = None + if prev_mr: + prev_iid = prev_mr["iid"] + prev_pipelines = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{prev_iid}/pipelines", + params={"per_page": "5"}, + ) + for p in prev_pipelines[:1]: + jobs = await _collect_failed_jobs_recursive(project_enc, p["id"], log_lines=10) + prev_failed.update(j["name"] for j in jobs) + + except ToolError: + raise + except Exception as e: + raise ToolError(f"Failed to compare test failures for !{tool_input.mr_iid}: {e}") from e + + new_failures = sorted(current_failed - prev_failed) + waived_failures = sorted(current_failed & prev_failed) + fixed = sorted(prev_failed - current_failed) + + return JSONToolOutput(result={ + "mr_iid": tool_input.mr_iid, + "previous_mr_iid": prev_iid, + "target_branch": target_branch, + "new_failures": new_failures, + "waived_failures": waived_failures, + "fixed": fixed, + "total_current_failures": len(current_failed), + }) + + +# -- CreateMrInlineCommentTool -- + +class CreateMrInlineCommentToolInput(BaseModel): + project: str = Field(description="GitLab project path") + mr_iid: int = Field(description="Merge request IID") + body: str = Field(description="Comment text (supports GitLab Markdown)") + new_path: str = Field(description="File path in the new version of the diff") + new_line: int = Field(description="Line number in the new version of the file") + base_sha: str = Field(default="", description="base_sha from diff_refs (get_mr_changes returns this)") + start_sha: str = Field(default="", description="start_sha from diff_refs") + head_sha: str = Field(default="", description="head_sha from diff_refs") + + +class CreateMrInlineCommentTool( + Tool[CreateMrInlineCommentToolInput, ToolRunOptions, StringToolOutput] +): + name = "create_mr_inline_comment" + timeout = 120 + description = """ + Posts an inline review comment at a specific file and line in the MR diff. + Use get_mr_changes first to get the diff_refs (base_sha, start_sha, head_sha) + and file paths. Use this for code review findings on specific lines. + """ + input_schema = CreateMrInlineCommentToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child(namespace=["tool", "gitlab", self.name], creator=self) + + async def _run( + self, + tool_input: CreateMrInlineCommentToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + project_enc = quote(tool_input.project, safe="") + base_sha = tool_input.base_sha + start_sha = tool_input.start_sha + head_sha = tool_input.head_sha + + # Auto-fetch diff_refs if not provided + if not (base_sha and start_sha and head_sha): + try: + mr = await _gitlab_api_get( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" + ) + diff_refs = mr.get("diff_refs", {}) + base_sha = base_sha or diff_refs.get("base_sha", "") + start_sha = start_sha or diff_refs.get("start_sha", "") + head_sha = head_sha or diff_refs.get("head_sha", "") + except Exception as e: + raise ToolError(f"Failed to fetch diff_refs: {e}") from e + + position = { + "position_type": "text", + "base_sha": base_sha, + "start_sha": start_sha, + "head_sha": head_sha, + "new_path": tool_input.new_path, + "old_path": tool_input.new_path, + "new_line": tool_input.new_line, + } + try: + result = await _gitlab_api_post( + f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}/discussions", + body={"body": tool_input.body, "position": position}, + ) + except Exception as e: + raise ToolError(f"Failed to create inline comment: {e}") from e + return StringToolOutput(result=f"Inline comment created (discussion ID: {result.get('id', '?')})") diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 716a76366..48834b9b3 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1713,6 +1713,62 @@ async def _run( ) +class UpdateTestCoverageToolInput(BaseModel): + issue_key: str = Field(description="Jira issue key (e.g. RHEL-12345)") + value: str = Field( + description="Test Coverage value to set (e.g. 'Automated', 'Manual', 'RegressionOnly', 'New Test Coverage')" + ) + + +class UpdateTestCoverageTool(Tool[UpdateTestCoverageToolInput, ToolRunOptions, StringToolOutput]): + name = "update_test_coverage" + timeout = 120 + description = """ + Updates the Test Coverage custom field on a Jira issue. + Allowed values: 'Automated', 'Manual', 'RegressionOnly', 'New Test Coverage'. + """ + input_schema = UpdateTestCoverageToolInput + + def _create_emitter(self) -> Emitter: + return Emitter.root().child( + namespace=["tool", "jira", self.name], + creator=self, + ) + + async def _run( + self, + tool_input: UpdateTestCoverageToolInput, + options: ToolRunOptions | None, + context: RunContext, + ) -> StringToolOutput: + issue_key = tool_input.issue_key + value = tool_input.value + + if os.getenv("DRY_RUN", "False").lower() == "true": + return StringToolOutput( + result=f"Dry run, not updating Test Coverage on {issue_key} (this is expected, not an error)" + ) + if _skip_jira_writes(): + return StringToolOutput( + result=f"JIRA_DRY_RUN is set, not updating Test Coverage on {issue_key} (this is expected, not an error)" + ) + + headers = get_jira_auth_headers() + jira_base = os.getenv("JIRA_URL") + url = urljoin(jira_base, f"rest/api/2/issue/{issue_key}") + payload = {"fields": {TEST_COVERAGE_CUSTOM_FIELD: {"value": value}}} + + async with aiohttpClientSession(timeout=AIOHTTP_TIMEOUT) as session: + with tool_error_context( + f"Failed to update Test Coverage on {issue_key}", + test_coverage_value=value, + ): + async with session.put(url, json=payload, headers=headers) as response: + response.raise_for_status() + + return StringToolOutput(result=f"Successfully updated Test Coverage to '{value}' on {issue_key}") + + class UpdateJiraCommentToolInput(BaseModel): issue_key: str = Field(description="Jira issue key (e.g. RHEL-12345)") comment_id: str = Field(description="ID of the comment to update") From 342ea0348670edee8b4c0fc778acf58e5dc8a3ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:54:58 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ymir/agents/issue_verification_agent.py | 21 ++-- ymir/tools/privileged/gitlab.py | 137 +++++++++++++----------- 2 files changed, 80 insertions(+), 78 deletions(-) diff --git a/ymir/agents/issue_verification_agent.py b/ymir/agents/issue_verification_agent.py index de918eb67..b6731dfb0 100644 --- a/ymir/agents/issue_verification_agent.py +++ b/ymir/agents/issue_verification_agent.py @@ -466,9 +466,7 @@ async def _check_and_post_pipeline_triage( if any(s in _PIPELINE_FAILED_STATUSES for s in child_statuses): pipeline_status = "failed" - logger.info( - "Pipeline #%d for MR !%d in %s: status=%s", pipeline_id, mr_iid, project, pipeline_status - ) + logger.info("Pipeline #%d for MR !%d in %s: status=%s", pipeline_id, mr_iid, project, pipeline_status) if pipeline_status in _PIPELINE_RUNNING_STATUSES: return WorkflowResult( @@ -499,8 +497,7 @@ async def _check_and_post_pipeline_triage( await _post_ship_mr_comment(project, mr_iid, issue_key, tools) else: logger.info( - "Pipeline #%d passed for !%d — PIPELINE_AUTO_SHIP=%s, dry_run=%s. " - "Manual QE review required.", + "Pipeline #%d passed for !%d — PIPELINE_AUTO_SHIP=%s, dry_run=%s. Manual QE review required.", pipeline_id, mr_iid, auto_ship, @@ -522,9 +519,7 @@ async def _check_and_post_pipeline_triage( ) -async def _post_pipeline_triage_comment( - project: str, mr_iid: int, pipeline_id: int, tools: list -) -> None: +async def _post_pipeline_triage_comment(project: str, mr_iid: int, pipeline_id: int, tools: list) -> None: """Fetch failed jobs and comparison data, then post a structured triage comment on the MR.""" failed_jobs: list[dict] = [] comparison: dict = {} @@ -626,7 +621,9 @@ async def _post_pipeline_triage_comment( discussion_id=pipeline_discussion_id, body=comment_body, ) - logger.info("Posted pipeline triage reply to discussion %s on !%d", pipeline_discussion_id, mr_iid) + logger.info( + "Posted pipeline triage reply to discussion %s on !%d", pipeline_discussion_id, mr_iid + ) else: await run_tool( "add_merge_request_comment", @@ -639,9 +636,7 @@ async def _post_pipeline_triage_comment( logger.error("Failed to post pipeline triage comment on !%d: %s", mr_iid, e) -async def _post_ship_mr_comment( - project: str, mr_iid: int, issue_key: str, tools: list -) -> None: +async def _post_ship_mr_comment(project: str, mr_iid: int, issue_key: str, tools: list) -> None: """Post a ship-mr readiness summary when the pipeline passes.""" try: comparison = await run_tool( @@ -669,7 +664,7 @@ async def _post_ship_mr_comment( comment_lines = [ f"### Pipeline Passed — Ship MR Check for !{mr_iid}", "", - f"**Pipeline:** passed", + "**Pipeline:** passed", f"**Jira:** {issue_key}", f"**Pre-existing failures waived:** {len(waived)}", "", diff --git a/ymir/tools/privileged/gitlab.py b/ymir/tools/privileged/gitlab.py index da24b71e8..ef3dee3c3 100644 --- a/ymir/tools/privileged/gitlab.py +++ b/ymir/tools/privileged/gitlab.py @@ -1716,16 +1716,18 @@ async def _collect_failed_jobs_recursive( trace = await _get_job_trace(project_enc, job["id"]) trace_lines = trace.splitlines() tail = "\n".join(trace_lines[-log_lines:]) if len(trace_lines) > log_lines else trace - output.append({ - "job_id": job["id"], - "name": job.get("name", ""), - "stage": job.get("stage", ""), - "status": job.get("status", ""), - "failure_reason": job.get("failure_reason", ""), - "web_url": job.get("web_url", ""), - "log_tail": tail, - "pipeline_id": pipeline_id, - }) + output.append( + { + "job_id": job["id"], + "name": job.get("name", ""), + "stage": job.get("stage", ""), + "status": job.get("status", ""), + "failure_reason": job.get("failure_reason", ""), + "web_url": job.get("web_url", ""), + "log_tail": tail, + "pipeline_id": pipeline_id, + } + ) # Recurse into child pipelines via bridges try: bridges = await _gitlab_api_get( @@ -1739,13 +1741,16 @@ async def _collect_failed_jobs_recursive( if not downstream: continue child_proj = str(downstream.get("project_id", "")) or project_enc - child_output = await _collect_failed_jobs_recursive(child_proj, downstream["id"], log_lines, depth + 1) + child_output = await _collect_failed_jobs_recursive( + child_proj, downstream["id"], log_lines, depth + 1 + ) output.extend(child_output) return output # -- GetMrPipelinesWithStatusTool -- + class GetMrPipelinesWithStatusToolInput(BaseModel): project: str = Field(description="GitLab project path (e.g. 'redhat/centos-stream/rpms/bash')") mr_iid: int = Field(description="Merge request IID (the number after !)") @@ -1800,12 +1805,14 @@ async def _run( for bridge in bridges: downstream = bridge.get("downstream_pipeline") if downstream: - entry["child_pipelines"].append({ - "id": downstream["id"], - "status": downstream.get("status", ""), - "web_url": downstream.get("web_url", ""), - "triggered_by": bridge.get("name", ""), - }) + entry["child_pipelines"].append( + { + "id": downstream["id"], + "status": downstream.get("status", ""), + "web_url": downstream.get("web_url", ""), + "triggered_by": bridge.get("name", ""), + } + ) except Exception: pass results.append(entry) @@ -1814,13 +1821,13 @@ async def _run( # -- GetMrChangesTool -- + class GetMrChangesToolInput(BaseModel): project: str = Field(description="GitLab project path (e.g. 'redhat/centos-stream/rpms/bash')") mr_iid: int = Field(description="Merge request IID") -class GetMrChangesTool(Tool[GetMrChangesToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]] -): +class GetMrChangesTool(Tool[GetMrChangesToolInput, ToolRunOptions, JSONToolOutput[list[dict[str, Any]]]]): name = "get_mr_changes" timeout = 120 description = """ @@ -1865,6 +1872,7 @@ async def _run( # -- GetMrDiscussionsTool -- + class GetMrDiscussionsToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") @@ -1908,28 +1916,33 @@ async def _run( continue first = notes[0] position = first.get("position") - results.append({ - "discussion_id": disc.get("id", ""), - "resolved": first.get("resolved", False), - "position": { - "new_path": position.get("new_path", "") if position else "", - "new_line": position.get("new_line") if position else None, - } if position else None, - "notes": [ - { - "author": n.get("author", {}).get("username", ""), - "body": n.get("body", ""), - "created_at": n.get("created_at", ""), - "system": n.get("system", False), + results.append( + { + "discussion_id": disc.get("id", ""), + "resolved": first.get("resolved", False), + "position": { + "new_path": position.get("new_path", "") if position else "", + "new_line": position.get("new_line") if position else None, } - for n in notes - ], - }) + if position + else None, + "notes": [ + { + "author": n.get("author", {}).get("username", ""), + "body": n.get("body", ""), + "created_at": n.get("created_at", ""), + "system": n.get("system", False), + } + for n in notes + ], + } + ) return JSONToolOutput(result=results) # -- ReplyToMrDiscussionTool -- + class ReplyToMrDiscussionToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") @@ -1937,9 +1950,7 @@ class ReplyToMrDiscussionToolInput(BaseModel): body: str = Field(description="Reply text (supports GitLab Markdown)") -class ReplyToMrDiscussionTool( - Tool[ReplyToMrDiscussionToolInput, ToolRunOptions, StringToolOutput] -): +class ReplyToMrDiscussionTool(Tool[ReplyToMrDiscussionToolInput, ToolRunOptions, StringToolOutput]): name = "reply_to_mr_discussion" timeout = 120 description = """ @@ -1972,6 +1983,7 @@ async def _run( # -- ResolveMrDiscussionTool -- + class ResolveMrDiscussionToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") @@ -1979,9 +1991,7 @@ class ResolveMrDiscussionToolInput(BaseModel): resolved: bool = Field(default=True, description="True to resolve, False to unresolve") -class ResolveMrDiscussionTool( - Tool[ResolveMrDiscussionToolInput, ToolRunOptions, StringToolOutput] -): +class ResolveMrDiscussionTool(Tool[ResolveMrDiscussionToolInput, ToolRunOptions, StringToolOutput]): name = "resolve_mr_discussion" timeout = 120 description = """ @@ -2015,14 +2025,13 @@ async def _run( # -- ApproveMergeRequestTool -- + class ApproveMergeRequestToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") -class ApproveMergeRequestTool( - Tool[ApproveMergeRequestToolInput, ToolRunOptions, StringToolOutput] -): +class ApproveMergeRequestTool(Tool[ApproveMergeRequestToolInput, ToolRunOptions, StringToolOutput]): name = "approve_merge_request" timeout = 120 description = """ @@ -2053,14 +2062,13 @@ async def _run( # -- SetMrAutoMergeTool -- + class SetMrAutoMergeToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") -class SetMrAutoMergeTool( - Tool[SetMrAutoMergeToolInput, ToolRunOptions, StringToolOutput] -): +class SetMrAutoMergeTool(Tool[SetMrAutoMergeToolInput, ToolRunOptions, StringToolOutput]): name = "set_mr_auto_merge" timeout = 120 description = """ @@ -2094,6 +2102,7 @@ async def _run( # -- GetPipelineFailedJobsDeepTool -- + class GetPipelineFailedJobsDeepToolInput(BaseModel): project: str = Field(description="GitLab project path") pipeline_id: int = Field(description="Pipeline ID (numeric)") @@ -2134,6 +2143,7 @@ async def _run( # -- CompareMrTestFailuresTool -- + class CompareMrTestFailuresToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") @@ -2165,9 +2175,7 @@ async def _run( ) -> JSONToolOutput[dict[str, Any]]: project_enc = quote(tool_input.project, safe="") try: - mr = await _gitlab_api_get( - f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" - ) + mr = await _gitlab_api_get(f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}") target_branch = mr.get("target_branch", "") # Get current MR pipelines and collect failed job names @@ -2214,19 +2222,22 @@ async def _run( waived_failures = sorted(current_failed & prev_failed) fixed = sorted(prev_failed - current_failed) - return JSONToolOutput(result={ - "mr_iid": tool_input.mr_iid, - "previous_mr_iid": prev_iid, - "target_branch": target_branch, - "new_failures": new_failures, - "waived_failures": waived_failures, - "fixed": fixed, - "total_current_failures": len(current_failed), - }) + return JSONToolOutput( + result={ + "mr_iid": tool_input.mr_iid, + "previous_mr_iid": prev_iid, + "target_branch": target_branch, + "new_failures": new_failures, + "waived_failures": waived_failures, + "fixed": fixed, + "total_current_failures": len(current_failed), + } + ) # -- CreateMrInlineCommentTool -- + class CreateMrInlineCommentToolInput(BaseModel): project: str = Field(description="GitLab project path") mr_iid: int = Field(description="Merge request IID") @@ -2238,9 +2249,7 @@ class CreateMrInlineCommentToolInput(BaseModel): head_sha: str = Field(default="", description="head_sha from diff_refs") -class CreateMrInlineCommentTool( - Tool[CreateMrInlineCommentToolInput, ToolRunOptions, StringToolOutput] -): +class CreateMrInlineCommentTool(Tool[CreateMrInlineCommentToolInput, ToolRunOptions, StringToolOutput]): name = "create_mr_inline_comment" timeout = 120 description = """ @@ -2267,9 +2276,7 @@ async def _run( # Auto-fetch diff_refs if not provided if not (base_sha and start_sha and head_sha): try: - mr = await _gitlab_api_get( - f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}" - ) + mr = await _gitlab_api_get(f"/projects/{project_enc}/merge_requests/{tool_input.mr_iid}") diff_refs = mr.get("diff_refs", {}) base_sha = base_sha or diff_refs.get("base_sha", "") start_sha = start_sha or diff_refs.get("start_sha", "")