Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 56 additions & 18 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -582,18 +582,46 @@ export function App() {
}, 250);
}

async function queueAction(fn: () => Promise<AcceptedJob>): Promise<AcceptedJob | null> {
async function queueAction(
fn: () => Promise<AcceptedJob>,
onError?: (message: string) => void
): Promise<AcceptedJob | null> {
setError("");
if (onError) {
onError("");
}
try {
const accepted = await fn();
optimisticTicketJob(accepted);
return accepted;
} catch (err) {
setError(err instanceof Error ? err.message : "action failed");
const message = err instanceof Error ? err.message : "action failed";
if (onError) {
onError(message);
} else {
setError(message);
}
return null;
}
}

function isPendingAdd(repoPath: string, ticketNumber: string): boolean {
return pendingAddedTickets.includes(pendingTicketKey(repoPath, ticketNumber));
}

async function enqueueTicketRun(
repoPath: string,
ticketNumber: string,
baseBranch?: string,
onError?: (message: string) => void
): Promise<AcceptedJob | null> {
const pendingKey = pendingTicketKey(repoPath, ticketNumber);
setPendingAddedTickets((current) => [...current, pendingKey]);
const accepted = await queueAction(() => runTicket(repoPath, ticketNumber, baseBranch), onError);
setPendingAddedTickets((current) => current.filter((key) => key !== pendingKey));
return accepted;
}

async function submitAddTicket() {
const repoPath = newTicketRepoPath.trim();
const ticketNumber = newTicketNumber.trim();
Expand All @@ -604,8 +632,7 @@ export function App() {
return;
}

const pendingKey = pendingTicketKey(repoPath, ticketNumber);
if (pendingAddedTickets.includes(pendingKey)) {
if (isPendingAdd(repoPath, ticketNumber)) {
setAddTicketError(`ticket ${ticketNumber} is already being added to AutoPR for this repository`);
return;
}
Expand All @@ -622,9 +649,7 @@ export function App() {
return;
}

setPendingAddedTickets((current) => [...current, pendingKey]);
const accepted = await queueAction(() => runTicket(repoPath, ticketNumber, baseBranch));
setPendingAddedTickets((current) => current.filter((key) => key !== pendingKey));
const accepted = await enqueueTicketRun(repoPath, ticketNumber, baseBranch);
if (accepted) {
closeAddTicketDialog();
scheduleFullRefresh();
Expand Down Expand Up @@ -758,20 +783,30 @@ export function App() {
setDiscoverError("");
setDiscoverLoading(true);
setShowDiscoverModal(true);
void discoverTickets(repoPath)
.then((found) => { setDiscoveredTickets(found); })
void Promise.all([discoverTickets(repoPath), listTickets(repoPath)])
.then(([found, tracked]) => {
const trackedNumbers = new Set(tracked.map((ticket) => ticket.ticket_number));
setDiscoveredTickets(
found.filter((ticket) => !trackedNumbers.has(ticket.ticket_number) && !isPendingAdd(repoPath, ticket.ticket_number))
);
})
.catch((err) => { setDiscoverError(err instanceof Error ? err.message : "discovery failed"); })
.finally(() => { setDiscoverLoading(false); });
}

function handleDiscoverAdd(ticketNumber: string) {
setShowDiscoverModal(false);
setError("");
setAddTicketError("");
setNewTicketRepoPath(discoverRepoPath);
setNewTicketNumber(ticketNumber);
setShowAddTicketDialog(true);
void refreshRepositories();
async function handleDiscoverAdd(ticketNumber: string) {
if (!discoverRepoPath) {
return;
}
if (isPendingAdd(discoverRepoPath, ticketNumber)) {
setDiscoverError(`ticket ${ticketNumber} is already being added to AutoPR for this repository`);
return;
}
const ok = await enqueueTicketRun(discoverRepoPath, ticketNumber, undefined, setDiscoverError);
if (ok) {
setDiscoveredTickets((current) => current.filter((ticket) => ticket.ticket_number !== ticketNumber));
scheduleFullRefresh();
}
}

return (
Expand Down Expand Up @@ -865,7 +900,10 @@ export function App() {
tickets={discoveredTickets}
loading={discoverLoading}
error={discoverError}
onAdd={handleDiscoverAdd}
pendingTicketNumbers={discoveredTickets
.filter((ticket) => isPendingAdd(discoverRepoPath, ticket.ticket_number))
.map((ticket) => ticket.ticket_number)}
onAdd={(ticketNumber) => { void handleDiscoverAdd(ticketNumber); }}
onClose={() => setShowDiscoverModal(false)}
/>
) : null}
Expand Down
19 changes: 16 additions & 3 deletions web/src/DiscoverTicketsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ type DiscoverTicketsModalProps = {
tickets: DiscoveredTicket[];
loading: boolean;
error: string;
pendingTicketNumbers: string[];
onAdd: (ticketNumber: string) => void;
onClose: () => void;
};

export function DiscoverTicketsModal({ repoPath, tickets, loading, error, onAdd, onClose }: DiscoverTicketsModalProps) {
export function DiscoverTicketsModal({
repoPath,
tickets,
loading,
error,
pendingTicketNumbers,
onAdd,
onClose
}: DiscoverTicketsModalProps) {
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal discover-modal" onClick={(event) => event.stopPropagation()}>
Expand All @@ -26,8 +35,12 @@ export function DiscoverTicketsModal({ repoPath, tickets, loading, error, onAdd,
<li key={ticket.ticket_number} className="discover-item">
<span className="discover-ticket-id">{ticket.ticket_number}</span>
<span className="discover-ticket-title">{ticket.title}</span>
<button type="button" onClick={() => onAdd(ticket.ticket_number)}>
Add
<button
type="button"
disabled={pendingTicketNumbers.includes(ticket.ticket_number)}
onClick={() => onAdd(ticket.ticket_number)}
>
{pendingTicketNumbers.includes(ticket.ticket_number) ? "Adding..." : "Add"}
</button>
</li>
))}
Expand Down
66 changes: 66 additions & 0 deletions web/src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,69 @@ describe("App", () => {
});
});
});

describe("App discover tickets", () => {
it("filters tracked tickets and keeps the discover modal open after adding", async () => {
apiMocks.getHealth.mockResolvedValue({ discover_tickets_configured: true });
apiMocks.listTickets.mockImplementation(async (repoPath?: string) => {
if (repoPath === "/repo1") {
return [
{
repo_id: "repo1",
repo_path: "/repo1",
ticket_number: "GH-4",
title: "Already tracked",
status: "waiting",
busy: false,
approved: false,
updated_at: "2024-01-01T00:00:00Z"
}
];
}
return [
{
repo_id: "repo1",
repo_path: "/repo1",
ticket_number: "GH-5",
title: "Structured feedback",
status: "waiting",
busy: false,
approved: false,
updated_at: "2024-01-01T00:00:00Z"
}
];
});
apiMocks.discoverTickets.mockResolvedValue([
{ ticket_number: "GH-4", title: "Already tracked" },
{ ticket_number: "GH-11", title: "Fresh discover ticket" }
]);
apiMocks.runTicket.mockResolvedValue({
status: "accepted",
job_id: "job-2",
action: "run_ticket",
repo_id: "repo1",
repo_path: "/repo1"
});

render(<App />);

fireEvent.click(await screen.findByRole("button", { name: "Discover Tickets" }));

expect(await screen.findByText("Fresh discover ticket")).toBeInTheDocument();
expect(apiMocks.discoverTickets).toHaveBeenCalledWith("/repo1");
expect(apiMocks.listTickets).toHaveBeenCalledWith("/repo1");
expect(screen.queryByText("Already tracked")).not.toBeInTheDocument();

fireEvent.click(screen.getByRole("button", { name: "Add" }));

await waitFor(() => {
expect(apiMocks.runTicket).toHaveBeenCalledWith("/repo1", "GH-11", undefined);
});
await waitFor(() => {
expect(screen.queryByText("Fresh discover ticket")).not.toBeInTheDocument();
});

expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
expect(screen.queryByText("Schedule a ticket run for a repository.")).not.toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions web/src/__tests__/DiscoverTicketsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const defaultProps = {
tickets: [],
loading: false,
error: "",
pendingTicketNumbers: [],
onAdd: vi.fn(),
onClose: vi.fn(),
};
Expand Down Expand Up @@ -46,6 +47,18 @@ describe("DiscoverTicketsModal", () => {
expect(onAdd).toHaveBeenCalledWith("GH-4");
});

it("disables pending add buttons", () => {
render(
<DiscoverTicketsModal
{...defaultProps}
tickets={[{ ticket_number: "GH-4", title: "Remove Shortcut references" }]}
pendingTicketNumbers={["GH-4"]}
/>
);

expect(screen.getByRole("button", { name: "Adding..." })).toBeDisabled();
});

it("calls onClose when the backdrop is clicked", () => {
const onClose = vi.fn();
const { container } = render(<DiscoverTicketsModal {...defaultProps} onClose={onClose} />);
Expand Down
Loading