mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Resolve source recovery actions
This commit is contained in:
1 parent
ed2c6382e3
commit
d604d323fd
11 files changed
+458
-23
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -202,6 +202,54 @@ export const issueRecoveryActionReadModelSchema = z.object({
|
||||
|
||||
export type IssueRecoveryActionReadModel = z.infer<typeof issueRecoveryActionReadModelSchema>;
|
||||
|
||||
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<typeof resolveIssueRecoveryActionSchema>;
|
||||
|
||||
const issueRequestDepthInputSchema = z
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IssueRecoveryActionStatus, "resolved" | "cancelled">;
|
||||
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<IssueRecoveryAction | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<Issue>(`/companies/${companyId}/issues`, data),
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
|
||||
resolveRecoveryAction: (
|
||||
id: string,
|
||||
data: {
|
||||
actionId?: string;
|
||||
outcome: IssueRecoveryActionOutcome;
|
||||
sourceIssueStatus?: "done" | "in_review" | "blocked" | null;
|
||||
resolutionNote?: string | null;
|
||||
},
|
||||
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
|
||||
previewTreeControl: (id: string, data: PreviewIssueTreeControl) =>
|
||||
api.post<IssueTreeControlPreview>(`/issues/${id}/tree-control/preview`, data),
|
||||
createTreeHold: (id: string, data: CreateIssueTreeHold) =>
|
||||
|
||||
@@ -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(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={() => {}} />,
|
||||
);
|
||||
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(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} canCancelRecovery />,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>([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(
|
||||
|
||||
Reference in new issue
Block a user