From 9ebe27376b2c200eee902a7f932a5b5b4e5dd018 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Sun, 9 Aug 2026 20:05:44 +0000 Subject: [PATCH 1/2] Cascade project deletion with a confirmation that names its scope Drop the DELETE /api/ship/projects/{id} 409 reference guard and delete the project's work items with it in the same session. Add each project's total work-item count (resolved included) to the projects-list response so the FE delete confirmation can state the project name and full destructive scope, and surface any delete failure in a page-level error toast instead of a silent no-op. Co-Authored-By: Claude Opus 4.8 --- backend/druks/contrib/ship/routes.py | 39 +++++--- backend/druks/contrib/ship/schemas.py | 10 +- .../tests/ship/test_project_repo_routes.py | 64 +++++++++++++ .../ship/projects/ProjectsPage.test.tsx | 91 +++++++++++++++++++ .../extensions/ship/projects/ProjectsPage.tsx | 27 +++++- .../src/extensions/ship/projects/types.ts | 9 +- frontend/src/styles.css | 10 ++ 7 files changed, 230 insertions(+), 20 deletions(-) create mode 100644 frontend/src/extensions/ship/projects/ProjectsPage.test.tsx diff --git a/backend/druks/contrib/ship/routes.py b/backend/druks/contrib/ship/routes.py index 11c0be4b..8fea2d00 100644 --- a/backend/druks/contrib/ship/routes.py +++ b/backend/druks/contrib/ship/routes.py @@ -1,7 +1,7 @@ import logging from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Response, status -from sqlalchemy import func, select, update +from sqlalchemy import delete, func, select, update from druks.accounts.dependencies import current_account from druks.accounts.models import Account @@ -15,6 +15,7 @@ DashboardItem, GitHubReposResponse, GitHubRepoSummary, + ProjectListItem, ProjectRepoSummary, ProjectsResponse, ProjectSummary, @@ -37,8 +38,24 @@ @projects_router.get("", response_model=ProjectsResponse, response_model_by_alias=True) async def list_projects() -> ProjectsResponse: - rows = list(db_session().scalars(select(Project).order_by(Project.name))) - return ProjectsResponse(projects=[ProjectSummary.model_validate(p) for p in rows]) + # Each project's total work-item count — every child, resolved or not — so the + # dashboard states a delete's full destructive scope, including closed items. + work_item_count = ( + select(func.count()) + .select_from(WorkItem) + .where(WorkItem.project_id == Project.id) + .scalar_subquery() + ) + rows = ( + db_session() + .execute(select(Project, work_item_count.label("work_item_count")).order_by(Project.name)) + .all() + ) + projects = [] + for project, count in rows: + project.work_item_count = count + projects.append(ProjectListItem.model_validate(project)) + return ProjectsResponse(projects=projects) @projects_router.post( @@ -131,21 +148,15 @@ async def update_project( @projects_router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_project(project_id: int) -> None: - """Delete a project. Refuses when any WorkItem still points at it — - ``work_items.project_id`` is NOT NULL, so the operator must move - or delete the children first.""" + """Delete a project and everything it owns. A project's work items are its own + children, so they go with it — ``work_items.project_id`` is a plain FK, so the + cascade is an explicit child delete in the same session before the project (and + its repo ``delete-orphan`` cascade) is deleted.""" session = db_session() project = Project.get(project_id) if not project: raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found") - referencing = session.scalar( - select(func.count()).select_from(WorkItem).where(WorkItem.project_id == project_id) - ) - if referencing: - raise HTTPException( - status.HTTP_409_CONFLICT, - f"{referencing} work item(s) still reference this project; move or delete them first.", - ) + session.execute(delete(WorkItem).where(WorkItem.project_id == project_id)) session.delete(project) session.flush() diff --git a/backend/druks/contrib/ship/schemas.py b/backend/druks/contrib/ship/schemas.py index bd8bfbc4..87f3a1b0 100644 --- a/backend/druks/contrib/ship/schemas.py +++ b/backend/druks/contrib/ship/schemas.py @@ -31,8 +31,16 @@ class ProjectSummary(BaseResponse): repos: list[ProjectRepoSummary] = Field(default_factory=list) +class ProjectListItem(ProjectSummary): + # The projects list adds each project's total work-item count — every child, + # resolved or not — so the dashboard can state a delete's full destructive + # scope. Kept off the plain ``ProjectSummary`` so the detail and mutation + # responses aren't widened with a field they don't populate. + work_item_count: int + + class ProjectsResponse(BaseResponse): - projects: list[ProjectSummary] + projects: list[ProjectListItem] class CreateProjectRequest(BaseModel): diff --git a/backend/tests/ship/test_project_repo_routes.py b/backend/tests/ship/test_project_repo_routes.py index 76cea72a..6b7c4eec 100644 --- a/backend/tests/ship/test_project_repo_routes.py +++ b/backend/tests/ship/test_project_repo_routes.py @@ -107,6 +107,70 @@ def test_nested_repo_routes_are_scoped_to_their_project(client: TestClient, monk assert ProjectRepo.get(repo_id) is None +def _make_work_item(project_id: int, ticket_key: str, *, resolved: bool = False): + from datetime import datetime + + from druks.contrib.ship.models import WorkItem + + item = WorkItem.create( + project_id=project_id, + title=ticket_key, + ticket_key=ticket_key, + repo="acme/widget", + ) + if resolved: + item.resolution = "closed" + item.resolved_at = datetime(2026, 1, 1) + return item + + +def test_projects_list_reports_total_work_item_count_including_resolved( + client: TestClient, druks_db +): + """The projects list carries each project's full child count — resolved items + included — so the dashboard states a delete's real scope (the closed-item case + that caused ENG-846), and one project's items never leak into another's count.""" + from druks.contrib.ship.models import Project + + target = Project.create(name="Target") + control = Project.create(name="Control") + _make_work_item(target.id, "ENG-1") + _make_work_item(target.id, "ENG-2", resolved=True) + _make_work_item(control.id, "ENG-3") + + projects = {p["name"]: p for p in client.get("/api/ship/projects").json()["projects"]} + + assert projects["Target"]["workItemCount"] == 2 + assert projects["Control"]["workItemCount"] == 1 + + +def test_deleting_a_project_cascades_its_work_items_and_spares_others(client: TestClient, druks_db): + """DELETE cascades: the project and every work item it owns go, with no 409 + reference guard — while another project's graph is left fully intact.""" + from druks.contrib.ship.models import Project, WorkItem + + target = Project.create(name="Target") + control = Project.create(name="Control") + doomed = _make_work_item(target.id, "ENG-1") + doomed_resolved = _make_work_item(target.id, "ENG-2", resolved=True) + survivor = _make_work_item(control.id, "ENG-3") + target_id, control_id = target.id, control.id + doomed_id, doomed_resolved_id, survivor_id = doomed.id, doomed_resolved.id, survivor.id + + response = client.delete(f"/api/ship/projects/{target_id}") + + assert response.status_code == 204 + # The route committed on its own session; drop this session's identity map so + # the reads below reflect the committed graph rather than cached instances. + druks_db.expire_all() + assert Project.get(target_id) is None + assert WorkItem.get(doomed_id) is None + assert WorkItem.get(doomed_resolved_id) is None + # The control project and its work item are untouched. + assert Project.get(control_id) is not None + assert WorkItem.get(survivor_id) is not None + + def test_the_repo_subject_read_side_mounts(client: TestClient, druks_db): """Profile is about a repo, so the repo gets the board and the page it never had — its own runs' status and timeline, keyed by the repo's id.""" diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx b/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx new file mode 100644 index 00000000..270656fb --- /dev/null +++ b/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx @@ -0,0 +1,91 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { projectsApi } from './api' +import { ProjectsPage } from './ProjectsPage' +import type { ProjectListItem, ProjectsResponse } from './types' + +// The page reads the projects list and the repo board through projectsApi and +// deletes through it — stub the module so the test drives those directly. +vi.mock('./api', () => ({ + projectsApi: { + list: vi.fn(), + repoBoard: vi.fn(), + delete: vi.fn(), + }, +})) + +const listMock = vi.mocked(projectsApi.list) +const repoBoardMock = vi.mocked(projectsApi.repoBoard) +const deleteMock = vi.mocked(projectsApi.delete) + +function project(overrides: Partial = {}): ProjectListItem { + return { + id: 7, + name: 'Target', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + repos: [], + workItemCount: 2, + ...overrides, + } +} + +function renderPage(projects: ProjectListItem[]) { + listMock.mockResolvedValue({ projects } satisfies ProjectsResponse) + repoBoardMock.mockResolvedValue({ rows: [] }) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.restoreAllMocks() +}) + +describe('ProjectsPage delete', () => { + it('confirms with the project name and work-item count, and sends no DELETE on cancel', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + renderPage([project({ name: 'Target', workItemCount: 2 })]) + + fireEvent.click(await screen.findByText('delete')) + + expect(confirmSpy).toHaveBeenCalledTimes(1) + const prompt = confirmSpy.mock.calls[0]![0] as string + expect(prompt).toContain('Target') + expect(prompt).toContain('2 work items') + expect(deleteMock).not.toHaveBeenCalled() + }) + + it('sends the DELETE for the project once confirmed', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + deleteMock.mockResolvedValue(undefined) + renderPage([project({ id: 7, name: 'Target' })]) + + fireEvent.click(await screen.findByText('delete')) + + await waitFor(() => expect(deleteMock).toHaveBeenCalledWith(7)) + expect(deleteMock).toHaveBeenCalledTimes(1) + }) + + it('surfaces a failed delete in an error toast and leaves the card in place', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true) + deleteMock.mockRejectedValue(new Error('project is locked')) + renderPage([project({ name: 'Target' })]) + + fireEvent.click(await screen.findByText('delete')) + + const toast = await screen.findByRole('alert') + expect(toast.textContent).toContain('project is locked') + // The card is still rendered — the failure was surfaced, not swallowed. + expect(screen.getByText('Target')).toBeTruthy() + }) +}) diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.tsx b/frontend/src/extensions/ship/projects/ProjectsPage.tsx index 8692cc6d..376fb5a3 100644 --- a/frontend/src/extensions/ship/projects/ProjectsPage.tsx +++ b/frontend/src/extensions/ship/projects/ProjectsPage.tsx @@ -3,9 +3,10 @@ import { useState } from 'react' import { EmptyState } from '../../../components/EmptyState' import { Page } from '../../../components/Page' +import { useFlashNote } from '../../../lib/useFlashNote' import { projectsApi } from './api' import { repoProfiling, useRepoRuns, type RepoProfiling } from './profiling' -import type { Project, ProjectRepo } from './types' +import type { ProjectListItem, ProjectRepo } from './types' function splitRepo(full: string): { org: string; short: string } { const i = full.indexOf('/') @@ -26,6 +27,9 @@ export function ProjectsPage() { }) const [draft, setDraft] = useState('') + // A project delete can still fail (a race, a server error); surface it here at + // page level as a transient error toast so it's never a silent no-op. + const [deleteError, setDeleteError] = useFlashNote() const createMutation = useMutation({ mutationFn: projectsApi.create, onSuccess: () => { @@ -56,6 +60,11 @@ export function ProjectsPage() { return (
+ {deleteError && ( +
+ {deleteError} +
+ )}
Projects ({data.projects.length}) @@ -92,7 +101,7 @@ export function ProjectsPage() { {String(createMutation.error)} )} {data.projects.map((p) => ( - + ))}
)} @@ -138,7 +147,13 @@ function CreateRow({ ) } -function ProjectCard({ project }: { project: Project }) { +function ProjectCard({ + project, + onDeleteError, +}: { + project: ProjectListItem + onDeleteError: (message: string) => void +}) { const queryClient = useQueryClient() const invalidate = () => void queryClient.invalidateQueries({ queryKey: ['projects'] }) @@ -157,9 +172,13 @@ function ProjectCard({ project }: { project: Project }) { const remove = useMutation({ mutationFn: () => projectsApi.delete(project.id), onSuccess: invalidate, + // Leave the card in place and surface the reason at page level — never a + // silent no-op, which is the failure ENG-846 set out to fix. + onError: (error) => onDeleteError(String(error)), }) const repoCount = `${project.repos.length} ${project.repos.length === 1 ? 'repo' : 'repos'}` + const workItems = `${project.workItemCount} ${project.workItemCount === 1 ? 'work item' : 'work items'}` return (
@@ -213,7 +232,7 @@ function ProjectCard({ project }: { project: Project }) { onClick={() => { if ( confirm( - `Delete project "${project.name}"? Move or delete its work items first — a project still referenced by work items can't be deleted.`, + `Delete project "${project.name}" and its ${workItems}? This permanently deletes the project and every work item it owns.`, ) ) { remove.mutate() diff --git a/frontend/src/extensions/ship/projects/types.ts b/frontend/src/extensions/ship/projects/types.ts index 337e770f..11f1c223 100644 --- a/frontend/src/extensions/ship/projects/types.ts +++ b/frontend/src/extensions/ship/projects/types.ts @@ -34,8 +34,15 @@ export interface Project { repos: ProjectRepo[] } +// The projects list carries each project's total work-item count — resolved items +// included — so the delete confirmation can state a project's full destructive +// scope. Only the list projection populates it; the detail/mutation shapes don't. +export interface ProjectListItem extends Project { + workItemCount: number +} + export interface ProjectsResponse { - projects: Project[] + projects: ProjectListItem[] } export interface CreateProjectRequest { diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 2d853d38..4bb3e642 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2066,6 +2066,16 @@ textarea.set-textarea { background-image: none; height: auto; min-height: 96px; .pj-err { display: block; margin-top: 8px; font-size: 11px; color: var(--bucket-dead); } +/* page-level transient toast — e.g. a project delete that failed */ +.pj-toast { + margin-bottom: 16px; padding: 10px 14px; border-radius: 4px; font-size: 12px; +} +.pj-toast-error { + color: var(--bucket-dead); + border: 1px solid color-mix(in oklch, var(--bucket-dead) 45%, transparent); + background: color-mix(in oklch, var(--bucket-dead) 12%, transparent); +} + /* create-project row */ .pj-create { display: flex; gap: 10px; margin-bottom: 26px; } .pj-create-input { From 7fc11f7a7476ba2340e1532e3e92cf3fa61a26ce Mon Sep 17 00:00:00 2001 From: Paulo Date: Sun, 9 Aug 2026 22:57:31 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Drop=20the=20work-item=20count=20from=20the?= =?UTF-8?q?=20projects=20list=20=E2=80=94=20the=20confirm=20states=20scope?= =?UTF-8?q?,=20not=20magnitude?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/druks/contrib/ship/routes.py | 21 ++----------------- backend/druks/contrib/ship/schemas.py | 10 +-------- .../tests/ship/test_project_repo_routes.py | 20 ------------------ .../ship/projects/ProjectsPage.test.tsx | 13 ++++++------ .../extensions/ship/projects/ProjectsPage.tsx | 7 +++---- .../src/extensions/ship/projects/types.ts | 9 +------- 6 files changed, 13 insertions(+), 67 deletions(-) diff --git a/backend/druks/contrib/ship/routes.py b/backend/druks/contrib/ship/routes.py index 8fea2d00..f6c88403 100644 --- a/backend/druks/contrib/ship/routes.py +++ b/backend/druks/contrib/ship/routes.py @@ -15,7 +15,6 @@ DashboardItem, GitHubReposResponse, GitHubRepoSummary, - ProjectListItem, ProjectRepoSummary, ProjectsResponse, ProjectSummary, @@ -38,24 +37,8 @@ @projects_router.get("", response_model=ProjectsResponse, response_model_by_alias=True) async def list_projects() -> ProjectsResponse: - # Each project's total work-item count — every child, resolved or not — so the - # dashboard states a delete's full destructive scope, including closed items. - work_item_count = ( - select(func.count()) - .select_from(WorkItem) - .where(WorkItem.project_id == Project.id) - .scalar_subquery() - ) - rows = ( - db_session() - .execute(select(Project, work_item_count.label("work_item_count")).order_by(Project.name)) - .all() - ) - projects = [] - for project, count in rows: - project.work_item_count = count - projects.append(ProjectListItem.model_validate(project)) - return ProjectsResponse(projects=projects) + rows = list(db_session().scalars(select(Project).order_by(Project.name))) + return ProjectsResponse(projects=[ProjectSummary.model_validate(p) for p in rows]) @projects_router.post( diff --git a/backend/druks/contrib/ship/schemas.py b/backend/druks/contrib/ship/schemas.py index 87f3a1b0..bd8bfbc4 100644 --- a/backend/druks/contrib/ship/schemas.py +++ b/backend/druks/contrib/ship/schemas.py @@ -31,16 +31,8 @@ class ProjectSummary(BaseResponse): repos: list[ProjectRepoSummary] = Field(default_factory=list) -class ProjectListItem(ProjectSummary): - # The projects list adds each project's total work-item count — every child, - # resolved or not — so the dashboard can state a delete's full destructive - # scope. Kept off the plain ``ProjectSummary`` so the detail and mutation - # responses aren't widened with a field they don't populate. - work_item_count: int - - class ProjectsResponse(BaseResponse): - projects: list[ProjectListItem] + projects: list[ProjectSummary] class CreateProjectRequest(BaseModel): diff --git a/backend/tests/ship/test_project_repo_routes.py b/backend/tests/ship/test_project_repo_routes.py index 6b7c4eec..c8f0d96a 100644 --- a/backend/tests/ship/test_project_repo_routes.py +++ b/backend/tests/ship/test_project_repo_routes.py @@ -124,26 +124,6 @@ def _make_work_item(project_id: int, ticket_key: str, *, resolved: bool = False) return item -def test_projects_list_reports_total_work_item_count_including_resolved( - client: TestClient, druks_db -): - """The projects list carries each project's full child count — resolved items - included — so the dashboard states a delete's real scope (the closed-item case - that caused ENG-846), and one project's items never leak into another's count.""" - from druks.contrib.ship.models import Project - - target = Project.create(name="Target") - control = Project.create(name="Control") - _make_work_item(target.id, "ENG-1") - _make_work_item(target.id, "ENG-2", resolved=True) - _make_work_item(control.id, "ENG-3") - - projects = {p["name"]: p for p in client.get("/api/ship/projects").json()["projects"]} - - assert projects["Target"]["workItemCount"] == 2 - assert projects["Control"]["workItemCount"] == 1 - - def test_deleting_a_project_cascades_its_work_items_and_spares_others(client: TestClient, druks_db): """DELETE cascades: the project and every work item it owns go, with no 409 reference guard — while another project's graph is left fully intact.""" diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx b/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx index 270656fb..3da702a6 100644 --- a/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx +++ b/frontend/src/extensions/ship/projects/ProjectsPage.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { projectsApi } from './api' import { ProjectsPage } from './ProjectsPage' -import type { ProjectListItem, ProjectsResponse } from './types' +import type { Project, ProjectsResponse } from './types' // The page reads the projects list and the repo board through projectsApi and // deletes through it — stub the module so the test drives those directly. @@ -20,19 +20,18 @@ const listMock = vi.mocked(projectsApi.list) const repoBoardMock = vi.mocked(projectsApi.repoBoard) const deleteMock = vi.mocked(projectsApi.delete) -function project(overrides: Partial = {}): ProjectListItem { +function project(overrides: Partial = {}): Project { return { id: 7, name: 'Target', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', repos: [], - workItemCount: 2, ...overrides, } } -function renderPage(projects: ProjectListItem[]) { +function renderPage(projects: Project[]) { listMock.mockResolvedValue({ projects } satisfies ProjectsResponse) repoBoardMock.mockResolvedValue({ rows: [] }) const queryClient = new QueryClient({ @@ -52,16 +51,16 @@ afterEach(() => { }) describe('ProjectsPage delete', () => { - it('confirms with the project name and work-item count, and sends no DELETE on cancel', async () => { + it('confirms with the project name and destructive scope, and sends no DELETE on cancel', async () => { const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) - renderPage([project({ name: 'Target', workItemCount: 2 })]) + renderPage([project({ name: 'Target' })]) fireEvent.click(await screen.findByText('delete')) expect(confirmSpy).toHaveBeenCalledTimes(1) const prompt = confirmSpy.mock.calls[0]![0] as string expect(prompt).toContain('Target') - expect(prompt).toContain('2 work items') + expect(prompt).toContain('every work item it owns') expect(deleteMock).not.toHaveBeenCalled() }) diff --git a/frontend/src/extensions/ship/projects/ProjectsPage.tsx b/frontend/src/extensions/ship/projects/ProjectsPage.tsx index 376fb5a3..4c53b921 100644 --- a/frontend/src/extensions/ship/projects/ProjectsPage.tsx +++ b/frontend/src/extensions/ship/projects/ProjectsPage.tsx @@ -6,7 +6,7 @@ import { Page } from '../../../components/Page' import { useFlashNote } from '../../../lib/useFlashNote' import { projectsApi } from './api' import { repoProfiling, useRepoRuns, type RepoProfiling } from './profiling' -import type { ProjectListItem, ProjectRepo } from './types' +import type { Project, ProjectRepo } from './types' function splitRepo(full: string): { org: string; short: string } { const i = full.indexOf('/') @@ -151,7 +151,7 @@ function ProjectCard({ project, onDeleteError, }: { - project: ProjectListItem + project: Project onDeleteError: (message: string) => void }) { const queryClient = useQueryClient() @@ -178,7 +178,6 @@ function ProjectCard({ }) const repoCount = `${project.repos.length} ${project.repos.length === 1 ? 'repo' : 'repos'}` - const workItems = `${project.workItemCount} ${project.workItemCount === 1 ? 'work item' : 'work items'}` return (
@@ -232,7 +231,7 @@ function ProjectCard({ onClick={() => { if ( confirm( - `Delete project "${project.name}" and its ${workItems}? This permanently deletes the project and every work item it owns.`, + `Delete project "${project.name}"? This permanently deletes the project and every work item it owns.`, ) ) { remove.mutate() diff --git a/frontend/src/extensions/ship/projects/types.ts b/frontend/src/extensions/ship/projects/types.ts index 11f1c223..337e770f 100644 --- a/frontend/src/extensions/ship/projects/types.ts +++ b/frontend/src/extensions/ship/projects/types.ts @@ -34,15 +34,8 @@ export interface Project { repos: ProjectRepo[] } -// The projects list carries each project's total work-item count — resolved items -// included — so the delete confirmation can state a project's full destructive -// scope. Only the list projection populates it; the detail/mutation shapes don't. -export interface ProjectListItem extends Project { - workItemCount: number -} - export interface ProjectsResponse { - projects: ProjectListItem[] + projects: Project[] } export interface CreateProjectRequest {