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 00000000..d8a131a0 Binary files /dev/null and b/cmd/web/frontend/docs/screenshots/resolve-blocked-card.png differ 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..cda43215 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,9 @@ export type LayoutShellData = { rejectIssue: IssueSummary | null; rejectIssueError: string; 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; @@ -92,6 +98,9 @@ export type LayoutShellData = { onDeleteProject: () => void; onConfirmDeleteProject: () => Promise; onMoveRejectedIssueReady: () => Promise; + onMoveResolvedIssueReady: () => Promise; + onResolvedRequestCreated: (body: string) => void; + onResolvedIssueSuccess: () => void; }; const layoutDataContext = createContext(null); @@ -132,6 +141,14 @@ 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 [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( defaultRefreshIntervalMs, @@ -315,6 +332,46 @@ 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); + 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 +397,7 @@ function LayoutContent({ children }: { children: ReactNode }) { setAddIssueError(""); setDeleteProjectError(""); setRejectIssueError(""); + setResolveIssueError(""); modal.closeModal(); } @@ -375,6 +433,7 @@ function LayoutContent({ children }: { children: ReactNode }) { onAddIssue: handleAddIssue, onRejectIssue: handleRejectIssue, onRejectShortcut: handleRejectShortcut, + onResolveIssue: handleResolveIssue, onStatusChange: handleStatusChange, } : null; @@ -388,6 +447,7 @@ function LayoutContent({ children }: { children: ReactNode }) { isIssueDetailPage, isDeletingProject, isMovingRejectedIssue, + isMovingResolvedIssue, isProjectIssueScope, issues, layoutData, @@ -396,6 +456,12 @@ function LayoutContent({ children }: { children: ReactNode }) { rejectIssue: issues.find((issue) => issue.id === rejectIssueID) ?? null, rejectIssueError, rejectRequestRecovery, + resolveIssue: issues.find((issue) => issue.id === resolveIssueID) ?? null, + resolveIssueError, + resolveRequestRecovery: { + body: resolveRequestRecovery.body, + requestCreated: resolveRequestRecovery.requestCreated, + }, summary, title: issueDetailTitleOverride ?? issueDetailTitle ?? issueScopeTitle( issueScope, @@ -410,6 +476,9 @@ function LayoutContent({ children }: { children: ReactNode }) { onDeleteProject: handleDeleteProject, onConfirmDeleteProject: handleConfirmDeleteProject, onMoveRejectedIssueReady: handleMoveRejectedIssueReady, + onMoveResolvedIssueReady: handleMoveResolvedIssueReady, + onResolvedRequestCreated: handleResolvedRequestCreated, + onResolvedIssueSuccess: handleResolvedIssueSuccess, }; return ( @@ -558,6 +627,23 @@ 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..856138d8 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.stories.tsx @@ -0,0 +1,27 @@ +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", + 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, + }, +} 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..0eb2d67c --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.test.tsx @@ -0,0 +1,155 @@ +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 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, + 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); + + const firstDialog = 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"); + 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 new file mode 100644 index 00000000..00db7cd0 --- /dev/null +++ b/cmd/web/frontend/src/features/issues/components/resolve-issue-dialog/index.tsx @@ -0,0 +1,105 @@ +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, + 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(); + const [blockerState, setBlockerState] = useState({ kind: "loading" }); + + const loadBlocker = useCallback(async () => { + setBlockerState({ kind: "loading" }); + try { + const comment = await loadLatestBlocker(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, loadLatestBlocker, 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..14d1be1e 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,9 @@ export const storyShellData: LayoutShellData = { rejectIssue: null, rejectIssueError: "", rejectRequestRecovery: { body: "", requestCreated: false }, + resolveIssue: null, + resolveIssueError: "", + resolveRequestRecovery: { body: "", requestCreated: false }, summary: storySummary, title: "Tasq", onIssueDetailTitleChange: noop, @@ -130,4 +135,7 @@ export const storyShellData: LayoutShellData = { onDeleteProject: noop, onConfirmDeleteProject: asyncNoop, onMoveRejectedIssueReady: asyncNoop, + onMoveResolvedIssueReady: asyncNoop, + onResolvedRequestCreated: noop, + onResolvedIssueSuccess: noop, };