From 793f569bc5935561672d610cfedc4aebbcad94e7 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 13:52:16 +0900 Subject: [PATCH 1/4] feat(web): resolve blocked issues from cards Replace the blocked-card Ready action with a Resolve flow that loads the latest blocker comment, supports free-text and shortcut change requests, and moves the issue to ready with recovery-safe submission behavior. Add frontend tests, Storybook coverage, and synchronized UI documentation. --- cmd/web/frontend/docs/ui-design-system.ja.md | 5 + cmd/web/frontend/docs/ui-design-system.md | 5 + cmd/web/frontend/src/app/dashboard/page.tsx | 3 +- cmd/web/frontend/src/app/issues/page.tsx | 3 +- .../frontend/src/components/layout/index.tsx | 55 ++++++++ cmd/web/frontend/src/constants/index.ts | 1 + .../issues/components/board/index.tsx | 4 + .../issues/components/card/index.test.tsx | 14 +- .../features/issues/components/card/index.tsx | 18 ++- .../change-request-dialog/index.module.css | 25 ++++ .../change-request-dialog/index.tsx | 52 ++++++- .../issues/components/issues-view/index.tsx | 5 +- .../issues/components/issues-view/types.ts | 1 + .../resolve-issue-dialog/index.module.css | 44 ++++++ .../resolve-issue-dialog/index.stories.tsx | 19 +++ .../resolve-issue-dialog/index.test.tsx | 130 ++++++++++++++++++ .../components/resolve-issue-dialog/index.tsx | 94 +++++++++++++ .../issues/latest-blocker-comment.test.ts | 39 ++++++ .../features/issues/latest-blocker-comment.ts | 21 +++ cmd/web/frontend/src/lib/i18n.ts | 20 +++ cmd/web/frontend/src/stories/fixtures.ts | 5 + 21 files changed, 547 insertions(+), 16 deletions(-) create mode 100644 cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.module.css create mode 100644 cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx create mode 100644 cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx create mode 100644 cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx create mode 100644 cmd/web/frontend/src/features/issues/latest-blocker-comment.test.ts create mode 100644 cmd/web/frontend/src/features/issues/latest-blocker-comment.ts diff --git a/cmd/web/frontend/docs/ui-design-system.ja.md b/cmd/web/frontend/docs/ui-design-system.ja.md index f14cf6a7..241f3c8e 100644 --- a/cmd/web/frontend/docs/ui-design-system.ja.md +++ b/cmd/web/frontend/docs/ui-design-system.ja.md @@ -579,6 +579,11 @@ gap: 14px; - クイックアクションボタンはスコープ付き変数 (`--quick-action-bg`、 `--quick-action-border`、`--quick-action-text`) を使い、各バリアント (`quickAction-ready`、`quickAction-done`) は色だけを上書きします。 +- blocked のカードでは、`Ready` への直接遷移を `Resolve` に置き換えます。 + この操作は comment の全ページから最新の blocker comment を取得し、 + Continue with Comment ダイアログを開きます。blocker は読み取り専用の文脈として + 表示し、フリーテキストまたは組み込みの `Ok` / `Retry` ショートカットで + Change Request を作成してから Issue を `ready` へ移動します。 ### Issue Board diff --git a/cmd/web/frontend/docs/ui-design-system.md b/cmd/web/frontend/docs/ui-design-system.md index 71170430..f7922a6c 100644 --- a/cmd/web/frontend/docs/ui-design-system.md +++ b/cmd/web/frontend/docs/ui-design-system.md @@ -581,6 +581,11 @@ This is the recurring shape for `runs-section`, `comment-list`, - Quick action button uses scoped variables (`--quick-action-bg`, `--quick-action-border`, `--quick-action-text`) so each variant (`quickAction-ready`, `quickAction-done`) only overrides colors. +- A blocked card replaces the direct `Ready` transition with `Resolve`. The + action loads the latest blocker comment across all comment pages and opens a + continue-with-comment dialog. The blocker is read-only context; free text or + the built-in `Ok` / `Retry` shortcuts create the change request before the + issue moves to `ready`. ### Issue Board diff --git a/cmd/web/frontend/src/app/dashboard/page.tsx b/cmd/web/frontend/src/app/dashboard/page.tsx index 7200775e..fe25f4e1 100644 --- a/cmd/web/frontend/src/app/dashboard/page.tsx +++ b/cmd/web/frontend/src/app/dashboard/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function DashboardPage() { - const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange } = useLayoutData(); return ( ); diff --git a/cmd/web/frontend/src/app/issues/page.tsx b/cmd/web/frontend/src/app/issues/page.tsx index 382d7c31..75b8861d 100644 --- a/cmd/web/frontend/src/app/issues/page.tsx +++ b/cmd/web/frontend/src/app/issues/page.tsx @@ -4,7 +4,7 @@ import { useLayoutData, useLayoutShellData } from "@/components/layout"; import { IssuesView } from "@/features/issues/components/issues-view"; export default function IssuesPage() { - const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onStatusChange } = useLayoutData(); + const { summary, onAddIssue, onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange } = useLayoutData(); const { isProjectIssueScope } = useLayoutShellData(); return ( @@ -14,6 +14,7 @@ export default function IssuesPage() { onAddIssue={onAddIssue} onRejectIssue={onRejectIssue} onRejectShortcut={onRejectShortcut} + onResolveIssue={onResolveIssue} onStatusChange={onStatusChange} /> ); diff --git a/cmd/web/frontend/src/components/layout/index.tsx b/cmd/web/frontend/src/components/layout/index.tsx index c96b7585..c56995f9 100644 --- a/cmd/web/frontend/src/components/layout/index.tsx +++ b/cmd/web/frontend/src/components/layout/index.tsx @@ -36,6 +36,7 @@ import { AddIssueDialog } from "@/components/dialog/add-issue"; import { AddProjectDialog } from "@/components/dialog/add-project"; import { DeleteProjectDialog } from "@/components/dialog/delete-project"; import { ChangeRequestDialog } from "@/features/issues/components/change-request-dialog"; +import { ResolveIssueDialog } from "@/features/issues/components/resolve-issue-dialog"; import type { ChangeRequestShortcut } from "@/features/issues/change-request-shortcuts"; import { Header } from "./header"; import { Sidebar } from "./sidebar"; @@ -62,6 +63,7 @@ export type LayoutData = { onAddIssue: (status?: IssueStatus) => void; onRejectIssue: (issueID: number) => void; onRejectShortcut: (issueID: number, shortcut: ChangeRequestShortcut) => Promise; + onResolveIssue: (issueID: number) => void; onStatusChange: (id: number, status: IssueStatus) => Promise; }; @@ -73,6 +75,7 @@ export type LayoutShellData = { deleteProjectError: string; isDeletingProject: boolean; isMovingRejectedIssue: boolean; + isMovingResolvedIssue: boolean; isIssueDetailPage: boolean; isProjectIssueScope: boolean; issues: IssueSummary[]; @@ -82,6 +85,8 @@ export type LayoutShellData = { rejectIssue: IssueSummary | null; rejectIssueError: string; rejectRequestRecovery: { body: string; requestCreated: boolean }; + resolveIssue: IssueSummary | null; + resolveIssueError: string; summary: Summary | null; title: string | null; onIssueDetailTitleChange: (title: string | null) => void; @@ -92,6 +97,7 @@ export type LayoutShellData = { onDeleteProject: () => void; onConfirmDeleteProject: () => Promise; onMoveRejectedIssueReady: () => Promise; + onMoveResolvedIssueReady: () => Promise; }; const layoutDataContext = createContext(null); @@ -132,6 +138,9 @@ function LayoutContent({ children }: { children: ReactNode }) { requestCreated: boolean; }>({ body: "", requestCreated: false }); const [isMovingRejectedIssue, setIsMovingRejectedIssue] = useState(false); + const [resolveIssueID, setResolveIssueID] = useState(null); + const [resolveIssueError, setResolveIssueError] = useState(""); + const [isMovingResolvedIssue, setIsMovingResolvedIssue] = useState(false); const [issueDetailTitleOverride, setIssueDetailTitleOverride] = useState(null); const [refreshIntervalMs, setRefreshIntervalMs] = useState( defaultRefreshIntervalMs, @@ -315,6 +324,32 @@ function LayoutContent({ children }: { children: ReactNode }) { } } + function handleResolveIssue(issueID: number) { + setResolveIssueID(issueID); + setResolveIssueError(""); + modal.openModal(modalIDs.resolveIssue); + } + + async function handleMoveResolvedIssueReady() { + if (resolveIssueID === null) return; + setIsMovingResolvedIssue(true); + setResolveIssueError(""); + try { + await updateIssueStatus(resolveIssueID, "ready", { silent: true }); + toast.success({ message: t("toast.success.continuedWithComment") }); + void load({ silent: true }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : t("issues.continueWithComment.errors.statusUpdateFailed"); + setResolveIssueError(message); + throw new Error(message); + } finally { + setIsMovingResolvedIssue(false); + } + } + async function handleConfirmDeleteProject() { if (!activeProject) return; setIsDeletingProject(true); @@ -340,6 +375,7 @@ function LayoutContent({ children }: { children: ReactNode }) { setAddIssueError(""); setDeleteProjectError(""); setRejectIssueError(""); + setResolveIssueError(""); modal.closeModal(); } @@ -375,6 +411,7 @@ function LayoutContent({ children }: { children: ReactNode }) { onAddIssue: handleAddIssue, onRejectIssue: handleRejectIssue, onRejectShortcut: handleRejectShortcut, + onResolveIssue: handleResolveIssue, onStatusChange: handleStatusChange, } : null; @@ -388,6 +425,7 @@ function LayoutContent({ children }: { children: ReactNode }) { isIssueDetailPage, isDeletingProject, isMovingRejectedIssue, + isMovingResolvedIssue, isProjectIssueScope, issues, layoutData, @@ -396,6 +434,8 @@ function LayoutContent({ children }: { children: ReactNode }) { rejectIssue: issues.find((issue) => issue.id === rejectIssueID) ?? null, rejectIssueError, rejectRequestRecovery, + resolveIssue: issues.find((issue) => issue.id === resolveIssueID) ?? null, + resolveIssueError, summary, title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle( issueScope, @@ -410,6 +450,7 @@ function LayoutContent({ children }: { children: ReactNode }) { onDeleteProject: handleDeleteProject, onConfirmDeleteProject: handleConfirmDeleteProject, onMoveRejectedIssueReady: handleMoveRejectedIssueReady, + onMoveResolvedIssueReady: handleMoveResolvedIssueReady, }; return ( @@ -558,6 +599,20 @@ function LayoutModalContent({ shellData }: { shellData: LayoutShellData }) { ); } + if (modal.activeModalID === modalIDs.resolveIssue && shellData.resolveIssue) { + return ( + + ); + } + return null; } diff --git a/cmd/web/frontend/src/constants/index.ts b/cmd/web/frontend/src/constants/index.ts index c48ef89b..6130fdde 100644 --- a/cmd/web/frontend/src/constants/index.ts +++ b/cmd/web/frontend/src/constants/index.ts @@ -3,4 +3,5 @@ export const modalIDs = { addProject: "addProject", deleteProject: "deleteProject", rejectIssue: "rejectIssue", + resolveIssue: "resolveIssue", } as const; diff --git a/cmd/web/frontend/src/features/issues/components/board/index.tsx b/cmd/web/frontend/src/features/issues/components/board/index.tsx index c29e14b7..1fdc4651 100644 --- a/cmd/web/frontend/src/features/issues/components/board/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/board/index.tsx @@ -9,6 +9,7 @@ import styles from "./index.module.css"; type StatusChangeHandler = (id: number, status: IssueStatus) => Promise; type RejectIssueHandler = (id: number) => void; type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; +type ResolveIssueHandler = (id: number) => void; const boardActions = [ { icon: "filter", titleKey: "issues.board.filter" }, @@ -22,6 +23,7 @@ export function IssueBoard({ onAddIssue, onRejectIssue, onRejectShortcut, + onResolveIssue, onStatusChange, }: { showFilterSortActions?: boolean; @@ -29,6 +31,7 @@ export function IssueBoard({ onAddIssue: (status?: IssueStatus) => void; onRejectIssue?: RejectIssueHandler; onRejectShortcut?: RejectShortcutHandler; + onResolveIssue?: ResolveIssueHandler; onStatusChange: StatusChangeHandler; }) { const { t } = useTranslation(); @@ -85,6 +88,7 @@ export function IssueBoard({ issue={issue} onRejectIssue={onRejectIssue} onRejectShortcut={onRejectShortcut} + onResolveIssue={onResolveIssue} onStatusChange={onStatusChange} /> )) diff --git a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx index 15cb732f..f4ec5de6 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.test.tsx @@ -48,6 +48,7 @@ function issueWithCommentCount(commentCount: number): IssueSummary { function renderCard(props: Partial[0]> = {}) { const onRejectIssue = vi.fn(); const onRejectShortcut = vi.fn(async () => undefined); + const onResolveIssue = vi.fn(); const onStatusChange = vi.fn(async () => undefined); const rendered = render( @@ -56,13 +57,14 @@ function renderCard(props: Partial[0]> = {}) { issue={issue} onRejectIssue={onRejectIssue} onRejectShortcut={onRejectShortcut} + onResolveIssue={onResolveIssue} onStatusChange={onStatusChange} {...props} /> , ); - return { onRejectIssue, onRejectShortcut, onStatusChange, unmount: rendered.unmount }; + return { onRejectIssue, onRejectShortcut, onResolveIssue, onStatusChange, unmount: rendered.unmount }; } describe("IssueCard", () => { @@ -251,16 +253,16 @@ describe("IssueCard", () => { expect(onStatusChange).toHaveBeenCalledWith(24, "ready"); }); - it("renders draft actions for blocked issues", async () => { + it("opens the resolve flow for blocked issues", async () => { const user = userEvent.setup(); - const { onStatusChange } = renderCard({ + const { onResolveIssue, onStatusChange } = renderCard({ issue: { ...issue, status: "blocked", }, }); - const quickAction = screen.getByRole("button", { name: "Ready" }); + const quickAction = screen.getByRole("button", { name: "Resolve" }); await user.click(screen.getByRole("button", { name: "Issue actions for Wire issue board to generated client", @@ -274,7 +276,8 @@ describe("IssueCard", () => { await user.click(quickAction); - expect(onStatusChange).toHaveBeenCalledWith(24, "ready"); + expect(onResolveIssue).toHaveBeenCalledWith(24); + expect(onStatusChange).not.toHaveBeenCalled(); }); it("renders a done quick action for review issues", async () => { @@ -325,6 +328,7 @@ describe("IssueCard", () => { }); expect(screen.queryByRole("button", { name: "Ready" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Resolve" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); }); diff --git a/cmd/web/frontend/src/features/issues/components/card/index.tsx b/cmd/web/frontend/src/features/issues/components/card/index.tsx index 8ebf535b..fea28795 100644 --- a/cmd/web/frontend/src/features/issues/components/card/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/card/index.tsx @@ -23,6 +23,7 @@ import styles from "./index.module.css"; type IssueStatusChangeHandler = (id: number, status: IssueStatus) => Promise; type IssueRejectHandler = (id: number) => void; +type IssueResolveHandler = (id: number) => void; type IssueRejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; type IssueMetric = { @@ -40,7 +41,6 @@ const statusTransitionTargets: Partial> = { const quickStatusTargets: Partial> = { backlog: "ready", - blocked: "ready", review: "done", }; @@ -49,6 +49,7 @@ export function IssueCard({ onStatusChange, onRejectIssue, onRejectShortcut, + onResolveIssue, readonly = false, runCount, }: { @@ -56,6 +57,7 @@ export function IssueCard({ onStatusChange: IssueStatusChangeHandler; onRejectIssue?: IssueRejectHandler; onRejectShortcut?: IssueRejectShortcutHandler; + onResolveIssue?: IssueResolveHandler; readonly?: boolean; runCount?: number; }) { @@ -63,6 +65,7 @@ export function IssueCard({ const statusOptions = statusOptionsFor(issue.status); const canChangeStatus = !readonly && statusOptions.length > 1; const quickStatusTarget = readonly ? undefined : quickStatusTargets[issue.status]; + const canResolve = !readonly && issue.status === "blocked" && onResolveIssue !== undefined; const canReject = !readonly && issue.status === "review" && onRejectIssue !== undefined && onRejectShortcut !== undefined; const cardRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -221,7 +224,7 @@ export function IssueCard({ ))} - {quickStatusTarget || canReject ? ( + {quickStatusTarget || canReject || canResolve ? (
{canReject ? ( onRejectShortcut(issue.id, shortcut)} /> ) : null} - {quickStatusTarget ? ( + {canResolve ? ( + + ) : quickStatusTarget ? ( + ))} +
+ ) : null} +
{t(`${translationKey}.fields.body`)} {t(`${translationKey}.cancel`)} -
diff --git a/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx b/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx index 5641217f..8d521ca8 100644 --- a/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/issues-view/index.tsx @@ -2,7 +2,7 @@ import type { IssueStatus, Summary } from "@/lib/types"; import { IssueBoard } from "@/features/issues/components/board"; -import type { RejectIssueHandler, RejectShortcutHandler, StatusChangeHandler } from "./types"; +import type { RejectIssueHandler, RejectShortcutHandler, ResolveIssueHandler, StatusChangeHandler } from "./types"; import styles from "./index.module.css"; export function IssuesView({ @@ -11,6 +11,7 @@ export function IssuesView({ onAddIssue, onRejectIssue, onRejectShortcut, + onResolveIssue, onStatusChange, }: { showFilterSortActions?: boolean; @@ -18,6 +19,7 @@ export function IssuesView({ onAddIssue: (status?: IssueStatus) => void; onRejectIssue?: RejectIssueHandler; onRejectShortcut?: RejectShortcutHandler; + onResolveIssue?: ResolveIssueHandler; onStatusChange: StatusChangeHandler; }) { return ( @@ -28,6 +30,7 @@ export function IssuesView({ onAddIssue={onAddIssue} onRejectIssue={onRejectIssue} onRejectShortcut={onRejectShortcut} + onResolveIssue={onResolveIssue} onStatusChange={onStatusChange} /> diff --git a/cmd/web/frontend/src/features/issues/components/issues-view/types.ts b/cmd/web/frontend/src/features/issues/components/issues-view/types.ts index 86aae079..364079b4 100644 --- a/cmd/web/frontend/src/features/issues/components/issues-view/types.ts +++ b/cmd/web/frontend/src/features/issues/components/issues-view/types.ts @@ -3,4 +3,5 @@ import type { ChangeRequestShortcut } from "@/features/issues/change-request-sho export type StatusChangeHandler = (id: number, status: IssueStatus) => Promise; export type RejectIssueHandler = (id: number) => void; +export type ResolveIssueHandler = (id: number) => void; export type RejectShortcutHandler = (id: number, shortcut: ChangeRequestShortcut) => Promise; diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.module.css b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.module.css new file mode 100644 index 00000000..d426fcec --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.module.css @@ -0,0 +1,44 @@ +.blockerContext { + background: var(--extra-light-gray); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + display: grid; + gap: var(--space-2); + padding: var(--space-3); +} + +.blockerContext h3, +.blockerContext p { + margin: 0; +} + +.blockerContext h3 { + color: var(--dark-gray); + font-size: 13px; +} + +.blockerBody { + max-block-size: 180px; + overflow-y: auto; +} + +.blockerError { + align-items: flex-start; + display: flex; + gap: var(--space-3); + justify-content: space-between; +} + +.blockerError p { + color: var(--danger); + font-size: 13px; +} + +.blockerError button { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + flex-shrink: 0; + min-block-size: 32px; + padding: var(--space-0) var(--space-3); +} diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx new file mode 100644 index 00000000..e7eecd08 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ResolveIssueDialog } from "./index"; + +const meta = { + title: "Features/Issues/ResolveIssueDialog", + component: ResolveIssueDialog, + args: { + issueID: 42, + issueTitle: "Restore CI access", + onCancel: () => undefined, + onMoveIssueReady: async () => undefined, + onSuccess: () => undefined, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx new file mode 100644 index 00000000..8d333888 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx @@ -0,0 +1,130 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createChangeRequest, fetchComments } from "@/lib/api"; +import type { CommentListResponse } from "@/lib/types"; +import "@/lib/i18n"; +import { ResolveIssueDialog } from "./index"; + +vi.mock("@/lib/api", () => ({ + createChangeRequest: vi.fn(), + fetchComments: vi.fn(), +})); + +describe("ResolveIssueDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("shows the latest blocker and immediately submits a shortcut", async () => { + const user = userEvent.setup(); + const onMoveIssueReady = vi.fn().mockResolvedValue(undefined); + const onSuccess = vi.fn(); + vi.mocked(fetchComments).mockResolvedValue({ + data: [{ + id: 7, + issueId: 42, + author: "runner", + type: "blocker", + body: "Approval reason was missing.", + createdAt: "2026-08-14T00:00:00.000Z", + }], + meta: { cursor: 0, limit: 100, direction: "forward", nextCursor: null }, + } satisfies CommentListResponse); + vi.mocked(createChangeRequest).mockResolvedValue({} as never); + + render( + , + ); + + expect(await screen.findByText("Approval reason was missing.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(createChangeRequest).toHaveBeenCalledWith(42, { + author: "reviewer", + body: "Retry", + }, { silent: true }); + }); + expect(onMoveIssueReady).toHaveBeenCalledOnce(); + expect(onSuccess).toHaveBeenCalledOnce(); + }); + + it("keeps submissions disabled and allows retrying when blocker loading fails", async () => { + const user = userEvent.setup(); + vi.mocked(fetchComments) + .mockRejectedValueOnce(new Error("comments unavailable")) + .mockResolvedValueOnce({ + data: [{ + id: 7, + issueId: 42, + author: "runner", + type: "blocker", + body: "Recovered blocker.", + createdAt: "2026-08-14T00:00:00.000Z", + }], + meta: { cursor: 0, limit: 100, direction: "forward", nextCursor: null }, + } satisfies CommentListResponse); + + render( + , + ); + + expect(await screen.findByRole("alert")).toHaveTextContent("comments unavailable"); + expect(screen.getByRole("button", { name: "Retry" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Continue with comment" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: "Reload" })); + expect(await screen.findByText("Recovered blocker.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + }); + + it("does not create a second request when status recovery follows a shortcut", async () => { + const user = userEvent.setup(); + const onMoveIssueReady = vi.fn() + .mockRejectedValueOnce(new Error("status unavailable")) + .mockResolvedValueOnce(undefined); + vi.mocked(fetchComments).mockResolvedValue({ + data: [{ + id: 7, + issueId: 42, + author: "runner", + type: "blocker", + body: "Retry deployment.", + createdAt: "2026-08-14T00:00:00.000Z", + }], + meta: { cursor: 0, limit: 100, direction: "forward", nextCursor: null }, + } satisfies CommentListResponse); + vi.mocked(createChangeRequest).mockResolvedValue({} as never); + + render( + , + ); + + await screen.findByText("Retry deployment."); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("status unavailable")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Change request" })).toHaveValue("Retry"); + expect(screen.getByRole("textbox", { name: "Change request" })).toHaveAttribute("readonly"); + + await user.click(screen.getByRole("button", { name: "Continue with comment" })); + expect(createChangeRequest).toHaveBeenCalledOnce(); + expect(onMoveIssueReady).toHaveBeenCalledTimes(2); + }); +}); diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx new file mode 100644 index 00000000..35e8884d --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Markdown } from "@/components/ui/markdown"; +import type { Comment } from "@/lib/types"; +import { builtInChangeRequestShortcuts } from "@/features/issues/change-request-shortcuts"; +import { fetchLatestBlockerComment } from "@/features/issues/latest-blocker-comment"; +import { ChangeRequestDialog } from "@/features/issues/components/change-request-dialog"; +import styles from "./index.module.css"; + +type BlockerState = + | { kind: "loading" } + | { kind: "ready"; comment: Comment } + | { kind: "error"; message: string }; + +export function ResolveIssueDialog({ + error, + isMovingIssue, + issueID, + issueTitle, + onCancel, + onMoveIssueReady, + onSuccess, +}: { + error?: string; + isMovingIssue?: boolean; + issueID: number; + issueTitle: string; + onCancel: () => void; + onMoveIssueReady: () => Promise; + onSuccess: () => void; +}) { + const { t } = useTranslation(); + const [blockerState, setBlockerState] = useState({ kind: "loading" }); + + const loadBlocker = useCallback(async () => { + setBlockerState({ kind: "loading" }); + try { + const comment = await fetchLatestBlockerComment(issueID); + setBlockerState( + comment + ? { kind: "ready", comment } + : { kind: "error", message: t("issues.resolve.blockerNotFound") }, + ); + } catch (caught) { + setBlockerState({ + kind: "error", + message: + caught instanceof Error ? caught.message : t("issues.resolve.blockerLoadFailed"), + }); + } + }, [issueID, t]); + + useEffect(() => { + void loadBlocker(); + }, [loadBlocker]); + + const context = ( +
+

{t("issues.resolve.latestBlocker")}

+ {blockerState.kind === "loading" ?

{t("issues.resolve.loadingBlocker")}

: null} + {blockerState.kind === "error" ? ( +
+

{blockerState.message}

+ +
+ ) : null} + {blockerState.kind === "ready" ? ( + + ) : null} +
+ ); + + return ( + + ); +} diff --git a/cmd/web/frontend/src/features/issues/latest-blocker-comment.test.ts b/cmd/web/frontend/src/features/issues/latest-blocker-comment.test.ts new file mode 100644 index 00000000..f889b816 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/latest-blocker-comment.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchComments } from "@/lib/api"; +import type { CommentListResponse } from "@/lib/types"; +import { fetchLatestBlockerComment } from "./latest-blocker-comment"; + +vi.mock("@/lib/api", () => ({ fetchComments: vi.fn() })); + +describe("fetchLatestBlockerComment", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the blocker with the greatest ID across all pages", async () => { + vi.mocked(fetchComments) + .mockResolvedValueOnce({ + data: [comment(4, "blocker", "Older blocker"), comment(8, "general", "Note")], + meta: { cursor: 0, limit: 100, direction: "forward", nextCursor: 8 }, + } satisfies CommentListResponse) + .mockResolvedValueOnce({ + data: [comment(12, "blocker", "Latest blocker")], + meta: { cursor: 8, limit: 100, direction: "forward", nextCursor: null }, + } satisfies CommentListResponse); + + await expect(fetchLatestBlockerComment(42)).resolves.toMatchObject({ + id: 12, + body: "Latest blocker", + }); + expect(fetchComments).toHaveBeenNthCalledWith(2, 42, 8, 100, { silent: true }); + }); +}); + +function comment(id: number, type: "blocker" | "general", body: string) { + return { + id, + issueId: 42, + author: "runner", + type, + body, + createdAt: "2026-08-14T00:00:00.000Z", + }; +} diff --git a/cmd/web/frontend/src/features/issues/latest-blocker-comment.ts b/cmd/web/frontend/src/features/issues/latest-blocker-comment.ts new file mode 100644 index 00000000..cea38754 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/latest-blocker-comment.ts @@ -0,0 +1,21 @@ +import { fetchComments } from "@/lib/api"; +import type { Comment } from "@/lib/types"; + +const commentPageSize = 100; + +export async function fetchLatestBlockerComment(issueID: number): Promise { + let cursor: number | undefined; + let latest: Comment | null = null; + + do { + const page = await fetchComments(issueID, cursor, commentPageSize, { silent: true }); + for (const comment of page.data) { + if (comment.type === "blocker" && (latest === null || comment.id > latest.id)) { + latest = comment; + } + } + cursor = page.meta.nextCursor ?? undefined; + } while (cursor !== undefined); + + return latest; +} diff --git a/cmd/web/frontend/src/lib/i18n.ts b/cmd/web/frontend/src/lib/i18n.ts index 82e6d4bc..a94c1298 100644 --- a/cmd/web/frontend/src/lib/i18n.ts +++ b/cmd/web/frontend/src/lib/i18n.ts @@ -211,9 +211,19 @@ const resources = { retryNote: "Change request は作成済みです。再送信すると Issue の ready 更新だけを再試行します。", saving: "送信中...", + shortcuts: "定型文", submit: "コメントをつけて継続", title: "コメントをつけて継続 #{{id}}", }, + resolve: { + action: "Resolve", + blockerLoadFailed: "最新の blocker comment を取得できませんでした", + blockerNotFound: "Blocker comment が見つかりません", + emptyBlocker: "Blocker comment の本文がありません", + latestBlocker: "最新の blocker comment", + loadingBlocker: "Blocker comment を読み込んでいます...", + retryLoad: "再読み込み", + }, changeRequest: { writeComment: "コメントを入力…", }, @@ -812,9 +822,19 @@ const resources = { retryNote: "The change request has already been created. Submitting again only retries moving the issue to ready.", saving: "Sending...", + shortcuts: "Shortcuts", submit: "Continue with comment", title: "Continue with comment #{{id}}", }, + resolve: { + action: "Resolve", + blockerLoadFailed: "Failed to load the latest blocker comment", + blockerNotFound: "No blocker comment was found", + emptyBlocker: "The blocker comment has no body", + latestBlocker: "Latest blocker comment", + loadingBlocker: "Loading blocker comment...", + retryLoad: "Reload", + }, changeRequest: { writeComment: "Write a comment…", }, diff --git a/cmd/web/frontend/src/stories/fixtures.ts b/cmd/web/frontend/src/stories/fixtures.ts index 56a645e8..06230a1e 100644 --- a/cmd/web/frontend/src/stories/fixtures.ts +++ b/cmd/web/frontend/src/stories/fixtures.ts @@ -95,6 +95,7 @@ export const storyShellData: LayoutShellData = { isIssueDetailPage: false, isDeletingProject: false, isMovingRejectedIssue: false, + isMovingResolvedIssue: false, isProjectIssueScope: false, issues: storySummary.columns.flatMap((column) => column.issues), layoutData: { @@ -109,6 +110,7 @@ export const storyShellData: LayoutShellData = { onAddIssue: noop, onRejectIssue: noop, onRejectShortcut: asyncNoop, + onResolveIssue: noop, onStatusChange: asyncNoop, }, loadState: { @@ -120,6 +122,8 @@ export const storyShellData: LayoutShellData = { rejectIssue: null, rejectIssueError: "", rejectRequestRecovery: { body: "", requestCreated: false }, + resolveIssue: null, + resolveIssueError: "", summary: storySummary, title: "Tasq", onIssueDetailTitleChange: noop, @@ -130,4 +134,5 @@ export const storyShellData: LayoutShellData = { onDeleteProject: noop, onConfirmDeleteProject: asyncNoop, onMoveRejectedIssueReady: asyncNoop, + onMoveResolvedIssueReady: asyncNoop, }; From 2482ef10eca82569cc48690f91a59c11f3949547 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 13:54:25 +0900 Subject: [PATCH 2/4] test(web): stabilize resolve dialog story Inject the latest-blocker loader so Storybook can render the resolved dialog deterministically without relying on a live issue tracker. --- .../components/resolve-issue-dialog/index.stories.tsx | 8 ++++++++ .../issues/components/resolve-issue-dialog/index.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx index e7eecd08..856138d8 100644 --- a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx @@ -7,6 +7,14 @@ const meta = { args: { issueID: 42, issueTitle: "Restore CI access", + loadLatestBlocker: async () => ({ + id: 18, + issueId: 42, + author: "codex", + type: "blocker", + body: "Approval is required to update the protected CI configuration. Approve the workflow file change so the agent can restore the failing checks.", + createdAt: "2026-08-14T03:30:00.000Z", + }), onCancel: () => undefined, onMoveIssueReady: async () => undefined, onSuccess: () => undefined, diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx index 35e8884d..4b5626d8 100644 --- a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx @@ -17,6 +17,7 @@ export function ResolveIssueDialog({ isMovingIssue, issueID, issueTitle, + loadLatestBlocker = fetchLatestBlockerComment, onCancel, onMoveIssueReady, onSuccess, @@ -25,6 +26,7 @@ export function ResolveIssueDialog({ isMovingIssue?: boolean; issueID: number; issueTitle: string; + loadLatestBlocker?: (issueID: number) => Promise; onCancel: () => void; onMoveIssueReady: () => Promise; onSuccess: () => void; @@ -35,7 +37,7 @@ export function ResolveIssueDialog({ const loadBlocker = useCallback(async () => { setBlockerState({ kind: "loading" }); try { - const comment = await fetchLatestBlockerComment(issueID); + const comment = await loadLatestBlocker(issueID); setBlockerState( comment ? { kind: "ready", comment } @@ -48,7 +50,7 @@ export function ResolveIssueDialog({ caught instanceof Error ? caught.message : t("issues.resolve.blockerLoadFailed"), }); } - }, [issueID, t]); + }, [issueID, loadLatestBlocker, t]); useEffect(() => { void loadBlocker(); From 76e337de6075b47cb0e403afe0531e0eb26897da Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 14:36:23 +0900 Subject: [PATCH 3/4] docs(web): add resolve dialog screenshot Capture the deterministic ResolveIssueDialog Storybook story for inclusion in the pull request description. --- .../docs/screenshots/resolve-blocked-card.png | Bin 0 -> 39818 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 cmd/web/frontend/docs/screenshots/resolve-blocked-card.png diff --git a/cmd/web/frontend/docs/screenshots/resolve-blocked-card.png b/cmd/web/frontend/docs/screenshots/resolve-blocked-card.png new file mode 100644 index 0000000000000000000000000000000000000000..d8a131a00195bf98ce3d70ae046d20563061ac5c GIT binary patch literal 39818 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

WfE;FFGkMJr{t&Phe4L1 zW8-Z3!eIr*(>u}p>6pN|7Kxv`%RZsi7YA=wh*a&4QI$&`-kd^&liY<}m<_xruC`?`teQwqG;E69T8?+LyRZcCdw1wL?-H*Qb zEquR~tJrw?or%tbk>aEo(-gZ$7jjQdx0blN2DLwINoMRCpyvFj?>tq;sXgW-6*>;M z)b2uK8w&N?ob`F%M8q@ctTm*izF zmuPbq2+O%$_vFAR<-sN?%R0tq^#pXbXXDZ-F&3xwE5WxCBRf}IC_@rDBab>i@S`XH zI8Bc3TcMEjxYb!GTC?oU`kMEOde-#4qz{js7QXATEdxLvPF7RzoeVA;ZTgLg$Fho^ zlG!iJJyh>j4yXI8t#E^TKO1ay)#QFqe{;ZOL7j4T9VoIuw+K1PMD}~XUtNsN5S91F z2#hhbE>lFFQk6eiBMIo3qMbVr!0ufey~G|>Rn_LS83iS;3qk8^FAFrd*tt@gnl!7G z{4S8SP285&AG>>ZJk4ISCxuzAwAHmwiZAd>c)SXplyDU>9iLXPoK*%iRWkP4%dXn~ zp9_uZdRpR+Ri|r!eBTvzEyNd$eXOfwNzJ`v*On(^F5GVMkbs~N+E;T_ucw(jDrE=bE{7dN0d`=cb-`xx7&T3X6->C<5XdSEBp8*Al zJ;KFZo^m1C8_&*`HXjf+4_9amY21l2MBPvQg{|V=go0;9{|HJn=93ks6QSnG!{NqP z-tA}}_ZuGn^+<%9{{Nc6Em6}~NbK45K;N0xKEjNpc^~Vg62J6fnx3aFHDBXcUvrAr zQELY%c1P`a>2s8gE~_vp)Wbk1wIIhK!dz9?=}^=9hddTBirsP#6+ZEPpt{VKF8$^0 zo|#Q&<>IlY)wc+xmtJc+YC=h&JFgFV$}~m^M8E@wYNsJtZs+sJ&DI#8sgOG&BR(LaP!rFUYgu;ixrB7<^;M0kA$_-lZ zbZEIS<9shSsYVxbI}B~|(Aj$Dv3 zkB@(&!98YnL|ZB~baFK#@a90@M~;`6&mR;LngOuv2zJKImu0XQ+RwUH>0V&00gM>e zY%Y2|4+ybbxJU*VBbe)gD-$ta&{voYG7AP3kyUM+@?*~@!Ry&*LwTDt&jg2+a*Ivy zpgPx2(0-Xv=n&({|5vtf-6@#Q5rfJ21K^axJ0a{8TkR zRBrX1u+h3}7?2As5sdz|2?)qPZ6&U&xz(JR|H1+Q*51s-473}!Tew@h$T6E*qa4+; zf%BAvja-=E2xK~FkCl|p0SDj4Y^Ww zzm7cjIq3M8;#ok}{>EuCpB=65I1&fi)XSCgnnt)Jf|GSj&;k?-coY~e5Y9F_q!~?_ zRZM1=jrlQ@;roCP7Jc>LWD!S}E>8f}K&c;WD8s7ndlBmUZ8O(@=Iyh>tke#n@o=sO};&&gBQ}VrqZR`Dl9LrftK1o(e2;a-hOnBD`*{x{ua>t`Nn z!yVvmLZJg=nR-|T9es?v-Nm@S+%Qkn9f z{h5hbVe-ZEngbd(?6qe4GGssA>q`TvJt~8{p)A`JNk!e zw3MK?-`E0a*!Y3|cVv2nJGvvnTm z!V2Zc=UN^$1^Ahy=?dMEqZ&tnNqvkCn?TZj^W5`Cw?GD3pJl3Ac4an&V3GXZ3z9^t znvf9VhV2K~b>IEez7cUfGHkoMT#&8$cEmA7Y-N6j-~FKf$U3w#URKvWbL8;h&;+tG zX9DzFEFZj**%{2<%Wb#5ad42XknBtzG+k&)(Qb?|eg!gx#(+t!W|E3e>S)I9eW~tt zErT~o$u2J=&j){Rx73laI{JW?fAyb>`hfR)18IPv}M(c$&# zS}oT=MXm9MUP$F&p19X8b+VeW#4@N=j_Updbs!*hpG19*L5T}_6EZ>9RFFF{eM8JQ zO(M*XK=vj64&e^zHv`^|K+WL-LwM3=!NlxHlfb>jkvY?vfm1TA-^#9EqZiG=7_*WC|?fSS%n0ZY3 z@D`i6oH$KP$X^hSW)V6-J+p(dQe9T&Z%};$gU; z@RgC3Map^E5ZE00kVNtFg~s-J&1jo%>HtMGq*aP>aOeg+a>~L zH^;&+-;!>XJNPRmbn%_UyF*$6z1U2g_GP<_epIzvGL*|DJ%pEb$E~@GR(B z(dOR>nTWU_X)DV&<)n87{se38)As)_s!M#(e=1KOYusN))zHuYlUqW1A*pvj-i}45 z(8tZBth^lNa_tDY701nP?ZM!hVkhG|zMZL1jWji>L2AWPotP25rpdrk*YWc5I?47j zAfOk&!RUT}zmd?1rhW^OxF-au=b#kX0s#l_(TIqF*;4%Ik)}kY#$MD_G9_lWL`X58 zcWiQNt@{?>UAo}iz_t(C4sl-a3hHx^3YuK*Ye5Xc|9qHuaHNVL)Y`-!{tq|}Z!9G~ z_gs0HqKW64A5Q<()Kieo(Ie54xOD=`Dz^6QTT>A-~ z#WfX`fB;>DyNDFKr8!Fp?cFA_f$YyFCcl?F3Vl(pPP|{%_b6qyvg?vfv>~v2^yty8 z>K9?pjG^2kL@qyn5qyO>k;w0)A!hxh54frWh@wW=HvI0IdYJR*|m_J zF9xV6b-=&`XjWnU4WvF3;^dJU$3uy^eFtAMYZ zdxv-ReROWB50!KhN>M@_ihkzZt?#H&i99huLWwU6-UTES&vzrZ2JPvFDHHRFTUv)= z?{wtPAphColic*Yy6{m_BSecGCU8RmKZbfuIm@Iz52~CHz8+AeRm;}|p)Tpk3@nxCKtkYeq2%S*qW6qw< zOG6QMk;FqaqT{(wo#~bIinzx6yv>QqALmOb>=EUWRH1iSB@9oH$WMJA!yEzG@zu%g z%wkKZP(_MaCoy75^@B$k%E~eF)E79SDylc#Zg}mrxsY%!-4!WfX$Wx>gpYj+X3$%J zAFAw47|yD5Me<+sF#9l?ZZ0tZfj!Y%owJQzhl$r+*QB|*xzb>TlOu;p#5iNpV4;cX z3VK|t%!tt(h@Y)cfL3N>GKUpGjBxLw4nIHLf$XJ*qmQm zn+1op$&%G&Ma8oV!)#~J2L8gwG$3nTa2MHt@aXpQBJm!e3`s5r+ls0<3*|z&-KLBm zH_*L19)PLy^WBkn4kL{T$#aF!-N&_HE@cieYT@h6Z7koK9Q_gn^eo8gKn&SD{<2d=}OvRknmar^{d)(dLu)6JwdR zCB8OehJxnphkiWzyI`2-3}Ly{L&*W=t36jKq3_;@U7qRv-h4(#Dhm$1(05!ev?oA- zfu-5I1H`Xhaz*Fz1YrSJAiIpD5E2%~V5TBdSXBh;UyV1vS~FtT)(|fW$$MZBznVS; zNgi0gX7DM%mbi!ED^Jn13RMR(_Fi87_EQ#^WCJB&q#7h%K&3KhT={lE7c%~Z1@sln z{{hV4{AMGsOw04OJmnU^^^{ptA)ap&ic0wl5HMy=Pg0O292 ze?8G|>~T{+&aE6H4rQJ>HWIe9L3SN)$rAdRDq}aq=Ujgfc{MF^?W$6G;j2e|D~nJlYS$H9 zC7yyd`P`S44?9l?*h%=kPRJ?gA`K?x48bv*&13W*b=375-!_;H9+8w&P$0A^Tkl>8 zDXVDTAHVn3{D zn};muOjvX*EiHkt*jWYcWtn_Yo!0*5QRrTmX8eF#MSaTp7+PdoIAip)bG!XNGa4Ht zqnuE$!dmZnY!*q!S1En)|DLo+TgACxp2)+4O?FRVdwoha@jaCNbGwXn^8cCGh;juq zr0HDfA`snf{7Ano#8zwmHS)~Fx<}90WJ65%>8$0)3++@;`VV9Q0|$r{_YW1Vwqjg8 zA`PpAx40Hd?Ftg6ZyHex?A(M}K)_2(f#=0y@J@W8;v^Im`2YWevm*#G@G-(+2iPc~ zyK1@&6acy9bJ=pq(Gax$Z(z@WJgtr-l+gf{KY$1$L^x7~DY7Ru*yzymw28|68jI-#~ph;$^MszE*Q z~dQDytiq?61I2+mH8> z7`~H1%YC5dqvy>K^o^C%zcB6wl_(*YfYk)v5H{w5mIAR*Biw>=2=j#I}A

e#;oBy#85CU)-f@Ph6mJm+rSepb59vTQ@ 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

IP5#tfs$pxBLz97jx_dP!6;_%WQ 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 literal 0 HcmV?d00001 From 14b339aa9c38eea6996b8aac68dc9305b1ccc124 Mon Sep 17 00:00:00 2001 From: Jiro Date: Fri, 14 Aug 2026 14:38:46 +0900 Subject: [PATCH 4/4] fix(web): preserve resolve request recovery Keep the created change-request state in the layout when a status update fails so closing and reopening Resolve retries only the ready transition instead of creating a duplicate request. --- .../frontend/src/components/layout/index.tsx | 33 ++++++++++++++++++- .../change-request-dialog/index.tsx | 3 ++ .../resolve-issue-dialog/index.test.tsx | 29 ++++++++++++++-- .../components/resolve-issue-dialog/index.tsx | 9 +++++ cmd/web/frontend/src/stories/fixtures.ts | 3 ++ 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/cmd/web/frontend/src/components/layout/index.tsx b/cmd/web/frontend/src/components/layout/index.tsx index c56995f9..cda43215 100644 --- a/cmd/web/frontend/src/components/layout/index.tsx +++ b/cmd/web/frontend/src/components/layout/index.tsx @@ -87,6 +87,7 @@ export type LayoutShellData = { rejectRequestRecovery: { body: string; requestCreated: boolean }; resolveIssue: IssueSummary | null; resolveIssueError: string; + resolveRequestRecovery: { body: string; requestCreated: boolean }; summary: Summary | null; title: string | null; onIssueDetailTitleChange: (title: string | null) => void; @@ -98,6 +99,8 @@ export type LayoutShellData = { onConfirmDeleteProject: () => Promise; onMoveRejectedIssueReady: () => Promise; onMoveResolvedIssueReady: () => Promise; + onResolvedRequestCreated: (body: string) => void; + onResolvedIssueSuccess: () => void; }; const layoutDataContext = createContext(null); @@ -140,6 +143,11 @@ function LayoutContent({ children }: { children: ReactNode }) { const [isMovingRejectedIssue, setIsMovingRejectedIssue] = useState(false); const [resolveIssueID, setResolveIssueID] = useState(null); const [resolveIssueError, setResolveIssueError] = useState(""); + const [resolveRequestRecovery, setResolveRequestRecovery] = useState<{ + issueID: number | null; + body: string; + requestCreated: boolean; + }>({ issueID: null, body: "", requestCreated: false }); const [isMovingResolvedIssue, setIsMovingResolvedIssue] = useState(false); const [issueDetailTitleOverride, setIssueDetailTitleOverride] = useState(null); const [refreshIntervalMs, setRefreshIntervalMs] = useState( @@ -327,9 +335,23 @@ function LayoutContent({ children }: { children: ReactNode }) { function handleResolveIssue(issueID: number) { setResolveIssueID(issueID); setResolveIssueError(""); + setResolveRequestRecovery((current) => + current.issueID === issueID + ? current + : { issueID, body: "", requestCreated: false }, + ); modal.openModal(modalIDs.resolveIssue); } + function handleResolvedRequestCreated(body: string) { + setResolveRequestRecovery({ issueID: resolveIssueID, body, requestCreated: true }); + } + + function handleResolvedIssueSuccess() { + setResolveRequestRecovery({ issueID: null, body: "", requestCreated: false }); + modal.closeModal(); + } + async function handleMoveResolvedIssueReady() { if (resolveIssueID === null) return; setIsMovingResolvedIssue(true); @@ -436,6 +458,10 @@ function LayoutContent({ children }: { children: ReactNode }) { rejectRequestRecovery, resolveIssue: issues.find((issue) => issue.id === resolveIssueID) ?? null, resolveIssueError, + resolveRequestRecovery: { + body: resolveRequestRecovery.body, + requestCreated: resolveRequestRecovery.requestCreated, + }, summary, title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle( issueScope, @@ -451,6 +477,8 @@ function LayoutContent({ children }: { children: ReactNode }) { onConfirmDeleteProject: handleConfirmDeleteProject, onMoveRejectedIssueReady: handleMoveRejectedIssueReady, onMoveResolvedIssueReady: handleMoveResolvedIssueReady, + onResolvedRequestCreated: handleResolvedRequestCreated, + onResolvedIssueSuccess: handleResolvedIssueSuccess, }; return ( @@ -606,9 +634,12 @@ function LayoutModalContent({ shellData }: { shellData: LayoutShellData }) { isMovingIssue={shellData.isMovingResolvedIssue} issueID={shellData.resolveIssue.id} issueTitle={shellData.resolveIssue.title} + initialBody={shellData.resolveRequestRecovery.body} + initialRequestCreated={shellData.resolveRequestRecovery.requestCreated} onCancel={shellData.onCloseModal} onMoveIssueReady={shellData.onMoveResolvedIssueReady} - onSuccess={shellData.onCloseModal} + onRequestCreated={shellData.onResolvedRequestCreated} + onSuccess={shellData.onResolvedIssueSuccess} /> ); } diff --git a/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx b/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx index bc48d78a..953b2873 100644 --- a/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/change-request-dialog/index.tsx @@ -20,6 +20,7 @@ export function ChangeRequestDialog({ context, onCancel, onMoveIssueReady, + onRequestCreated, onSuccess, shortcuts = [], variant, @@ -34,6 +35,7 @@ export function ChangeRequestDialog({ context?: ReactNode; onCancel: () => void; onMoveIssueReady: () => Promise; + onRequestCreated?: (body: string) => void; onSuccess: () => void; shortcuts?: readonly ChangeRequestShortcut[]; variant: ChangeRequestDialogVariant; @@ -80,6 +82,7 @@ export function ChangeRequestDialog({ body: trimmedBody, }, { silent: true }); setHasCreatedRequest(true); + onRequestCreated?.(trimmedBody); } setIsCreatingRequest(false); await onMoveIssueReady(); diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx index 8d333888..0eb2d67c 100644 --- a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx @@ -89,11 +89,12 @@ describe("ResolveIssueDialog", () => { expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); }); - it("does not create a second request when status recovery follows a shortcut", async () => { + it("does not create a second request after status failure and dialog reopen", async () => { const user = userEvent.setup(); const onMoveIssueReady = vi.fn() .mockRejectedValueOnce(new Error("status unavailable")) .mockResolvedValueOnce(undefined); + const onRequestCreated = vi.fn(); vi.mocked(fetchComments).mockResolvedValue({ data: [{ id: 7, @@ -107,12 +108,13 @@ describe("ResolveIssueDialog", () => { } satisfies CommentListResponse); vi.mocked(createChangeRequest).mockResolvedValue({} as never); - render( + const firstDialog = render( , ); @@ -122,7 +124,30 @@ describe("ResolveIssueDialog", () => { expect(await screen.findByText("status unavailable")).toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "Change request" })).toHaveValue("Retry"); expect(screen.getByRole("textbox", { name: "Change request" })).toHaveAttribute("readonly"); + expect(onRequestCreated).toHaveBeenCalledWith("Retry"); + firstDialog.unmount(); + render( + ({ + id: 7, + issueId: 42, + author: "runner", + type: "blocker", + body: "Retry deployment.", + createdAt: "2026-08-14T00:00:00.000Z", + })} + onCancel={vi.fn()} + onMoveIssueReady={onMoveIssueReady} + onSuccess={vi.fn()} + />, + ); + + await screen.findByText("Retry deployment."); await user.click(screen.getByRole("button", { name: "Continue with comment" })); expect(createChangeRequest).toHaveBeenCalledOnce(); expect(onMoveIssueReady).toHaveBeenCalledTimes(2); diff --git a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx index 4b5626d8..00db7cd0 100644 --- a/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx @@ -17,18 +17,24 @@ export function ResolveIssueDialog({ isMovingIssue, issueID, issueTitle, + initialBody = "", + initialRequestCreated = false, loadLatestBlocker = fetchLatestBlockerComment, onCancel, onMoveIssueReady, + onRequestCreated, onSuccess, }: { error?: string; isMovingIssue?: boolean; issueID: number; issueTitle: string; + initialBody?: string; + initialRequestCreated?: boolean; loadLatestBlocker?: (issueID: number) => Promise; onCancel: () => void; onMoveIssueReady: () => Promise; + onRequestCreated?: (body: string) => void; onSuccess: () => void; }) { const { t } = useTranslation(); @@ -86,8 +92,11 @@ export function ResolveIssueDialog({ isSubmissionDisabled={blockerState.kind !== "ready"} issueID={issueID} issueTitle={issueTitle} + initialBody={initialBody} + initialRequestCreated={initialRequestCreated} onCancel={onCancel} onMoveIssueReady={onMoveIssueReady} + onRequestCreated={onRequestCreated} onSuccess={onSuccess} shortcuts={builtInChangeRequestShortcuts.continue} variant="continue" diff --git a/cmd/web/frontend/src/stories/fixtures.ts b/cmd/web/frontend/src/stories/fixtures.ts index 06230a1e..14d1be1e 100644 --- a/cmd/web/frontend/src/stories/fixtures.ts +++ b/cmd/web/frontend/src/stories/fixtures.ts @@ -124,6 +124,7 @@ export const storyShellData: LayoutShellData = { rejectRequestRecovery: { body: "", requestCreated: false }, resolveIssue: null, resolveIssueError: "", + resolveRequestRecovery: { body: "", requestCreated: false }, summary: storySummary, title: "Tasq", onIssueDetailTitleChange: noop, @@ -135,4 +136,6 @@ export const storyShellData: LayoutShellData = { onConfirmDeleteProject: asyncNoop, onMoveRejectedIssueReady: asyncNoop, onMoveResolvedIssueReady: asyncNoop, + onResolvedRequestCreated: noop, + onResolvedIssueSuccess: noop, };