@calltelemetry/openclaw-linear 0.6.0 → 0.7.0
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.
- package/README.md +115 -17
- package/index.ts +18 -22
- package/openclaw.plugin.json +35 -2
- package/package.json +1 -1
- package/prompts.yaml +47 -0
- package/src/api/linear-api.ts +180 -9
- package/src/infra/cli.ts +214 -0
- package/src/infra/doctor.test.ts +399 -0
- package/src/infra/doctor.ts +759 -0
- package/src/infra/notify.test.ts +357 -108
- package/src/infra/notify.ts +114 -35
- package/src/pipeline/planner.test.ts +334 -0
- package/src/pipeline/planner.ts +282 -0
- package/src/pipeline/planning-state.test.ts +236 -0
- package/src/pipeline/planning-state.ts +216 -0
- package/src/pipeline/webhook.ts +69 -17
- package/src/tools/planner-tools.test.ts +535 -0
- package/src/tools/planner-tools.ts +450 -0
package/src/infra/notify.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* notify.ts —
|
|
2
|
+
* notify.ts — Unified notification provider for dispatch lifecycle events.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Uses OpenClaw's native runtime channel API for all providers (Discord, Slack,
|
|
5
|
+
* Telegram, Signal, etc). One formatter, one send function, config-driven
|
|
6
|
+
* fan-out with per-event-type toggles.
|
|
7
|
+
*
|
|
8
|
+
* Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
|
|
9
|
+
* formatting differences (markdown vs mrkdwn), and delivery per channel.
|
|
7
10
|
*/
|
|
11
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
8
12
|
|
|
9
13
|
// ---------------------------------------------------------------------------
|
|
10
14
|
// Types
|
|
@@ -32,58 +36,133 @@ export interface NotifyPayload {
|
|
|
32
36
|
export type NotifyFn = (kind: NotifyKind, payload: NotifyPayload) => Promise<void>;
|
|
33
37
|
|
|
34
38
|
// ---------------------------------------------------------------------------
|
|
35
|
-
//
|
|
39
|
+
// Provider config
|
|
36
40
|
// ---------------------------------------------------------------------------
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
export interface NotifyTarget {
|
|
43
|
+
/** OpenClaw channel name: "discord", "slack", "telegram", "signal", etc. */
|
|
44
|
+
channel: string;
|
|
45
|
+
/** Channel/group/user ID to send to */
|
|
46
|
+
target: string;
|
|
47
|
+
/** Optional account ID for multi-account channel setups */
|
|
48
|
+
accountId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface NotificationsConfig {
|
|
52
|
+
targets?: NotifyTarget[];
|
|
53
|
+
events?: Partial<Record<NotifyKind, boolean>>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Unified message formatter
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
39
59
|
|
|
40
|
-
function
|
|
41
|
-
const
|
|
60
|
+
export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
|
|
61
|
+
const id = payload.identifier;
|
|
42
62
|
switch (kind) {
|
|
43
63
|
case "dispatch":
|
|
44
|
-
return `${
|
|
64
|
+
return `${id} dispatched — ${payload.title}`;
|
|
45
65
|
case "working":
|
|
46
|
-
return `${
|
|
66
|
+
return `${id} worker started (attempt ${payload.attempt ?? 0})`;
|
|
47
67
|
case "auditing":
|
|
48
|
-
return `${
|
|
68
|
+
return `${id} audit in progress`;
|
|
49
69
|
case "audit_pass":
|
|
50
|
-
return `${
|
|
70
|
+
return `${id} passed audit. PR ready.`;
|
|
51
71
|
case "audit_fail": {
|
|
52
72
|
const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
|
|
53
|
-
return `${
|
|
73
|
+
return `${id} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
|
|
54
74
|
}
|
|
55
75
|
case "escalation":
|
|
56
|
-
return `🚨 ${
|
|
76
|
+
return `🚨 ${id} needs human review — ${payload.reason ?? "audit failed 2x"}`;
|
|
57
77
|
case "stuck":
|
|
58
|
-
return `⏰ ${
|
|
78
|
+
return `⏰ ${id} stuck — ${payload.reason ?? "stale 2h"}`;
|
|
59
79
|
case "watchdog_kill":
|
|
60
|
-
return `⚡ ${
|
|
80
|
+
return `⚡ ${id} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
|
|
61
81
|
payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
|
|
62
82
|
}`;
|
|
63
83
|
default:
|
|
64
|
-
return `${
|
|
84
|
+
return `${id} — ${kind}: ${payload.status}`;
|
|
65
85
|
}
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Unified send — routes to OpenClaw runtime channel API
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export async function sendToTarget(
|
|
93
|
+
target: NotifyTarget,
|
|
94
|
+
message: string,
|
|
95
|
+
runtime: PluginRuntime,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const ch = target.channel;
|
|
98
|
+
const to = target.target;
|
|
99
|
+
|
|
100
|
+
if (ch === "discord") {
|
|
101
|
+
await runtime.channel.discord.sendMessageDiscord(to, message);
|
|
102
|
+
} else if (ch === "slack") {
|
|
103
|
+
await runtime.channel.slack.sendMessageSlack(to, message, {
|
|
104
|
+
accountId: target.accountId,
|
|
105
|
+
});
|
|
106
|
+
} else if (ch === "telegram") {
|
|
107
|
+
await runtime.channel.telegram.sendMessageTelegram(to, message, { silent: true });
|
|
108
|
+
} else if (ch === "signal") {
|
|
109
|
+
await runtime.channel.signal.sendMessageSignal(to, message);
|
|
110
|
+
} else {
|
|
111
|
+
// Fallback: use CLI for any channel the runtime doesn't expose directly
|
|
112
|
+
const { execFileSync } = await import("node:child_process");
|
|
113
|
+
execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message", message, "--json"], {
|
|
114
|
+
timeout: 30_000,
|
|
115
|
+
stdio: "ignore",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Config-driven factory
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse notification config from plugin config.
|
|
126
|
+
*/
|
|
127
|
+
export function parseNotificationsConfig(
|
|
128
|
+
pluginConfig: Record<string, unknown> | undefined,
|
|
129
|
+
): NotificationsConfig {
|
|
130
|
+
const raw = pluginConfig?.notifications as NotificationsConfig | undefined;
|
|
131
|
+
return {
|
|
132
|
+
targets: raw?.targets ?? [],
|
|
133
|
+
events: raw?.events ?? {},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a notifier from plugin config. Returns a NotifyFn that:
|
|
139
|
+
* 1. Checks event toggles (skip suppressed events)
|
|
140
|
+
* 2. Formats the message
|
|
141
|
+
* 3. Fans out to all configured targets (failures isolated via Promise.allSettled)
|
|
142
|
+
*/
|
|
143
|
+
export function createNotifierFromConfig(
|
|
144
|
+
pluginConfig: Record<string, unknown> | undefined,
|
|
145
|
+
runtime: PluginRuntime,
|
|
146
|
+
): NotifyFn {
|
|
147
|
+
const config = parseNotificationsConfig(pluginConfig);
|
|
148
|
+
|
|
149
|
+
if (!config.targets?.length) return createNoopNotifier();
|
|
150
|
+
|
|
69
151
|
return async (kind, payload) => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error("Discord notify error:", err);
|
|
86
|
-
}
|
|
152
|
+
// Check event toggle — default is enabled (true)
|
|
153
|
+
if (config.events?.[kind] === false) return;
|
|
154
|
+
|
|
155
|
+
const message = formatMessage(kind, payload);
|
|
156
|
+
|
|
157
|
+
await Promise.allSettled(
|
|
158
|
+
config.targets!.map(async (target) => {
|
|
159
|
+
try {
|
|
160
|
+
await sendToTarget(target, message, runtime);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`Notify error (${target.channel}:${target.target}):`, err);
|
|
163
|
+
}
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
87
166
|
};
|
|
88
167
|
}
|
|
89
168
|
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Mocks (vi.hoisted + vi.mock)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const { runAgentMock } = vi.hoisted(() => ({
|
|
8
|
+
runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../agent/agent.js", () => ({
|
|
12
|
+
runAgent: runAgentMock,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../api/linear-api.js", () => ({}));
|
|
16
|
+
|
|
17
|
+
vi.mock("openclaw/plugin-sdk", () => ({}));
|
|
18
|
+
|
|
19
|
+
const mockLinearApi = {
|
|
20
|
+
getProject: vi.fn().mockResolvedValue({
|
|
21
|
+
id: "proj-1",
|
|
22
|
+
name: "Test Project",
|
|
23
|
+
teams: { nodes: [{ id: "team-1", name: "Team" }] },
|
|
24
|
+
}),
|
|
25
|
+
getProjectIssues: vi.fn().mockResolvedValue([]),
|
|
26
|
+
getTeamStates: vi.fn().mockResolvedValue([
|
|
27
|
+
{ id: "st-1", name: "Backlog", type: "backlog" },
|
|
28
|
+
]),
|
|
29
|
+
getTeamLabels: vi.fn().mockResolvedValue([]),
|
|
30
|
+
createComment: vi.fn().mockResolvedValue("comment-id"),
|
|
31
|
+
getIssueDetails: vi.fn().mockResolvedValue({
|
|
32
|
+
id: "issue-1",
|
|
33
|
+
identifier: "PROJ-1",
|
|
34
|
+
title: "Root",
|
|
35
|
+
comments: { nodes: [] },
|
|
36
|
+
project: { id: "proj-1" },
|
|
37
|
+
team: { id: "team-1" },
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
vi.mock("./planning-state.js", () => ({
|
|
42
|
+
registerPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
updatePlanningSession: vi.fn().mockResolvedValue({
|
|
44
|
+
turnCount: 1,
|
|
45
|
+
projectId: "proj-1",
|
|
46
|
+
status: "interviewing",
|
|
47
|
+
}),
|
|
48
|
+
endPlanningSession: vi.fn().mockResolvedValue(undefined),
|
|
49
|
+
setPlanningCache: vi.fn(),
|
|
50
|
+
clearPlanningCache: vi.fn(),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock("../tools/planner-tools.js", () => ({
|
|
54
|
+
setActivePlannerContext: vi.fn(),
|
|
55
|
+
clearActivePlannerContext: vi.fn(),
|
|
56
|
+
buildPlanSnapshot: vi.fn().mockReturnValue("_No issues created yet._"),
|
|
57
|
+
auditPlan: vi.fn().mockReturnValue({ pass: true, problems: [], warnings: [] }),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Imports (AFTER mocks)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
|
|
65
|
+
import {
|
|
66
|
+
registerPlanningSession,
|
|
67
|
+
updatePlanningSession,
|
|
68
|
+
endPlanningSession,
|
|
69
|
+
setPlanningCache,
|
|
70
|
+
} from "./planning-state.js";
|
|
71
|
+
import {
|
|
72
|
+
setActivePlannerContext,
|
|
73
|
+
clearActivePlannerContext,
|
|
74
|
+
auditPlan,
|
|
75
|
+
} from "../tools/planner-tools.js";
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function createApi() {
|
|
82
|
+
return {
|
|
83
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
|
84
|
+
pluginConfig: {},
|
|
85
|
+
} as any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createCtx(overrides?: Partial<{ api: any; linearApi: any; pluginConfig: any }>) {
|
|
89
|
+
return {
|
|
90
|
+
api: createApi(),
|
|
91
|
+
linearApi: mockLinearApi,
|
|
92
|
+
pluginConfig: {},
|
|
93
|
+
...overrides,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createSession(overrides?: Record<string, unknown>) {
|
|
98
|
+
return {
|
|
99
|
+
projectId: "proj-1",
|
|
100
|
+
projectName: "Test Project",
|
|
101
|
+
rootIssueId: "issue-1",
|
|
102
|
+
rootIdentifier: "PROJ-1",
|
|
103
|
+
teamId: "team-1",
|
|
104
|
+
status: "interviewing" as const,
|
|
105
|
+
startedAt: new Date().toISOString(),
|
|
106
|
+
turnCount: 0,
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Reset
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.clearAllMocks();
|
|
117
|
+
runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
|
|
118
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// initiatePlanningSession
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("initiatePlanningSession", () => {
|
|
126
|
+
const rootIssue = {
|
|
127
|
+
id: "issue-1",
|
|
128
|
+
identifier: "PROJ-1",
|
|
129
|
+
title: "Root",
|
|
130
|
+
team: { id: "team-1" },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
it("registers session in state with projectId and status interviewing", async () => {
|
|
134
|
+
const ctx = createCtx();
|
|
135
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
136
|
+
|
|
137
|
+
expect(registerPlanningSession).toHaveBeenCalledWith(
|
|
138
|
+
"proj-1",
|
|
139
|
+
expect.objectContaining({
|
|
140
|
+
projectId: "proj-1",
|
|
141
|
+
status: "interviewing",
|
|
142
|
+
}),
|
|
143
|
+
undefined,
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("sets planning cache with the session", async () => {
|
|
148
|
+
const ctx = createCtx();
|
|
149
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
150
|
+
|
|
151
|
+
expect(setPlanningCache).toHaveBeenCalledWith(
|
|
152
|
+
expect.objectContaining({
|
|
153
|
+
projectId: "proj-1",
|
|
154
|
+
projectName: "Test Project",
|
|
155
|
+
status: "interviewing",
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("posts welcome comment containing the project name", async () => {
|
|
161
|
+
const ctx = createCtx();
|
|
162
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
163
|
+
|
|
164
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
165
|
+
"issue-1",
|
|
166
|
+
expect.stringContaining("Test Project"),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("fetches project metadata and team states", async () => {
|
|
171
|
+
const ctx = createCtx();
|
|
172
|
+
await initiatePlanningSession(ctx, "proj-1", rootIssue);
|
|
173
|
+
|
|
174
|
+
expect(mockLinearApi.getProject).toHaveBeenCalledWith("proj-1");
|
|
175
|
+
expect(mockLinearApi.getTeamStates).toHaveBeenCalledWith("team-1");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// handlePlannerTurn
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
describe("handlePlannerTurn", () => {
|
|
184
|
+
const input = {
|
|
185
|
+
issueId: "issue-1",
|
|
186
|
+
commentBody: "Let's add a search feature",
|
|
187
|
+
commentorName: "Tester",
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
it("increments turn count via updatePlanningSession", async () => {
|
|
191
|
+
const ctx = createCtx();
|
|
192
|
+
const session = createSession();
|
|
193
|
+
await handlePlannerTurn(ctx, session, input);
|
|
194
|
+
|
|
195
|
+
expect(updatePlanningSession).toHaveBeenCalledWith(
|
|
196
|
+
"proj-1",
|
|
197
|
+
{ turnCount: 1 },
|
|
198
|
+
undefined,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("builds plan snapshot from project issues", async () => {
|
|
203
|
+
const ctx = createCtx();
|
|
204
|
+
const session = createSession();
|
|
205
|
+
await handlePlannerTurn(ctx, session, input);
|
|
206
|
+
|
|
207
|
+
expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith("proj-1");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("calls runAgent with system prompt", async () => {
|
|
211
|
+
const ctx = createCtx();
|
|
212
|
+
const session = createSession();
|
|
213
|
+
await handlePlannerTurn(ctx, session, input);
|
|
214
|
+
|
|
215
|
+
expect(runAgentMock).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
message: expect.stringContaining("planning"),
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("posts agent response as comment", async () => {
|
|
223
|
+
const ctx = createCtx();
|
|
224
|
+
const session = createSession();
|
|
225
|
+
await handlePlannerTurn(ctx, session, input);
|
|
226
|
+
|
|
227
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
228
|
+
"issue-1",
|
|
229
|
+
"Mock planner response",
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("detects finalize plan intent and triggers audit instead of regular turn", async () => {
|
|
234
|
+
const ctx = createCtx();
|
|
235
|
+
const session = createSession();
|
|
236
|
+
|
|
237
|
+
await handlePlannerTurn(ctx, session, {
|
|
238
|
+
issueId: "issue-1",
|
|
239
|
+
commentBody: "finalize plan",
|
|
240
|
+
commentorName: "Tester",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Audit path: auditPlan is called, runAgent is NOT called
|
|
244
|
+
expect(auditPlan).toHaveBeenCalled();
|
|
245
|
+
expect(runAgentMock).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("detects abandon intent and ends session as abandoned", async () => {
|
|
249
|
+
const ctx = createCtx();
|
|
250
|
+
const session = createSession();
|
|
251
|
+
|
|
252
|
+
await handlePlannerTurn(ctx, session, {
|
|
253
|
+
issueId: "issue-1",
|
|
254
|
+
commentBody: "abandon",
|
|
255
|
+
commentorName: "Tester",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(endPlanningSession).toHaveBeenCalledWith(
|
|
259
|
+
"proj-1",
|
|
260
|
+
"abandoned",
|
|
261
|
+
undefined,
|
|
262
|
+
);
|
|
263
|
+
// Should NOT run the agent
|
|
264
|
+
expect(runAgentMock).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// runPlanAudit
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
describe("runPlanAudit", () => {
|
|
273
|
+
it("posts success comment on passing audit", async () => {
|
|
274
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
275
|
+
const ctx = createCtx();
|
|
276
|
+
const session = createSession();
|
|
277
|
+
|
|
278
|
+
await runPlanAudit(ctx, session);
|
|
279
|
+
|
|
280
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
281
|
+
"issue-1",
|
|
282
|
+
expect.stringContaining("Approved"),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("ends session as approved on pass", async () => {
|
|
287
|
+
vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
|
|
288
|
+
const ctx = createCtx();
|
|
289
|
+
const session = createSession();
|
|
290
|
+
|
|
291
|
+
await runPlanAudit(ctx, session);
|
|
292
|
+
|
|
293
|
+
expect(endPlanningSession).toHaveBeenCalledWith(
|
|
294
|
+
"proj-1",
|
|
295
|
+
"approved",
|
|
296
|
+
undefined,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("posts problems on failing audit", async () => {
|
|
301
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
302
|
+
pass: false,
|
|
303
|
+
problems: ["Missing description on PROJ-2"],
|
|
304
|
+
warnings: [],
|
|
305
|
+
});
|
|
306
|
+
const ctx = createCtx();
|
|
307
|
+
const session = createSession();
|
|
308
|
+
|
|
309
|
+
await runPlanAudit(ctx, session);
|
|
310
|
+
|
|
311
|
+
expect(mockLinearApi.createComment).toHaveBeenCalledWith(
|
|
312
|
+
"issue-1",
|
|
313
|
+
expect.stringContaining("Missing description on PROJ-2"),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("does NOT end session as approved on fail", async () => {
|
|
318
|
+
vi.mocked(auditPlan).mockReturnValue({
|
|
319
|
+
pass: false,
|
|
320
|
+
problems: ["No estimates"],
|
|
321
|
+
warnings: [],
|
|
322
|
+
});
|
|
323
|
+
const ctx = createCtx();
|
|
324
|
+
const session = createSession();
|
|
325
|
+
|
|
326
|
+
await runPlanAudit(ctx, session);
|
|
327
|
+
|
|
328
|
+
expect(endPlanningSession).not.toHaveBeenCalledWith(
|
|
329
|
+
"proj-1",
|
|
330
|
+
"approved",
|
|
331
|
+
expect.anything(),
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|