diff --git a/plugins/oape/commands/review.md b/plugins/oape/commands/review.md index 1531da4..5faed53 100644 --- a/plugins/oape/commands/review.md +++ b/plugins/oape/commands/review.md @@ -23,7 +23,8 @@ The review covers four key modules: ## Arguments -- `$1` (ticket_id): The Jira Ticket ID (e.g., OCPBUGS-12345). **Required.** +- `$1` (ticket_id_or_NO_TICKET): The Jira Ticket ID (e.g., OCPBUGS-12345) **OR** the literal string `NO_TICKET`. **Required.** + - When `NO_TICKET` is provided, the review skips Jira validation and focuses on code quality, safety, and build consistency only. - `$2` (base_ref): The base git ref to diff against. Defaults to `origin/master`. **Optional.** @@ -38,11 +39,16 @@ BASE_REF="${2:-origin/master}" ``` ### Step 2: Fetch Context -1. **Jira Issue**: Fetch the Jira issue details using curl: - ```bash - curl -s "https://issues.redhat.com/browse/$1" - ``` - Focus on Acceptance Criteria as the primary validation source. +1. **Jira Issue** (skip if `$1` is `NO_TICKET`): + - If `$1` is NOT `NO_TICKET`, fetch the Jira issue details using curl: + ```bash + curl -s "https://issues.redhat.com/browse/$1" + ``` + Focus on Acceptance Criteria as the primary validation source. + - If `$1` IS `NO_TICKET`, skip Jira fetching entirely. The review will focus + on code quality (Modules A-D) without validating against Jira acceptance criteria. + In the Logic Verification module, skip the "Intent Match" check that compares + code against Jira requirements. 2. **Git Diff**: Get the code changes: ```bash @@ -61,7 +67,7 @@ Apply the following review criteria: #### Module A: Golang (Logic & Safety) **Logic Verification (The "Mental Sandbox")**: -- **Intent Match:** Does the code implementation match the Jira Acceptance Criteria? Quote the Jira line that justifies the change. +- **Intent Match:** (Skip if `NO_TICKET` mode) Does the code implementation match the Jira Acceptance Criteria? Quote the Jira line that justifies the change. In `NO_TICKET` mode, verify that the code changes are internally consistent and logically correct. - **Execution Trace:** Mentally simulate the function. - *Happy Path:* Does it succeed as expected? - *Error Path:* If the API fails, does it retry or return an error? @@ -116,7 +122,7 @@ Returns a JSON report with the following structure, followed by an automatic fix "simplicity_score": "1-10" }, "logic_verification": { - "jira_intent_met": true, + "jira_intent_met": true, // Set to null in NO_TICKET mode "missing_edge_cases": ["List handled edge cases or gaps (e.g., 'Does not handle pod deletion')"] }, "issues": [ diff --git a/server/agent.py b/server/agent.py index 369ee49..fc81014 100644 --- a/server/agent.py +++ b/server/agent.py @@ -1,10 +1,14 @@ """ Core agent execution logic shared between sync and async endpoints. + +Supports both single-command execution and full workflow orchestration. """ import json import logging +import os import traceback +import uuid from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path @@ -22,6 +26,7 @@ # Resolve the plugin directory (repo root) relative to this file. PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape") +TEAM_REPOS_CSV = str(Path(__file__).resolve().parent.parent / "team-repos.csv") CONVERSATION_LOG = Path("/tmp/conversation.log") @@ -34,10 +39,125 @@ 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", -} +# --------------------------------------------------------------------------- +# Workflow system prompt — instructs a single long-running agent session +# to execute the full feature development pipeline. +# --------------------------------------------------------------------------- + +WORKFLOW_SYSTEM_PROMPT = """\ +You are an OpenShift operator AI feature development agent. Your job is to +execute a complete feature development workflow given an Enhancement Proposal +(EP) PR URL. You MUST follow every phase below **in order**, and you MUST NOT +skip any phase. Stream your progress clearly so the user can follow along. + +## Inputs +- EP_URL: the GitHub Enhancement Proposal PR URL (provided in the user prompt). + +## Allowed repositories +Read the file {team_repos_csv} to get the list of allowed repositories and +their base branches (CSV columns: product, role, repo_url, base_branch). + +## Phase 0 — Detect target repository +1. Fetch the EP PR description using `gh pr view --json body -q .body` + (or WebFetch the PR page). +2. From the EP content, determine which operator repository it targets. + Match against the repos in {team_repos_csv}. +3. Extract: REPO_SHORT_NAME, REPO_URL, BASE_BRANCH. + - REPO_SHORT_NAME is the last path component of the repo URL without .git + (e.g. "cert-manager-operator"). +4. Print a summary: "Detected repo: , base branch: ". + +If you cannot determine the repo, STOP and report the failure. + +## Phase 1 — Clone repository +1. Run `/oape:init ` in the current working directory. +2. After init completes, `cd` into the cloned repo directory. +3. Run `git fetch origin` and `git checkout ` to ensure you are + on the correct base branch. +4. Extract the EP number from EP_URL (the numeric part after /pull/). + +## Phase 2 — PR #1: API Types + Tests +1. Create and checkout a new branch: `git checkout -b oape/api-types-` +2. Run `/oape:api-generate `. +3. Identify the generated API directory (typically `api/` or a subdirectory). +4. Run `/oape:api-generate-tests `. +5. Run `make generate && make manifests` (if Makefile targets exist). +6. Run `make build && make test` to verify the code compiles and tests pass. + If build or tests fail, attempt to fix the issues. +7. Run `/oape:review NO_TICKET origin/` to review code quality. + The review will auto-fix issues it finds. +8. Run `make build && make test` again after review fixes. +9. Stage all changes: `git add -A` +10. Commit: `git commit -m "oape: generate API types and tests from EP #"` +11. Push: `git push -u origin oape/api-types-` +12. Create PR #1: + ``` + gh pr create \\ + --base \\ + --title "oape: API types and tests from EP #" \\ + --body "Auto-generated API type definitions and integration tests from " + ``` +13. Save the PR #1 URL. + +## Phase 3 — PR #2: Controller Implementation +1. From the current branch (oape/api-types-), create a new branch: + `git checkout -b oape/controller-` +2. Run `/oape:api-implement `. +3. Run `make generate && make manifests` (if targets exist). +4. Run `make build && make test`. Fix failures if any. +5. Run `/oape:review NO_TICKET origin/` to review. +6. Run `make build && make test` again after review fixes. +7. Stage, commit: `git commit -m "oape: implement controller from EP #"` +8. Push: `git push -u origin oape/controller-` +9. Create PR #2 (base = the api-types branch so it stacks): + ``` + gh pr create \\ + --base oape/api-types- \\ + --title "oape: controller implementation from EP #" \\ + --body "Auto-generated controller/reconciler code from " + ``` +10. Save the PR #2 URL. + +## Phase 4 — PR #3: E2E Tests +1. Go back to the api-types branch: `git checkout oape/api-types-` +2. Create a new branch: `git checkout -b oape/e2e-` +3. Run `/oape:e2e-generate `. +4. Run `/oape:review NO_TICKET origin/` to review. +5. Fix any issues, verify build passes. +6. Stage, commit: `git commit -m "oape: generate e2e tests from EP #"` +7. Push: `git push -u origin oape/e2e-` +8. Create PR #3 (base = the api-types branch): + ``` + gh pr create \\ + --base oape/api-types- \\ + --title "oape: e2e tests from EP #" \\ + --body "Auto-generated e2e test artifacts from " + ``` +9. Save the PR #3 URL. + +## Phase 5 — Summary +Output a final summary in this exact format: + +``` +=== OAPE Workflow Complete === + +Enhancement Proposal: +Target Repository: +Base Branch: + +PR #1 (API Types + Tests): +PR #2 (Controller): +PR #3 (E2E Tests): +``` + +## Critical Rules +- NEVER skip a phase. Execute them in order. +- If a phase fails and you cannot recover, STOP and report which phase failed and why. +- Always use `git add -A` before committing to include all generated files. +- The review command with NO_TICKET skips Jira validation and reviews code quality only. +- Use `gh pr create` (not manual URL construction) for creating PRs. +- Do NOT modify the EP or any upstream repository. +""" @dataclass @@ -55,37 +175,25 @@ def success(self) -> bool: async def run_agent( - command: str, - ep_url: str, + prompt: str, working_dir: str, + system_prompt: str, on_message: Callable[[dict], None] | None = None, ) -> AgentResult: - """Run the Claude agent and return the result. + """Run the Claude agent with an arbitrary prompt and system prompt. Args: - command: The command key (e.g. "api-implement"). - ep_url: The enhancement proposal PR URL. - working_dir: Absolute path to the operator repo. + prompt: The user prompt to send to the agent. + working_dir: Absolute path to the working directory. + system_prompt: The system prompt for the agent. on_message: Optional callback invoked with each conversation message dict as it arrives, enabling real-time streaming. Returns: An AgentResult with the output or error. """ - skill_name = SUPPORTED_COMMANDS.get(command) - if skill_name is None: - return AgentResult( - output="", - cost_usd=0.0, - error=f"Unsupported command: {command}. " - f"Supported: {', '.join(SUPPORTED_COMMANDS)}", - ) - options = ClaudeAgentOptions( - system_prompt=( - "You are an OpenShift operator code generation assistant. " - f"Execute the {skill_name} plugin with the provided EP URL. " - ), + system_prompt=system_prompt, cwd=working_dir, permission_mode="bypassPermissions", allowed_tools=CONFIGS["claude_allowed_tools"], @@ -97,7 +205,7 @@ async def run_agent( cost_usd = 0.0 conv_logger.info( - f"\n{'=' * 60}\n[request] command={command} ep_url={ep_url} " + f"\n{'=' * 60}\n[request] prompt={prompt[:120]} " f"cwd={working_dir}\n{'=' * 60}" ) @@ -109,7 +217,7 @@ def _emit(entry: dict) -> None: try: async for message in query( - prompt=f"/{skill_name} {ep_url}", + prompt=prompt, options=options, ): if isinstance(message, AssistantMessage): @@ -198,3 +306,39 @@ def _emit(entry: dict) -> None: error=str(exc), conversation=conversation, ) + + +async def run_workflow( + ep_url: str, + on_message: Callable[[dict], None] | None = None, +) -> AgentResult: + """Run the full OAPE feature development workflow. + + Creates a temp directory, then launches a single long-running agent session + that executes all phases: init, api-generate, api-generate-tests, review, + PR creation, api-implement, e2e-generate, etc. + + Args: + ep_url: The enhancement proposal PR URL. + on_message: Optional streaming callback. + + Returns: + An AgentResult with the final summary or error. + """ + job_id = uuid.uuid4().hex[:12] + working_dir = f"/tmp/oape-{job_id}" + os.makedirs(working_dir, exist_ok=True) + + system_prompt = WORKFLOW_SYSTEM_PROMPT.format( + team_repos_csv=TEAM_REPOS_CSV, + ) + + return await run_agent( + prompt=( + f"Execute the full OAPE feature development workflow for this " + f"Enhancement Proposal: {ep_url}" + ), + working_dir=working_dir, + system_prompt=system_prompt, + on_message=on_message, + ) diff --git a/server/homepage.html b/server/homepage.html index f890fba..ed2ac63 100644 --- a/server/homepage.html +++ b/server/homepage.html @@ -13,9 +13,9 @@ 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; } - input[type=text], select { width: 100%; padding: 10px 12px; border: 1px solid #ccc; + input[type=text] { width: 100%; padding: 10px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; } - input[type=text]:focus, select:focus { outline: none; border-color: #4a90d9; } + input[type=text]:focus { outline: none; border-color: #4a90d9; } button { margin-top: 16px; padding: 10px 24px; background: #4a90d9; color: #fff; border: none; border-radius: 6px; font-size: .95rem; cursor: pointer; } button:disabled { background: #aaa; cursor: not-allowed; } @@ -25,6 +25,20 @@ vertical-align: middle; margin-right: 8px; } @keyframes spin { to { transform: rotate(360deg); } } #status { margin-top: 20px; font-size: .9rem; color: #555; } + + /* Workflow phase tracker */ + .phases { margin-top: 16px; display: none; } + .phase { display: flex; align-items: center; gap: 8px; padding: 6px 0; + font-size: .85rem; color: #999; } + .phase.active { color: #4a90d9; font-weight: 600; } + .phase.done { color: #2E7D32; } + .phase.failed { color: #c0392b; } + .phase-icon { width: 20px; text-align: center; flex-shrink: 0; } + .phase.active .phase-icon::after { content: '\25B6'; } + .phase.done .phase-icon::after { content: '\2713'; } + .phase.failed .phase-icon::after { content: '\2717'; } + .phase:not(.active):not(.done):not(.failed) .phase-icon::after { content: '\25CB'; } + #conversation { margin-top: 16px; background: #fafafa; border: 1px solid #e0e0e0; border-radius: 6px; max-height: 500px; overflow-y: auto; display: none; } @@ -53,20 +67,40 @@

OAPE Operator Feature Developer

-

Generate controller code from an OpenShift Enhancement Proposal

+

Full workflow: API types, controller, and E2E tests from an Enhancement Proposal

- - - + - - - +
+
+
+ + Phase 0: Detect target repository +
+
+ + Phase 1: Clone repository +
+
+ + Phase 2: PR #1 — API types + tests +
+
+ + Phase 3: PR #2 — Controller implementation +
+
+ + Phase 4: PR #3 — E2E tests +
+
+ + Phase 5: Summary +
+

 
@@ -74,6 +108,7 @@

OAPE Operator Feature Developer

const form = document.getElementById('epForm'); const btn = document.getElementById('submitBtn'); const statusEl = document.getElementById('status'); +const phasesEl = document.getElementById('phases'); const convEl = document.getElementById('conversation'); const outputEl = document.getElementById('output'); @@ -88,23 +123,57 @@

OAPE Operator Feature Developer

return str.length <= maxLen ? str : str.substring(0, maxLen) + '\u2026'; } +// Phase detection from agent text output +const PHASE_PATTERNS = [ + { phase: 'detect', pattern: /phase\s*0|detect.*repo/i }, + { phase: 'clone', pattern: /phase\s*1|clone.*repo|oape:init/i }, + { phase: 'api-types', pattern: /phase\s*2|pr\s*#?1|api.types|api.generate(?!-t)/i }, + { phase: 'controller', pattern: /phase\s*3|pr\s*#?2|controller|api.implement/i }, + { phase: 'e2e', pattern: /phase\s*4|pr\s*#?3|e2e.gen/i }, + { phase: 'summary', pattern: /phase\s*5|workflow\s*complete|summary/i }, +]; + +let currentPhase = null; + +function updatePhase(text) { + if (!text) return; + for (const {phase, pattern} of PHASE_PATTERNS) { + if (pattern.test(text)) { + if (phase !== currentPhase) { + // Mark previous as done + if (currentPhase) { + const prev = phasesEl.querySelector(`[data-phase="${currentPhase}"]`); + if (prev) { prev.classList.remove('active'); prev.classList.add('done'); } + } + currentPhase = phase; + const el = phasesEl.querySelector(`[data-phase="${phase}"]`); + if (el) { el.classList.add('active'); } + } + break; + } + } +} + form.addEventListener('submit', async (e) => { e.preventDefault(); - const epUrl = document.getElementById('ep_url').value.trim(); - const cwd = document.getElementById('cwd').value.trim(); - const command = document.getElementById('command').value; + const epUrl = document.getElementById('ep_url').value.trim(); if (!epUrl) return; btn.disabled = true; + currentPhase = null; outputEl.style.display = 'none'; outputEl.textContent = ''; convEl.style.display = 'none'; convEl.innerHTML = ''; - statusEl.innerHTML = ' Submitting job\u2026'; + phasesEl.style.display = 'block'; + // Reset phase indicators + phasesEl.querySelectorAll('.phase').forEach(p => { + p.classList.remove('active', 'done', 'failed'); + }); + statusEl.innerHTML = ' Submitting workflow\u2026'; try { - const body = new URLSearchParams({ep_url: epUrl, command: command}); - if (cwd) body.append('cwd', cwd); + const body = new URLSearchParams({ep_url: epUrl}); const res = await fetch('/submit', {method: 'POST', body}); if (!res.ok) { throw new Error((await res.json()).detail || res.statusText); } const {job_id} = await res.json(); @@ -124,6 +193,9 @@

OAPE Operator Feature Developer

convEl.style.display = 'block'; appendMessage(msg); updateStatus(msg); + // Try to detect phase from text or tool use + if (msg.content) updatePhase(msg.content); + if (msg.tool_name) updatePhase(msg.tool_name); }); es.addEventListener('complete', (e) => { @@ -131,8 +203,14 @@

OAPE Operator Feature Developer

es.close(); btn.disabled = false; + // Mark current phase as done + if (currentPhase) { + const el = phasesEl.querySelector(`[data-phase="${currentPhase}"]`); + if (el) { el.classList.remove('active'); el.classList.add('done'); } + } + if (result.status === 'success') { - statusEl.innerHTML = 'Done! Cost: $' + (result.cost_usd || 0).toFixed(4); + statusEl.innerHTML = 'Workflow complete! Cost: $' + (result.cost_usd || 0).toFixed(4); if (result.output) { outputEl.textContent = result.output; outputEl.style.display = 'block'; @@ -140,6 +218,11 @@

OAPE Operator Feature Developer

} else { statusEl.innerHTML = 'Failed: ' + escapeHtml(result.error || 'unknown error') + ''; + // Mark current phase as failed + if (currentPhase) { + const el = phasesEl.querySelector(`[data-phase="${currentPhase}"]`); + if (el) { el.classList.remove('active', 'done'); el.classList.add('failed'); } + } } }); diff --git a/server/server.py b/server/server.py index f0a50e9..0cdb7a8 100644 --- a/server/server.py +++ b/server/server.py @@ -1,36 +1,35 @@ """ -FastAPI server that exposes OAPE Claude Code skills via the Claude Agent SDK. +FastAPI server that exposes the OAPE full workflow agent via SSE streaming. Usage: uvicorn server:app --reload Endpoints: - GET / - Homepage with submission form - POST /submit - Submit a job (returns job_id) - GET /status/{job_id} - Poll job status - GET /stream/{job_id} - SSE stream of agent conversation - GET /api/v1/oape-api-implement?ep_url=.. - Synchronous API-implement endpoint + GET / - Homepage with submission form + POST /submit - Submit a workflow job (returns job_id) + GET /status/{job_id} - Poll job status + GET /stream/{job_id} - SSE stream of agent conversation """ import asyncio import json -import os -from pathlib import Path import re import uuid -from fastapi import FastAPI, HTTPException, Query, Form +from fastapi import FastAPI, HTTPException, Form from fastapi.responses import HTMLResponse +from pathlib import Path from sse_starlette.sse import EventSourceResponse -from agent import run_agent, SUPPORTED_COMMANDS +from agent import run_workflow app = FastAPI( title="OAPE Operator Feature Developer", - description="Invokes OAPE Claude Code commands to generate " - "controller/reconciler code from an OpenShift enhancement proposal.", - version="0.1.0", + description="Runs the full OAPE feature development workflow: " + "API types, controller implementation, and E2E tests " + "from an OpenShift enhancement proposal.", + version="0.2.0", ) EP_URL_PATTERN = re.compile( @@ -53,17 +52,6 @@ def _validate_ep_url(ep_url: str) -> None: ) -def _resolve_working_dir(cwd: str) -> str: - """Resolve and validate the working directory.""" - working_dir = cwd if cwd else os.getcwd() - if not os.path.isdir(working_dir): - raise HTTPException( - status_code=400, - detail=f"The provided cwd is not a valid directory: {working_dir}", - ) - return working_dir - - _HOMEPAGE_PATH = Path(__file__).parent / "homepage.html" HOMEPAGE_HTML = _HOMEPAGE_PATH.read_text() @@ -77,31 +65,21 @@ async def homepage(): @app.post("/submit") async def submit_job( ep_url: str = Form(...), - command: str = Form(default="api-implement"), - cwd: str = Form(default=""), ): - """Validate inputs, create a background job, and return its ID.""" + """Validate the EP URL, create a background workflow job, and return its ID.""" _validate_ep_url(ep_url) - if command not in SUPPORTED_COMMANDS: - raise HTTPException( - status_code=400, - detail=f"Unsupported command: {command}. " - f"Supported: {', '.join(SUPPORTED_COMMANDS)}", - ) - working_dir = _resolve_working_dir(cwd) job_id = uuid.uuid4().hex[:12] jobs[job_id] = { "status": "running", "ep_url": ep_url, - "cwd": working_dir, "conversation": [], "message_event": asyncio.Condition(), "output": "", "cost_usd": 0.0, "error": None, } - asyncio.create_task(_run_job(job_id, command, ep_url, working_dir)) + asyncio.create_task(_run_job(job_id, ep_url)) return {"job_id": job_id} @@ -114,7 +92,6 @@ async def job_status(job_id: str): return { "status": job["status"], "ep_url": job["ep_url"], - "cwd": job["cwd"], "output": job.get("output", ""), "cost_usd": job.get("cost_usd", 0.0), "error": job.get("error"), @@ -166,8 +143,8 @@ async def event_generator(): return EventSourceResponse(event_generator()) -async def _run_job(job_id: str, command: str, ep_url: str, working_dir: str): - """Run the Claude agent in the background and stream messages to the job store.""" +async def _run_job(job_id: str, ep_url: str): + """Run the full workflow in the background and stream messages to the job store.""" condition = jobs[job_id]["message_event"] loop = asyncio.get_running_loop() @@ -176,7 +153,7 @@ def on_message(msg: dict) -> None: jobs[job_id]["conversation"].append(msg) loop.create_task(_notify(condition)) - result = await run_agent(command, ep_url, working_dir, on_message=on_message) + result = await run_workflow(ep_url, on_message=on_message) if result.success: jobs[job_id]["status"] = "success" jobs[job_id]["output"] = result.output @@ -194,35 +171,3 @@ async def _notify(condition: asyncio.Condition) -> None: """Notify all waiters on the condition.""" async with condition: condition.notify_all() - - -@app.get("/api/v1/oape-api-implement") -async def api_implement( - ep_url: str = Query( - ..., - description="GitHub PR URL for the OpenShift enhancement proposal " - "(e.g. https://github.com/openshift/enhancements/pull/1234)", - ), - cwd: str = Query( - default="", - description="Absolute path to the operator repository where code " - "will be generated. Defaults to the current working directory.", - ), -): - """Generate controller/reconciler code from an enhancement proposal (synchronous).""" - _validate_ep_url(ep_url) - working_dir = _resolve_working_dir(cwd) - - result = await run_agent("api-implement", ep_url, working_dir) - if not result.success: - raise HTTPException( - status_code=500, detail=f"Agent execution failed: {result.error}" - ) - - return { - "status": "success", - "ep_url": ep_url, - "cwd": working_dir, - "output": result.output, - "cost_usd": result.cost_usd, - }