Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This project provides AI-driven tools for end-to-end feature development in Open
| `/oape:analyze-rfe <rfe-key>` | Analyze RFE and output EPIC, user stories, and outcomes |
| `/oape:e2e-generate <base-branch>` | Generate e2e test artifacts from git diff against base branch |
| `/oape:predict-regressions <base-branch>` | Predict API regressions and breaking changes from git diff |
| `/oape:ci-monitor <pr1> [pr2] [pr3] [--timeout-min N] [--max-fix-rounds N]` | Monitor CI/Prow with adaptive polling, SHA tracking, fix loop |
| `/oape:review <ticket_id> [base_ref]` | Production-grade code review against Jira requirements |
| `/oape:implement-review-fixes <report>` | Automatically apply fixes from a review report |

Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ ln -s oape-ai-e2e ~/.cursor/commands/oape-ai-e2e

| Plugin | Description | Commands |
| ------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
| **[oape](plugins/oape/)** | AI-driven OpenShift operator development tools | `/oape:init`, `/oape:api-generate`, `/oape:api-generate-tests`, `/oape:api-implement`, `/oape:analyze-rfe`, `/oape:e2e-generate`, `/oape:predict-regressions`, `/oape:review`, `/oape:implement-review-fixes` |
| **[oape](plugins/oape/)** | AI-driven OpenShift operator development tools | `/oape:init`, `/oape:api-generate`, `/oape:api-generate-tests`, `/oape:api-implement`, `/oape:analyze-rfe`, `/oape:e2e-generate`, `/oape:predict-regressions`, `/oape:ci-monitor`, `/oape:review`, `/oape:implement-review-fixes` |

## Commands

Expand Down Expand Up @@ -175,6 +175,17 @@ Analyzes git diff to predict potential regressions, breaking changes, and backwa
/oape:predict-regressions origin/release-4.18 --output .reports
```

### `/oape:ci-monitor` -- Monitor CI/Prow Jobs and Analyze Failures

Monitors CI checks and Prow status contexts with adaptive polling (60s/120s/60s), SHA-change tracking, retest detection, failure analysis, and optional fix-push-rewatch loop. Handles cluster-provisioning jobs (45-60 min) efficiently by backing off polling during provisioning.

```shell
/oape:ci-monitor https://github.com/openshift/cert-manager-operator/pull/101 https://github.com/openshift/cert-manager-operator/pull/102 https://github.com/openshift/cert-manager-operator/pull/103
/oape:ci-monitor https://github.com/openshift/must-gather-operator/pull/342
/oape:ci-monitor 101 102 103 --repo openshift/cert-manager-operator --timeout-min 120 --max-fix-rounds 2
/oape:ci-monitor 342 --repo openshift/must-gather-operator --max-fix-rounds 0 --fast
```

### `/oape:review` -- Code Review Against Jira Requirements

Performs a production-grade code review that verifies code changes against Jira requirements.
Expand Down
15 changes: 15 additions & 0 deletions agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
1. PR #1: init → api-generate → api-generate-tests → review-and-fix → raise PR
2. PR #2: api-implement → review-and-fix → raise PR
3. PR #3: e2e-generate → review-and-fix → raise PR
4. CI stage: monitor PR checks and analyze likely fixes for failures
"""

import csv
Expand Down Expand Up @@ -94,6 +95,11 @@ def _build_workflow_prompt(
6. Run `/oape:review OCPBUGS-0 {repo_info['base_branch']}` to review and auto-fix issues
7. Commit all changes with a descriptive message
8. Push the branch and create a PR against `{repo_info['base_branch']}`
9. Run `/oape:ci-monitor <pr1-url> --timeout-min 120 --max-fix-rounds 2`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a feasible idea to run directly in the workflow, because it would cause the workflow to wait for a very long period of time blocking the flow.

Could be trigger this as a separate workflow instead, please?

- ci-monitor uses adaptive polling (60s for fast jobs, 120s during cluster provisioning)
- If CI fails with a fixable error (build/lint/test), apply the fix, push, and ci-monitor re-polls automatically
- If CI fails with infra flake or repo-wide issue, report it and continue to PR #2
- If max-fix-rounds (2) exhausted, report and continue

### PR #2: Controller Implementation
Branch: `feature/controller-impl-<ep-number>`
Expand All @@ -103,6 +109,8 @@ def _build_workflow_prompt(
4. Run `/oape:review OCPBUGS-0 {repo_info['base_branch']}` to review and auto-fix issues
5. Commit all changes with a descriptive message
6. Push the branch and create a PR against `{repo_info['base_branch']}`
7. Run `/oape:ci-monitor <pr2-url> --timeout-min 120 --max-fix-rounds 2`
- Same adaptive polling and fix loop as PR #1

### PR #3: E2E Tests
Branch: `feature/e2e-tests-<ep-number>`
Expand All @@ -111,6 +119,8 @@ def _build_workflow_prompt(
3. Run `/oape:review OCPBUGS-0 {repo_info['base_branch']}` to review and auto-fix issues
4. Commit all changes with a descriptive message
5. Push the branch and create a PR against `{repo_info['base_branch']}`
6. Run `/oape:ci-monitor <pr3-url> --timeout-min 120 --max-fix-rounds 2`
- Same adaptive polling and fix loop as PR #1

## Execution Instructions

Expand All @@ -120,6 +130,11 @@ def _build_workflow_prompt(
4. For the review step, the `/oape:review` command will automatically apply fixes
5. When creating PRs, use `gh pr create` with descriptive titles and bodies
6. Report the PR URL after each PR is created
7. The `/oape:ci-monitor` call handles the full CI watch + fix loop autonomously:
- It uses adaptive intervals (60s/120s/60s) to minimize API usage
- It detects SHA changes from pushes and retests automatically
- It applies fixes and re-polls up to 2 times before giving up
- It reports infra flakes as non-fixable and continues

## CRITICAL: Fully Autonomous Execution

Expand Down
223 changes: 223 additions & 0 deletions agent/ci_monitor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""
CI Monitor agent: runs /oape:ci-monitor for one or more PR URLs.

Uses the Claude Agent SDK to execute the ci-monitor skill autonomously
without user prompts or confirmations.
"""

import json
import logging
import tempfile
import traceback
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path

from claude_agent_sdk import (
query,
ClaudeAgentOptions,
AssistantMessage,
ResultMessage,
TextBlock,
ThinkingBlock,
ToolUseBlock,
ToolResultBlock,
)

PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape")

CONVERSATION_LOG = Path("/tmp/ci-monitor-conversation.log")

conv_logger = logging.getLogger("ci_monitor_conversation")
conv_logger.setLevel(logging.INFO)
_handler = logging.FileHandler(CONVERSATION_LOG)
_handler.setFormatter(logging.Formatter("%(message)s"))
conv_logger.addHandler(_handler)

with open(Path(__file__).resolve().parent.parent / "config" / "config.json") as cf:
CONFIGS = json.loads(cf.read())


@dataclass
class CIMonitorResult:
"""Result returned after running CI monitoring."""

output: str
cost_usd: float
error: str | None = None
conversation: list[dict] = field(default_factory=list)

@property
def success(self) -> bool:
return self.error is None


def _build_ci_monitor_prompt(pr_urls: list[str]) -> str:
pr_args = " ".join(pr_urls)
return f"""You are an OpenShift CI monitoring assistant. Your ONLY task is to run the ci-monitor command and produce a report.

## Task

Run the following command and let it complete:

```
/oape:ci-monitor {pr_args}
```

## CRITICAL: Fully Autonomous Execution

- Execute the ci-monitor command immediately without asking for any user input.
- Do NOT ask "should I proceed?", "shall I continue?", or any confirmation questions.
- Do NOT ask the user to approve tool calls or confirm actions.
- Accept and approve ALL tool calls automatically.
- Let the ci-monitor skill handle everything: polling, evidence collection, analysis, and report generation.
- When the command completes, present the final report to the user.
- If the ci-monitor suggests a retest or fix, execute it without asking.
- This is a fully automated pipeline — run to completion in one go.
"""


async def run_ci_monitor(
pr_urls: list[str],
on_message: Callable[[dict], None] | None = None,
) -> CIMonitorResult:
"""Run CI monitoring for one or more PR URLs.

Args:
pr_urls: List of GitHub PR URLs to monitor.
on_message: Optional callback for real-time streaming.

Returns:
A CIMonitorResult with the monitoring report or error.
"""
prompt = _build_ci_monitor_prompt(pr_urls)

working_dir = tempfile.mkdtemp(prefix="oape-ci-monitor-")

options = ClaudeAgentOptions(
system_prompt=(
"You are an OpenShift CI monitoring assistant. "
"Run the ci-monitor command and produce a complete report. "
"IMPORTANT: This is a fully automated pipeline. Execute ALL steps "
"without pausing, asking for confirmation, or waiting for user input. "
"Never ask 'should I proceed?' or 'shall I continue?'. "
"Accept all tool calls automatically. Complete the monitoring autonomously."
),
cwd=working_dir,
permission_mode="bypassPermissions",
allowed_tools=CONFIGS["claude_allowed_tools"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recently, Claude code CLI has the ability to trigger a cron directly in the Claude session.

We should verify in Claude Agent SDK really has that capability too - if it does we can reuse it where Claude writes the script to watch the CI jobs every few quantum duration, and only invoke when actually failing..

https://code.claude.com/docs/en/scheduled-tasks#let-claude-choose-the-interval

[more deterministic behaviour] If not, we can write a script ourselves keep it here and only let Claude kick-off a session when jobs are actually failing.

plugins=[{"type": "local", "path": PLUGIN_DIR}],
)

output_parts: list[str] = []
conversation: list[dict] = []
cost_usd = 0.0

conv_logger.info(
f"\n{'=' * 60}\n[ci-monitor] pr_urls={pr_urls} "
f"cwd={working_dir}\n{'=' * 60}"
)

def _emit(entry: dict) -> None:
conversation.append(entry)
if on_message is not None:
on_message(entry)

try:
async for message in query(
prompt=prompt,
options=options,
):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
output_parts.append(block.text)
entry = {
"type": "assistant",
"block_type": "text",
"content": block.text,
}
_emit(entry)
conv_logger.info(f"[assistant] {block.text}")
elif isinstance(block, ThinkingBlock):
entry = {
"type": "assistant",
"block_type": "thinking",
"content": block.thinking,
}
_emit(entry)
conv_logger.info("[assistant:ThinkingBlock] (thinking)")
elif isinstance(block, ToolUseBlock):
entry = {
"type": "assistant",
"block_type": "tool_use",
"tool_name": block.name,
"tool_input": block.input,
}
_emit(entry)
conv_logger.info(f"[assistant:ToolUseBlock] {block.name}")
elif isinstance(block, ToolResultBlock):
content = block.content
if not isinstance(content, str):
content = json.dumps(content, default=str)
entry = {
"type": "assistant",
"block_type": "tool_result",
"tool_use_id": block.tool_use_id,
"content": content,
"is_error": block.is_error or False,
}
_emit(entry)
conv_logger.info(
f"[assistant:ToolResultBlock] {block.tool_use_id}"
)
else:
detail = json.dumps(
getattr(block, "__dict__", str(block)),
default=str,
)
entry = {
"type": "assistant",
"block_type": type(block).__name__,
"content": detail,
}
_emit(entry)
conv_logger.info(
f"[assistant:{type(block).__name__}] {detail}"
)
elif isinstance(message, ResultMessage):
cost_usd = message.total_cost_usd
if message.result:
output_parts.append(message.result)
entry = {
"type": "result",
"content": message.result,
"cost_usd": cost_usd,
}
_emit(entry)
conv_logger.info(f"[result] {message.result} cost=${cost_usd:.4f}")
else:
detail = json.dumps(
getattr(message, "__dict__", str(message)), default=str
)
entry = {
"type": type(message).__name__,
"content": detail,
}
_emit(entry)
conv_logger.info(f"[{type(message).__name__}] {detail}")

conv_logger.info(f"[done] cost=${cost_usd:.4f} parts={len(output_parts)}\n")
return CIMonitorResult(
output="\n".join(output_parts),
cost_usd=cost_usd,
conversation=conversation,
)
except Exception as exc:
conv_logger.info(f"[error] {traceback.format_exc()}")
return CIMonitorResult(
output="",
cost_usd=cost_usd,
error=str(exc),
conversation=conversation,
)
51 changes: 36 additions & 15 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,50 @@
from rich import print_json

from agent import run_workflow
from ci_monitor import run_ci_monitor


async def main():
ep_url = os.environ.get("EP_URL")
repo = os.environ.get("REPO_URL")
base_branch = os.environ.get("BASE_BRANCH")
workflow_type = os.environ.get("WORKFLOW_TYPE", "")

if not ep_url or not repo or not base_branch:
print("ERROR: EP_URL, REPO, BASE_BRANCH environment variables are required", file=sys.stderr)
sys.exit(1)
if workflow_type == "ci-monitor":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, I believe the intent was to implement something similar (i.e. run as a separate workflow than the primary one) but if possible we can de-duplicate the parts into 2 separate .py files. The common code can be moved to agent.py or the alike.

pr_urls_raw = os.environ.get("PR_URLS", "")
if not pr_urls_raw:
print("ERROR: PR_URLS environment variable is required for ci-monitor", file=sys.stderr)
sys.exit(1)

print(f"Starting workflow: ep_url={ep_url} repo={repo}", flush=True)
pr_urls = pr_urls_raw.split()
print(f"Starting ci-monitor: pr_urls={pr_urls}", flush=True)

result = await run_workflow(ep_url, repo, base_branch, on_message=lambda msg: print_json(data=msg))
result = await run_ci_monitor(pr_urls, on_message=lambda msg: print_json(data=msg))

if result.success:
print(f"WORKFLOW_SUCCESS cost=${result.cost_usd:.4f}", flush=True)
for pr in result.prs:
print(f"PR_CREATED: {pr.pr_url}", flush=True)
sys.exit(0)
if result.success:
print(f"CI_MONITOR_SUCCESS cost=${result.cost_usd:.4f}", flush=True)
sys.exit(0)
else:
print(f"CI_MONITOR_FAILED: {result.error}", file=sys.stderr, flush=True)
sys.exit(1)
else:
print(f"WORKFLOW_FAILED: {result.error}", file=sys.stderr, flush=True)
sys.exit(1)
ep_url = os.environ.get("EP_URL")
repo = os.environ.get("REPO_URL")
base_branch = os.environ.get("BASE_BRANCH")

if not ep_url or not repo or not base_branch:
print("ERROR: EP_URL, REPO_URL, BASE_BRANCH environment variables are required", file=sys.stderr)
sys.exit(1)

print(f"Starting workflow: ep_url={ep_url} repo={repo}", flush=True)

result = await run_workflow(ep_url, repo, base_branch, on_message=lambda msg: print_json(data=msg))

if result.success:
print(f"WORKFLOW_SUCCESS cost=${result.cost_usd:.4f}", flush=True)
for pr in result.prs:
print(f"PR_CREATED: {pr.pr_url}", flush=True)
sys.exit(0)
else:
print(f"WORKFLOW_FAILED: {result.error}", file=sys.stderr, flush=True)
sys.exit(1)


if __name__ == "__main__":
Expand Down
Loading