diff --git a/backend/app/model/job.py b/backend/app/model/job.py index ff9fec6..f48ff90 100644 --- a/backend/app/model/job.py +++ b/backend/app/model/job.py @@ -61,6 +61,8 @@ class Proposal(Base): status = Column(Enum(ProposalStatus), nullable=False, default=ProposalStatus.PENDING) after_screenshot_path = Column(String(500), nullable=True) diff_path = Column(Text, nullable=True) + pr_url = Column(String(500), nullable=True) + pr_status = Column(String(20), nullable=True) # creating, created, failed k8s_job_name = Column(String(200), nullable=True) error_message = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), default=_utcnow) diff --git a/backend/app/router/jobs.py b/backend/app/router/jobs.py index 2449528..8e7ce08 100644 --- a/backend/app/router/jobs.py +++ b/backend/app/router/jobs.py @@ -15,7 +15,7 @@ ProposalResponse, ) from app.service.artifact_service import ArtifactService -from app.usecase.job_usecase import CreateJobUseCase, ImplementProposalUseCase +from app.usecase.job_usecase import CreateJobUseCase, CreatePRUseCase, ImplementProposalUseCase router = APIRouter(prefix="/api/jobs", tags=["jobs"]) @@ -45,6 +45,8 @@ def _to_proposal_response(proposal: Proposal, job_id: UUID) -> ProposalResponse: if proposal.after_screenshot_path else None ), + pr_url=proposal.pr_url, + pr_status=proposal.pr_status, error_message=proposal.error_message, created_at=proposal.created_at, ) @@ -109,6 +111,20 @@ async def implement_proposals( return _to_job_response(job) +@router.post("/{job_id}/proposals/{proposal_index}/create-pr", response_model=ProposalResponse) +async def create_pr( + job_id: UUID, + proposal_index: int, + db: Session = Depends(get_db), +) -> ProposalResponse: + usecase = CreatePRUseCase(db) + try: + proposal = await usecase.execute(job_id, proposal_index) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + return _to_proposal_response(proposal, job_id) + + @router.get("/{job_id}/screenshot/before") async def get_before_screenshot( job_id: UUID, artifacts: ArtifactService = Depends(get_artifact_service) diff --git a/backend/app/schema/job_schema.py b/backend/app/schema/job_schema.py index fc8e489..1858875 100644 --- a/backend/app/schema/job_schema.py +++ b/backend/app/schema/job_schema.py @@ -22,6 +22,8 @@ class ProposalResponse(BaseModel): complexity: str | None status: str after_screenshot_url: str | None = None + pr_url: str | None = None + pr_status: str | None = None error_message: str | None = None created_at: datetime diff --git a/backend/app/service/artifact_service.py b/backend/app/service/artifact_service.py index 4378adb..330f7b4 100644 --- a/backend/app/service/artifact_service.py +++ b/backend/app/service/artifact_service.py @@ -136,6 +136,17 @@ def get_diff(self, job_id: str, proposal_index: int) -> str | None: logger.error("Failed to read diff: %s", e) return None + def get_pr_url(self, job_id: str, proposal_index: int) -> str | None: + """Read the PR URL artifact for a proposal.""" + path = self.base_dir / job_id / "proposals" / str(proposal_index) / "pr_url.txt" + if not path.exists(): + return None + try: + return path.read_text().strip() + except OSError as e: + logger.error("Failed to read PR URL: %s", e) + return None + def write_proposal_plan(self, job_id: str, proposal_index: int, plan_text: str) -> Path: """Write proposal plan to a file (used for local reference).""" path = self.base_dir / job_id / f"proposal_{proposal_index}_plan.txt" diff --git a/backend/app/service/k8s_service.py b/backend/app/service/k8s_service.py index b03fdc6..39c77a7 100644 --- a/backend/app/service/k8s_service.py +++ b/backend/app/service/k8s_service.py @@ -162,6 +162,43 @@ def create_implementation_job( logger.info("Created implementation K8s Job: %s", job_name) return job_name + def create_pr_job( + self, + job_id: str, + repo_url: str, + branch: str, + proposal_index: int, + ) -> str: + """Create a K8s Job for PR creation. Returns the K8s job name.""" + job_name = f"ui-worker-{job_id[:8]}-pr-{proposal_index}" + labels = { + "app": "ui-recommender", + "component": "worker", + "job-id": job_id[:8], + "mode": "createpr", + } + env_vars = [ + client.V1EnvVar(name="JOB_ID", value=job_id), + client.V1EnvVar(name="REPO_URL", value=repo_url), + client.V1EnvVar(name="BRANCH", value=branch), + client.V1EnvVar(name="PROPOSAL_INDEX", value=str(proposal_index)), + client.V1EnvVar( + name="GITHUB_TOKEN", + value_from=client.V1EnvVarSource( + secret_key_ref=client.V1SecretKeySelector( + name="ui-recommender-secrets", + key="github-token", + ) + ), + ), + ] + container = self._build_worker_container("createpr", env_vars) + job = self._build_job_spec(job_name, labels, container) + + self.batch_v1.create_namespaced_job(namespace=self.namespace, body=job) + logger.info("Created PR creation K8s Job: %s", job_name) + return job_name + async def wait_for_job(self, job_name: str, timeout: int = 900, poll_interval: int = 5) -> str: """Poll K8s Job status until completion. Returns 'succeeded', 'failed', or 'timeout'.""" elapsed = 0 diff --git a/backend/app/usecase/job_usecase.py b/backend/app/usecase/job_usecase.py index 3bf21ac..fedf2ea 100644 --- a/backend/app/usecase/job_usecase.py +++ b/backend/app/usecase/job_usecase.py @@ -12,6 +12,7 @@ from app.repository.proposal_repository import ProposalRepository from app.service.artifact_service import ArtifactService from app.workflow.analyzer_graph import build_analyzer_graph +from app.workflow.create_pr_graph import build_create_pr_graph from app.workflow.implementation_graph import build_implementation_graph logger = logging.getLogger(__name__) @@ -281,3 +282,118 @@ def _check_job_completion(db: Session, job_id: UUID) -> None: new_status = JobStatus.COMPLETED if any_succeeded else JobStatus.FAILED job_repo.update_status(job_id, new_status) logger.info("Job %s completed with status: %s", job_id, new_status) + + +class CreatePRUseCase: + """Trigger PR creation for a completed proposal.""" + + def __init__(self, db: Session) -> None: + self.db = db + self.job_repo = JobRepository(db) + self.proposal_repo = ProposalRepository(db) + + async def execute(self, job_id: UUID, proposal_index: int) -> Proposal: + job = self.job_repo.get_by_id(job_id) + if not job: + raise ValueError("Job not found") + + proposal = self.proposal_repo.get_by_job_and_index(job_id, proposal_index) + if not proposal: + raise ValueError(f"Proposal {proposal_index} not found") + if proposal.status != ProposalStatus.COMPLETED: + raise ValueError(f"Proposal is not completed: {proposal.status}") + if proposal.pr_status == "created": + raise ValueError("PR already created") + if proposal.pr_status == "creating": + raise ValueError("PR creation already in progress") + + proposal_id = UUID(str(proposal.id)) + self.proposal_repo.update_status(proposal_id, proposal.status, pr_status="creating") + + asyncio.create_task( + self._run_create_pr( + str(job_id), + str(job.repo_url), + str(job.branch), + proposal_index, + str(proposal.id), + ) + ) + + updated = self.proposal_repo.get_by_id(proposal_id) + if not updated: + raise ValueError("Proposal not found after update") + return updated + + @staticmethod + async def _run_create_pr( + job_id: str, + repo_url: str, + branch: str, + proposal_index: int, + proposal_id: str, + ) -> None: + """Background task: run the PR creation LangGraph workflow.""" + db = SessionLocal() + try: + proposal_repo = ProposalRepository(db) + + graph = build_create_pr_graph() + result = await graph.ainvoke( + { + "job_id": job_id, + "repo_url": repo_url, + "branch": branch, + "proposal_index": proposal_index, + "k8s_job_name": None, + "status": "pending", + "error": None, + "pr_url": None, + } + ) + + if result.get("pr_url"): + proposal_repo.update_status( + UUID(proposal_id), + ProposalStatus.COMPLETED, + pr_url=result["pr_url"], + pr_status="created", + ) + logger.info( + "PR created for proposal %d of job %s: %s", + proposal_index, + job_id, + result["pr_url"], + ) + else: + error = result.get("error", "PR creation failed") + proposal_repo.update_status( + UUID(proposal_id), + ProposalStatus.COMPLETED, + pr_status="failed", + error_message=error, + ) + logger.warning( + "PR creation failed for proposal %d of job %s: %s", + proposal_index, + job_id, + error, + ) + + except Exception: + logger.exception( + "PR creation failed for proposal %d of job %s", + proposal_index, + job_id, + ) + try: + proposal_repo.update_status( + UUID(proposal_id), + ProposalStatus.COMPLETED, + pr_status="failed", + error_message="Internal error during PR creation", + ) + except Exception: + logger.exception("Failed to update proposal PR status") + finally: + db.close() diff --git a/backend/app/workflow/create_pr_graph.py b/backend/app/workflow/create_pr_graph.py new file mode 100644 index 0000000..1902e84 --- /dev/null +++ b/backend/app/workflow/create_pr_graph.py @@ -0,0 +1,81 @@ +import logging +from typing import Any + +from langgraph.graph import END, START, StateGraph + +from app.service.artifact_service import ArtifactService +from app.service.k8s_service import K8sService +from app.workflow.state import CreatePRState + +logger = logging.getLogger(__name__) + + +async def create_k8s_job(state: CreatePRState) -> dict: + """Create the K8s Job for PR creation.""" + k8s = K8sService() + job_name = k8s.create_pr_job( + job_id=state["job_id"], + repo_url=state["repo_url"], + branch=state["branch"], + proposal_index=state["proposal_index"], + ) + return {"k8s_job_name": job_name, "status": "running"} + + +async def wait_for_job(state: CreatePRState) -> dict: + """Poll K8s Job until completion.""" + k8s = K8sService() + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + result = await k8s.wait_for_job(k8s_job_name) + + if result == "succeeded": + return {"status": "succeeded"} + + logs = k8s.get_job_logs(k8s_job_name) + error_msg = f"PR creation job {result}." + if logs: + error_msg += f" Logs: {logs[:500]}" + return {"status": "failed", "error": error_msg} + + +async def extract_results(state: CreatePRState) -> dict: + """Extract PR URL artifact from pod logs.""" + k8s = K8sService() + artifacts = ArtifactService() + + k8s_job_name = state["k8s_job_name"] + assert k8s_job_name is not None + logs = k8s.get_job_logs(k8s_job_name) + if logs: + artifacts.extract_impl_artifacts_from_logs(state["job_id"], state["proposal_index"], logs) + + pr_url = artifacts.get_pr_url(state["job_id"], state["proposal_index"]) + return {"pr_url": pr_url} + + +def route_after_wait(state: CreatePRState) -> str: + """Route based on job completion status.""" + if state["status"] == "succeeded": + return "extract_results" + return END + + +def build_create_pr_graph() -> Any: + """Build and compile the PR creation LangGraph.""" + graph = StateGraph(CreatePRState) + + graph.add_node("create_k8s_job", create_k8s_job) + graph.add_node("wait_for_job", wait_for_job) + graph.add_node("extract_results", extract_results) + + graph.add_edge(START, "create_k8s_job") + graph.add_edge("create_k8s_job", "wait_for_job") + graph.add_conditional_edges( + "wait_for_job", + route_after_wait, + {"extract_results": "extract_results", END: END}, + ) + graph.add_edge("extract_results", END) + + return graph.compile() diff --git a/backend/app/workflow/state.py b/backend/app/workflow/state.py index 5b8a0b1..010d292 100644 --- a/backend/app/workflow/state.py +++ b/backend/app/workflow/state.py @@ -39,3 +39,21 @@ class ImplementationState(TypedDict): # Output after_screenshot_path: str | None diff_content: str | None + + +class CreatePRState(TypedDict): + """State for the PR creation workflow.""" + + # Input + job_id: str + repo_url: str + branch: str + proposal_index: int + + # Intermediate + k8s_job_name: str | None + status: str + error: str | None + + # Output + pr_url: str | None diff --git a/backend/migration/versions/a1b2c3d4e5f6_add_pr_fields_to_proposals.py b/backend/migration/versions/a1b2c3d4e5f6_add_pr_fields_to_proposals.py new file mode 100644 index 0000000..cc6a6d1 --- /dev/null +++ b/backend/migration/versions/a1b2c3d4e5f6_add_pr_fields_to_proposals.py @@ -0,0 +1,30 @@ +"""add pr fields to proposals + +Revision ID: a1b2c3d4e5f6 +Revises: 199f015d6e7d +Create Date: 2026-02-24 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, None] = '199f015d6e7d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add pr_url and pr_status columns to proposals table.""" + op.add_column('proposals', sa.Column('pr_url', sa.String(length=500), nullable=True)) + op.add_column('proposals', sa.Column('pr_status', sa.String(length=20), nullable=True)) + + +def downgrade() -> None: + """Remove pr_url and pr_status columns from proposals table.""" + op.drop_column('proposals', 'pr_status') + op.drop_column('proposals', 'pr_url') diff --git a/docker/worker-createpr.py b/docker/worker-createpr.py new file mode 100644 index 0000000..6f0d78a --- /dev/null +++ b/docker/worker-createpr.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""PR Creation Worker: Applies a diff and creates a PR using Claude Agent SDK for quality descriptions.""" + +import asyncio +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +from claude_agent_sdk import ClaudeAgentOptions, query + + +async def push_and_create_pr( + repo_dir: str, branch_name: str, base_branch: str, diff_summary: str +) -> str: + """Use Claude Agent SDK to push the branch and create a PR with a good description.""" + prompt = f"""You are creating a GitHub Pull Request for UI design changes. + +The repository is at {repo_dir}. You are on branch "{branch_name}". +The base branch is "{base_branch}". + +Here is a summary of the changes that were applied (from the diff): + +```diff +{diff_summary} +``` + +Please do the following: + +1. Read the changed files to understand the full context of the changes. +2. Push the current branch to the remote: + `git push origin {branch_name}` +3. Create a GitHub PR using the `gh` CLI: + `gh pr create --base {base_branch} --head {branch_name} --title "" --body ""` + + The PR title should be concise and descriptive (under 70 chars). + The PR description should include: + - A clear summary of what UI changes were made and why + - A list of the key files changed + - Any visual/behavioral changes the reviewer should look for + +4. After creating the PR, output the PR URL on a line by itself prefixed with "PR_URL:" like: + PR_URL: https://github.com/... + +IMPORTANT: You MUST output the PR URL in that exact format so it can be extracted. +""" + + collected_text: list[str] = [] + async for msg in query( + prompt=prompt, + options=ClaudeAgentOptions( + allowed_tools=["Read", "Bash", "Glob", "Grep"], + cwd=repo_dir, + max_turns=15, + max_budget_usd=1.0, + ), + ): + if hasattr(msg, "content"): + for block in msg.content: + if hasattr(block, "text"): + collected_text.append(block.text) + print(f"Agent: {block.text[:200]}") + + full_text = "\n".join(collected_text) + + # Extract PR URL from agent output + for line in full_text.split("\n"): + if "PR_URL:" in line: + return line.split("PR_URL:")[-1].strip() + + # Fallback: find a github.com PR URL in the output + urls = re.findall(r"https://github\.com/[^\s)\"']+/pull/\d+", full_text) + if urls: + return urls[0] + + return "" + + +async def main() -> None: + job_id = os.environ["JOB_ID"] + repo_url = os.environ["REPO_URL"] + branch = os.environ.get("BRANCH", "main") + proposal_index = os.environ["PROPOSAL_INDEX"] + github_token = os.environ["GITHUB_TOKEN"] + + artifact_dir = f"/artifacts/{job_id}/proposals/{proposal_index}" + + # Configure gh CLI authentication + os.environ["GH_TOKEN"] = github_token + + # Step 1: Read the diff + diff_path = f"{artifact_dir}/changes.diff" + if not Path(diff_path).exists(): + print(f"Error: Diff not found at {diff_path}", file=sys.stderr) + sys.exit(1) + diff_content = Path(diff_path).read_text() + print(f"=== Diff loaded ({len(diff_content)} chars) ===") + + # Step 2: Clone repository (shallow, with token auth for push) + print("=== Cloning repository ===") + repo_dir = "/workspace/repo" + if repo_url.startswith("https://github.com/"): + auth_repo_url = repo_url.replace( + "https://github.com/", + f"https://x-access-token:{github_token}@github.com/", + ) + else: + auth_repo_url = repo_url + + subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, auth_repo_url, repo_dir], + check=True, + ) + + # Step 3: Create a new branch + ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + branch_name = f"feat/{ts}-ui-proposal-{proposal_index}" + print(f"=== Creating branch: {branch_name} ===") + subprocess.run( + ["git", "checkout", "-b", branch_name], + cwd=repo_dir, + check=True, + ) + + # Step 4: Apply the diff using git am (format-patch output) + print("=== Applying diff ===") + result = subprocess.run( + ["git", "am", "--3way", diff_path], + cwd=repo_dir, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(f"git am failed: {result.stderr}", file=sys.stderr) + print("Falling back to git apply...") + subprocess.run( + ["git", "am", "--abort"], cwd=repo_dir, capture_output=True + ) + subprocess.run( + ["git", "apply", "--3way", diff_path], + cwd=repo_dir, + check=True, + ) + subprocess.run(["git", "add", "-A"], cwd=repo_dir, check=True) + subprocess.run( + ["git", "commit", "-m", f"feat: apply UI proposal {proposal_index}"], + cwd=repo_dir, + check=True, + ) + + # Step 5: Unshallow for push compatibility + subprocess.run( + ["git", "fetch", "--unshallow"], + cwd=repo_dir, + capture_output=True, + ) + + # Step 6: Use Agent SDK to push and create PR with good description + print("=== Creating PR via Agent SDK ===") + # Provide first 10000 chars of diff for context + diff_summary = diff_content[:10000] + pr_url = await push_and_create_pr(repo_dir, branch_name, branch, diff_summary) + + if pr_url: + print(f"PR created: {pr_url}") + pr_url_file = f"{artifact_dir}/pr_url.txt" + Path(pr_url_file).write_text(pr_url) + + print("===ARTIFACT:pr_url.txt:START===") + print(pr_url) + print("===ARTIFACT:pr_url.txt:END===") + else: + print("Error: Failed to extract PR URL from agent output", file=sys.stderr) + sys.exit(1) + + print("=== PR Creation Complete ===") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker/worker-entrypoint.sh b/docker/worker-entrypoint.sh index 9c1c633..458a389 100644 --- a/docker/worker-entrypoint.sh +++ b/docker/worker-entrypoint.sh @@ -7,7 +7,7 @@ echo "Job ID: ${JOB_ID}" echo "Repository: ${REPO_URL}" if [ -z "$WORKER_MODE" ]; then - echo "Error: WORKER_MODE is required (analyze or implement)" + echo "Error: WORKER_MODE is required (analyze, implement, or createpr)" exit 1 fi @@ -18,6 +18,9 @@ case "$WORKER_MODE" in implement) exec python /usr/local/bin/worker-implement.py ;; + createpr) + exec python /usr/local/bin/worker-createpr.py + ;; *) echo "Error: Unknown WORKER_MODE: $WORKER_MODE" exit 1 diff --git a/docker/worker.Dockerfile b/docker/worker.Dockerfile index ec0341a..307fdd3 100644 --- a/docker/worker.Dockerfile +++ b/docker/worker.Dockerfile @@ -51,6 +51,13 @@ WORKDIR /workspace RUN npm install -g playwright@1.49.0 && \ npx playwright install chromium +# Install GitHub CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \ + dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | \ + tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ + apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/* + # Install Claude Agent SDK RUN pip install --no-cache-dir claude-agent-sdk @@ -62,6 +69,7 @@ RUN git config --global user.name "Claude Code" && \ COPY docker/worker-entrypoint.sh /usr/local/bin/worker-entrypoint.sh COPY docker/worker-analyze.py /usr/local/bin/worker-analyze.py COPY docker/worker-implement.py /usr/local/bin/worker-implement.py +COPY docker/worker-createpr.py /usr/local/bin/worker-createpr.py COPY docker/take-screenshot.mjs /usr/local/bin/take-screenshot.mjs RUN chmod +x /usr/local/bin/worker-entrypoint.sh diff --git a/frontend/src/pages/JobDetail.tsx b/frontend/src/pages/JobDetail.tsx index 437f7ea..acfdd2f 100644 --- a/frontend/src/pages/JobDetail.tsx +++ b/frontend/src/pages/JobDetail.tsx @@ -1,18 +1,72 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { useParams, Link } from 'react-router-dom' import { useJobPolling } from '../hooks/useJobPolling' +import { createPR, getJob } from '../services/api' import StatusBadge from '../components/StatusBadge' import ProposalCard from '../components/ProposalCard' export default function JobDetail() { const { jobId } = useParams<{ jobId: string }>() - const { job, error, isLoading } = useJobPolling(jobId ?? null) + const { job, error, isLoading, refetch } = useJobPolling(jobId ?? null) const [selectedIndex, setSelectedIndex] = useState(null) + const [prLoading, setPrLoading] = useState(false) + const [prError, setPrError] = useState(null) + const prPollRef = useRef | null>(null) const toggleProposal = useCallback((index: number) => { setSelectedIndex((prev) => (prev === index ? null : index)) }, []) + const handleCreatePR = useCallback(async () => { + if (selectedIndex === null || !jobId) return + setPrLoading(true) + setPrError(null) + try { + await createPR(jobId, selectedIndex) + refetch() + } catch (e) { + setPrError((e as Error).message) + } finally { + setPrLoading(false) + } + }, [selectedIndex, jobId, refetch]) + + // Poll for PR status changes when a proposal is "creating" + useEffect(() => { + if (!job || !jobId) return + + const hasCreating = job.proposals.some((p) => p.pr_status === 'creating') + if (hasCreating && !prPollRef.current) { + prPollRef.current = setInterval(async () => { + try { + const updated = await getJob(jobId) + const stillCreating = updated.proposals.some((p) => p.pr_status === 'creating') + if (!stillCreating) { + if (prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + refetch() + } + } catch { + // Ignore polling errors + } + }, 3000) + } + + if (!hasCreating && prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + + return () => { + if (prPollRef.current) { + clearInterval(prPollRef.current) + prPollRef.current = null + } + } + }, [job, jobId, refetch]) + if (!jobId) return

Invalid job ID

if (isLoading && !job) { @@ -39,6 +93,11 @@ export default function JobDetail() { (p) => p.status === 'completed' && p.after_screenshot_url, ) + const selectedProposal = + selectedIndex !== null + ? completedProposals.find((p) => p.proposal_index === selectedIndex) + : null + return (
@@ -126,6 +185,91 @@ export default function JobDetail() { /> ))}
+ + {selectedProposal && ( +
+ {selectedProposal.pr_url && ( + + View PR + + )} + {selectedProposal.pr_status === 'creating' && ( +
+ Creating PR... +
+ )} + {selectedProposal.pr_status === 'failed' && ( +
+

+ PR creation failed +

+ +
+ )} + {!selectedProposal.pr_status && !selectedProposal.pr_url && ( + + )} + {prError && ( +

+ {prError} +

+ )} +
+ )} )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b3246f2..02fb3e1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -10,6 +10,8 @@ export interface Proposal { complexity: string | null status: 'pending' | 'implementing' | 'completed' | 'failed' after_screenshot_url: string | null + pr_url: string | null + pr_status: string | null error_message: string | null created_at: string } @@ -75,6 +77,14 @@ export async function implementProposals(jobId: string, proposalIndices: number[ return handleResponse(res) } +export async function createPR(jobId: string, proposalIndex: number): Promise { + const res = await fetch(`${BASE_URL}/jobs/${jobId}/proposals/${proposalIndex}/create-pr`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + return handleResponse(res) +} + export async function getDiff(jobId: string, proposalIndex: number): Promise { const res = await fetch(`${BASE_URL}/jobs/${jobId}/proposals/${proposalIndex}/diff`) const data = await handleResponse<{ diff: string }>(res) diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml index 14685f9..4a27c5f 100644 --- a/k8s/secrets.yaml +++ b/k8s/secrets.yaml @@ -6,3 +6,4 @@ metadata: type: Opaque stringData: anthropic-api-key: "${ANTHROPIC_API_KEY}" + github-token: "${GITHUB_TOKEN}"