diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index ba3b7a1..815aad0 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -5,6 +5,7 @@ import * as os from 'os'; const PID_FILE = '/tmp/autopr-e2e.pid'; export const FIXTURES_FILE = '/tmp/autopr-e2e-fixtures.json'; +const AUTO_PRD_PATH = process.env.AUTO_PRD_PATH || '/usr/local/bin/auto-prd'; export interface E2EFixtures { repoDir: string; @@ -118,7 +119,7 @@ providers: } async function startDaemon(homeDir: string): Promise { - const daemon = spawn('/usr/local/bin/auto-prd', [], { + const daemon = spawn(AUTO_PRD_PATH, [], { env: { ...process.env, HOME: homeDir }, stdio: ['ignore', 'pipe', 'pipe'], detached: false, diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 0af8e6c..f538d73 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "commonjs", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true, "resolveJsonModule": true, diff --git a/web/src/App.tsx b/web/src/App.tsx index 40c6304..09b68cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { import { AddTicketDialog } from "./AddTicketDialog"; import { DiscoverTicketsModal } from "./DiscoverTicketsModal"; import { ExecutionLogsModal } from "./ExecutionLogsModal"; +import { extractOpenQuestions, formatFeedbackMessage } from "./investigationFeedback"; import { TicketDetailPanel } from "./TicketDetailPanel"; import { TicketList } from "./TicketList"; import { @@ -38,7 +39,9 @@ export function App() { const [details, setDetails] = useState(null); const [selectedRunId, setSelectedRunId] = useState(""); const [selectedArtifactContent, setSelectedArtifactContent] = useState(""); - const [feedbackMessage, setFeedbackMessage] = useState(""); + const [currentFeedbackArtifactContent, setCurrentFeedbackArtifactContent] = useState(""); + const [questionAnswers, setQuestionAnswers] = useState>({}); + const [generalFeedback, setGeneralFeedback] = useState(""); const [activeJobId, setActiveJobId] = useState(""); const [activeJob, setActiveJob] = useState(null); const [loading, setLoading] = useState(false); @@ -64,14 +67,80 @@ export function App() { const availableRepoPaths = useMemo(() => knownRepoPaths(repositoryOptions, tickets), [repositoryOptions, tickets]); const stateRuns = useMemo(() => stateRunsFromDetails(details), [details]); const feedbackAction = useMemo(() => getFeedbackAction(details, selectedSummary), [details, selectedSummary]); + const currentRunId = details?.state.current_run_id ?? ""; + const currentRun = useMemo( + () => stateRuns.find((run) => run.id === currentRunId) ?? null, + [currentRunId, stateRuns] + ); + const openQuestions = useMemo( + () => extractOpenQuestions(currentFeedbackArtifactContent), + [currentFeedbackArtifactContent] + ); const selectedSummaryRef = useRef(null); const activeJobIdRef = useRef(""); const showLogsModalRef = useRef(false); const fullRefreshScheduledRef = useRef(false); const prevLastRunIdRef = useRef(""); + const handleServerEventRef = useRef<(evt: ServerEvent) => Promise>(async () => {}); const reconnectErrorMessage = "event stream connection lost; reconnecting"; + async function handleServerEvent(evt: ServerEvent) { + const selected = selectedSummaryRef.current; + const trackedJobID = activeJobIdRef.current; + if (evt.type === "job" && evt.job_id && trackedJobID && evt.job_id === trackedJobID) { + const status = evt.status ?? ""; + if (status === "failed") { + try { + const job = await getJob(evt.job_id); + setActiveJob(job); + } catch (err) { + setError(err instanceof Error ? err.message : "failed to refresh job"); + } finally { + setActiveJobId(""); + } + } else if (status === "done") { + setActiveJob(null); + setActiveJobId(""); + } else if (status === "queued" || status === "running") { + setActiveJob((current) => { + if (!current) { + return current; + } + return { ...current, status }; + }); + } + } + + let needsFullRefresh = evt.type === "repo_tickets_synced"; + setTickets((current) => { + const ticketUpdate = applyTicketEvent(current, evt); + if (ticketUpdate.needsFullRefresh) { + needsFullRefresh = true; + } + return ticketUpdate.tickets; + }); + if (needsFullRefresh) { + scheduleFullRefresh(); + } + + if ( + selected && + evt.type === "ticket_updated" && + evt.repo_path === selected.repo_path && + evt.ticket_number === selected.ticket_number + ) { + if ((evt.status ?? "") !== "running" && activeJobIdRef.current) { + activeJobIdRef.current = ""; + setActiveJobId(""); + } + await refreshTicketDetails(selected.repo_path, selected.ticket_number, false); + if (showLogsModalRef.current) { + await refreshExecutionLogs(selected.repo_path, selected.ticket_number); + } + } + } + useEffect(() => { selectedSummaryRef.current = selectedSummary; }, [selectedSummary]); @@ -84,6 +153,10 @@ export function App() { showLogsModalRef.current = showLogsModal; }, [showLogsModal]); + useEffect(() => { + handleServerEventRef.current = handleServerEvent; + }); + useEffect(() => { void refreshTickets(); void refreshRepositories(); @@ -92,7 +165,7 @@ export function App() { .catch(() => { setDiscoverConfigured(false); }); const stream = connectEvents( (evt) => { - void handleServerEvent(evt); + void handleServerEventRef.current(evt); }, () => { setError(reconnectErrorMessage); @@ -109,6 +182,9 @@ export function App() { setDetails(null); setSelectedRunId(""); setSelectedArtifactContent(""); + setCurrentFeedbackArtifactContent(""); + setQuestionAnswers({}); + setGeneralFeedback(""); setArtifactLoading(false); setShowLogsModal(false); setExecutionLogs([]); @@ -116,7 +192,7 @@ export function App() { return; } void refreshTicketDetails(selectedSummary.repo_path, selectedSummary.ticket_number); - }, [selectedSummary?.repo_path, selectedSummary?.ticket_number]); + }, [selectedSummary]); useEffect(() => { if (stateRuns.length === 0) { @@ -182,6 +258,40 @@ export function App() { }; }, [selectedRunId, selectedSummary, stateRuns]); + useEffect(() => { + if (!selectedSummary || !currentRun) { + setCurrentFeedbackArtifactContent(""); + return; + } + const artifactRef = currentRun.artifact_ref || currentRun.log_ref; + if (!artifactRef) { + setCurrentFeedbackArtifactContent(""); + return; + } + + let cancelled = false; + void getArtifact(selectedSummary.repo_path, selectedSummary.ticket_number, artifactRef) + .then((content) => { + if (!cancelled) { + setCurrentFeedbackArtifactContent(content); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "failed to load feedback artifact"); + setCurrentFeedbackArtifactContent(""); + } + }); + return () => { + cancelled = true; + }; + }, [currentRun, selectedSummary]); + + useEffect(() => { + setQuestionAnswers({}); + setGeneralFeedback(""); + }, [selectedSummary?.repo_path, selectedSummary?.ticket_number, currentRunId]); + useEffect(() => { if (!showLogsModal || !selectedSummary) { return; @@ -207,7 +317,7 @@ export function App() { return () => { cancelled = true; }; - }, [showLogsModal, selectedSummary?.repo_path, selectedSummary?.ticket_number]); + }, [showLogsModal, selectedSummary]); async function refreshTickets(showLoader = true) { if (showLoader) { @@ -263,58 +373,6 @@ export function App() { } } - async function handleServerEvent(evt: ServerEvent) { - const selected = selectedSummaryRef.current; - const trackedJobID = activeJobIdRef.current; - if (evt.type === "job" && evt.job_id && trackedJobID && evt.job_id === trackedJobID) { - const status = evt.status ?? ""; - if (status === "failed") { - try { - const job = await getJob(evt.job_id); - setActiveJob(job); - } catch (err) { - setError(err instanceof Error ? err.message : "failed to refresh job"); - } finally { - setActiveJobId(""); - } - } else if (status === "done") { - setActiveJob(null); - setActiveJobId(""); - } else if (status === "queued" || status === "running") { - setActiveJob((current) => { - if (!current) { - return current; - } - return { ...current, status }; - }); - } - } - - let needsFullRefresh = evt.type === "repo_tickets_synced"; - setTickets((current) => { - const ticketUpdate = applyTicketEvent(current, evt); - if (ticketUpdate.needsFullRefresh) { - needsFullRefresh = true; - } - return ticketUpdate.tickets; - }); - if (needsFullRefresh) { - scheduleFullRefresh(); - } - - if ( - selected && - evt.type === "ticket_updated" && - evt.repo_path === selected.repo_path && - evt.ticket_number === selected.ticket_number - ) { - await refreshTicketDetails(selected.repo_path, selected.ticket_number, false); - if (showLogsModalRef.current) { - await refreshExecutionLogs(selected.repo_path, selected.ticket_number); - } - } - } - function scheduleFullRefresh() { if (fullRefreshScheduledRef.current) { return; @@ -330,6 +388,7 @@ export function App() { setError(""); try { const accepted = await fn(); + activeJobIdRef.current = accepted.job_id; setActiveJobId(accepted.job_id); setActiveJob(null); return true; @@ -402,7 +461,11 @@ export function App() { } function submitFeedback() { - if (!selectedSummary || !feedbackAction || !feedbackMessage.trim()) { + if (!selectedSummary || !feedbackAction) { + return; + } + const message = formatFeedbackMessage(openQuestions, questionAnswers, generalFeedback); + if (!message.trim()) { return; } void queueAction(() => @@ -410,15 +473,20 @@ export function App() { selectedSummary.repo_path, selectedSummary.ticket_number, feedbackAction.label, - feedbackMessage + message ) ).then((ok) => { if (ok) { - setFeedbackMessage(""); + setQuestionAnswers({}); + setGeneralFeedback(""); } }); } + function updateQuestionAnswer(index: number, value: string) { + setQuestionAnswers((current) => ({ ...current, [String(index)]: value })); + } + function applyNamedAction(label: string) { if (!selectedSummary) { return; @@ -521,9 +589,13 @@ export function App() { selectedArtifactContent={selectedArtifactContent} artifactLoading={artifactLoading} feedbackAction={feedbackAction} - feedbackMessage={feedbackMessage} + openQuestions={feedbackAction ? openQuestions : []} + questionAnswers={questionAnswers} + generalFeedback={generalFeedback} + isRunning={!!activeJobId} onSelectRun={setSelectedRunId} - onFeedbackMessageChange={setFeedbackMessage} + onQuestionAnswerChange={updateQuestionAnswer} + onGeneralFeedbackChange={setGeneralFeedback} onSubmitFeedback={submitFeedback} onApplyAction={applyNamedAction} onOpenLogs={() => setShowLogsModal(true)} diff --git a/web/src/TicketDetailPanel.tsx b/web/src/TicketDetailPanel.tsx index ccf2576..13842e6 100644 --- a/web/src/TicketDetailPanel.tsx +++ b/web/src/TicketDetailPanel.tsx @@ -12,9 +12,13 @@ type TicketDetailPanelProps = { selectedArtifactContent: string; artifactLoading: boolean; feedbackAction?: ActionInfo; - feedbackMessage: string; + openQuestions: string[]; + questionAnswers: Record; + generalFeedback: string; + isRunning: boolean; onSelectRun: (runId: string) => void; - onFeedbackMessageChange: (value: string) => void; + onQuestionAnswerChange: (index: number, value: string) => void; + onGeneralFeedbackChange: (value: string) => void; onSubmitFeedback: () => void; onApplyAction: (label: string) => void; onOpenLogs: () => void; @@ -31,9 +35,13 @@ export function TicketDetailPanel({ selectedArtifactContent, artifactLoading, feedbackAction, - feedbackMessage, + openQuestions, + questionAnswers, + generalFeedback, + isRunning, onSelectRun, - onFeedbackMessageChange, + onQuestionAnswerChange, + onGeneralFeedbackChange, onSubmitFeedback, onApplyAction, onOpenLogs, @@ -71,7 +79,7 @@ export function TicketDetailPanel({
{actionButtons.map((action) => ( - ))} @@ -89,9 +97,9 @@ export function TicketDetailPanel({ workflowStates={details?.workflow_states ?? []} currentStateName={details?.state.current_state} rerunLabel={selectedSummary.status === "failed" ? "Retry" : "Rerun"} - rerunDisabled={selectedSummary.busy} + rerunDisabled={isRunning || selectedSummary.busy} cleanupDisabled={selectedSummary.busy} - moveDisabled={selectedSummary.busy} + moveDisabled={isRunning || selectedSummary.busy} />
@@ -103,23 +111,6 @@ export function TicketDetailPanel({
Timeline - {feedbackAction ? ( -
{ - event.preventDefault(); - onSubmitFeedback(); - }} - > -