From 7f09c34e6983c98bbf6b22d3850c8b053ada4aa0 Mon Sep 17 00:00:00 2001 From: Swarup Ghosh Date: Tue, 17 Feb 2026 11:58:26 +0530 Subject: [PATCH 1/2] Add workflow --- server/agent.py | 371 +++++++++++++++++++++++++++++++++++++++---- server/homepage.html | 123 +++++++++++--- server/server.py | 182 ++++++++++++++++++--- 3 files changed, 600 insertions(+), 76 deletions(-) diff --git a/server/agent.py b/server/agent.py index 369ee49..73af803 100644 --- a/server/agent.py +++ b/server/agent.py @@ -1,7 +1,13 @@ """ -Core agent execution logic shared between sync and async endpoints. +Core agent execution logic for multi-PR operator feature development workflow. + +Uses the Claude Agent SDK to orchestrate a sequence of OAPE skills that: +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 """ +import csv import json import logging import traceback @@ -22,6 +28,7 @@ # Resolve the plugin directory (repo root) relative to this file. PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape") +TEAM_REPOS_CSV = Path(__file__).resolve().parent.parent / "team-repos.csv" CONVERSATION_LOG = Path("/tmp/conversation.log") @@ -34,33 +41,320 @@ with open(Path(__file__).resolve().parent / "config.json") as cf: CONFIGS = json.loads(cf.read()) -# Supported commands and their corresponding plugin skill names. -SUPPORTED_COMMANDS = { - "api-implement": "oape:api-implement", -} + +def load_team_repos() -> dict[str, dict]: + """Load team repositories from CSV file. + + Returns: + dict mapping repo short name to {url, base_branch, product, role} + """ + repos = {} + with open(TEAM_REPOS_CSV, newline="") as f: + reader = csv.DictReader(f) + for row in reader: + url = row["repo_url"].rstrip(".git") + # Extract short name from URL (e.g., "cert-manager-operator" from URL) + short_name = url.split("/")[-1] + repos[short_name] = { + "url": url, + "base_branch": row["base_branch"], + "product": row["product"], + "role": row["role"], + } + return repos + + +TEAM_REPOS = load_team_repos() + + +def get_repo_info(repo_short_name: str) -> dict | None: + """Get repository info by short name (case-insensitive, partial match).""" + name_lower = repo_short_name.lower() + + # Exact match first + for key, info in TEAM_REPOS.items(): + if key.lower() == name_lower: + return {**info, "short_name": key} + + # Partial match + matches = [ + (key, info) + for key, info in TEAM_REPOS.items() + if name_lower in key.lower() + ] + if len(matches) == 1: + key, info = matches[0] + return {**info, "short_name": key} + + return None @dataclass -class AgentResult: - """Result returned after running the Claude agent.""" +class PRResult: + """Result of a single PR creation.""" + + pr_number: int + pr_url: str + branch_name: str + title: str + + +@dataclass +class WorkflowResult: + """Result returned after running the full workflow.""" output: str cost_usd: float error: str | None = None conversation: list[dict] = field(default_factory=list) + prs: list[PRResult] = field(default_factory=list) @property def success(self) -> bool: return self.error is None +# Legacy single-command support +SUPPORTED_COMMANDS = { + "api-implement": "oape:api-implement", +} + + +def _build_workflow_prompt( + ep_url: str, + repo_short_name: str, + repo_info: dict, +) -> str: + """Build the system prompt for the full workflow.""" + base_branch = repo_info["base_branch"] + + return f"""You are an OpenShift operator feature developer assistant. Your task is to take an Enhancement Proposal (EP) and generate a complete implementation across three Pull Requests. + +## Input Information + +- **Enhancement Proposal URL**: {ep_url} +- **Target Repository**: {repo_short_name} +- **Repository URL**: {repo_info['url']} +- **Base Branch**: {base_branch} +- **Product**: {repo_info['product']} + +## Workflow Overview + +You will create THREE separate Pull Requests, each building on the previous one: + +### PR #1: API Type Definitions +Branch: `feature/api-types-` +1. Run `/oape:init {repo_short_name}` to clone the repository +2. Create and checkout a new branch from `{base_branch}` +3. Run `/oape:api-generate {ep_url}` to generate API type definitions +4. Run `/oape:api-generate-tests ` to generate integration tests +5. Run `make generate && make manifests` to regenerate code +6. Run `/oape:review OCPBUGS-0 {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 `{base_branch}` + +### PR #2: Controller Implementation +Branch: `feature/controller-impl-` +1. Create and checkout a new branch from `{base_branch}` (or from PR #1's branch if needed) +2. Run `/oape:api-implement {ep_url}` to generate controller/reconciler code +3. Run `make generate && make build` to verify the build +4. Run `/oape:review OCPBUGS-0 {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 `{base_branch}` + +### PR #3: E2E Tests +Branch: `feature/e2e-tests-` +1. Create and checkout a new branch from `{base_branch}` (or from PR #2's branch if needed) +2. Run `/oape:e2e-generate {base_branch}` to generate e2e test artifacts +3. Run `/oape:review OCPBUGS-0 {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 `{base_branch}` + +## Execution Instructions + +1. Execute each PR workflow in sequence +2. After each step, verify it completed successfully before proceeding +3. If any step fails, stop and report the error clearly +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 + +## Important Notes + +- Extract the EP number from the URL (e.g., 1234 from .../pull/1234) for branch naming +- Use conventional commit messages (e.g., "feat: add API types for ") +- The review command uses OCPBUGS-0 as a placeholder ticket ID since we're generating new code +- If the repository is already cloned, the init command will use the existing directory +- Ensure each PR has a clear description of what was generated + +Begin by executing PR #1 workflow. After each PR is created, proceed to the next one. +""" + + +async def run_workflow( + ep_url: str, + repo_short_name: str, + working_dir: str, + on_message: Callable[[dict], None] | None = None, +) -> WorkflowResult: + """Run the full operator feature development workflow. + + Args: + ep_url: The enhancement proposal PR URL. + repo_short_name: Short name of the target repository. + working_dir: Absolute path to the working directory. + on_message: Optional callback invoked with each conversation message + dict as it arrives, enabling real-time streaming. + + Returns: + A WorkflowResult with the output, PRs created, or error. + """ + repo_info = get_repo_info(repo_short_name) + if repo_info is None: + return WorkflowResult( + output="", + cost_usd=0.0, + error=f"Unknown repository: {repo_short_name}. " + f"Available: {', '.join(TEAM_REPOS.keys())}", + ) + + prompt = _build_workflow_prompt(ep_url, repo_short_name, repo_info) + + options = ClaudeAgentOptions( + system_prompt=( + "You are an OpenShift operator code generation assistant. " + "Follow the workflow instructions precisely and execute each step. " + "Use the OAPE plugins to generate code, tests, and reviews. " + "Create git branches, commits, and pull requests as instructed. " + ), + cwd=working_dir, + permission_mode="bypassPermissions", + allowed_tools=CONFIGS["claude_allowed_tools"], + plugins=[{"type": "local", "path": PLUGIN_DIR}], + ) + + output_parts: list[str] = [] + conversation: list[dict] = [] + cost_usd = 0.0 + + conv_logger.info( + f"\n{'=' * 60}\n[workflow] ep_url={ep_url} repo={repo_short_name} " + f"cwd={working_dir}\n{'=' * 60}" + ) + + def _emit(entry: dict) -> None: + """Append to conversation and invoke on_message callback if set.""" + 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 WorkflowResult( + output="\n".join(output_parts), + cost_usd=cost_usd, + conversation=conversation, + ) + except Exception as exc: + conv_logger.info(f"[error] {traceback.format_exc()}") + return WorkflowResult( + output="", + cost_usd=cost_usd, + error=str(exc), + conversation=conversation, + ) + + async def run_agent( command: str, ep_url: str, working_dir: str, on_message: Callable[[dict], None] | None = None, -) -> AgentResult: - """Run the Claude agent and return the result. +) -> WorkflowResult: + """Run a single OAPE command (legacy support). Args: command: The command key (e.g. "api-implement"). @@ -70,11 +364,11 @@ async def run_agent( dict as it arrives, enabling real-time streaming. Returns: - An AgentResult with the output or error. + A WorkflowResult with the output or error. """ skill_name = SUPPORTED_COMMANDS.get(command) if skill_name is None: - return AgentResult( + return WorkflowResult( output="", cost_usd=0.0, error=f"Unsupported command: {command}. " @@ -116,34 +410,45 @@ def _emit(entry: dict) -> None: for block in message.content: if isinstance(block, TextBlock): output_parts.append(block.text) - entry = {"type": "assistant", "block_type": "text", - "content": 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} + entry = { + "type": "assistant", + "block_type": "thinking", + "content": block.thinking, + } _emit(entry) - conv_logger.info( - f"[assistant:ThinkingBlock] (thinking)") + 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} + 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}") + 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} + 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}") + f"[assistant:ToolResultBlock] {block.tool_use_id}" + ) else: detail = json.dumps( getattr(block, "__dict__", str(block)), @@ -168,9 +473,7 @@ def _emit(entry: dict) -> None: "cost_usd": cost_usd, } _emit(entry) - conv_logger.info( - f"[result] {message.result} cost=${cost_usd:.4f}" - ) + conv_logger.info(f"[result] {message.result} cost=${cost_usd:.4f}") else: detail = json.dumps( getattr(message, "__dict__", str(message)), default=str @@ -182,17 +485,15 @@ def _emit(entry: dict) -> None: _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 AgentResult( + conv_logger.info(f"[done] cost=${cost_usd:.4f} parts={len(output_parts)}\n") + return WorkflowResult( output="\n".join(output_parts), cost_usd=cost_usd, conversation=conversation, ) except Exception as exc: conv_logger.info(f"[error] {traceback.format_exc()}") - return AgentResult( + return WorkflowResult( output="", cost_usd=cost_usd, error=str(exc), diff --git a/server/homepage.html b/server/homepage.html index f890fba..cc051ab 100644 --- a/server/homepage.html +++ b/server/homepage.html @@ -9,7 +9,7 @@ body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; display: flex; justify-content: center; padding: 40px 16px; } .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1); - padding: 32px; max-width: 720px; width: 100%; } + padding: 32px; max-width: 800px; width: 100%; } h1 { font-size: 1.4rem; margin-bottom: 4px; } p.sub { color: #666; font-size: .9rem; margin-bottom: 24px; } label { display: block; font-weight: 600; margin-bottom: 6px; font-size: .9rem; } @@ -48,34 +48,97 @@ .content-preview.expanded { max-height: none; } .toggle-expand { color: #4a90d9; cursor: pointer; font-size: .8rem; display: inline-block; margin-top: 4px; } + .workflow-info { background: #E3F2FD; border: 1px solid #BBDEFB; border-radius: 6px; + padding: 12px 16px; margin-bottom: 20px; font-size: .85rem; } + .workflow-info h3 { font-size: .9rem; margin-bottom: 8px; color: #1565C0; } + .workflow-info ul { margin: 0; padding-left: 20px; color: #333; } + .workflow-info li { margin-bottom: 4px; } + .pr-list { margin-top: 16px; background: #E8F5E9; border: 1px solid #C8E6C9; + border-radius: 6px; padding: 12px 16px; display: none; } + .pr-list h3 { font-size: .9rem; margin-bottom: 8px; color: #2E7D32; } + .pr-list a { color: #1565C0; text-decoration: none; } + .pr-list a:hover { text-decoration: underline; } + .pr-item { margin-bottom: 8px; font-size: .85rem; } + .field-group { margin-bottom: 14px; } + .field-hint { font-size: .8rem; color: #888; margin-top: 4px; }

OAPE Operator Feature Developer

-

Generate controller code from an OpenShift Enhancement Proposal

+

Generate complete operator implementation from an OpenShift Enhancement Proposal

+ +
+

Workflow Overview

+
    +
  • PR #1: API Types - init, api-generate, api-generate-tests, review
  • +
  • PR #2: Controller - api-implement, review
  • +
  • PR #3: E2E Tests - e2e-generate, review
  • +
+
+
- - - - - - - +
+ + +
The operator repository where code will be generated
+
+ +
+ + +
The enhancement proposal that describes the feature
+
+ +
+ + +
Directory where repositories will be cloned (defaults to current directory)
+
+ +
+
+
+

Created Pull Requests

+
+