@alexsarrell/jenkins-mcp-server 1.0.0 → 1.3.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 +84 -41
- package/dist/index.js +9 -3
- package/dist/jenkins-client.d.ts +6 -0
- package/dist/jenkins-client.js +28 -0
- package/dist/schemas/parameter.d.ts +48 -0
- package/dist/schemas/parameter.js +57 -0
- package/dist/tools/builds.js +69 -22
- package/dist/tools/discovery.js +151 -41
- package/dist/tools/jobs.d.ts +1 -1
- package/dist/tools/jobs.js +112 -8
- package/dist/tools/pipeline.d.ts +1 -1
- package/dist/tools/pipeline.js +36 -34
- package/dist/types.d.ts +20 -0
- package/dist/utils/build-payload.d.ts +10 -0
- package/dist/utils/build-payload.js +19 -0
- package/dist/utils/formatters.d.ts +2 -1
- package/dist/utils/formatters.js +40 -0
- package/dist/utils/job-diff.d.ts +7 -0
- package/dist/utils/job-diff.js +29 -0
- package/dist/utils/job-xml.d.ts +66 -0
- package/dist/utils/job-xml.js +415 -0
- package/dist/utils/log-grep.d.ts +20 -0
- package/dist/utils/log-grep.js +43 -0
- package/dist/utils/parameter-mapper.d.ts +13 -0
- package/dist/utils/parameter-mapper.js +70 -0
- package/package.json +13 -3
package/dist/tools/discovery.js
CHANGED
|
@@ -1,51 +1,71 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatQueue, ok, error } from "../utils/formatters.js";
|
|
2
|
+
import { formatQueue, formatQueueItem, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
3
|
export function registerDiscoveryTools(client, register) {
|
|
4
|
-
// 16. searchBuildLogs
|
|
5
|
-
register("searchBuildLogs", "Search build logs for a
|
|
4
|
+
// 16. searchBuildLogs (v2)
|
|
5
|
+
register("searchBuildLogs", "Search build logs for a pattern across recent builds. Supports regex, context lines, result filter, and progressive read for large logs. Streams the log and stops early once maxMatchesPerBuild is hit per build.", z.object({
|
|
6
6
|
jobPath: z.string().describe("Full job path"),
|
|
7
|
-
pattern: z.string().describe("
|
|
8
|
-
buildNumber: z.number().optional().describe("Search a specific build
|
|
9
|
-
lastN: z.number().optional().default(5).describe("Number of recent builds to search (
|
|
7
|
+
pattern: z.string().describe("Pattern to search for"),
|
|
8
|
+
buildNumber: z.number().optional().describe("Search a specific build only. Overrides lastN/onlyResults."),
|
|
9
|
+
lastN: z.number().optional().default(5).describe("Number of recent builds to search (max 20)"),
|
|
10
|
+
regex: z.boolean().optional().default(false).describe("Treat pattern as regex (default: case-insensitive substring)"),
|
|
11
|
+
before: z.number().optional().default(0).describe("Lines of context before each match"),
|
|
12
|
+
after: z.number().optional().default(0).describe("Lines of context after each match"),
|
|
13
|
+
maxMatchesPerBuild: z.number().optional().default(10).describe("Stop scanning a build after this many matches"),
|
|
14
|
+
onlyResults: z.array(z.enum(["SUCCESS", "FAILURE", "UNSTABLE", "ABORTED", "NOT_BUILT"])).optional()
|
|
15
|
+
.describe("Filter builds by result before searching. Default: search all."),
|
|
10
16
|
}), async (args) => {
|
|
11
17
|
const jobPath = args.jobPath;
|
|
12
18
|
const pattern = args.pattern;
|
|
13
19
|
const buildNumber = args.buildNumber;
|
|
14
20
|
const lastN = Math.min(args.lastN || 5, 20);
|
|
21
|
+
const regex = args.regex ?? false;
|
|
22
|
+
const before = args.before ?? 0;
|
|
23
|
+
const after = args.after ?? 0;
|
|
24
|
+
const maxMatchesPerBuild = args.maxMatchesPerBuild ?? 10;
|
|
25
|
+
const onlyResults = args.onlyResults;
|
|
15
26
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
results.push(...matches.map((m) => ` L${m.line}: ${m.text}`));
|
|
27
|
+
const matcher = (() => {
|
|
28
|
+
try {
|
|
29
|
+
return regex ? new RegExp(pattern) : null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
throw new Error(`Invalid regex: ${msg}`);
|
|
24
34
|
}
|
|
35
|
+
})();
|
|
36
|
+
const isMatch = (line) => matcher ? matcher.test(line) : line.toLowerCase().includes(pattern.toLowerCase());
|
|
37
|
+
// Determine which builds to search.
|
|
38
|
+
let buildsToSearch;
|
|
39
|
+
if (buildNumber !== undefined) {
|
|
40
|
+
buildsToSearch = [buildNumber];
|
|
25
41
|
}
|
|
26
42
|
else {
|
|
27
|
-
// Get recent builds
|
|
28
43
|
const data = await client.get(jobPath, "/api/json", {
|
|
29
|
-
tree:
|
|
44
|
+
tree: `builds[number,result]{0,${lastN}}`,
|
|
30
45
|
});
|
|
31
46
|
const jobData = data;
|
|
32
|
-
const builds = jobData.builds
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
const builds = jobData.builds ?? [];
|
|
48
|
+
buildsToSearch = builds
|
|
49
|
+
.filter((b) => !onlyResults || (b.result !== null && onlyResults.includes(b.result)))
|
|
50
|
+
.map((b) => b.number);
|
|
51
|
+
}
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const num of buildsToSearch) {
|
|
54
|
+
const matches = await searchOneBuild(client, jobPath, num, isMatch, before, after, maxMatchesPerBuild);
|
|
55
|
+
if (matches.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
results.push(`Build #${num} (${matches.length}${matches.length >= maxMatchesPerBuild ? "+" : ""} matches):`);
|
|
58
|
+
for (const m of matches.slice(0, maxMatchesPerBuild)) {
|
|
59
|
+
for (const c of m.context) {
|
|
60
|
+
results.push(` ${String(c.lineNumber).padStart(6)}: ${c.text}`);
|
|
42
61
|
}
|
|
62
|
+
results.push("");
|
|
43
63
|
}
|
|
44
64
|
}
|
|
45
65
|
if (results.length === 0) {
|
|
46
|
-
return ok(`No matches
|
|
66
|
+
return ok(`No matches for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${buildsToSearch.length} build(s)`}.`);
|
|
47
67
|
}
|
|
48
|
-
return ok(
|
|
68
|
+
return ok(truncateText(results.join("\n")));
|
|
49
69
|
}
|
|
50
70
|
catch (e) {
|
|
51
71
|
return handleError(e);
|
|
@@ -64,7 +84,20 @@ export function registerDiscoveryTools(client, register) {
|
|
|
64
84
|
return handleError(e);
|
|
65
85
|
}
|
|
66
86
|
});
|
|
67
|
-
// 18.
|
|
87
|
+
// 18. getQueueItem
|
|
88
|
+
register("getQueueItem", "Get the state of a specific queue item by ID. Use the queue ID returned from triggerBuild ('Queue item: #N') to find which build was started — this bridges queue → build.", z.object({
|
|
89
|
+
queueId: z.number().int().describe("Queue item ID"),
|
|
90
|
+
}), async (args) => {
|
|
91
|
+
const queueId = args.queueId;
|
|
92
|
+
try {
|
|
93
|
+
const data = await client.getAbsolute(`/queue/item/${queueId}/api/json`);
|
|
94
|
+
return ok(formatQueueItem(data));
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return handleError(e);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
// 19. enableDisableJob
|
|
68
101
|
register("enableDisableJob", "Enable or disable a Jenkins job. Disabled jobs cannot be triggered.", z.object({
|
|
69
102
|
jobPath: z.string().describe("Full job path"),
|
|
70
103
|
enabled: z.boolean().describe("true to enable, false to disable"),
|
|
@@ -81,24 +114,101 @@ export function registerDiscoveryTools(client, register) {
|
|
|
81
114
|
}
|
|
82
115
|
});
|
|
83
116
|
}
|
|
84
|
-
async function
|
|
117
|
+
async function searchOneBuild(client, jobPath, buildNumber, isMatch, before, after, maxMatches) {
|
|
118
|
+
const matches = [];
|
|
119
|
+
let lineNumber = 0;
|
|
120
|
+
// Sliding context buffer of recent lines.
|
|
121
|
+
const tail = [];
|
|
122
|
+
// Pending matches awaiting `after` lines.
|
|
123
|
+
const pending = [];
|
|
124
|
+
let leftover = "";
|
|
85
125
|
try {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
for await (const chunk of client.getProgressiveText(jobPath, buildNumber)) {
|
|
127
|
+
const data = leftover + chunk;
|
|
128
|
+
const lines = data.split("\n");
|
|
129
|
+
// The last fragment may be a partial line — keep it for the next chunk.
|
|
130
|
+
leftover = lines.pop() ?? "";
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
lineNumber++;
|
|
133
|
+
// Drain pending after-context.
|
|
134
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
135
|
+
const p = pending[i];
|
|
136
|
+
p.context.push({ lineNumber, text: line });
|
|
137
|
+
p.collected++;
|
|
138
|
+
if (p.collected >= after) {
|
|
139
|
+
matches.push({ lineNumber: p.lineNumber, context: p.context });
|
|
140
|
+
pending.splice(i, 1);
|
|
141
|
+
if (matches.length >= maxMatches)
|
|
142
|
+
return matches;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (isMatch(line)) {
|
|
146
|
+
const ctx = [];
|
|
147
|
+
// Take last `before` lines from tail.
|
|
148
|
+
const startIdx = Math.max(0, tail.length - before);
|
|
149
|
+
for (let j = startIdx; j < tail.length; j++) {
|
|
150
|
+
ctx.push({ lineNumber: lineNumber - (tail.length - j), text: tail[j] });
|
|
151
|
+
}
|
|
152
|
+
ctx.push({ lineNumber, text: line });
|
|
153
|
+
if (after === 0) {
|
|
154
|
+
matches.push({ lineNumber, context: ctx });
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
pending.push({ lineNumber, collected: 0, context: ctx });
|
|
158
|
+
}
|
|
159
|
+
if (matches.length >= maxMatches)
|
|
160
|
+
return matches;
|
|
161
|
+
}
|
|
162
|
+
tail.push(line);
|
|
163
|
+
if (tail.length > before)
|
|
164
|
+
tail.shift();
|
|
95
165
|
}
|
|
96
166
|
}
|
|
97
|
-
return matches;
|
|
98
167
|
}
|
|
99
168
|
catch {
|
|
100
|
-
|
|
169
|
+
// Fallback to /consoleText (e.g., progressiveText not available).
|
|
170
|
+
const text = await client.getRaw(jobPath, `/${buildNumber}/consoleText`);
|
|
171
|
+
return fallbackSearch(text, isMatch, before, after, maxMatches);
|
|
172
|
+
}
|
|
173
|
+
// Process trailing fragment if log doesn't end with \n.
|
|
174
|
+
if (leftover.length > 0) {
|
|
175
|
+
lineNumber++;
|
|
176
|
+
for (const p of pending) {
|
|
177
|
+
p.context.push({ lineNumber, text: leftover });
|
|
178
|
+
}
|
|
179
|
+
if (isMatch(leftover)) {
|
|
180
|
+
const ctx = [];
|
|
181
|
+
const startIdx = Math.max(0, tail.length - before);
|
|
182
|
+
for (let j = startIdx; j < tail.length; j++) {
|
|
183
|
+
ctx.push({ lineNumber: lineNumber - (tail.length - j), text: tail[j] });
|
|
184
|
+
}
|
|
185
|
+
ctx.push({ lineNumber, text: leftover });
|
|
186
|
+
matches.push({ lineNumber, context: ctx });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Final flush: emit pending matches with whatever after-context we got.
|
|
190
|
+
for (const p of pending) {
|
|
191
|
+
matches.push({ lineNumber: p.lineNumber, context: p.context });
|
|
192
|
+
}
|
|
193
|
+
return matches.slice(0, maxMatches);
|
|
194
|
+
}
|
|
195
|
+
function fallbackSearch(text, isMatch, before, after, maxMatches) {
|
|
196
|
+
const lines = text.split("\n");
|
|
197
|
+
const matches = [];
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
if (!isMatch(lines[i]))
|
|
200
|
+
continue;
|
|
201
|
+
const ctx = [];
|
|
202
|
+
const start = Math.max(0, i - before);
|
|
203
|
+
const end = Math.min(lines.length - 1, i + after);
|
|
204
|
+
for (let j = start; j <= end; j++) {
|
|
205
|
+
ctx.push({ lineNumber: j + 1, text: lines[j] });
|
|
206
|
+
}
|
|
207
|
+
matches.push({ lineNumber: i + 1, context: ctx });
|
|
208
|
+
if (matches.length >= maxMatches)
|
|
209
|
+
return matches;
|
|
101
210
|
}
|
|
211
|
+
return matches;
|
|
102
212
|
}
|
|
103
213
|
function handleError(e) {
|
|
104
214
|
if (e && typeof e === "object" && "errorCode" in e) {
|
package/dist/tools/jobs.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { JenkinsClient } from "../jenkins-client.js";
|
|
3
3
|
import type { ToolResult } from "../types.js";
|
|
4
|
-
export declare function registerJobTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void): void;
|
|
4
|
+
export declare function registerJobTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void, allowUnsafe: boolean): void;
|
package/dist/tools/jobs.js
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatJobList, formatJobDetail, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
|
-
|
|
3
|
+
import { mapJenkinsParameter } from "../utils/parameter-mapper.js";
|
|
4
|
+
import { parseJobConfig, buildJobXml } from "../utils/job-xml.js";
|
|
5
|
+
import { diffJobSpecs } from "../utils/job-diff.js";
|
|
6
|
+
import { parameterSpec } from "../schemas/parameter.js";
|
|
7
|
+
const pipelineSpecSchema = z.object({
|
|
8
|
+
type: z.literal("pipeline"),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
disabled: z.boolean().optional(),
|
|
11
|
+
scm: z.object({
|
|
12
|
+
type: z.literal("git"),
|
|
13
|
+
url: z.string(),
|
|
14
|
+
branches: z.array(z.string()).optional(),
|
|
15
|
+
credentialsId: z.string().optional(),
|
|
16
|
+
jenkinsfilePath: z.string().optional(),
|
|
17
|
+
}),
|
|
18
|
+
triggers: z.object({ cron: z.string().optional() }).optional(),
|
|
19
|
+
parameters: z.array(parameterSpec).optional(),
|
|
20
|
+
buildRetention: z.object({ numToKeep: z.number().optional(), daysToKeep: z.number().optional() }).optional(),
|
|
21
|
+
});
|
|
22
|
+
const multibranchSpecSchema = z.object({
|
|
23
|
+
type: z.literal("multibranch"),
|
|
24
|
+
description: z.string().optional(),
|
|
25
|
+
source: z.object({
|
|
26
|
+
type: z.literal("git"),
|
|
27
|
+
url: z.string(),
|
|
28
|
+
credentialsId: z.string().optional(),
|
|
29
|
+
}),
|
|
30
|
+
jenkinsfilePath: z.string().optional(),
|
|
31
|
+
orphanedItemStrategy: z.object({ numToKeep: z.number().optional(), daysToKeep: z.number().optional() }).optional(),
|
|
32
|
+
});
|
|
33
|
+
const folderSpecSchema = z.object({
|
|
34
|
+
type: z.literal("folder"),
|
|
35
|
+
description: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
const jobSpecSchema = z.discriminatedUnion("type", [pipelineSpecSchema, multibranchSpecSchema, folderSpecSchema]);
|
|
38
|
+
export function registerJobTools(client, register, allowUnsafe) {
|
|
4
39
|
// 1. getJobs
|
|
5
40
|
register("getJobs", "List Jenkins jobs at root level or in a specific folder. Returns job names, status, and last build info. For multibranch pipelines, lists branches as sub-jobs.", z.object({
|
|
6
41
|
folder: z.string().optional().describe("Folder path (e.g., 'my-folder' or 'folder/subfolder'). Omit for root level."),
|
|
@@ -52,21 +87,90 @@ export function registerJobTools(client, register) {
|
|
|
52
87
|
return handleError(e);
|
|
53
88
|
}
|
|
54
89
|
});
|
|
55
|
-
//
|
|
56
|
-
register("
|
|
57
|
-
jobPath: z.string().describe("Full job path
|
|
58
|
-
configXml: z.string().describe("Complete XML configuration for the job"),
|
|
90
|
+
// 3b. getJobParameters
|
|
91
|
+
register("getJobParameters", "Get the structured parameter schema for a Jenkins job. Returns each parameter's type (string/choice/boolean/password/file/run/credentials/unknown), default, choices, and description as JSON. Use this instead of getJob when you need to know what to pass to triggerBuild.", z.object({
|
|
92
|
+
jobPath: z.string().describe("Full job path"),
|
|
59
93
|
}), async (args) => {
|
|
60
94
|
const jobPath = args.jobPath;
|
|
61
|
-
const configXml = args.configXml;
|
|
62
95
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
96
|
+
const tree = "property[parameterDefinitions[name,type,description,defaultParameterValue[value],choices,projectName,credentialType,_class]]";
|
|
97
|
+
const data = await client.get(jobPath, "/api/json", { tree });
|
|
98
|
+
const job = data;
|
|
99
|
+
const defs = (job.property ?? []).flatMap((p) => p.parameterDefinitions ?? []);
|
|
100
|
+
const parameters = defs.map(mapJenkinsParameter);
|
|
101
|
+
return ok(JSON.stringify({ parameters }, null, 2));
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return handleError(e);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
// 3c. describeJob
|
|
108
|
+
register("describeJob", "Get a structured view of a job's config (SCM url/branch, Jenkinsfile path, parameters, retention, cron) without parsing raw XML. Reports unrecognised XML elements via 'unknownXmlElements'. Read-only and safe.", z.object({
|
|
109
|
+
jobPath: z.string().describe("Full job path"),
|
|
110
|
+
}), async (args) => {
|
|
111
|
+
const jobPath = args.jobPath;
|
|
112
|
+
try {
|
|
113
|
+
const xml = await client.getRaw(jobPath, "/config.xml");
|
|
114
|
+
const desc = parseJobConfig(xml);
|
|
115
|
+
return ok(JSON.stringify(desc, null, 2));
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return handleError(e);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// 3d. previewJobConfig
|
|
122
|
+
register("previewJobConfig", "Generate Jenkins job XML config from a structured spec. Optionally diff against an existing job's current config (read-only — does not modify Jenkins). Use the resulting XML with updateJobConfig (gated by JENKINS_ALLOW_UNSAFE_OPERATIONS) once it looks right.", z.object({
|
|
123
|
+
spec: jobSpecSchema,
|
|
124
|
+
diffAgainstJobPath: z.string().optional().describe("If set, fetch the current config of this job and emit a structured diff."),
|
|
125
|
+
}), async (args) => {
|
|
126
|
+
const spec = args.spec;
|
|
127
|
+
const diffPath = args.diffAgainstJobPath;
|
|
128
|
+
try {
|
|
129
|
+
const xml = buildJobXml(spec);
|
|
130
|
+
const out = [`=== Generated config.xml (${spec.type}) ===`, xml];
|
|
131
|
+
if (diffPath) {
|
|
132
|
+
const currentXml = await client.getRaw(diffPath, "/config.xml");
|
|
133
|
+
const before = parseJobConfig(currentXml);
|
|
134
|
+
const after = parseJobConfig(xml);
|
|
135
|
+
const changes = diffJobSpecs(before, after);
|
|
136
|
+
out.push(`\n=== Diff against ${diffPath} ===`);
|
|
137
|
+
if (changes.length === 0) {
|
|
138
|
+
out.push("(no structural changes)");
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
for (const c of changes) {
|
|
142
|
+
out.push(`@@ ${c.path} @@`);
|
|
143
|
+
out.push(`- ${c.before}`);
|
|
144
|
+
out.push(`+ ${c.after}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (before.unknownXmlElements.length > 0) {
|
|
148
|
+
out.push(`\n(skipped: ${before.unknownXmlElements.join(", ")})`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ok(out.join("\n"));
|
|
65
152
|
}
|
|
66
153
|
catch (e) {
|
|
67
154
|
return handleError(e);
|
|
68
155
|
}
|
|
69
156
|
});
|
|
157
|
+
// 4. updateJobConfig (unsafe — requires JENKINS_ALLOW_UNSAFE_OPERATIONS=true)
|
|
158
|
+
if (allowUnsafe) {
|
|
159
|
+
register("updateJobConfig", "Update a Jenkins job's XML configuration. Send the complete config.xml content. Use getJobConfig first to read the current config, modify it, then submit.", z.object({
|
|
160
|
+
jobPath: z.string().describe("Full job path (e.g., 'my-folder/my-job')"),
|
|
161
|
+
configXml: z.string().describe("Complete XML configuration for the job"),
|
|
162
|
+
}), async (args) => {
|
|
163
|
+
const jobPath = args.jobPath;
|
|
164
|
+
const configXml = args.configXml;
|
|
165
|
+
try {
|
|
166
|
+
await client.post(jobPath, "/config.xml", configXml, "application/xml");
|
|
167
|
+
return ok(`Job config updated successfully: ${jobPath}`);
|
|
168
|
+
}
|
|
169
|
+
catch (e) {
|
|
170
|
+
return handleError(e);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
70
174
|
}
|
|
71
175
|
function handleError(e) {
|
|
72
176
|
if (e && typeof e === "object" && "errorCode" in e) {
|
package/dist/tools/pipeline.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { JenkinsClient } from "../jenkins-client.js";
|
|
3
3
|
import type { ToolResult } from "../types.js";
|
|
4
|
-
export declare function registerPipelineTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void): void;
|
|
4
|
+
export declare function registerPipelineTools(client: JenkinsClient, register: (name: string, description: string, schema: z.ZodType, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => void, allowUnsafe: boolean): void;
|
package/dist/tools/pipeline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatStages, formatDuration, ok, error, truncateText } from "../utils/formatters.js";
|
|
3
|
-
export function registerPipelineTools(client, register) {
|
|
3
|
+
export function registerPipelineTools(client, register, allowUnsafe) {
|
|
4
4
|
// 11. getPipelineStages
|
|
5
5
|
register("getPipelineStages", "Get pipeline stages overview for a build - shows stage names, status, and duration. Works with Pipeline (Workflow) jobs. Useful for understanding which stage failed.", z.object({
|
|
6
6
|
jobPath: z.string().describe("Full job path"),
|
|
@@ -116,43 +116,45 @@ export function registerPipelineTools(client, register) {
|
|
|
116
116
|
return handleError(e, "Replay page may not be available. Ensure the build is a Pipeline job.");
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
|
-
// 14. replayBuild
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
119
|
+
// 14. replayBuild (unsafe — requires JENKINS_ALLOW_UNSAFE_OPERATIONS=true)
|
|
120
|
+
if (allowUnsafe) {
|
|
121
|
+
register("replayBuild", "Replay a pipeline build with optional script modifications. If no script is provided, replays with the same script. Use getPipelineScript first to get the current script, modify it, then replay.", z.object({
|
|
122
|
+
jobPath: z.string().describe("Full job path"),
|
|
123
|
+
buildNumber: z.number().describe("Build number to replay"),
|
|
124
|
+
mainScript: z.string().optional().describe("Modified Jenkinsfile content. If omitted, replays with the original script."),
|
|
125
|
+
}), async (args) => {
|
|
126
|
+
const jobPath = args.jobPath;
|
|
127
|
+
const buildNumber = args.buildNumber;
|
|
128
|
+
let mainScript = args.mainScript;
|
|
129
|
+
try {
|
|
130
|
+
// If no script provided, fetch current one
|
|
131
|
+
if (!mainScript) {
|
|
132
|
+
const html = await client.getRaw(jobPath, `/${buildNumber}/replay`);
|
|
133
|
+
const match = html.match(/name="_.mainScript"[^>]*>([\s\S]*?)<\/textarea>/);
|
|
134
|
+
if (match) {
|
|
135
|
+
mainScript = decodeHtmlEntities(match[1]);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
return error("Could not extract current pipeline script for replay.", "Try providing the script explicitly via the mainScript parameter.");
|
|
139
|
+
}
|
|
135
140
|
}
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
// Submit replay
|
|
142
|
+
const formData = new URLSearchParams();
|
|
143
|
+
formData.set("mainScript", mainScript);
|
|
144
|
+
formData.set("json", JSON.stringify({ mainScript }));
|
|
145
|
+
const result = await client.postForm(jobPath, `/${buildNumber}/replay/run`, formData);
|
|
146
|
+
// The response redirects to the new build page
|
|
147
|
+
const location = result.response.headers.get("location");
|
|
148
|
+
if (location) {
|
|
149
|
+
return ok(`Replay triggered successfully.\nNew build: ${location}`);
|
|
138
150
|
}
|
|
151
|
+
return ok(`Replay triggered for ${jobPath} build #${buildNumber}.`);
|
|
139
152
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
formData.set("mainScript", mainScript);
|
|
143
|
-
formData.set("json", JSON.stringify({ mainScript }));
|
|
144
|
-
const result = await client.postForm(jobPath, `/${buildNumber}/replay/run`, formData);
|
|
145
|
-
// The response redirects to the new build page
|
|
146
|
-
const location = result.response.headers.get("location");
|
|
147
|
-
if (location) {
|
|
148
|
-
return ok(`Replay triggered successfully.\nNew build: ${location}`);
|
|
153
|
+
catch (e) {
|
|
154
|
+
return handleError(e, "Replay requires Pipeline job type and appropriate permissions.");
|
|
149
155
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
catch (e) {
|
|
153
|
-
return handleError(e, "Replay requires Pipeline job type and appropriate permissions.");
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
156
158
|
// 15. restartFromStage
|
|
157
159
|
register("restartFromStage", "Restart a pipeline from a specific stage. Requires the Declarative Pipeline plugin with 'Restart from Stage' support. Only works with top-level stages in Declarative pipelines and completed builds.", z.object({
|
|
158
160
|
jobPath: z.string().describe("Full job path"),
|
package/dist/types.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface JenkinsBuild {
|
|
|
60
60
|
shortDescription: string;
|
|
61
61
|
userName?: string;
|
|
62
62
|
}>;
|
|
63
|
+
parameters?: Array<{
|
|
64
|
+
_class?: string;
|
|
65
|
+
name: string;
|
|
66
|
+
value?: string | boolean | number;
|
|
67
|
+
}>;
|
|
63
68
|
}>;
|
|
64
69
|
artifacts?: BuildArtifact[];
|
|
65
70
|
changeSets?: Array<{
|
|
@@ -128,6 +133,21 @@ export interface QueueItem {
|
|
|
128
133
|
stuck: boolean;
|
|
129
134
|
blocked: boolean;
|
|
130
135
|
}
|
|
136
|
+
export interface QueueItemDetail {
|
|
137
|
+
id: number;
|
|
138
|
+
task: {
|
|
139
|
+
name: string;
|
|
140
|
+
url: string;
|
|
141
|
+
};
|
|
142
|
+
why: string | null;
|
|
143
|
+
cancelled?: boolean;
|
|
144
|
+
executable?: {
|
|
145
|
+
number: number;
|
|
146
|
+
url: string;
|
|
147
|
+
};
|
|
148
|
+
_class?: string;
|
|
149
|
+
}
|
|
150
|
+
export type QueueItemState = "WAITING" | "BLOCKED" | "BUILDABLE" | "LEFT_QUEUE" | "CANCELLED" | "UNKNOWN";
|
|
131
151
|
export interface JenkinsError {
|
|
132
152
|
statusCode: number;
|
|
133
153
|
message: string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface JsonParameter {
|
|
2
|
+
name: string;
|
|
3
|
+
value: string | string[];
|
|
4
|
+
}
|
|
5
|
+
export interface TriggerPayload {
|
|
6
|
+
formData: URLSearchParams;
|
|
7
|
+
jsonParameters: JsonParameter[];
|
|
8
|
+
}
|
|
9
|
+
export type ParameterValue = string | string[];
|
|
10
|
+
export declare function buildTriggerPayload(parameters: Record<string, ParameterValue>, splitOnComma: boolean): TriggerPayload;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function buildTriggerPayload(parameters, splitOnComma) {
|
|
2
|
+
const formData = new URLSearchParams();
|
|
3
|
+
const jsonParameters = [];
|
|
4
|
+
for (const [name, raw] of Object.entries(parameters)) {
|
|
5
|
+
const values = Array.isArray(raw)
|
|
6
|
+
? raw
|
|
7
|
+
: splitOnComma && raw.includes(",")
|
|
8
|
+
? raw.split(",").map((v) => v.trim())
|
|
9
|
+
: [raw];
|
|
10
|
+
for (const v of values) {
|
|
11
|
+
formData.append(name, v);
|
|
12
|
+
}
|
|
13
|
+
jsonParameters.push({
|
|
14
|
+
name,
|
|
15
|
+
value: values.length === 1 ? values[0] : values,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return { formData, jsonParameters };
|
|
19
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JenkinsJob, JenkinsBuild, PipelineStage, QueueItem, ToolResult } from "../types.js";
|
|
1
|
+
import type { JenkinsJob, JenkinsBuild, PipelineStage, QueueItem, QueueItemDetail, ToolResult } from "../types.js";
|
|
2
2
|
export declare function truncateText(text: string, maxBytes?: number): string;
|
|
3
3
|
export declare function formatJobStatus(color: string): string;
|
|
4
4
|
export declare function formatTimestamp(ts: number): string;
|
|
@@ -10,3 +10,4 @@ export declare function formatStages(stages: PipelineStage[]): string;
|
|
|
10
10
|
export declare function formatQueue(items: QueueItem[]): string;
|
|
11
11
|
export declare function ok(text: string): ToolResult;
|
|
12
12
|
export declare function error(message: string, suggestion?: string): ToolResult;
|
|
13
|
+
export declare function formatQueueItem(item: QueueItemDetail): string;
|
package/dist/utils/formatters.js
CHANGED
|
@@ -116,6 +116,17 @@ export function formatBuild(build) {
|
|
|
116
116
|
if (causes && causes.length > 0) {
|
|
117
117
|
lines.push(`Triggered by: ${causes.join(", ")}`);
|
|
118
118
|
}
|
|
119
|
+
// Parameters
|
|
120
|
+
const parametersAction = build.actions?.find((a) => a.parameters && a.parameters.length > 0);
|
|
121
|
+
if (parametersAction?.parameters) {
|
|
122
|
+
lines.push(`\nParameters (${parametersAction.parameters.length}):`);
|
|
123
|
+
for (const p of parametersAction.parameters) {
|
|
124
|
+
// Loose substring match — covers PasswordParameterValue and most plugin variants.
|
|
125
|
+
const isPassword = (p._class || "").toLowerCase().includes("password");
|
|
126
|
+
const display = isPassword ? "[hidden]" : String(p.value ?? "");
|
|
127
|
+
lines.push(` ${p.name} = ${display}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
119
130
|
// Artifacts
|
|
120
131
|
if (build.artifacts && build.artifacts.length > 0) {
|
|
121
132
|
lines.push(`\nArtifacts (${build.artifacts.length}):`);
|
|
@@ -164,3 +175,32 @@ export function error(message, suggestion) {
|
|
|
164
175
|
text += `\nSuggestion: ${suggestion}`;
|
|
165
176
|
return { content: [{ type: "text", text }], isError: true };
|
|
166
177
|
}
|
|
178
|
+
function classifyQueueItem(item) {
|
|
179
|
+
if (item.cancelled)
|
|
180
|
+
return "CANCELLED";
|
|
181
|
+
const cls = item._class || "";
|
|
182
|
+
if (cls.endsWith("$WaitingItem"))
|
|
183
|
+
return "WAITING";
|
|
184
|
+
if (cls.endsWith("$BlockedItem"))
|
|
185
|
+
return "BLOCKED";
|
|
186
|
+
if (cls.endsWith("$BuildableItem"))
|
|
187
|
+
return "BUILDABLE";
|
|
188
|
+
if (cls.endsWith("$LeftItem"))
|
|
189
|
+
return "LEFT_QUEUE";
|
|
190
|
+
if (cls.endsWith("$CancelledItem"))
|
|
191
|
+
return "CANCELLED";
|
|
192
|
+
return "UNKNOWN";
|
|
193
|
+
}
|
|
194
|
+
export function formatQueueItem(item) {
|
|
195
|
+
const state = classifyQueueItem(item);
|
|
196
|
+
const lines = [`Queue item #${item.id}: ${state}`];
|
|
197
|
+
lines.push(`Task: ${item.task.name}`);
|
|
198
|
+
if (state === "LEFT_QUEUE" && item.executable) {
|
|
199
|
+
lines.push(`Build started: ${item.task.name} #${item.executable.number}`);
|
|
200
|
+
lines.push(`URL: ${item.executable.url}`);
|
|
201
|
+
}
|
|
202
|
+
else if (item.why) {
|
|
203
|
+
lines.push(`Why: ${item.why}`);
|
|
204
|
+
}
|
|
205
|
+
return lines.join("\n");
|
|
206
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function flatten(obj, prefix) {
|
|
2
|
+
if (obj === null || obj === undefined)
|
|
3
|
+
return { [prefix]: "(unset)" };
|
|
4
|
+
if (typeof obj !== "object")
|
|
5
|
+
return { [prefix]: String(obj) };
|
|
6
|
+
if (Array.isArray(obj))
|
|
7
|
+
return { [prefix]: JSON.stringify(obj) };
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
10
|
+
const next = prefix ? `${prefix}.${k}` : k;
|
|
11
|
+
Object.assign(out, flatten(v, next));
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
export function diffJobSpecs(before, after) {
|
|
16
|
+
const a = flatten(before, "");
|
|
17
|
+
const b = flatten(after, "");
|
|
18
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
19
|
+
const changes = [];
|
|
20
|
+
for (const k of keys) {
|
|
21
|
+
if (k === "rawConfigSha" || k.startsWith("unknownXmlElements"))
|
|
22
|
+
continue;
|
|
23
|
+
const left = a[k] ?? "(unset)";
|
|
24
|
+
const right = b[k] ?? "(unset)";
|
|
25
|
+
if (left !== right)
|
|
26
|
+
changes.push({ path: k, before: left, after: right });
|
|
27
|
+
}
|
|
28
|
+
return changes.sort((x, y) => x.path.localeCompare(y.path));
|
|
29
|
+
}
|