From 72d73b63554b92d93b71c8f49e5df52411d3efee Mon Sep 17 00:00:00 2001 From: cteyton Date: Fri, 6 Feb 2026 17:44:57 +0100 Subject: [PATCH] Add batch URL submission for evaluating up to 50 repos sequentially When --cloud is not set, users can now toggle between single URL and batch mode on the homepage. Batch mode accepts up to 50 Git URLs (one per line) and processes them strictly one at a time via a new BatchManager that listens for job completion callbacks before submitting the next URL. Backend: BatchManager orchestration, POST/GET batch API routes, JobManager onJobFinished hooks, shared git-url-validation, rate limit pre-check. Frontend: Single/Batch toggle in RepositoryUrlInput, BatchStatusPage with polling, batch API methods in useEvaluationApi hook. Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.tsx | 38 ++- frontend/src/components/BatchStatusPage.tsx | 260 +++++++++++++++ .../src/components/EvaluationInputPanel.tsx | 9 + .../src/components/RepositoryUrlInput.tsx | 244 +++++++++++--- frontend/src/hooks/useEvaluationApi.ts | 96 +++++- frontend/src/types/job.ts | 33 ++ src/api/index.ts | 17 + src/api/jobs/batch-manager.test.ts | 299 ++++++++++++++++++ src/api/jobs/batch-manager.ts | 265 ++++++++++++++++ src/api/jobs/job-manager.ts | 27 ++ src/api/routes/evaluation.ts | 172 +++++++++- src/shared/file-system/git-url-validation.ts | 33 ++ src/shared/types/api.ts | 33 ++ 13 files changed, 1484 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/BatchStatusPage.tsx create mode 100644 src/api/jobs/batch-manager.test.ts create mode 100644 src/api/jobs/batch-manager.ts create mode 100644 src/shared/file-system/git-url-validation.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8190894..6a731a4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { } from "react-router-dom"; import { AppHeader } from "./components/AppHeader"; import { AssessmentPage } from "./components/AssessmentPage"; +import { BatchStatusPage } from "./components/BatchStatusPage"; import { CostAnalysisPanel } from "./components/CostAnalysisPanel"; import { EmptyState } from "./components/EmptyState"; import { EvaluationInputPanel } from "./components/EvaluationInputPanel"; @@ -649,6 +650,37 @@ function AppContent() { [api, navigate], ); + // Handle batch URL submission + const handleBatchSubmit = useCallback( + async ( + urls: string[], + _evaluators: number, + provider?: "claude" | "opencode" | "cursor" | "github-copilot", + evaluatorFilter?: EvaluatorFilter, + concurrency?: number, + ) => { + setApiError(null); + try { + const response = await api.submitBatch( + urls, + undefined, + provider, + evaluatorFilter, + undefined, + concurrency, + ); + navigate(`/batch/${response.batchId}`); + } catch (error) { + setApiError( + error instanceof Error + ? error.message + : "Failed to submit batch evaluation", + ); + } + }, + [api, navigate], + ); + // Handle job cancellation const handleCancelJob = useCallback(async () => { if (currentJobId) { @@ -1772,6 +1804,7 @@ function AppContent() {
@@ -1876,6 +1909,9 @@ function AppRoutes() { {assessmentEnabled && ( } /> )} + {!cloudMode && ( + } /> + )} ); } diff --git a/frontend/src/components/BatchStatusPage.tsx b/frontend/src/components/BatchStatusPage.tsx new file mode 100644 index 0000000..f7cfca1 --- /dev/null +++ b/frontend/src/components/BatchStatusPage.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { useEvaluationApi } from "../hooks/useEvaluationApi"; +import { parseGitUrl } from "../lib/url-validation"; +import type { BatchEntryStatus, IBatchStatusResponse } from "../types/job"; + +const POLL_INTERVAL_MS = 3000; + +function getStatusBadge(status: BatchEntryStatus) { + switch (status) { + case "completed": + return ( + + + + + Completed + + ); + case "failed": + return ( + + + + + Failed + + ); + case "running": + return ( + + + + + Running + + ); + case "queued": + return ( + + Queued + + ); + case "pending": + return ( + + Pending + + ); + } +} + +function getRepoName(url: string): string { + const parsed = parseGitUrl(url); + if (parsed) { + return `${parsed.owner}/${parsed.repo}`; + } + // Fallback: extract last two path segments + try { + const urlObj = new URL(url); + const parts = urlObj.pathname.split("/").filter(Boolean); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; + } + } catch { + // ignore + } + return url; +} + +export function BatchStatusPage() { + const { batchId } = useParams<{ batchId: string }>(); + const api = useEvaluationApi(); + const [batchStatus, setBatchStatus] = useState(null); + const [loadError, setLoadError] = useState(null); + const pollRef = useRef | null>(null); + + const fetchStatus = useCallback(async () => { + if (!batchId) return; + try { + const status = await api.getBatchStatus(batchId); + setBatchStatus(status); + setLoadError(null); + + // Stop polling when finished + if (status.isFinished && pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + } catch (err) { + setLoadError( + err instanceof Error ? err.message : "Failed to load batch status", + ); + } + }, [batchId, api]); + + // Initial fetch and polling + useEffect(() => { + fetchStatus(); + pollRef.current = setInterval(fetchStatus, POLL_INTERVAL_MS); + + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + }, [fetchStatus]); + + if (loadError) { + return ( +
+
+
+ + + +
+

Error loading batch

+

{loadError}

+
+
+
+
+ ); + } + + if (!batchStatus) { + return ( +
+
+ + + + +

Loading batch status...

+
+
+ ); + } + + const completedOrFailed = batchStatus.completed + batchStatus.failed; + const progressPercent = + batchStatus.totalUrls > 0 + ? Math.round((completedOrFailed / batchStatus.totalUrls) * 100) + : 0; + + return ( +
+ {/* Header */} +
+
+ + + + + +

Batch Evaluation

+
+

+ {batchStatus.totalUrls} repositories —{" "} + {batchStatus.isFinished + ? `Finished (${batchStatus.completed} completed, ${batchStatus.failed} failed)` + : `Processing sequentially...`} +

+
+ + {/* Progress Bar */} +
+
+ + Overall Progress + + + {completedOrFailed} / {batchStatus.totalUrls} + +
+
+
+
+
+ + {/* Stats Row */} +
+
+

{batchStatus.completed}

+

Completed

+
+
+

{batchStatus.failed}

+

Failed

+
+
+

{batchStatus.running}

+

Running

+
+
+

{batchStatus.queued}

+

Queued

+
+
+

{batchStatus.pending}

+

Pending

+
+
+ + {/* Jobs List */} +
+
+

Repositories

+
+
+ {batchStatus.jobs.map((job, index) => ( +
+
+ + {index + 1} + +
+

+ {getRepoName(job.url)} +

+

+ {job.url} +

+
+
+
+ {getStatusBadge(job.status)} + {job.status === "completed" && ( + + View Results + + )} +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/EvaluationInputPanel.tsx b/frontend/src/components/EvaluationInputPanel.tsx index 2c31ecf..c39e816 100644 --- a/frontend/src/components/EvaluationInputPanel.tsx +++ b/frontend/src/components/EvaluationInputPanel.tsx @@ -11,6 +11,13 @@ interface IEvaluationInputPanelProps { evaluatorFilter: EvaluatorFilter, concurrency: number, ) => Promise; + onBatchSubmit?: ( + urls: string[], + evaluators: number, + provider: ProviderName, + evaluatorFilter: EvaluatorFilter, + concurrency: number, + ) => Promise; isLoading: boolean; urlError?: string | null; hasData: boolean; @@ -18,6 +25,7 @@ interface IEvaluationInputPanelProps { export const EvaluationInputPanel: React.FC = ({ onUrlSubmit, + onBatchSubmit, isLoading, urlError, hasData, @@ -31,6 +39,7 @@ export const EvaluationInputPanel: React.FC = ({
diff --git a/frontend/src/components/RepositoryUrlInput.tsx b/frontend/src/components/RepositoryUrlInput.tsx index 5b8c830..0cb582a 100644 --- a/frontend/src/components/RepositoryUrlInput.tsx +++ b/frontend/src/components/RepositoryUrlInput.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useFeatureFlags } from "../contexts/FeatureFlagContext"; import type { ProviderName } from "../hooks/useEvaluationApi"; import { useEvaluatorsApi } from "../hooks/useEvaluatorsApi"; @@ -6,6 +6,8 @@ import { useProviderDetection } from "../hooks/useProviderDetection"; import { isValidGitUrl } from "../lib/url-validation"; import type { EvaluatorFilter } from "../types/evaluation"; +type InputMode = "single" | "batch"; + interface IRepositoryUrlInputProps { onSubmit: ( url: string, @@ -14,6 +16,13 @@ interface IRepositoryUrlInputProps { evaluatorFilter: EvaluatorFilter, concurrency: number, ) => Promise; + onBatchSubmit?: ( + urls: string[], + evaluators: number, + provider: ProviderName, + evaluatorFilter: EvaluatorFilter, + concurrency: number, + ) => Promise; isLoading: boolean; error?: string | null; disabled?: boolean; @@ -30,6 +39,7 @@ const PROVIDERS: { name: ProviderName; displayName: string }[] = [ export const RepositoryUrlInput: React.FC = ({ onSubmit, + onBatchSubmit, isLoading, error, disabled = false, @@ -42,6 +52,8 @@ export const RepositoryUrlInput: React.FC = ({ getProviderStatus, } = useProviderDetection(); const [url, setUrl] = useState(""); + const [batchText, setBatchText] = useState(""); + const [inputMode, setInputMode] = useState("single"); const [concurrency, setConcurrency] = useState(3); const [totalEvaluators, setTotalEvaluators] = useState(17); // Default fallback const [evaluatorFilter, setEvaluatorFilter] = @@ -49,6 +61,22 @@ export const RepositoryUrlInput: React.FC = ({ const [provider, setProvider] = useState("claude"); const [validationError, setValidationError] = useState(null); + // Parse batch URLs from textarea + const parsedBatchUrls = useMemo(() => { + return batchText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }, [batchText]); + + const validBatchUrls = useMemo(() => { + return parsedBatchUrls.filter((u) => isValidGitUrl(u)); + }, [parsedBatchUrls]); + + const invalidBatchUrls = useMemo(() => { + return parsedBatchUrls.filter((u) => !isValidGitUrl(u)); + }, [parsedBatchUrls]); + // Fetch evaluators list on mount to get the total count useEffect(() => { fetchEvaluatorsList() @@ -78,10 +106,47 @@ export const RepositoryUrlInput: React.FC = ({ return true; }, []); + const validateBatchUrls = useCallback((): boolean => { + if (parsedBatchUrls.length === 0) { + setValidationError("Please enter at least one repository URL"); + return false; + } + if (parsedBatchUrls.length > 50) { + setValidationError( + `Maximum 50 URLs allowed (${parsedBatchUrls.length} entered)`, + ); + return false; + } + if (invalidBatchUrls.length > 0) { + setValidationError( + `${invalidBatchUrls.length} invalid URL(s) found. Please fix them before submitting.`, + ); + return false; + } + setValidationError(null); + return true; + }, [parsedBatchUrls, invalidBatchUrls]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); + if (inputMode === "batch") { + if (!validateBatchUrls() || !onBatchSubmit) return; + try { + await onBatchSubmit( + validBatchUrls, + totalEvaluators, + provider, + evaluatorFilter, + concurrency, + ); + } catch { + // Error is handled by parent component + } + return; + } + if (!validateUrl(url)) { return; } @@ -102,13 +167,17 @@ export const RepositoryUrlInput: React.FC = ({ } }, [ + inputMode, url, + validBatchUrls, totalEvaluators, provider, evaluatorFilter, concurrency, validateUrl, + validateBatchUrls, onSubmit, + onBatchSubmit, cloudMode, ], ); @@ -124,6 +193,16 @@ export const RepositoryUrlInput: React.FC = ({ [validationError], ); + const handleBatchTextChange = useCallback( + (e: React.ChangeEvent) => { + setBatchText(e.target.value); + if (validationError) { + setValidationError(null); + } + }, + [validationError], + ); + const displayError = validationError || error; /** @@ -243,6 +322,13 @@ export const RepositoryUrlInput: React.FC = ({ ); }; + // Determine if submit button should be enabled + const isSubmitDisabled = + disabled || + isLoading || + (inputMode === "single" && !url.trim()) || + (inputMode === "batch" && validBatchUrls.length === 0); + return (
@@ -251,47 +337,123 @@ export const RepositoryUrlInput: React.FC = ({ {/* Text Content */}

- Enter a Git repository URL to analyze + {inputMode === "batch" + ? "Enter Git repository URLs to analyze (one per line)" + : "Enter a Git repository URL to analyze"}

- {/* URL Input with focus glow */} -
-
-
- - - -
- + +
+ )} + + {/* URL Input / Batch Textarea */} +
+ {inputMode === "batch" ? ( + <> +