From d604d323fd8f249c967841da26bc342e59403d96 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 9 May 2026 21:35:46 -0500 Subject: [PATCH] Resolve source recovery actions --- packages/shared/src/index.ts | 2 + packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/issue.ts | 48 ++++++ .../__tests__/issue-recovery-actions.test.ts | 152 +++++++++++++++++- server/src/routes/issues.ts | 109 +++++++++++++ server/src/services/issue-recovery-actions.ts | 40 +++++ ui/src/api/issues.test.ts | 20 +++ ui/src/api/issues.ts | 16 ++ .../IssueRecoveryActionCard.test.tsx | 40 ++++- ui/src/components/IssueRecoveryActionCard.tsx | 8 +- ui/src/pages/IssueDetail.tsx | 44 ++++- 11 files changed, 458 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f4699968515..84c3aff6e06 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -761,6 +761,7 @@ export { updateIssueSchema, issueExecutionPolicySchema, issueExecutionStateSchema, + resolveIssueRecoveryActionSchema, issueReviewRequestSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, @@ -820,6 +821,7 @@ export { type CreateChildIssue, type CreateIssueLabel, type UpdateIssue, + type ResolveIssueRecoveryAction, type CheckoutIssue, type AddIssueComment, type CreateIssueThreadInteraction, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3f9b40fd62c..1b6c1561877 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -157,6 +157,7 @@ export { issueExecutionPolicySchema, issueExecutionStateSchema, issueRecoveryActionReadModelSchema, + resolveIssueRecoveryActionSchema, issueReviewRequestSchema, issueExecutionWorkspaceSettingsSchema, checkoutIssueSchema, @@ -200,6 +201,7 @@ export { type UpdateIssue, type IssueExecutionWorkspaceSettings, type IssueRecoveryActionReadModel, + type ResolveIssueRecoveryAction, type CheckoutIssue, type AddIssueComment, type CreateIssueThreadInteraction, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 4ab3206f4b6..2df73bf645c 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -202,6 +202,54 @@ export const issueRecoveryActionReadModelSchema = z.object({ export type IssueRecoveryActionReadModel = z.infer; +export const resolveIssueRecoveryActionSchema = z.object({ + actionId: z.string().uuid().optional(), + outcome: z.enum(ISSUE_RECOVERY_ACTION_OUTCOMES), + sourceIssueStatus: z.enum(["done", "in_review", "blocked"]).optional().nullable(), + resolutionNote: multilineTextSchema.optional().nullable(), +}).strict().superRefine((value, ctx) => { + if (value.outcome === "delegated" || value.outcome === "escalated") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This recovery outcome is not supported by the source-scoped resolution endpoint", + path: ["outcome"], + }); + return; + } + + if (value.outcome === "restored") { + if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Restored recovery actions must move the source issue to done or in_review", + path: ["sourceIssueStatus"], + }); + } + return; + } + + if (value.outcome === "blocked") { + if (value.sourceIssueStatus !== "blocked") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Blocked recovery actions must move the source issue to blocked", + path: ["sourceIssueStatus"], + }); + } + return; + } + + if (value.sourceIssueStatus !== undefined && value.sourceIssueStatus !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "This recovery outcome does not change the source issue status", + path: ["sourceIssueStatus"], + }); + } +}); + +export type ResolveIssueRecoveryAction = z.infer; + const issueRequestDepthInputSchema = z .number() .int() diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 2fe32cfee2e..22f5c86c56d 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -16,6 +16,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; import { issueRecoveryActionService } from "../services/issue-recovery-actions.js"; import { recoveryService } from "../services/recovery/service.js"; @@ -102,6 +103,18 @@ describeEmbeddedPostgres("issue recovery actions", () => { return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! }; } + function createApp(actor: any = { type: "board", source: "local_implicit" }) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + it("upserts one active source-scoped action per issue and keeps company scoping explicit", async () => { const { companyId, managerId, sourceIssueId } = await seedCompany(); const svc = issueRecoveryActionService(db); @@ -376,12 +389,7 @@ describeEmbeddedPostgres("issue recovery actions", () => { nextAction: "Choose a valid issue disposition.", wakePolicy: { type: "wake_owner" }, }); - const app = express(); - app.use((req, _res, next) => { - (req as any).actor = { type: "board", source: "local_implicit" }; - next(); - }); - app.use("/api", issueRoutes(db, {} as any)); + const app = createApp(); const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); expect(detail.body.activeRecoveryAction).toMatchObject({ @@ -395,4 +403,136 @@ describeEmbeddedPostgres("issue recovery actions", () => { expect(list.body.active).toMatchObject({ id: action.id }); expect(list.body.actions).toHaveLength(1); }); + + it("resolves an active recovery action and removes it from active projections", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "missing_disposition", + ownerType: "agent", + ownerAgentId: managerId, + cause: "successful_run_missing_issue_disposition", + fingerprint: "missing-disposition:fingerprint", + evidence: { sourceRunId: "run-1" }, + nextAction: "Choose a valid issue disposition.", + wakePolicy: { type: "wake_owner" }, + }); + const app = createApp(); + + const resolved = await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + resolutionNote: "Operator confirmed the source issue is complete.", + }) + .expect(200); + + expect(resolved.body.issue).toMatchObject({ + id: sourceIssueId, + status: "done", + activeRecoveryAction: null, + }); + expect(resolved.body.recoveryAction).toMatchObject({ + id: action.id, + status: "resolved", + outcome: "restored", + resolutionNote: "Operator confirmed the source issue is complete.", + }); + expect(resolved.body.recoveryAction.resolvedAt).toBeTruthy(); + expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull(); + + const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200); + expect(detail.body.activeRecoveryAction).toBeNull(); + + const activityRows = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, sourceIssueId)); + expect(activityRows.map((row) => row.action)).toEqual( + expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]), + ); + }); + + it("records false positives without cancelling the source issue", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "issue_graph_liveness", + ownerType: "agent", + ownerAgentId: managerId, + cause: "issue_graph_liveness", + fingerprint: "graph-liveness:fingerprint", + evidence: { latestIssueStatus: "in_progress" }, + nextAction: "Confirm whether the issue is actually stranded.", + wakePolicy: { type: "manual" }, + }); + const app = createApp(); + + await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "false_positive", + resolutionNote: "The source issue still has a live execution path.", + }) + .expect(200); + + const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId)); + expect(sourceIssue?.status).toBe("in_progress"); + + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow).toMatchObject({ + status: "resolved", + outcome: "false_positive", + resolutionNote: "The source issue still has a live execution path.", + }); + }); + + it("enforces company scope when resolving recovery actions", async () => { + const { companyId, managerId, sourceIssueId } = await seedCompany(); + const recoveryActionSvc = issueRecoveryActionService(db); + const action = await recoveryActionSvc.upsertSourceScoped({ + companyId, + sourceIssueId, + kind: "missing_disposition", + ownerType: "agent", + ownerAgentId: managerId, + cause: "successful_run_missing_issue_disposition", + fingerprint: "missing-disposition:fingerprint", + evidence: { sourceRunId: "run-1" }, + nextAction: "Choose a valid issue disposition.", + wakePolicy: { type: "wake_owner" }, + }); + const app = createApp({ + type: "agent", + agentId: randomUUID(), + companyId: randomUUID(), + runId: randomUUID(), + source: "agent_jwt", + }); + + await request(app) + .post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`) + .send({ + actionId: action.id, + outcome: "restored", + sourceIssueStatus: "done", + }) + .expect(403); + + const [actionRow] = await db + .select() + .from(issueRecoveryActions) + .where(eq(issueRecoveryActions.id, action.id)); + expect(actionRow?.status).toBe("active"); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 017b8d05325..efea899ccbc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -18,6 +18,7 @@ import { createChildIssueSchema, createIssueSchema, resolveCreateIssueStatusDefault, + resolveIssueRecoveryActionSchema, feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, @@ -1754,6 +1755,114 @@ export function issueRoutes( }); }); + router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), async (req, res) => { + const id = req.params.id as string; + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; + + const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body; + if (outcome === "false_positive" || outcome === "cancelled") { + assertBoard(req); + } + + const actor = getActorInfo(req); + const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {}; + await assertAgentInReviewReviewPath({ + existing, + updateFields, + actorType: req.actor.type, + }); + + const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved"; + const result = await db.transaction(async (tx) => { + let issue = existing; + if (sourceIssueStatus) { + const updatedIssue = await svc.update( + id, + { + status: sourceIssueStatus, + actorAgentId: actor.agentId ?? null, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }, + tx, + ); + if (!updatedIssue) throw notFound("Issue not found"); + issue = updatedIssue; + } + + const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue( + { + companyId: existing.companyId, + sourceIssueId: existing.id, + actionId: actionId ?? null, + status: actionStatus, + outcome, + resolutionNote: resolutionNote ?? null, + }, + tx, + ); + if (!recoveryAction) throw notFound("Active recovery action not found"); + + return { issue, recoveryAction }; + }); + + await routinesSvc.syncRunStatusForIssue(result.issue.id); + + if (sourceIssueStatus && existing.status !== result.issue.status) { + await logActivity(db, { + companyId: result.issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.updated", + entityType: "issue", + entityId: result.issue.id, + details: { + identifier: result.issue.identifier, + status: result.issue.status, + source: "recovery_action_resolution", + recoveryActionId: result.recoveryAction.id, + _previous: { + status: existing.status, + }, + }, + }); + } + + await logActivity(db, { + companyId: result.issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.recovery_action_resolved", + entityType: "issue", + entityId: result.issue.id, + details: { + identifier: result.issue.identifier, + recoveryActionId: result.recoveryAction.id, + recoveryActionStatus: result.recoveryAction.status, + outcome: result.recoveryAction.outcome, + sourceIssueStatus: sourceIssueStatus ?? null, + resolutionNote: result.recoveryAction.resolutionNote, + }, + }); + + res.json({ + issue: { + ...result.issue, + activeRecoveryAction: null, + }, + recoveryAction: result.recoveryAction, + }); + }); + router.get("/issues/:id/work-products", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); diff --git a/server/src/services/issue-recovery-actions.ts b/server/src/services/issue-recovery-actions.ts index c34706c8eb3..067f47bdf1b 100644 --- a/server/src/services/issue-recovery-actions.ts +++ b/server/src/services/issue-recovery-actions.ts @@ -5,6 +5,7 @@ import type { IssueRecoveryAction, IssueRecoveryActionKind, IssueRecoveryActionOwnerType, + IssueRecoveryActionOutcome, IssueRecoveryActionStatus, } from "@paperclipai/shared"; @@ -33,6 +34,15 @@ export type UpsertIssueRecoveryActionInput = { lastAttemptAt?: Date | null; }; +export type ResolveIssueRecoveryActionInput = { + companyId: string; + sourceIssueId: string; + actionId?: string | null; + status: Extract; + outcome: IssueRecoveryActionOutcome; + resolutionNote?: string | null; +}; + function toReadModel(row: IssueRecoveryActionRow): IssueRecoveryAction { return { id: row.id, @@ -189,9 +199,39 @@ export function issueRecoveryActionService(db: Db) { } } + async function resolveActiveForIssue( + input: ResolveIssueRecoveryActionInput, + dbOrTx: any = db, + ): Promise { + const now = new Date(); + const predicates = [ + eq(issueRecoveryActions.companyId, input.companyId), + eq(issueRecoveryActions.sourceIssueId, input.sourceIssueId), + inArray(issueRecoveryActions.status, [...ACTIVE_RECOVERY_ACTION_STATUSES]), + ]; + if (input.actionId) { + predicates.push(eq(issueRecoveryActions.id, input.actionId)); + } + + const [updated] = await dbOrTx + .update(issueRecoveryActions) + .set({ + status: input.status, + outcome: input.outcome, + resolutionNote: input.resolutionNote ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and(...predicates)) + .returning(); + + return updated ? toReadModel(updated) : null; + } + return { getActiveForIssue, listActiveForIssues, + resolveActiveForIssue, upsertSourceScoped, }; } diff --git a/ui/src/api/issues.test.ts b/ui/src/api/issues.test.ts index 107f12b1a36..4013f67d792 100644 --- a/ui/src/api/issues.test.ts +++ b/ui/src/api/issues.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockApi = vi.hoisted(() => ({ get: vi.fn(), + post: vi.fn(), })); vi.mock("./client", () => ({ @@ -13,7 +14,9 @@ import { issuesApi } from "./issues"; describe("issuesApi.list", () => { beforeEach(() => { mockApi.get.mockReset(); + mockApi.post.mockReset(); mockApi.get.mockResolvedValue([]); + mockApi.post.mockResolvedValue({}); }); it("passes parentId through to the company issues endpoint", async () => { @@ -47,4 +50,21 @@ describe("issuesApi.list", () => { "/companies/company-1/issues?limit=500&offset=1500", ); }); + + it("posts recovery action resolution to the source issue endpoint", async () => { + await issuesApi.resolveRecoveryAction("issue-1", { + actionId: "00000000-0000-0000-0000-0000000000aa", + outcome: "restored", + sourceIssueStatus: "done", + }); + + expect(mockApi.post).toHaveBeenCalledWith( + "/issues/issue-1/recovery-actions/resolve", + { + actionId: "00000000-0000-0000-0000-0000000000aa", + outcome: "restored", + sourceIssueStatus: "done", + }, + ); + }); }); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 10427c50eb1..f404d5e48ec 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -12,6 +12,8 @@ import type { IssueComment, IssueDocument, IssueLabel, + IssueRecoveryAction, + IssueRecoveryActionOutcome, IssueRetryNowResponse, IssueThreadInteraction, IssueTreeControlPreview, @@ -27,6 +29,11 @@ export type IssueUpdateResponse = Issue & { comment?: IssueComment | null; }; +export type ResolveRecoveryActionResponse = { + issue: Issue; + recoveryAction: IssueRecoveryAction; +}; + export const issuesApi = { list: ( companyId: string, @@ -94,6 +101,15 @@ export const issuesApi = { api.post(`/companies/${companyId}/issues`, data), update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + resolveRecoveryAction: ( + id: string, + data: { + actionId?: string; + outcome: IssueRecoveryActionOutcome; + sourceIssueStatus?: "done" | "in_review" | "blocked" | null; + resolutionNote?: string | null; + }, + ) => api.post(`/issues/${id}/recovery-actions/resolve`, data), previewTreeControl: (id: string, data: PreviewIssueTreeControl) => api.post(`/issues/${id}/tree-control/preview`, data), createTreeHold: (id: string, data: CreateIssueTreeHold) => diff --git a/ui/src/components/IssueRecoveryActionCard.test.tsx b/ui/src/components/IssueRecoveryActionCard.test.tsx index 19ef6f890a5..9a81523944c 100644 --- a/ui/src/components/IssueRecoveryActionCard.test.tsx +++ b/ui/src/components/IssueRecoveryActionCard.test.tsx @@ -36,6 +36,13 @@ function render(element: ReactElement) { return container; } +function click(element: Element | null) { + if (!element) throw new Error("Expected element to exist"); + act(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + const ownerAgent: Agent = { id: "11111111-1111-1111-1111-111111111111", companyId: "company-1", @@ -157,10 +164,39 @@ describe("IssueRecoveryActionCard", () => { expect(node.textContent).toContain("Resolved as restored"); }); - it("hides false_positive option unless canCancelRecovery is set", () => { + it("calls resolve with done and does not offer delegated recovery", () => { + const onResolve = vi.fn(); const node = render( + , + ); + click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); + + expect(document.body.textContent).toContain("Mark issue done"); + expect(document.body.textContent).not.toContain("Delegate follow-up issue"); + click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null); + + expect(onResolve).toHaveBeenCalledWith("done"); + }); + + it("hides false_positive option unless canCancelRecovery is set", () => { + const first = render( {}} />, ); - expect(node.querySelector("[data-testid='recovery-action-resolve-trigger']")).not.toBeNull(); + click(first.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.textContent).not.toContain("Mark false positive"); + + act(() => root?.unmount()); + root = null; + container?.remove(); + container = null; + + const onResolve = vi.fn(); + const second = render( + , + ); + click(second.querySelector("[data-testid='recovery-action-resolve-trigger']")); + expect(document.body.textContent).toContain("Mark false positive"); + click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark false positive")) ?? null); + expect(onResolve).toHaveBeenCalledWith("false_positive"); }); }); diff --git a/ui/src/components/IssueRecoveryActionCard.tsx b/ui/src/components/IssueRecoveryActionCard.tsx index 384c3597cdb..cfe19cfa35a 100644 --- a/ui/src/components/IssueRecoveryActionCard.tsx +++ b/ui/src/components/IssueRecoveryActionCard.tsx @@ -28,7 +28,6 @@ export type RecoveryResolveOutcome = | "done" | "in_review" | "blocked" - | "delegated" | "false_positive"; export interface IssueRecoveryActionCardProps { @@ -317,12 +316,7 @@ const RESOLVE_OPTIONS: Array<{ { outcome: "blocked", label: "Mark blocked…", - description: "Block on a real follow-up issue.", - }, - { - outcome: "delegated", - label: "Delegate follow-up issue…", - description: "Create a child issue and block this one on it.", + description: "Resolve recovery by recording that the source issue is blocked.", }, { outcome: "false_positive", diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 33ff7dc5466..52a3a329b81 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -148,6 +148,7 @@ import { type Issue, type IssueAttachment, type IssueComment, + type IssueRecoveryActionOutcome, type IssueWorkMode, type IssueThreadInteraction, type RequestConfirmationInteraction, @@ -1725,6 +1726,34 @@ export function IssueDetail() { } }, }); + const resolveRecoveryAction = useMutation({ + mutationFn: (data: { + actionId?: string; + outcome: IssueRecoveryActionOutcome; + sourceIssueStatus?: "done" | "in_review" | "blocked" | null; + resolutionNote?: string | null; + }) => issuesApi.resolveRecoveryAction(issueId!, data), + onSuccess: ({ issue: nextIssue }) => { + const issueRefs = new Set([issueId!, nextIssue.id]); + if (nextIssue.identifier) issueRefs.add(nextIssue.identifier); + mergeIssueResponseIntoCaches(issueRefs, nextIssue); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }); + invalidateIssueCollections(); + }, + onError: (err) => { + pushToast({ + title: "Recovery resolution failed", + body: err instanceof Error ? err.message : "Unable to resolve recovery action", + tone: "error", + }); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }); + if (selectedCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + } + }, + }); const executeTreeControl = useMutation({ mutationFn: async () => { if (treeControlMode === "resume") { @@ -2927,25 +2956,24 @@ export function IssueDetail() { }, [updateIssue.mutateAsync]); const handleResolveRecoveryAction = useCallback( (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => { + const actionId = issue?.activeRecoveryAction?.id; + if (!actionId) return; switch (outcome) { case "done": - void updateIssue.mutateAsync({ status: "done" }); + void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" }); return; case "in_review": - void updateIssue.mutateAsync({ status: "in_review" }); + void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" }); return; case "blocked": - void updateIssue.mutateAsync({ status: "blocked" }); - return; - case "delegated": - if (issue) openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId)); + void resolveRecoveryAction.mutateAsync({ actionId, outcome: "blocked", sourceIssueStatus: "blocked" }); return; case "false_positive": - void updateIssue.mutateAsync({ status: "cancelled" }); + void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive" }); return; } }, - [currentUserId, issue, openNewIssue, updateIssue.mutateAsync], + [issue, resolveRecoveryAction.mutateAsync], ); const treePreviewAffectedIssues = useMemo(