@checkstack/incident-backend 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type { AddIncidentUpdateInput } from "@checkstack/incident-common";
4
+ import { createIncidentAddUpdateTool } from "./incident-add-update";
5
+
6
+ const principal: AuthUser = {
7
+ type: "user",
8
+ id: "u1",
9
+ accessRules: ["incident.incident.manage"],
10
+ };
11
+
12
+ const input: AddIncidentUpdateInput = {
13
+ incidentId: "inc1",
14
+ message: "We identified the root cause",
15
+ statusChange: "identified",
16
+ };
17
+
18
+ function fakeRpcClient({
19
+ addUpdate,
20
+ }: {
21
+ addUpdate: ReturnType<typeof mock>;
22
+ }): RpcClient {
23
+ return {
24
+ forPlugin: () => ({ addUpdate }),
25
+ } as unknown as RpcClient;
26
+ }
27
+
28
+ describe("incident.addUpdate tool", () => {
29
+ test("declares mutate effect + the manage rule", () => {
30
+ const tool = createIncidentAddUpdateTool();
31
+ expect(tool.name).toBe("incident.addUpdate");
32
+ expect(tool.effect).toBe("mutate");
33
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
34
+ expect(typeof tool.dryRun).toBe("function");
35
+ });
36
+
37
+ test("dryRun returns a payload and NEVER posts the update", async () => {
38
+ const addUpdate = mock(() => Promise.resolve({}));
39
+ const rpcClient = fakeRpcClient({ addUpdate });
40
+ const tool = createIncidentAddUpdateTool();
41
+ const preview = await tool.dryRun!({ input, principal, rpcClient });
42
+ expect(addUpdate).not.toHaveBeenCalled();
43
+ expect(preview.summary).toContain("identified");
44
+ expect(preview.payload).toEqual(input);
45
+ });
46
+
47
+ test("execute (apply) posts via addUpdate", async () => {
48
+ const created = { id: "upd1", ...input, createdAt: new Date() };
49
+ const addUpdate = mock(() => Promise.resolve(created));
50
+ const rpcClient = fakeRpcClient({ addUpdate });
51
+ const tool = createIncidentAddUpdateTool();
52
+ const result = await tool.execute({ input, principal, rpcClient });
53
+ expect(addUpdate).toHaveBeenCalledWith(input);
54
+ expect(result.update).toEqual(created);
55
+ });
56
+ });
@@ -0,0 +1,66 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ IncidentApi,
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ AddIncidentUpdateInputSchema,
8
+ type AddIncidentUpdateInput,
9
+ type IncidentUpdate,
10
+ } from "@checkstack/incident-common";
11
+ import type { AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /** Output returned once a human applies the update post (the new update). */
15
+ export interface IncidentAddUpdateApplyResult {
16
+ update: IncidentUpdate;
17
+ }
18
+
19
+ /**
20
+ * `incident.addUpdate` - post a status update (timeline entry) to an incident,
21
+ * optionally changing its status.
22
+ *
23
+ * `effect: "mutate"` - appending an update is a non-destructive change, so it
24
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
25
+ * returns the captured payload for human review WITHOUT mutating; `execute`
26
+ * (reached only via `apply`) posts the update. The underlying RPC uses the
27
+ * USER-SCOPED client passed at call time, so handler-side authorization is
28
+ * enforced exactly as a direct UI/RPC call.
29
+ */
30
+ export function createIncidentAddUpdateTool(): RegisteredAiTool<
31
+ AddIncidentUpdateInput,
32
+ IncidentAddUpdateApplyResult
33
+ > {
34
+ const dryRun = async ({
35
+ input,
36
+ }: {
37
+ input: AddIncidentUpdateInput;
38
+ principal: AuthUser;
39
+ rpcClient: RpcClient;
40
+ }): Promise<AiProposalPreview<AddIncidentUpdateInput>> => {
41
+ const statusNote = input.statusChange
42
+ ? ` and set status to "${input.statusChange}"`
43
+ : "";
44
+ return {
45
+ summary: `Post an update to incident ${input.incidentId}${statusNote}.`,
46
+ payload: input,
47
+ };
48
+ };
49
+
50
+ return {
51
+ name: "incident.addUpdate",
52
+ description:
53
+ "Post a status update (timeline entry) to an incident, optionally changing its status (investigating/identified/fixing/monitoring/resolved). Never posts directly; a person must approve unless the conversation is in auto mode. Find the incidentId with the incident read tools first.",
54
+ effect: "mutate",
55
+ input: AddIncidentUpdateInputSchema,
56
+ requiredAccessRules: [
57
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
58
+ ],
59
+ dryRun,
60
+ async execute({ input, rpcClient }) {
61
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
62
+ const update = await incidentClient.addUpdate(input);
63
+ return { update };
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type {
4
+ CreateIncidentInput,
5
+ IncidentWithSystems,
6
+ } from "@checkstack/incident-common";
7
+ import { createIncidentCreateTool } from "./incident-create";
8
+
9
+ const principal: AuthUser = {
10
+ type: "user",
11
+ id: "u1",
12
+ accessRules: ["incident.incident.manage"],
13
+ };
14
+
15
+ const input: CreateIncidentInput = {
16
+ title: "Checkout is down",
17
+ severity: "critical",
18
+ suppressNotifications: false,
19
+ systemIds: ["sys1", "sys2"],
20
+ };
21
+
22
+ function fakeRpcClient({
23
+ createIncident,
24
+ }: {
25
+ createIncident: ReturnType<typeof mock>;
26
+ }): RpcClient {
27
+ return {
28
+ forPlugin: () => ({ createIncident }),
29
+ } as unknown as RpcClient;
30
+ }
31
+
32
+ describe("incident.create tool", () => {
33
+ test("declares mutate effect + the manage rule", () => {
34
+ const tool = createIncidentCreateTool();
35
+ expect(tool.name).toBe("incident.create");
36
+ expect(tool.effect).toBe("mutate");
37
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
38
+ expect(typeof tool.dryRun).toBe("function");
39
+ });
40
+
41
+ test("dryRun returns a payload and NEVER creates", async () => {
42
+ const createIncident = mock(() => Promise.resolve({}));
43
+ const rpcClient = fakeRpcClient({ createIncident });
44
+ const tool = createIncidentCreateTool();
45
+ const preview = await tool.dryRun!({ input, principal, rpcClient });
46
+ expect(createIncident).not.toHaveBeenCalled();
47
+ expect(preview.summary).toContain("Checkout is down");
48
+ expect(preview.payload).toEqual(input);
49
+ });
50
+
51
+ test("execute (apply) creates via createIncident", async () => {
52
+ const created: IncidentWithSystems = {
53
+ id: "inc1",
54
+ title: input.title,
55
+ description: input.description,
56
+ status: "investigating",
57
+ severity: input.severity,
58
+ suppressNotifications: input.suppressNotifications,
59
+ systemIds: input.systemIds,
60
+ createdAt: new Date(),
61
+ updatedAt: new Date(),
62
+ };
63
+ const createIncident = mock(() => Promise.resolve(created));
64
+ const rpcClient = fakeRpcClient({ createIncident });
65
+ const tool = createIncidentCreateTool();
66
+ const result = await tool.execute({ input, principal, rpcClient });
67
+ expect(createIncident).toHaveBeenCalledWith(input);
68
+ expect(result.incident).toEqual(created);
69
+ });
70
+ });
@@ -0,0 +1,65 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ IncidentApi,
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ CreateIncidentInputSchema,
8
+ type CreateIncidentInput,
9
+ type IncidentWithSystems,
10
+ } from "@checkstack/incident-common";
11
+ import type { AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /** Output returned once a human applies the create (the created incident). */
15
+ export interface IncidentCreateApplyResult {
16
+ incident: IncidentWithSystems;
17
+ }
18
+
19
+ /**
20
+ * `incident.create` - open a new incident affecting one or more systems.
21
+ *
22
+ * `effect: "mutate"` - creating an incident is a non-destructive create, so it
23
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
24
+ * returns the captured payload for human review WITHOUT mutating; `execute`
25
+ * (reached only via `apply`) performs the create. Authorization is the same
26
+ * `incident.manage` rule the UI create form requires; the underlying RPC uses
27
+ * the USER-SCOPED client passed at call time, so handler-side authorization
28
+ * (access rules AND per-resource/team scoping) is enforced exactly as a direct
29
+ * UI/RPC call.
30
+ */
31
+ export function createIncidentCreateTool(): RegisteredAiTool<
32
+ CreateIncidentInput,
33
+ IncidentCreateApplyResult
34
+ > {
35
+ const dryRun = async ({
36
+ input,
37
+ }: {
38
+ input: CreateIncidentInput;
39
+ principal: AuthUser;
40
+ rpcClient: RpcClient;
41
+ }): Promise<AiProposalPreview<CreateIncidentInput>> => {
42
+ const systemCount = input.systemIds.length;
43
+ return {
44
+ summary: `Create ${input.severity} incident "${input.title}" affecting ${systemCount} system(s)${input.suppressNotifications ? " (notifications suppressed)" : ""}.`,
45
+ payload: input,
46
+ };
47
+ };
48
+
49
+ return {
50
+ name: "incident.create",
51
+ description:
52
+ "Open a new incident affecting one or more systems. Provide a title, severity (minor/major/critical), and the affected systemIds. Never creates directly; a person must approve unless the conversation is in auto mode. Find systemIds with the catalog read tools first.",
53
+ effect: "mutate",
54
+ input: CreateIncidentInputSchema,
55
+ requiredAccessRules: [
56
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
57
+ ],
58
+ dryRun,
59
+ async execute({ input, rpcClient }) {
60
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
61
+ const incident = await incidentClient.createIncident(input);
62
+ return { incident };
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createIncidentDeleteTool } from "./incident-delete";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["incident.incident.manage"],
9
+ };
10
+
11
+ const incident = {
12
+ id: "inc1",
13
+ title: "Checkout is down",
14
+ description: undefined,
15
+ status: "investigating",
16
+ severity: "critical",
17
+ suppressNotifications: false,
18
+ systemIds: ["sys1"],
19
+ createdAt: new Date(),
20
+ updatedAt: new Date(),
21
+ updates: [],
22
+ links: [],
23
+ };
24
+
25
+ function fakeRpcClient({
26
+ getIncident,
27
+ deleteIncident,
28
+ }: {
29
+ getIncident: ReturnType<typeof mock>;
30
+ deleteIncident: ReturnType<typeof mock>;
31
+ }): RpcClient {
32
+ return {
33
+ forPlugin: () => ({ getIncident, deleteIncident }),
34
+ } as unknown as RpcClient;
35
+ }
36
+
37
+ describe("incident.delete tool", () => {
38
+ test("declares destructive effect + the manage rule", () => {
39
+ const tool = createIncidentDeleteTool();
40
+ expect(tool.name).toBe("incident.delete");
41
+ expect(tool.effect).toBe("destructive");
42
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
43
+ expect(typeof tool.dryRun).toBe("function");
44
+ });
45
+
46
+ test("dryRun resolves the target and NEVER deletes", async () => {
47
+ const getIncident = mock(() => Promise.resolve(incident));
48
+ const deleteIncident = mock(() => Promise.resolve({ success: true }));
49
+ const rpcClient = fakeRpcClient({ getIncident, deleteIncident });
50
+ const tool = createIncidentDeleteTool();
51
+ const preview = await tool.dryRun!({
52
+ input: { id: "inc1" },
53
+ principal,
54
+ rpcClient,
55
+ });
56
+ expect(deleteIncident).not.toHaveBeenCalled();
57
+ expect(preview.summary).toContain("Checkout is down");
58
+ expect(preview.summary).toContain("permanent");
59
+ expect(preview.payload).toEqual({ id: "inc1" });
60
+ });
61
+
62
+ test("dryRun throws a clear error when the id is unknown", async () => {
63
+ const rpcClient = fakeRpcClient({
64
+ getIncident: mock(() => Promise.resolve(null)),
65
+ deleteIncident: mock(),
66
+ });
67
+ const tool = createIncidentDeleteTool();
68
+ await expect(
69
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
70
+ ).rejects.toThrow(/No incident found/);
71
+ });
72
+
73
+ test("execute (apply) deletes via deleteIncident with {id}", async () => {
74
+ const deleteIncident = mock(() => Promise.resolve({ success: true }));
75
+ const rpcClient = fakeRpcClient({
76
+ getIncident: mock(() => Promise.resolve(incident)),
77
+ deleteIncident,
78
+ });
79
+ const tool = createIncidentDeleteTool();
80
+ const result = await tool.execute({
81
+ input: { id: "inc1" },
82
+ principal,
83
+ rpcClient,
84
+ });
85
+ expect(deleteIncident).toHaveBeenCalledWith({ id: "inc1" });
86
+ expect(result).toEqual({ id: "inc1", deleted: true });
87
+ });
88
+ });
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ IncidentApi,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/incident-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `incident.delete`: the incident id to remove. */
13
+ export const IncidentDeleteInputSchema = z.object({
14
+ id: z.string(),
15
+ });
16
+ export type IncidentDeleteInput = z.infer<typeof IncidentDeleteInputSchema>;
17
+
18
+ /** Output returned once a human applies the deletion. */
19
+ export interface IncidentDeleteApplyResult {
20
+ id: string;
21
+ deleted: true;
22
+ }
23
+
24
+ /**
25
+ * `incident.delete` - permanently delete an incident by id.
26
+ *
27
+ * `effect: "destructive"` - deletion is irreversible, so it ALWAYS routes
28
+ * through the propose/apply confirm card in BOTH permission modes (it can never
29
+ * auto-apply). `dryRun` resolves the target so the confirm card names exactly
30
+ * what will be removed and rejects an unknown id; `execute` (reached only via
31
+ * `apply`) performs the delete. The underlying RPC uses the USER-SCOPED client
32
+ * passed at call time, so handler-side authorization is enforced exactly as a
33
+ * direct UI/RPC call.
34
+ */
35
+ export function createIncidentDeleteTool(): RegisteredAiTool<
36
+ IncidentDeleteInput,
37
+ IncidentDeleteApplyResult
38
+ > {
39
+ const dryRun = async ({
40
+ input,
41
+ rpcClient,
42
+ }: {
43
+ input: IncidentDeleteInput;
44
+ principal: AuthUser;
45
+ rpcClient: RpcClient;
46
+ }): Promise<AiProposalPreview<IncidentDeleteInput>> => {
47
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
48
+ const incident = await incidentClient.getIncident({ id: input.id });
49
+ if (!incident) {
50
+ throw new Error(
51
+ `No incident found with id "${input.id}". List incidents first to get a valid id.`,
52
+ );
53
+ }
54
+ return {
55
+ summary: `Delete incident "${incident.title}" (severity ${incident.severity}). This is permanent and also removes its updates, links, and system associations.`,
56
+ payload: { id: input.id },
57
+ };
58
+ };
59
+
60
+ return {
61
+ name: "incident.delete",
62
+ description:
63
+ "Delete an incident by id. DESTRUCTIVE and irreversible - it also removes the incident's updates, links, and system associations. Never deletes directly; a person must approve the confirmation. Find the id with the incident read tools first.",
64
+ effect: "destructive",
65
+ input: IncidentDeleteInputSchema,
66
+ requiredAccessRules: [
67
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
68
+ ],
69
+ dryRun,
70
+ async execute({ input, rpcClient }) {
71
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
72
+ await incidentClient.deleteIncident({ id: input.id });
73
+ return { id: input.id, deleted: true };
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildProjectedTool,
4
+ deferredProjectionExecute,
5
+ } from "@checkstack/ai-backend";
6
+ import { qualifyAccessRuleId } from "@checkstack/common";
7
+ import {
8
+ incidentContract,
9
+ incidentAccess,
10
+ pluginMetadata,
11
+ } from "@checkstack/incident-common";
12
+
13
+ // Build the projected tool with the SAME inputs the plugin exposes via
14
+ // aiToolProjectionExtensionPoint in `index.ts`, and assert the resulting tool
15
+ // carries the source procedure's contract access rules - NOT the chat
16
+ // transport's `ai.chat.read` gate.
17
+ describe("incident.get projection", () => {
18
+ const tool = buildProjectedTool({
19
+ procedure: incidentContract.getIncident,
20
+ sourcePluginMetadata: pluginMetadata,
21
+ procedureKey: "getIncident",
22
+ name: "incident.get",
23
+ description:
24
+ "Get one incident with its full timeline (updates) and links. Read-only.",
25
+ effect: "read",
26
+ execute: deferredProjectionExecute,
27
+ });
28
+
29
+ test("uses the overridden tool name", () => {
30
+ expect(tool.name).toBe("incident.get");
31
+ });
32
+
33
+ test("is classified as a read-only effect", () => {
34
+ expect(tool.effect).toBe("read");
35
+ });
36
+
37
+ test("inherits the source procedure's read rule, not the chat gate", () => {
38
+ expect(tool.requiredAccessRules).toEqual([
39
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.read),
40
+ ]);
41
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
42
+ });
43
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import { createIncidentRemoveLinkTool } from "./incident-remove-link";
4
+
5
+ const principal: AuthUser = {
6
+ type: "user",
7
+ id: "u1",
8
+ accessRules: ["incident.incident.manage"],
9
+ };
10
+
11
+ function fakeRpcClient({
12
+ removeLink,
13
+ }: {
14
+ removeLink: ReturnType<typeof mock>;
15
+ }): RpcClient {
16
+ return {
17
+ forPlugin: () => ({ removeLink }),
18
+ } as unknown as RpcClient;
19
+ }
20
+
21
+ describe("incident.removeLink tool", () => {
22
+ test("declares destructive effect + the manage rule", () => {
23
+ const tool = createIncidentRemoveLinkTool();
24
+ expect(tool.name).toBe("incident.removeLink");
25
+ expect(tool.effect).toBe("destructive");
26
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
27
+ expect(typeof tool.dryRun).toBe("function");
28
+ });
29
+
30
+ test("dryRun returns a payload and NEVER removes the link", async () => {
31
+ const removeLink = mock(() => Promise.resolve({ success: true }));
32
+ const rpcClient = fakeRpcClient({ removeLink });
33
+ const tool = createIncidentRemoveLinkTool();
34
+ const preview = await tool.dryRun!({
35
+ input: { id: "lnk1" },
36
+ principal,
37
+ rpcClient,
38
+ });
39
+ expect(removeLink).not.toHaveBeenCalled();
40
+ expect(preview.summary).toContain("permanent");
41
+ expect(preview.payload).toEqual({ id: "lnk1" });
42
+ });
43
+
44
+ test("execute (apply) removes via removeLink with {id}", async () => {
45
+ const removeLink = mock(() => Promise.resolve({ success: true }));
46
+ const rpcClient = fakeRpcClient({ removeLink });
47
+ const tool = createIncidentRemoveLinkTool();
48
+ const result = await tool.execute({
49
+ input: { id: "lnk1" },
50
+ principal,
51
+ rpcClient,
52
+ });
53
+ expect(removeLink).toHaveBeenCalledWith({ id: "lnk1" });
54
+ expect(result).toEqual({ id: "lnk1", removed: true });
55
+ });
56
+ });
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ IncidentApi,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/incident-common";
9
+ import type { AiProposalPreview } from "@checkstack/ai-common";
10
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
11
+
12
+ /** Input for `incident.removeLink`: the link id to remove. */
13
+ export const IncidentRemoveLinkInputSchema = z.object({
14
+ id: z.string(),
15
+ });
16
+ export type IncidentRemoveLinkInput = z.infer<
17
+ typeof IncidentRemoveLinkInputSchema
18
+ >;
19
+
20
+ /** Output returned once a human applies the link removal. */
21
+ export interface IncidentRemoveLinkApplyResult {
22
+ id: string;
23
+ removed: true;
24
+ }
25
+
26
+ /**
27
+ * `incident.removeLink` - detach a hotlink from an incident by link id.
28
+ *
29
+ * `effect: "destructive"` - removing a link is irreversible, so it ALWAYS routes
30
+ * through the propose/apply confirm card in BOTH permission modes (it can never
31
+ * auto-apply). `dryRun` returns the captured payload for human review WITHOUT
32
+ * mutating; `execute` (reached only via `apply`) removes the link. The
33
+ * underlying RPC uses the USER-SCOPED client passed at call time, so
34
+ * handler-side authorization is enforced exactly as a direct UI/RPC call.
35
+ */
36
+ export function createIncidentRemoveLinkTool(): RegisteredAiTool<
37
+ IncidentRemoveLinkInput,
38
+ IncidentRemoveLinkApplyResult
39
+ > {
40
+ const dryRun = async ({
41
+ input,
42
+ }: {
43
+ input: IncidentRemoveLinkInput;
44
+ principal: AuthUser;
45
+ rpcClient: RpcClient;
46
+ }): Promise<AiProposalPreview<IncidentRemoveLinkInput>> => {
47
+ return {
48
+ summary: `Remove link ${input.id} from its incident. This is permanent.`,
49
+ payload: { id: input.id },
50
+ };
51
+ };
52
+
53
+ return {
54
+ name: "incident.removeLink",
55
+ description:
56
+ "Remove a hotlink from an incident by link id. DESTRUCTIVE and irreversible. Never removes directly; a person must approve the confirmation. Find the link id by reading the incident's details first.",
57
+ effect: "destructive",
58
+ input: IncidentRemoveLinkInputSchema,
59
+ requiredAccessRules: [
60
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
61
+ ],
62
+ dryRun,
63
+ async execute({ input, rpcClient }) {
64
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
65
+ await incidentClient.removeLink({ id: input.id });
66
+ return { id: input.id, removed: true };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type { IncidentWithSystems } from "@checkstack/incident-common";
4
+ import { createIncidentResolveTool } from "./incident-resolve";
5
+
6
+ const principal: AuthUser = {
7
+ type: "user",
8
+ id: "u1",
9
+ accessRules: ["incident.incident.manage"],
10
+ };
11
+
12
+ const input = { id: "inc1", message: "All clear" };
13
+
14
+ function fakeRpcClient({
15
+ resolveIncident,
16
+ }: {
17
+ resolveIncident: ReturnType<typeof mock>;
18
+ }): RpcClient {
19
+ return {
20
+ forPlugin: () => ({ resolveIncident }),
21
+ } as unknown as RpcClient;
22
+ }
23
+
24
+ describe("incident.resolve tool", () => {
25
+ test("declares mutate effect + the manage rule", () => {
26
+ const tool = createIncidentResolveTool();
27
+ expect(tool.name).toBe("incident.resolve");
28
+ expect(tool.effect).toBe("mutate");
29
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
30
+ expect(typeof tool.dryRun).toBe("function");
31
+ });
32
+
33
+ test("dryRun returns a payload and NEVER resolves", async () => {
34
+ const resolveIncident = mock(() => Promise.resolve({}));
35
+ const rpcClient = fakeRpcClient({ resolveIncident });
36
+ const tool = createIncidentResolveTool();
37
+ const preview = await tool.dryRun!({ input, principal, rpcClient });
38
+ expect(resolveIncident).not.toHaveBeenCalled();
39
+ expect(preview.summary).toContain("inc1");
40
+ expect(preview.payload).toEqual(input);
41
+ });
42
+
43
+ test("execute (apply) resolves via resolveIncident", async () => {
44
+ const resolved: IncidentWithSystems = {
45
+ id: "inc1",
46
+ title: "Checkout is down",
47
+ status: "resolved",
48
+ severity: "critical",
49
+ suppressNotifications: false,
50
+ systemIds: ["sys1"],
51
+ createdAt: new Date(),
52
+ updatedAt: new Date(),
53
+ };
54
+ const resolveIncident = mock(() => Promise.resolve(resolved));
55
+ const rpcClient = fakeRpcClient({ resolveIncident });
56
+ const tool = createIncidentResolveTool();
57
+ const result = await tool.execute({ input, principal, rpcClient });
58
+ expect(resolveIncident).toHaveBeenCalledWith(input);
59
+ expect(result.incident).toEqual(resolved);
60
+ });
61
+ });