yontrack-mcp 1.0.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/LICENSE +674 -0
- package/README.md +305 -0
- package/build/auth.js +228 -0
- package/build/client.js +5 -0
- package/build/config.js +23 -0
- package/build/index.js +127 -0
- package/build/server.js +21 -0
- package/build/test/helpers.js +17 -0
- package/build/test/setup.js +5 -0
- package/build/tools/branches.js +55 -0
- package/build/tools/branches.test.js +82 -0
- package/build/tools/build-links.js +73 -0
- package/build/tools/build-links.test.js +84 -0
- package/build/tools/builds.js +139 -0
- package/build/tools/builds.test.js +156 -0
- package/build/tools/graphql.js +31 -0
- package/build/tools/graphql.test.js +82 -0
- package/build/tools/index.js +23 -0
- package/build/tools/projects.js +47 -0
- package/build/tools/projects.test.js +76 -0
- package/build/tools/promotion-levels.js +83 -0
- package/build/tools/promotion-levels.test.js +118 -0
- package/build/tools/promotion-runs.js +78 -0
- package/build/tools/promotion-runs.test.js +82 -0
- package/build/tools/search.js +27 -0
- package/build/tools/search.test.js +49 -0
- package/build/tools/validation-runs.js +93 -0
- package/build/tools/validation-runs.test.js +82 -0
- package/build/tools/validation-stamps.js +63 -0
- package/build/tools/validation-stamps.test.js +76 -0
- package/build/utils.js +16 -0
- package/package.json +37 -0
- package/yontrack.graphql +11937 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
import { registerBuildTools } from "./builds.js";
|
|
4
|
+
vi.mock("../client.js", () => ({
|
|
5
|
+
gqlClient: { request: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
// Import after mock is hoisted
|
|
8
|
+
const { gqlClient } = await import("../client.js");
|
|
9
|
+
const mockRequest = gqlClient.request;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockRequest.mockReset();
|
|
12
|
+
});
|
|
13
|
+
describe("list_builds", () => {
|
|
14
|
+
it("returns builds for a branch", async () => {
|
|
15
|
+
const builds = [
|
|
16
|
+
{ id: 1, name: "1.0.1", displayName: "1.0.1", description: null, creation: { user: "alice", time: "2024-01-01T00:00:00Z" } },
|
|
17
|
+
];
|
|
18
|
+
mockRequest.mockResolvedValueOnce({ branches: [{ builds }] });
|
|
19
|
+
const client = await createTestClient(registerBuildTools);
|
|
20
|
+
const result = await client.callTool({
|
|
21
|
+
name: "list_builds",
|
|
22
|
+
arguments: { project: "myproject", branch: "main" },
|
|
23
|
+
});
|
|
24
|
+
const text = result.content[0].text;
|
|
25
|
+
expect(JSON.parse(text)).toEqual(builds);
|
|
26
|
+
});
|
|
27
|
+
it("returns empty array when branch not found", async () => {
|
|
28
|
+
mockRequest.mockResolvedValueOnce({ branches: [] });
|
|
29
|
+
const client = await createTestClient(registerBuildTools);
|
|
30
|
+
const result = await client.callTool({
|
|
31
|
+
name: "list_builds",
|
|
32
|
+
arguments: { project: "myproject", branch: "missing" },
|
|
33
|
+
});
|
|
34
|
+
const text = result.content[0].text;
|
|
35
|
+
expect(JSON.parse(text)).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("find_build", () => {
|
|
39
|
+
it("returns the matching build", async () => {
|
|
40
|
+
const build = { id: 42, name: "1.0.2", displayName: "1.0.2", description: null };
|
|
41
|
+
mockRequest.mockResolvedValueOnce({ builds: [build] });
|
|
42
|
+
const client = await createTestClient(registerBuildTools);
|
|
43
|
+
const result = await client.callTool({
|
|
44
|
+
name: "find_build",
|
|
45
|
+
arguments: { project: "myproject", branch: "main", name: "1.0.2" },
|
|
46
|
+
});
|
|
47
|
+
const text = result.content[0].text;
|
|
48
|
+
expect(JSON.parse(text)).toEqual(build);
|
|
49
|
+
});
|
|
50
|
+
it("returns null when no build matches", async () => {
|
|
51
|
+
mockRequest.mockResolvedValueOnce({ builds: [] });
|
|
52
|
+
const client = await createTestClient(registerBuildTools);
|
|
53
|
+
const result = await client.callTool({
|
|
54
|
+
name: "find_build",
|
|
55
|
+
arguments: { project: "myproject", branch: "main", name: "9.9.9" },
|
|
56
|
+
});
|
|
57
|
+
const text = result.content[0].text;
|
|
58
|
+
expect(JSON.parse(text)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("create_build", () => {
|
|
62
|
+
it("returns the created build", async () => {
|
|
63
|
+
const build = { id: 10, name: "2.0.0", displayName: "2.0.0" };
|
|
64
|
+
mockRequest.mockResolvedValueOnce({ createBuild: { build, userErrors: [] } });
|
|
65
|
+
const client = await createTestClient(registerBuildTools);
|
|
66
|
+
const result = await client.callTool({
|
|
67
|
+
name: "create_build",
|
|
68
|
+
arguments: { project: "myproject", branch: "main", name: "2.0.0" },
|
|
69
|
+
});
|
|
70
|
+
expect(result.isError).toBeFalsy();
|
|
71
|
+
const text = result.content[0].text;
|
|
72
|
+
expect(JSON.parse(text)).toEqual(build);
|
|
73
|
+
});
|
|
74
|
+
it("returns an error when userErrors is non-empty", async () => {
|
|
75
|
+
mockRequest.mockResolvedValueOnce({
|
|
76
|
+
createBuild: { build: null, userErrors: [{ message: "Branch not found" }] },
|
|
77
|
+
});
|
|
78
|
+
const client = await createTestClient(registerBuildTools);
|
|
79
|
+
const result = await client.callTool({
|
|
80
|
+
name: "create_build",
|
|
81
|
+
arguments: { project: "myproject", branch: "ghost", name: "2.0.0" },
|
|
82
|
+
});
|
|
83
|
+
expect(result.isError).toBe(true);
|
|
84
|
+
const text = result.content[0].text;
|
|
85
|
+
expect(text).toContain("Branch not found");
|
|
86
|
+
});
|
|
87
|
+
it("is not registered when mutations are disabled", async () => {
|
|
88
|
+
const client = await createTestClient(registerBuildTools, false);
|
|
89
|
+
const { tools } = await client.listTools();
|
|
90
|
+
expect(tools.map((t) => t.name)).not.toContain("create_build");
|
|
91
|
+
});
|
|
92
|
+
it("is registered when mutations are enabled", async () => {
|
|
93
|
+
const client = await createTestClient(registerBuildTools, true);
|
|
94
|
+
const { tools } = await client.listTools();
|
|
95
|
+
expect(tools.map((t) => t.name)).toContain("create_build");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("get_build_duration", () => {
|
|
99
|
+
it("calculates duration in seconds from build creation to promotion", async () => {
|
|
100
|
+
mockRequest.mockResolvedValueOnce({
|
|
101
|
+
builds: [{
|
|
102
|
+
creation: { time: "2024-01-01T10:00:00Z" },
|
|
103
|
+
promotionRuns: [{ creation: { time: "2024-01-01T10:05:30Z" } }],
|
|
104
|
+
}],
|
|
105
|
+
});
|
|
106
|
+
const client = await createTestClient(registerBuildTools);
|
|
107
|
+
const result = await client.callTool({
|
|
108
|
+
name: "get_build_duration",
|
|
109
|
+
arguments: { project: "myproject", branch: "main", build: "1.0.1" },
|
|
110
|
+
});
|
|
111
|
+
const text = result.content[0].text;
|
|
112
|
+
const data = JSON.parse(text);
|
|
113
|
+
expect(data.durationSeconds).toBe(330); // 5m30s
|
|
114
|
+
expect(data.promoted).toBeUndefined(); // only present when not promoted
|
|
115
|
+
expect(data.promotedAt).toBe("2024-01-01T10:05:30Z");
|
|
116
|
+
});
|
|
117
|
+
it("reports not promoted when no promotion run exists", async () => {
|
|
118
|
+
mockRequest.mockResolvedValueOnce({
|
|
119
|
+
builds: [{
|
|
120
|
+
creation: { time: "2024-01-01T10:00:00Z" },
|
|
121
|
+
promotionRuns: [],
|
|
122
|
+
}],
|
|
123
|
+
});
|
|
124
|
+
const client = await createTestClient(registerBuildTools);
|
|
125
|
+
const result = await client.callTool({
|
|
126
|
+
name: "get_build_duration",
|
|
127
|
+
arguments: { project: "myproject", branch: "main", build: "1.0.1" },
|
|
128
|
+
});
|
|
129
|
+
const text = result.content[0].text;
|
|
130
|
+
const data = JSON.parse(text);
|
|
131
|
+
expect(data.promoted).toBe(false);
|
|
132
|
+
expect(data.durationSeconds).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
it("returns an error when build not found", async () => {
|
|
135
|
+
mockRequest.mockResolvedValueOnce({ builds: [] });
|
|
136
|
+
const client = await createTestClient(registerBuildTools);
|
|
137
|
+
const result = await client.callTool({
|
|
138
|
+
name: "get_build_duration",
|
|
139
|
+
arguments: { project: "myproject", branch: "main", build: "9.9.9" },
|
|
140
|
+
});
|
|
141
|
+
expect(result.isError).toBe(true);
|
|
142
|
+
const text = result.content[0].text;
|
|
143
|
+
expect(text).toContain("Build not found");
|
|
144
|
+
});
|
|
145
|
+
it("uses BRONZE as default promotion level", async () => {
|
|
146
|
+
mockRequest.mockResolvedValueOnce({
|
|
147
|
+
builds: [{ creation: { time: "2024-01-01T10:00:00Z" }, promotionRuns: [] }],
|
|
148
|
+
});
|
|
149
|
+
const client = await createTestClient(registerBuildTools);
|
|
150
|
+
await client.callTool({
|
|
151
|
+
name: "get_build_duration",
|
|
152
|
+
arguments: { project: "myproject", branch: "main", build: "1.0.1" },
|
|
153
|
+
});
|
|
154
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ promotion: "BRONZE" }));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { gqlClient } from "../client.js";
|
|
5
|
+
// Resolves to yontrack.graphql at the package root, both in dev (src/tools/) and built (build/tools/)
|
|
6
|
+
const schemaPath = fileURLToPath(new URL("../../yontrack.graphql", import.meta.url));
|
|
7
|
+
const schemaContent = readFileSync(schemaPath, "utf-8");
|
|
8
|
+
export function registerGraphQLTools(server, allowMutations) {
|
|
9
|
+
server.resource("yontrack-schema", "yontrack://schema", async (uri) => ({
|
|
10
|
+
contents: [{
|
|
11
|
+
uri: uri.href,
|
|
12
|
+
text: schemaContent,
|
|
13
|
+
mimeType: "text/plain",
|
|
14
|
+
}],
|
|
15
|
+
}));
|
|
16
|
+
server.tool("graphql_query", "Execute a raw GraphQL query against the Yontrack API. Prefer this over the simpler tools whenever: (1) no existing tool covers the exact data needed, (2) the task would otherwise require calling simpler tools in a loop (N+1 pattern — e.g. fetching validation runs for each of N builds), or (3) multiple related entities can be retrieved in a single round-trip (e.g. builds with their promotion runs and build links). Read the yontrack://schema resource first to understand available types and fields.", {
|
|
17
|
+
query: z.string().describe("GraphQL query string"),
|
|
18
|
+
variables: z.record(z.unknown()).optional().describe("Query variables"),
|
|
19
|
+
}, async ({ query, variables }) => {
|
|
20
|
+
if (!allowMutations && /^\s*mutation\b/i.test(query)) {
|
|
21
|
+
return {
|
|
22
|
+
isError: true,
|
|
23
|
+
content: [{ type: "text", text: "Mutations are disabled. Set YONTRACK_MUTATIONS_ENABLED=true to allow them." }],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const data = await gqlClient.request(query, variables ?? {});
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
// Mock fs before the module is imported — readFileSync runs at load time
|
|
4
|
+
vi.mock("node:fs", () => ({
|
|
5
|
+
readFileSync: vi.fn().mockReturnValue("type Query { hello: String }"),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../client.js", () => ({
|
|
8
|
+
gqlClient: { request: vi.fn() },
|
|
9
|
+
}));
|
|
10
|
+
// Import after mocks are hoisted
|
|
11
|
+
const { gqlClient } = await import("../client.js");
|
|
12
|
+
const mockRequest = gqlClient.request;
|
|
13
|
+
const { registerGraphQLTools } = await import("./graphql.js");
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockRequest.mockReset();
|
|
16
|
+
});
|
|
17
|
+
describe("yontrack://schema resource", () => {
|
|
18
|
+
it("returns the schema content", async () => {
|
|
19
|
+
const client = await createTestClient(registerGraphQLTools);
|
|
20
|
+
const result = await client.readResource({ uri: "yontrack://schema" });
|
|
21
|
+
expect(result.contents).toHaveLength(1);
|
|
22
|
+
const content = result.contents[0];
|
|
23
|
+
expect(content.uri).toBe("yontrack://schema");
|
|
24
|
+
expect(content.text).toBe("type Query { hello: String }");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("graphql_query tool", () => {
|
|
28
|
+
it("executes a query and returns JSON result", async () => {
|
|
29
|
+
const responseData = { branches: [{ id: 1, name: "main" }] };
|
|
30
|
+
mockRequest.mockResolvedValueOnce(responseData);
|
|
31
|
+
const client = await createTestClient(registerGraphQLTools);
|
|
32
|
+
const result = await client.callTool({
|
|
33
|
+
name: "graphql_query",
|
|
34
|
+
arguments: {
|
|
35
|
+
query: "query { branches(project: \"myproject\", name: \"main\") { id name } }",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const text = result.content[0].text;
|
|
39
|
+
expect(JSON.parse(text)).toEqual(responseData);
|
|
40
|
+
});
|
|
41
|
+
it("passes variables to the GraphQL client", async () => {
|
|
42
|
+
mockRequest.mockResolvedValueOnce({ builds: [] });
|
|
43
|
+
const client = await createTestClient(registerGraphQLTools);
|
|
44
|
+
await client.callTool({
|
|
45
|
+
name: "graphql_query",
|
|
46
|
+
arguments: {
|
|
47
|
+
query: "query ListBuilds($project: String!) { builds(project: $project) { id } }",
|
|
48
|
+
variables: { project: "myproject" },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { project: "myproject" });
|
|
52
|
+
});
|
|
53
|
+
it("uses empty object when variables are omitted", async () => {
|
|
54
|
+
mockRequest.mockResolvedValueOnce({ hello: "world" });
|
|
55
|
+
const client = await createTestClient(registerGraphQLTools);
|
|
56
|
+
await client.callTool({
|
|
57
|
+
name: "graphql_query",
|
|
58
|
+
arguments: { query: "query { hello }" },
|
|
59
|
+
});
|
|
60
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), {});
|
|
61
|
+
});
|
|
62
|
+
it("rejects mutation queries when mutations are disabled", async () => {
|
|
63
|
+
const client = await createTestClient(registerGraphQLTools, false);
|
|
64
|
+
const result = await client.callTool({
|
|
65
|
+
name: "graphql_query",
|
|
66
|
+
arguments: { query: "mutation CreateBuild { createBuild(input: {}) { build { id } } }" },
|
|
67
|
+
});
|
|
68
|
+
expect(result.isError).toBe(true);
|
|
69
|
+
const text = result.content[0].text;
|
|
70
|
+
expect(text).toMatch(/mutations are disabled/i);
|
|
71
|
+
expect(mockRequest).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
it("allows mutation queries when mutations are enabled", async () => {
|
|
74
|
+
mockRequest.mockResolvedValueOnce({ createBuild: { build: { id: 1 } } });
|
|
75
|
+
const client = await createTestClient(registerGraphQLTools, true);
|
|
76
|
+
const result = await client.callTool({
|
|
77
|
+
name: "graphql_query",
|
|
78
|
+
arguments: { query: "mutation CreateBuild { createBuild(input: {}) { build { id } } }" },
|
|
79
|
+
});
|
|
80
|
+
expect(result.isError).toBeFalsy();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mutationsEnabled } from "../config.js";
|
|
2
|
+
import { registerProjectTools } from "./projects.js";
|
|
3
|
+
import { registerBranchTools } from "./branches.js";
|
|
4
|
+
import { registerBuildTools } from "./builds.js";
|
|
5
|
+
import { registerValidationStampTools } from "./validation-stamps.js";
|
|
6
|
+
import { registerValidationRunTools } from "./validation-runs.js";
|
|
7
|
+
import { registerPromotionLevelTools } from "./promotion-levels.js";
|
|
8
|
+
import { registerPromotionRunTools } from "./promotion-runs.js";
|
|
9
|
+
import { registerBuildLinkTools } from "./build-links.js";
|
|
10
|
+
import { registerSearchTools } from "./search.js";
|
|
11
|
+
import { registerGraphQLTools } from "./graphql.js";
|
|
12
|
+
export function registerAllTools(server) {
|
|
13
|
+
registerProjectTools(server, mutationsEnabled);
|
|
14
|
+
registerBranchTools(server, mutationsEnabled);
|
|
15
|
+
registerBuildTools(server, mutationsEnabled);
|
|
16
|
+
registerValidationStampTools(server, mutationsEnabled);
|
|
17
|
+
registerValidationRunTools(server, mutationsEnabled);
|
|
18
|
+
registerPromotionLevelTools(server, mutationsEnabled);
|
|
19
|
+
registerPromotionRunTools(server, mutationsEnabled);
|
|
20
|
+
registerBuildLinkTools(server, mutationsEnabled);
|
|
21
|
+
registerSearchTools(server);
|
|
22
|
+
registerGraphQLTools(server, mutationsEnabled);
|
|
23
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gqlClient } from "../client.js";
|
|
3
|
+
const LIST_PROJECTS = `
|
|
4
|
+
query ListProjects($name: String) {
|
|
5
|
+
projects(name: $name) {
|
|
6
|
+
id
|
|
7
|
+
name
|
|
8
|
+
description
|
|
9
|
+
disabled
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
const CREATE_PROJECT = `
|
|
14
|
+
mutation CreateProject($name: String!, $description: String) {
|
|
15
|
+
createProject(input: { name: $name, description: $description }) {
|
|
16
|
+
project { id name description disabled }
|
|
17
|
+
userErrors { message }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
export function registerProjectTools(server, allowMutations) {
|
|
22
|
+
server.tool("list_projects", "List all projects in Yontrack, optionally filtered by name", {
|
|
23
|
+
name: z.string().optional().describe("Filter by exact project name"),
|
|
24
|
+
}, async ({ name }) => {
|
|
25
|
+
const data = await gqlClient.request(LIST_PROJECTS, { name });
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: JSON.stringify(data.projects, null, 2) }],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
if (allowMutations)
|
|
31
|
+
server.tool("create_project", "Create a new project in Yontrack", {
|
|
32
|
+
name: z.string().describe("Project name"),
|
|
33
|
+
description: z.string().optional().describe("Project description"),
|
|
34
|
+
}, async ({ name, description }) => {
|
|
35
|
+
const data = await gqlClient.request(CREATE_PROJECT, { name, description });
|
|
36
|
+
const { project, userErrors } = data.createProject;
|
|
37
|
+
if (userErrors?.length) {
|
|
38
|
+
return {
|
|
39
|
+
isError: true,
|
|
40
|
+
content: [{ type: "text", text: userErrors.map((e) => e.message).join(", ") }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify(project, null, 2) }],
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
import { registerProjectTools } from "./projects.js";
|
|
4
|
+
vi.mock("../client.js", () => ({
|
|
5
|
+
gqlClient: { request: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
const { gqlClient } = await import("../client.js");
|
|
8
|
+
const mockRequest = gqlClient.request;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockRequest.mockReset();
|
|
11
|
+
});
|
|
12
|
+
describe("list_projects", () => {
|
|
13
|
+
it("returns all projects", async () => {
|
|
14
|
+
const projects = [
|
|
15
|
+
{ id: 1, name: "myproject", description: "A project", disabled: false },
|
|
16
|
+
];
|
|
17
|
+
mockRequest.mockResolvedValueOnce({ projects });
|
|
18
|
+
const client = await createTestClient(registerProjectTools);
|
|
19
|
+
const result = await client.callTool({ name: "list_projects", arguments: {} });
|
|
20
|
+
const text = result.content[0].text;
|
|
21
|
+
expect(JSON.parse(text)).toEqual(projects);
|
|
22
|
+
});
|
|
23
|
+
it("returns empty array when no projects match", async () => {
|
|
24
|
+
mockRequest.mockResolvedValueOnce({ projects: [] });
|
|
25
|
+
const client = await createTestClient(registerProjectTools);
|
|
26
|
+
const result = await client.callTool({
|
|
27
|
+
name: "list_projects",
|
|
28
|
+
arguments: { name: "nonexistent" },
|
|
29
|
+
});
|
|
30
|
+
const text = result.content[0].text;
|
|
31
|
+
expect(JSON.parse(text)).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
it("passes the name filter to the GraphQL client", async () => {
|
|
34
|
+
mockRequest.mockResolvedValueOnce({ projects: [] });
|
|
35
|
+
const client = await createTestClient(registerProjectTools);
|
|
36
|
+
await client.callTool({ name: "list_projects", arguments: { name: "foo" } });
|
|
37
|
+
expect(mockRequest).toHaveBeenCalledWith(expect.any(String), { name: "foo" });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("create_project", () => {
|
|
41
|
+
it("returns the created project", async () => {
|
|
42
|
+
const project = { id: 1, name: "newproject", description: "New", disabled: false };
|
|
43
|
+
mockRequest.mockResolvedValueOnce({ createProject: { project, userErrors: [] } });
|
|
44
|
+
const client = await createTestClient(registerProjectTools);
|
|
45
|
+
const result = await client.callTool({
|
|
46
|
+
name: "create_project",
|
|
47
|
+
arguments: { name: "newproject", description: "New" },
|
|
48
|
+
});
|
|
49
|
+
expect(result.isError).toBeFalsy();
|
|
50
|
+
const text = result.content[0].text;
|
|
51
|
+
expect(JSON.parse(text)).toEqual(project);
|
|
52
|
+
});
|
|
53
|
+
it("returns an error when userErrors is non-empty", async () => {
|
|
54
|
+
mockRequest.mockResolvedValueOnce({
|
|
55
|
+
createProject: { project: null, userErrors: [{ message: "Project already exists" }] },
|
|
56
|
+
});
|
|
57
|
+
const client = await createTestClient(registerProjectTools);
|
|
58
|
+
const result = await client.callTool({
|
|
59
|
+
name: "create_project",
|
|
60
|
+
arguments: { name: "existing" },
|
|
61
|
+
});
|
|
62
|
+
expect(result.isError).toBe(true);
|
|
63
|
+
const text = result.content[0].text;
|
|
64
|
+
expect(text).toContain("Project already exists");
|
|
65
|
+
});
|
|
66
|
+
it("is not registered when mutations are disabled", async () => {
|
|
67
|
+
const client = await createTestClient(registerProjectTools, false);
|
|
68
|
+
const { tools } = await client.listTools();
|
|
69
|
+
expect(tools.map((t) => t.name)).not.toContain("create_project");
|
|
70
|
+
});
|
|
71
|
+
it("is registered when mutations are enabled", async () => {
|
|
72
|
+
const client = await createTestClient(registerProjectTools, true);
|
|
73
|
+
const { tools } = await client.listTools();
|
|
74
|
+
expect(tools.map((t) => t.name)).toContain("create_project");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gqlClient } from "../client.js";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
import { resolveBranchId } from "../utils.js";
|
|
5
|
+
const LIST_PROMOTION_LEVELS = `
|
|
6
|
+
query ListPromotionLevels($project: String!, $branch: String!) {
|
|
7
|
+
branches(project: $project, name: $branch) {
|
|
8
|
+
promotionLevels {
|
|
9
|
+
id
|
|
10
|
+
name
|
|
11
|
+
description
|
|
12
|
+
image
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
const CREATE_PROMOTION_LEVEL = `
|
|
18
|
+
mutation CreatePromotionLevelById(
|
|
19
|
+
$branchId: Int!
|
|
20
|
+
$name: String!
|
|
21
|
+
$description: String!
|
|
22
|
+
) {
|
|
23
|
+
createPromotionLevelById(input: {
|
|
24
|
+
branchId: $branchId
|
|
25
|
+
name: $name
|
|
26
|
+
description: $description
|
|
27
|
+
}) {
|
|
28
|
+
promotionLevel { id name description branch { id name } }
|
|
29
|
+
userErrors { message }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
export function registerPromotionLevelTools(server, allowMutations) {
|
|
34
|
+
server.tool("list_promotion_levels", "List all promotion levels for a branch", {
|
|
35
|
+
project: z.string().describe("Project name"),
|
|
36
|
+
branch: z.string().describe("Branch name"),
|
|
37
|
+
}, async ({ project, branch }) => {
|
|
38
|
+
const data = await gqlClient.request(LIST_PROMOTION_LEVELS, { project, branch });
|
|
39
|
+
const levels = data.branches?.[0]?.promotionLevels ?? [];
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: JSON.stringify(levels, null, 2) }],
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
server.tool("get_promotion_level_image", "Fetch the PNG image for a promotion level. Returns an image if the promotion level has one, or an error text if it does not.", {
|
|
45
|
+
promotionLevelId: z.number().int().describe("Numeric ID of the promotion level"),
|
|
46
|
+
}, async ({ promotionLevelId }) => {
|
|
47
|
+
const url = `${config.YONTRACK_URL}/rest/structure/promotionLevels/${promotionLevelId}/image`;
|
|
48
|
+
const response = await fetch(url, {
|
|
49
|
+
headers: { "X-Ontrack-Token": config.YONTRACK_TOKEN },
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
return {
|
|
53
|
+
isError: true,
|
|
54
|
+
content: [{ type: "text", text: `No image available for promotion level ${promotionLevelId} (HTTP ${response.status})` }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const buffer = await response.arrayBuffer();
|
|
58
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "image", data: base64, mimeType: "image/png" }],
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
if (allowMutations)
|
|
64
|
+
server.tool("create_promotion_level", "Create a new promotion level on a branch", {
|
|
65
|
+
project: z.string().describe("Project name"),
|
|
66
|
+
branch: z.string().describe("Branch name"),
|
|
67
|
+
name: z.string().describe("Promotion level name (e.g. 'GOLD', 'SILVER')"),
|
|
68
|
+
description: z.string().optional().default("").describe("Description"),
|
|
69
|
+
}, async ({ project, branch, name, description }) => {
|
|
70
|
+
const branchId = await resolveBranchId(project, branch);
|
|
71
|
+
const data = await gqlClient.request(CREATE_PROMOTION_LEVEL, { branchId, name, description });
|
|
72
|
+
const { promotionLevel, userErrors } = data.createPromotionLevelById;
|
|
73
|
+
if (userErrors?.length) {
|
|
74
|
+
return {
|
|
75
|
+
isError: true,
|
|
76
|
+
content: [{ type: "text", text: userErrors.map((e) => e.message).join(", ") }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: JSON.stringify(promotionLevel, null, 2) }],
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { createTestClient } from "../test/helpers.js";
|
|
3
|
+
import { registerPromotionLevelTools } from "./promotion-levels.js";
|
|
4
|
+
vi.mock("../client.js", () => ({
|
|
5
|
+
gqlClient: { request: vi.fn() },
|
|
6
|
+
}));
|
|
7
|
+
vi.mock("../utils.js", () => ({
|
|
8
|
+
resolveBranchId: vi.fn().mockResolvedValue(42),
|
|
9
|
+
}));
|
|
10
|
+
vi.mock("../config.js", () => ({
|
|
11
|
+
config: { YONTRACK_URL: "https://yontrack.example.com", YONTRACK_TOKEN: "test-token" },
|
|
12
|
+
mutationsEnabled: false,
|
|
13
|
+
oauthConfig: null,
|
|
14
|
+
}));
|
|
15
|
+
const { gqlClient } = await import("../client.js");
|
|
16
|
+
const mockRequest = gqlClient.request;
|
|
17
|
+
const mockFetch = vi.fn();
|
|
18
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockRequest.mockReset();
|
|
21
|
+
mockFetch.mockReset();
|
|
22
|
+
});
|
|
23
|
+
describe("list_promotion_levels", () => {
|
|
24
|
+
it("returns promotion levels for a branch", async () => {
|
|
25
|
+
const levels = [
|
|
26
|
+
{ id: 1, name: "BRONZE", description: "Bronze level" },
|
|
27
|
+
{ id: 2, name: "SILVER", description: "Silver level" },
|
|
28
|
+
];
|
|
29
|
+
mockRequest.mockResolvedValueOnce({ branches: [{ promotionLevels: levels }] });
|
|
30
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
31
|
+
const result = await client.callTool({
|
|
32
|
+
name: "list_promotion_levels",
|
|
33
|
+
arguments: { project: "myproject", branch: "main" },
|
|
34
|
+
});
|
|
35
|
+
const text = result.content[0].text;
|
|
36
|
+
expect(JSON.parse(text)).toEqual(levels);
|
|
37
|
+
});
|
|
38
|
+
it("returns empty array when branch not found", async () => {
|
|
39
|
+
mockRequest.mockResolvedValueOnce({ branches: [] });
|
|
40
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
41
|
+
const result = await client.callTool({
|
|
42
|
+
name: "list_promotion_levels",
|
|
43
|
+
arguments: { project: "myproject", branch: "missing" },
|
|
44
|
+
});
|
|
45
|
+
const text = result.content[0].text;
|
|
46
|
+
expect(JSON.parse(text)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("get_promotion_level_image", () => {
|
|
50
|
+
it("returns base64-encoded PNG image when available", async () => {
|
|
51
|
+
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes
|
|
52
|
+
const expectedBase64 = Buffer.from(pngBytes).toString("base64");
|
|
53
|
+
mockFetch.mockResolvedValueOnce({
|
|
54
|
+
ok: true,
|
|
55
|
+
arrayBuffer: () => Promise.resolve(pngBytes.buffer),
|
|
56
|
+
});
|
|
57
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
58
|
+
const result = await client.callTool({
|
|
59
|
+
name: "get_promotion_level_image",
|
|
60
|
+
arguments: { promotionLevelId: 1 },
|
|
61
|
+
});
|
|
62
|
+
expect(result.isError).toBeFalsy();
|
|
63
|
+
const item = result.content[0];
|
|
64
|
+
expect(item.type).toBe("image");
|
|
65
|
+
expect(item.mimeType).toBe("image/png");
|
|
66
|
+
expect(item.data).toBe(expectedBase64);
|
|
67
|
+
expect(mockFetch).toHaveBeenCalledWith("https://yontrack.example.com/rest/structure/promotionLevels/1/image", { headers: { "X-Ontrack-Token": "test-token" } });
|
|
68
|
+
});
|
|
69
|
+
it("returns error when image is not found", async () => {
|
|
70
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
|
|
71
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
72
|
+
const result = await client.callTool({
|
|
73
|
+
name: "get_promotion_level_image",
|
|
74
|
+
arguments: { promotionLevelId: 99 },
|
|
75
|
+
});
|
|
76
|
+
expect(result.isError).toBe(true);
|
|
77
|
+
const text = result.content[0].text;
|
|
78
|
+
expect(text).toContain("99");
|
|
79
|
+
expect(text).toContain("404");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe("create_promotion_level", () => {
|
|
83
|
+
it("returns the created promotion level", async () => {
|
|
84
|
+
const level = { id: 3, name: "GOLD", description: "Gold level", branch: { id: 42, name: "main" } };
|
|
85
|
+
mockRequest.mockResolvedValueOnce({ createPromotionLevelById: { promotionLevel: level, userErrors: [] } });
|
|
86
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
87
|
+
const result = await client.callTool({
|
|
88
|
+
name: "create_promotion_level",
|
|
89
|
+
arguments: { project: "myproject", branch: "main", name: "GOLD", description: "Gold level" },
|
|
90
|
+
});
|
|
91
|
+
expect(result.isError).toBeFalsy();
|
|
92
|
+
const text = result.content[0].text;
|
|
93
|
+
expect(JSON.parse(text)).toEqual(level);
|
|
94
|
+
});
|
|
95
|
+
it("returns an error when userErrors is non-empty", async () => {
|
|
96
|
+
mockRequest.mockResolvedValueOnce({
|
|
97
|
+
createPromotionLevelById: { promotionLevel: null, userErrors: [{ message: "Promotion level already exists" }] },
|
|
98
|
+
});
|
|
99
|
+
const client = await createTestClient(registerPromotionLevelTools);
|
|
100
|
+
const result = await client.callTool({
|
|
101
|
+
name: "create_promotion_level",
|
|
102
|
+
arguments: { project: "myproject", branch: "main", name: "BRONZE" },
|
|
103
|
+
});
|
|
104
|
+
expect(result.isError).toBe(true);
|
|
105
|
+
const text = result.content[0].text;
|
|
106
|
+
expect(text).toContain("Promotion level already exists");
|
|
107
|
+
});
|
|
108
|
+
it("is not registered when mutations are disabled", async () => {
|
|
109
|
+
const client = await createTestClient(registerPromotionLevelTools, false);
|
|
110
|
+
const { tools } = await client.listTools();
|
|
111
|
+
expect(tools.map((t) => t.name)).not.toContain("create_promotion_level");
|
|
112
|
+
});
|
|
113
|
+
it("is registered when mutations are enabled", async () => {
|
|
114
|
+
const client = await createTestClient(registerPromotionLevelTools, true);
|
|
115
|
+
const { tools } = await client.listTools();
|
|
116
|
+
expect(tools.map((t) => t.name)).toContain("create_promotion_level");
|
|
117
|
+
});
|
|
118
|
+
});
|