@opsee/mcp-server 0.9.25 → 0.9.27
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/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { blockNoteToMarkdown } from "../utils/format.js";
|
|
3
|
+
|
|
4
|
+
const bn = (blocks: unknown[]) => JSON.stringify(blocks);
|
|
5
|
+
|
|
6
|
+
const text = (t: string, styles?: Record<string, boolean>) => ({
|
|
7
|
+
type: "text",
|
|
8
|
+
text: t,
|
|
9
|
+
...(styles ? { styles } : {}),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("blockNoteToMarkdown", () => {
|
|
13
|
+
test("renders paragraphs", () => {
|
|
14
|
+
expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("hello")] }]))).toBe("hello");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("renders headings, bullets and checkboxes", () => {
|
|
18
|
+
const md = blockNoteToMarkdown(
|
|
19
|
+
bn([
|
|
20
|
+
{ type: "heading", props: { level: 2 }, content: [text("Acceptance criteria")] },
|
|
21
|
+
{ type: "checkListItem", props: { checked: true }, content: [text("done")] },
|
|
22
|
+
{ type: "checkListItem", props: { checked: false }, content: [text("todo")] },
|
|
23
|
+
]),
|
|
24
|
+
);
|
|
25
|
+
expect(md).toContain("## Acceptance criteria");
|
|
26
|
+
expect(md).toContain("- [x] done");
|
|
27
|
+
expect(md).toContain("- [ ] todo");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("renders bold, italic and code inline styles", () => {
|
|
31
|
+
expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { bold: true })] }]))).toBe("**a**");
|
|
32
|
+
expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { italic: true })] }]))).toBe("*a*");
|
|
33
|
+
expect(blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("a", { code: true })] }]))).toBe("`a`");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("renders links", () => {
|
|
37
|
+
const md = blockNoteToMarkdown(
|
|
38
|
+
bn([{ type: "paragraph", content: [{ type: "link", href: "https://x.test", content: [text("x")] }] }]),
|
|
39
|
+
);
|
|
40
|
+
expect(md).toBe("[x](https://x.test)");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("returns the raw string for non-BlockNote input", () => {
|
|
44
|
+
expect(blockNoteToMarkdown("just markdown")).toBe("just markdown");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Both Go writers persist strikethrough under the BlockNote style key
|
|
48
|
+
// "strike": backend/service/task.go parseInlineMarkdown and
|
|
49
|
+
// orchestrator/internal/util/blocknote.go. This reader is what renders an
|
|
50
|
+
// initiative's coreIdea for get-initiative-context, so dropping the markers
|
|
51
|
+
// makes an agent read struck-out (abandoned) plan items as live ones.
|
|
52
|
+
test("preserves strikethrough written by the Go markdown parser", () => {
|
|
53
|
+
const md = blockNoteToMarkdown(
|
|
54
|
+
bn([
|
|
55
|
+
{
|
|
56
|
+
type: "paragraph",
|
|
57
|
+
content: [text("Ship "), text("by Friday", { strike: true }), text(" - moved to next cycle")],
|
|
58
|
+
},
|
|
59
|
+
]),
|
|
60
|
+
);
|
|
61
|
+
expect(md).toBe("Ship ~~by Friday~~ - moved to next cycle");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The local BNInlineStyle type declares `strikethrough`, so cover that
|
|
65
|
+
// spelling too - whichever key the pipeline uses, the marker must survive.
|
|
66
|
+
test("preserves strikethrough under the declared style key", () => {
|
|
67
|
+
const md = blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("gone", { strikethrough: true })] }]));
|
|
68
|
+
expect(md).toContain("~~gone~~");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("preserves underline as declared in BNInlineStyle", () => {
|
|
72
|
+
const md = blockNoteToMarkdown(bn([{ type: "paragraph", content: [text("u", { underline: true })] }]));
|
|
73
|
+
expect(md).not.toBe("u");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("nests a code block inside a list item on its own indented lines", () => {
|
|
77
|
+
const md = blockNoteToMarkdown(
|
|
78
|
+
bn([
|
|
79
|
+
{
|
|
80
|
+
type: "bulletListItem",
|
|
81
|
+
content: [text("run this")],
|
|
82
|
+
children: [{ type: "codeBlock", props: { language: "sh" }, content: [text("make test\nmake lint")] }],
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
const lines = md.split("\n");
|
|
87
|
+
// Every line of the fenced block body must carry the nesting indent,
|
|
88
|
+
// otherwise the second command escapes the list item.
|
|
89
|
+
const bodyLines = lines.filter((l) => l.includes("make "));
|
|
90
|
+
expect(bodyLines.every((l) => l.startsWith(" "))).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
import type { ApiClients } from "../client/api.js";
|
|
6
|
+
|
|
7
|
+
const mockUser = { id: 5, fullName: "Alice Test" };
|
|
8
|
+
|
|
9
|
+
const mockComment = {
|
|
10
|
+
id: 11,
|
|
11
|
+
content: "old text",
|
|
12
|
+
isInternal: false,
|
|
13
|
+
taskId: 10,
|
|
14
|
+
userId: 5,
|
|
15
|
+
user: mockUser,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Records the last request each mock method received so tests can assert pass-through.
|
|
19
|
+
let lastAddReq: any = null;
|
|
20
|
+
let lastEditReq: any = null;
|
|
21
|
+
let lastListReq: any = null;
|
|
22
|
+
|
|
23
|
+
function createMockClients(): ApiClients {
|
|
24
|
+
lastAddReq = null;
|
|
25
|
+
lastEditReq = null;
|
|
26
|
+
lastListReq = null;
|
|
27
|
+
return {
|
|
28
|
+
users: {
|
|
29
|
+
getMe: async () => ({ user: mockUser }),
|
|
30
|
+
},
|
|
31
|
+
comments: {
|
|
32
|
+
getComments: async (req: any) => {
|
|
33
|
+
lastListReq = req;
|
|
34
|
+
return { comments: [mockComment] };
|
|
35
|
+
},
|
|
36
|
+
getComment: async () => ({ comment: mockComment }),
|
|
37
|
+
addComment: async (req: any) => {
|
|
38
|
+
lastAddReq = req;
|
|
39
|
+
return { comment: { ...mockComment, id: 12, content: req.content, isInternal: req.isInternal, userId: req.userId } };
|
|
40
|
+
},
|
|
41
|
+
editComment: async (req: any) => {
|
|
42
|
+
lastEditReq = req;
|
|
43
|
+
return { comment: { ...mockComment, content: req.content, isInternal: req.isInternal } };
|
|
44
|
+
},
|
|
45
|
+
deleteComment: async () => ({}),
|
|
46
|
+
},
|
|
47
|
+
} as unknown as ApiClients;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Comment tools", () => {
|
|
51
|
+
let client: Client;
|
|
52
|
+
let cleanup: () => Promise<void>;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
56
|
+
const server = createServer(() => createMockClients());
|
|
57
|
+
await server.connect(st);
|
|
58
|
+
client = new Client({ name: "comments-test", version: "1.0.0" });
|
|
59
|
+
await client.connect(ct);
|
|
60
|
+
cleanup = async () => {
|
|
61
|
+
await client.close();
|
|
62
|
+
await server.close();
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
lastAddReq = null;
|
|
72
|
+
lastEditReq = null;
|
|
73
|
+
lastListReq = null;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("opsee_list_comments returns the formatted list", async () => {
|
|
77
|
+
const result = await client.callTool({
|
|
78
|
+
name: "opsee_list_comments",
|
|
79
|
+
arguments: { taskId: 10 },
|
|
80
|
+
});
|
|
81
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
82
|
+
expect(text).toContain("Found 1 comment(s)");
|
|
83
|
+
expect(text).toContain("old text");
|
|
84
|
+
expect(text).toContain("Alice Test");
|
|
85
|
+
expect(lastListReq.taskId).toBe(10);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("opsee_list_comments reports none for an empty result", async () => {
|
|
89
|
+
const emptyClients = createMockClients();
|
|
90
|
+
(emptyClients.comments as any).getComments = async () => ({ comments: [] });
|
|
91
|
+
|
|
92
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
93
|
+
const server = createServer(() => emptyClients);
|
|
94
|
+
await server.connect(st);
|
|
95
|
+
const c = new Client({ name: "empty-comments-test", version: "1.0.0" });
|
|
96
|
+
await c.connect(ct);
|
|
97
|
+
|
|
98
|
+
const result = await c.callTool({
|
|
99
|
+
name: "opsee_list_comments",
|
|
100
|
+
arguments: { initiativeId: 2 },
|
|
101
|
+
});
|
|
102
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
103
|
+
expect(text).toContain("No comments found.");
|
|
104
|
+
|
|
105
|
+
await c.close();
|
|
106
|
+
await server.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("opsee_add_comment resolves the current user via getMe and posts the comment", async () => {
|
|
110
|
+
const result = await client.callTool({
|
|
111
|
+
name: "opsee_add_comment",
|
|
112
|
+
arguments: { taskId: 10, content: "new comment" },
|
|
113
|
+
});
|
|
114
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
115
|
+
expect(text).toContain("Comment added");
|
|
116
|
+
expect(text).toContain("new comment");
|
|
117
|
+
expect(lastAddReq.userId).toBe(5);
|
|
118
|
+
expect(lastAddReq.isInternal).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("opsee_add_comment passes isInternal through", async () => {
|
|
122
|
+
await client.callTool({
|
|
123
|
+
name: "opsee_add_comment",
|
|
124
|
+
arguments: { taskId: 10, content: "internal note", isInternal: true },
|
|
125
|
+
});
|
|
126
|
+
expect(lastAddReq.isInternal).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("opsee_add_comment errors when the current user cannot be resolved", async () => {
|
|
130
|
+
const noUserClients = createMockClients();
|
|
131
|
+
(noUserClients.users as any).getMe = async () => ({});
|
|
132
|
+
|
|
133
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
134
|
+
const server = createServer(() => noUserClients);
|
|
135
|
+
await server.connect(st);
|
|
136
|
+
const c = new Client({ name: "no-user-test", version: "1.0.0" });
|
|
137
|
+
await c.connect(ct);
|
|
138
|
+
|
|
139
|
+
const result = await c.callTool({
|
|
140
|
+
name: "opsee_add_comment",
|
|
141
|
+
arguments: { taskId: 10, content: "orphan comment" },
|
|
142
|
+
});
|
|
143
|
+
expect(result.isError).toBe(true);
|
|
144
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
145
|
+
expect(text).toContain("Could not resolve current user");
|
|
146
|
+
|
|
147
|
+
await c.close();
|
|
148
|
+
await server.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("opsee_edit_comment looks up the existing comment before editing", async () => {
|
|
152
|
+
const result = await client.callTool({
|
|
153
|
+
name: "opsee_edit_comment",
|
|
154
|
+
arguments: { commentId: 11, content: "edited text" },
|
|
155
|
+
});
|
|
156
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
157
|
+
expect(text).toContain("Comment updated");
|
|
158
|
+
expect(text).toContain("edited text");
|
|
159
|
+
expect(lastEditReq.id).toBe(11);
|
|
160
|
+
expect(lastEditReq.taskId).toBe(10);
|
|
161
|
+
expect(lastEditReq.isInternal).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("opsee_edit_comment reports not found when the comment does not exist", async () => {
|
|
165
|
+
const missingClients = createMockClients();
|
|
166
|
+
(missingClients.comments as any).getComment = async () => ({});
|
|
167
|
+
|
|
168
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
169
|
+
const server = createServer(() => missingClients);
|
|
170
|
+
await server.connect(st);
|
|
171
|
+
const c = new Client({ name: "missing-comment-test", version: "1.0.0" });
|
|
172
|
+
await c.connect(ct);
|
|
173
|
+
|
|
174
|
+
const result = await c.callTool({
|
|
175
|
+
name: "opsee_edit_comment",
|
|
176
|
+
arguments: { commentId: 999, content: "won't apply" },
|
|
177
|
+
});
|
|
178
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
179
|
+
expect(text).toContain("Comment not found.");
|
|
180
|
+
|
|
181
|
+
await c.close();
|
|
182
|
+
await server.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("opsee_delete_comment succeeds", async () => {
|
|
186
|
+
const result = await client.callTool({
|
|
187
|
+
name: "opsee_delete_comment",
|
|
188
|
+
arguments: { commentId: 11 },
|
|
189
|
+
});
|
|
190
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
191
|
+
expect(text).toContain("Comment deleted.");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
import type { ApiClients } from "../client/api.js";
|
|
6
|
+
import { TaskDependencyType } from "../../gen/api/v1/models_pb.js";
|
|
7
|
+
|
|
8
|
+
const mockDependency = {
|
|
9
|
+
id: 7,
|
|
10
|
+
fromTaskId: 10,
|
|
11
|
+
toTaskId: 11,
|
|
12
|
+
type: TaskDependencyType.BLOCKS,
|
|
13
|
+
fromTask: { id: 10, identifier: "TP-1" },
|
|
14
|
+
toTask: { id: 11, identifier: "TP-2" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Records the last request each mock method received so tests can assert pass-through.
|
|
18
|
+
let lastAddReq: any = null;
|
|
19
|
+
let lastListReq: any = null;
|
|
20
|
+
let lastChainReq: any = null;
|
|
21
|
+
|
|
22
|
+
function createMockClients(): ApiClients {
|
|
23
|
+
lastAddReq = null;
|
|
24
|
+
lastListReq = null;
|
|
25
|
+
lastChainReq = null;
|
|
26
|
+
return {
|
|
27
|
+
taskDependencies: {
|
|
28
|
+
addTaskDependency: async (req: any) => {
|
|
29
|
+
lastAddReq = req;
|
|
30
|
+
return { taskDependency: mockDependency };
|
|
31
|
+
},
|
|
32
|
+
getTaskDependencies: async (req: any) => {
|
|
33
|
+
lastListReq = req;
|
|
34
|
+
return { taskDependencies: [mockDependency] };
|
|
35
|
+
},
|
|
36
|
+
deleteTaskDependency: async () => ({}),
|
|
37
|
+
getDependencyChain: async (req: any) => {
|
|
38
|
+
lastChainReq = req;
|
|
39
|
+
return {
|
|
40
|
+
nodes: [
|
|
41
|
+
{ id: 10, identifier: "TP-1", title: "Fix the bug" },
|
|
42
|
+
{ id: 11, identifier: "TP-2", title: "Write the test" },
|
|
43
|
+
],
|
|
44
|
+
edges: [{ fromTaskId: 10, toTaskId: 11, type: TaskDependencyType.BLOCKS }],
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} as unknown as ApiClients;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("Task dependency tools", () => {
|
|
52
|
+
let client: Client;
|
|
53
|
+
let cleanup: () => Promise<void>;
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
57
|
+
const server = createServer(() => createMockClients());
|
|
58
|
+
await server.connect(st);
|
|
59
|
+
client = new Client({ name: "task-dependencies-test", version: "1.0.0" });
|
|
60
|
+
await client.connect(ct);
|
|
61
|
+
cleanup = async () => {
|
|
62
|
+
await client.close();
|
|
63
|
+
await server.close();
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
await cleanup();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
lastAddReq = null;
|
|
73
|
+
lastListReq = null;
|
|
74
|
+
lastChainReq = null;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("opsee_create_task_dependency maps type and returns the formatted dependency", async () => {
|
|
78
|
+
const result = await client.callTool({
|
|
79
|
+
name: "opsee_create_task_dependency",
|
|
80
|
+
arguments: { fromTaskId: 10, toTaskId: 11, type: "BLOCKS" },
|
|
81
|
+
});
|
|
82
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
83
|
+
expect(text).toContain("Task dependency created");
|
|
84
|
+
expect(text).toContain("TP-1 BLOCKS TP-2");
|
|
85
|
+
expect(lastAddReq.type).toBe(TaskDependencyType.BLOCKS);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("opsee_create_task_dependency reports failure when backend returns no dependency", async () => {
|
|
89
|
+
const emptyClients = createMockClients();
|
|
90
|
+
(emptyClients.taskDependencies as any).addTaskDependency = async () => ({});
|
|
91
|
+
|
|
92
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
93
|
+
const server = createServer(() => emptyClients);
|
|
94
|
+
await server.connect(st);
|
|
95
|
+
const c = new Client({ name: "empty-add-test", version: "1.0.0" });
|
|
96
|
+
await c.connect(ct);
|
|
97
|
+
|
|
98
|
+
const result = await c.callTool({
|
|
99
|
+
name: "opsee_create_task_dependency",
|
|
100
|
+
arguments: { fromTaskId: 10, toTaskId: 11, type: "RELATES_TO" },
|
|
101
|
+
});
|
|
102
|
+
expect(result.isError).toBe(true);
|
|
103
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
104
|
+
expect(text).toContain("Failed to create task dependency");
|
|
105
|
+
|
|
106
|
+
await c.close();
|
|
107
|
+
await server.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("opsee_list_task_dependencies returns the formatted list and default pagination", async () => {
|
|
111
|
+
const result = await client.callTool({
|
|
112
|
+
name: "opsee_list_task_dependencies",
|
|
113
|
+
arguments: { taskId: 10 },
|
|
114
|
+
});
|
|
115
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
116
|
+
expect(text).toContain("Found 1 dependency(ies)");
|
|
117
|
+
expect(text).toContain("TP-1 BLOCKS TP-2");
|
|
118
|
+
expect(lastListReq.taskId).toBe(10);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("opsee_list_task_dependencies reports none for an empty result", async () => {
|
|
122
|
+
const emptyClients = createMockClients();
|
|
123
|
+
(emptyClients.taskDependencies as any).getTaskDependencies = async () => ({ taskDependencies: [] });
|
|
124
|
+
|
|
125
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
126
|
+
const server = createServer(() => emptyClients);
|
|
127
|
+
await server.connect(st);
|
|
128
|
+
const c = new Client({ name: "empty-list-test", version: "1.0.0" });
|
|
129
|
+
await c.connect(ct);
|
|
130
|
+
|
|
131
|
+
const result = await c.callTool({
|
|
132
|
+
name: "opsee_list_task_dependencies",
|
|
133
|
+
arguments: { taskId: 10 },
|
|
134
|
+
});
|
|
135
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
136
|
+
expect(text).toContain("No task dependencies found.");
|
|
137
|
+
|
|
138
|
+
await c.close();
|
|
139
|
+
await server.close();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("opsee_delete_task_dependency succeeds", async () => {
|
|
143
|
+
const result = await client.callTool({
|
|
144
|
+
name: "opsee_delete_task_dependency",
|
|
145
|
+
arguments: { dependencyId: 7 },
|
|
146
|
+
});
|
|
147
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
148
|
+
expect(text).toContain("Task dependency deleted.");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("opsee_get_dependency_chain renders nodes and edges with defaults", async () => {
|
|
152
|
+
const result = await client.callTool({
|
|
153
|
+
name: "opsee_get_dependency_chain",
|
|
154
|
+
arguments: { taskId: 10 },
|
|
155
|
+
});
|
|
156
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
157
|
+
expect(text).toContain("direction: both, depth: 5");
|
|
158
|
+
expect(text).toContain("2 task(s), 1 edge(s)");
|
|
159
|
+
expect(text).toContain("[TP-1] Fix the bug");
|
|
160
|
+
expect(text).toContain("task 10 → task 11 (type");
|
|
161
|
+
expect(lastChainReq.direction).toBe("both");
|
|
162
|
+
expect(lastChainReq.depth).toBe(5);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("opsee_get_dependency_chain passes through an explicit direction and depth", async () => {
|
|
166
|
+
await client.callTool({
|
|
167
|
+
name: "opsee_get_dependency_chain",
|
|
168
|
+
arguments: { taskId: 10, direction: "blocked_by", depth: 2 },
|
|
169
|
+
});
|
|
170
|
+
expect(lastChainReq.direction).toBe("blocked_by");
|
|
171
|
+
expect(lastChainReq.depth).toBe(2);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
import type { ApiClients } from "../client/api.js";
|
|
6
|
+
|
|
7
|
+
const mockTaskWithLogs = {
|
|
8
|
+
id: 10,
|
|
9
|
+
identifier: "TP-1",
|
|
10
|
+
title: "Fix the bug",
|
|
11
|
+
estimatedHours: 5,
|
|
12
|
+
workLogs: [
|
|
13
|
+
{ id: 1, userId: 5, user: { fullName: "Alice" }, hours: 2, description: "implemented foo" },
|
|
14
|
+
{ id: 2, userId: 6, hours: 1.5 },
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function createMockClients(overrides: Partial<ApiClients["tasks"]> = {}): ApiClients {
|
|
19
|
+
return {
|
|
20
|
+
tasks: {
|
|
21
|
+
getTask: async () => ({ task: mockTaskWithLogs }),
|
|
22
|
+
addWorkLog: async (req: { taskId: number; hours: number }) => ({
|
|
23
|
+
workLog: { id: 42, taskId: req.taskId, userId: 5, hours: req.hours },
|
|
24
|
+
}),
|
|
25
|
+
deleteWorkLog: async () => ({}),
|
|
26
|
+
...overrides,
|
|
27
|
+
},
|
|
28
|
+
} as unknown as ApiClients;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function connect(clients: ApiClients) {
|
|
32
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
33
|
+
const server = createServer(() => clients);
|
|
34
|
+
await server.connect(st);
|
|
35
|
+
const client = new Client({ name: "work-logs-test", version: "1.0.0" });
|
|
36
|
+
await client.connect(ct);
|
|
37
|
+
return { client, cleanup: async () => { await client.close(); await server.close(); } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("Work log tools", () => {
|
|
41
|
+
let client: Client;
|
|
42
|
+
let cleanup: () => Promise<void>;
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const conn = await connect(createMockClients());
|
|
46
|
+
client = conn.client;
|
|
47
|
+
cleanup = conn.cleanup;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await cleanup();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("opsee_list_work_logs formats entries, users, and the running total", async () => {
|
|
55
|
+
const result = await client.callTool({
|
|
56
|
+
name: "opsee_list_work_logs",
|
|
57
|
+
arguments: { taskId: 10 },
|
|
58
|
+
});
|
|
59
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
60
|
+
expect(text).toContain("Work logs for TP-1 (total 3.5h)");
|
|
61
|
+
expect(text).toContain("Alice @");
|
|
62
|
+
expect(text).toContain("2h — implemented foo (ID: 1)");
|
|
63
|
+
expect(text).toContain("User #6 @");
|
|
64
|
+
expect(text).toContain("1.5h (ID: 2)");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("opsee_list_work_logs reports task not found", async () => {
|
|
68
|
+
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({}) } as any));
|
|
69
|
+
const result = await c.callTool({
|
|
70
|
+
name: "opsee_list_work_logs",
|
|
71
|
+
arguments: { taskId: 999 },
|
|
72
|
+
});
|
|
73
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
74
|
+
expect(text).toContain("Task not found.");
|
|
75
|
+
await cl();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("opsee_log_work logs hours against a task", async () => {
|
|
79
|
+
const result = await client.callTool({
|
|
80
|
+
name: "opsee_log_work",
|
|
81
|
+
arguments: { taskId: 10, hours: 2.5, description: "implemented foo" },
|
|
82
|
+
});
|
|
83
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
84
|
+
expect(text).toContain("Logged 2.5h on task 10 (work_log ID 42).");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("opsee_log_work reports failure when the backend returns no work log", async () => {
|
|
88
|
+
const { client: c, cleanup: cl } = await connect(createMockClients({ addWorkLog: async () => ({}) } as any));
|
|
89
|
+
const result = await c.callTool({
|
|
90
|
+
name: "opsee_log_work",
|
|
91
|
+
arguments: { taskId: 10, hours: 1 },
|
|
92
|
+
});
|
|
93
|
+
expect(result.isError).toBe(true);
|
|
94
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
95
|
+
expect(text).toContain("Failed to log work");
|
|
96
|
+
await cl();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("opsee_delete_work_log succeeds", async () => {
|
|
100
|
+
const result = await client.callTool({
|
|
101
|
+
name: "opsee_delete_work_log",
|
|
102
|
+
arguments: { taskId: 10, workLogId: 1 },
|
|
103
|
+
});
|
|
104
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
105
|
+
expect(text).toContain("Work log deleted.");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("opsee_get_task_actual_hours reports over-budget delta", async () => {
|
|
109
|
+
const result = await client.callTool({
|
|
110
|
+
name: "opsee_get_task_actual_hours",
|
|
111
|
+
arguments: { taskId: 10 },
|
|
112
|
+
});
|
|
113
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
114
|
+
// total logged is 3.5h against an estimate of 5h, so this is under budget.
|
|
115
|
+
expect(text).toContain("TP-1: 3.5h logged (estimated 5h, under by 1.5 h).");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("opsee_get_task_actual_hours reports over-budget when logged hours exceed the estimate", async () => {
|
|
119
|
+
const overBudgetTask = { ...mockTaskWithLogs, estimatedHours: 2 };
|
|
120
|
+
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({ task: overBudgetTask }) } as any));
|
|
121
|
+
const result = await c.callTool({
|
|
122
|
+
name: "opsee_get_task_actual_hours",
|
|
123
|
+
arguments: { taskId: 10 },
|
|
124
|
+
});
|
|
125
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
126
|
+
expect(text).toContain("TP-1: 3.5h logged (estimated 2h, over by 1.5 h).");
|
|
127
|
+
await cl();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("opsee_get_task_actual_hours reports task not found", async () => {
|
|
131
|
+
const { client: c, cleanup: cl } = await connect(createMockClients({ getTask: async () => ({}) } as any));
|
|
132
|
+
const result = await c.callTool({
|
|
133
|
+
name: "opsee_get_task_actual_hours",
|
|
134
|
+
arguments: { taskId: 999 },
|
|
135
|
+
});
|
|
136
|
+
const text = (result.content as Array<{ type: string; text: string }>)[0].text;
|
|
137
|
+
expect(text).toContain("Task not found.");
|
|
138
|
+
await cl();
|
|
139
|
+
});
|
|
140
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -290,7 +290,18 @@ export function formatTaskDependencyList(deps: TaskDependency[]): string {
|
|
|
290
290
|
// BlockNote JSON → Markdown
|
|
291
291
|
// ---------------------------------------------------------------------------
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
// `strike` is the key the BlockNote default schema uses, and it is what both Go
|
|
294
|
+
// writers persist (backend/service/task.go parseInlineMarkdown and
|
|
295
|
+
// orchestrator/internal/util/blocknote.go). `strikethrough` is accepted too so a
|
|
296
|
+
// payload using the long spelling is not silently dropped.
|
|
297
|
+
type BNInlineStyle = {
|
|
298
|
+
bold?: boolean;
|
|
299
|
+
italic?: boolean;
|
|
300
|
+
underline?: boolean;
|
|
301
|
+
strike?: boolean;
|
|
302
|
+
strikethrough?: boolean;
|
|
303
|
+
code?: boolean;
|
|
304
|
+
};
|
|
294
305
|
type BNTextContent = { type: "text"; text: string; styles?: BNInlineStyle };
|
|
295
306
|
type BNLinkContent = { type: "link"; href: string; content: BNTextContent[] };
|
|
296
307
|
type BNInlineContent = BNTextContent | BNLinkContent;
|
|
@@ -314,6 +325,10 @@ function renderInline(content: BNInlineContent[]): string {
|
|
|
314
325
|
if (t.styles?.code) s = `\`${s}\``;
|
|
315
326
|
if (t.styles?.bold) s = `**${s}**`;
|
|
316
327
|
if (t.styles?.italic) s = `*${s}*`;
|
|
328
|
+
// Struck-through text usually means "decided against this". Dropping the
|
|
329
|
+
// markers would present an abandoned plan item as a live one.
|
|
330
|
+
if (t.styles?.strike || t.styles?.strikethrough) s = `~~${s}~~`;
|
|
331
|
+
if (t.styles?.underline) s = `<u>${s}</u>`;
|
|
317
332
|
return s;
|
|
318
333
|
}).join("");
|
|
319
334
|
}
|
|
@@ -343,7 +358,9 @@ function renderBlock(block: BNBlock, indent: string): string[] {
|
|
|
343
358
|
case "codeBlock": {
|
|
344
359
|
const lang = (block.props?.language as string) || "";
|
|
345
360
|
lines.push(`${indent}\`\`\`${lang}`);
|
|
346
|
-
|
|
361
|
+
// A code block's text is one node containing newlines. Indent every line,
|
|
362
|
+
// not just the first, or the rest escapes the list item it is nested in.
|
|
363
|
+
for (const body of inline.split("\n")) lines.push(`${indent}${body}`);
|
|
347
364
|
lines.push(`${indent}\`\`\``);
|
|
348
365
|
break;
|
|
349
366
|
}
|