@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.
@@ -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 text pattern across recent builds. Like grep for Jenkins logs. Searches the last N builds (default: 5) and returns matching lines with context.", z.object({
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("Text pattern to search for (case-insensitive substring match)"),
8
- buildNumber: z.number().optional().describe("Search a specific build number only"),
9
- lastN: z.number().optional().default(5).describe("Number of recent builds to search (default: 5, max: 20)"),
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 patternLower = pattern.toLowerCase();
17
- const results = [];
18
- if (buildNumber) {
19
- // Search specific build
20
- const matches = await searchBuildLog(client, jobPath, buildNumber, patternLower);
21
- if (matches.length > 0) {
22
- results.push(`Build #${buildNumber} (${matches.length} matches):`);
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: "builds[number]{0," + lastN + "}",
44
+ tree: `builds[number,result]{0,${lastN}}`,
30
45
  });
31
46
  const jobData = data;
32
- const builds = jobData.builds || [];
33
- for (const build of builds) {
34
- const matches = await searchBuildLog(client, jobPath, build.number, patternLower);
35
- if (matches.length > 0) {
36
- results.push(`Build #${build.number} (${matches.length} matches):`);
37
- results.push(...matches.slice(0, 20).map((m) => ` L${m.line}: ${m.text}`));
38
- if (matches.length > 20) {
39
- results.push(` ... and ${matches.length - 20} more matches`);
40
- }
41
- results.push("");
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 found for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${lastN} builds`}.`);
66
+ return ok(`No matches for "${pattern}" in ${buildNumber ? `build #${buildNumber}` : `last ${buildsToSearch.length} build(s)`}.`);
47
67
  }
48
- return ok(`Search results for "${pattern}":\n\n${results.join("\n")}`);
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. enableDisableJob
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 searchBuildLog(client, jobPath, buildNumber, patternLower) {
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 logText = await client.getRaw(jobPath, `/${buildNumber}/consoleText`);
87
- const lines = logText.split("\n");
88
- const matches = [];
89
- for (let i = 0; i < lines.length; i++) {
90
- if (lines[i].toLowerCase().includes(patternLower)) {
91
- matches.push({
92
- line: i + 1,
93
- text: lines[i].substring(0, 200), // Truncate long lines
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
- return [];
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) {
@@ -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;
@@ -1,6 +1,41 @@
1
1
  import { z } from "zod";
2
2
  import { formatJobList, formatJobDetail, ok, error, truncateText } from "../utils/formatters.js";
3
- export function registerJobTools(client, register) {
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
- // 4. updateJobConfig
56
- 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({
57
- jobPath: z.string().describe("Full job path (e.g., 'my-folder/my-job')"),
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
- await client.post(jobPath, "/config.xml", configXml, "application/xml");
64
- return ok(`Job config updated successfully: ${jobPath}`);
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) {
@@ -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;
@@ -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
- 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({
121
- jobPath: z.string().describe("Full job path"),
122
- buildNumber: z.number().describe("Build number to replay"),
123
- mainScript: z.string().optional().describe("Modified Jenkinsfile content. If omitted, replays with the original script."),
124
- }), async (args) => {
125
- const jobPath = args.jobPath;
126
- const buildNumber = args.buildNumber;
127
- let mainScript = args.mainScript;
128
- try {
129
- // If no script provided, fetch current one
130
- if (!mainScript) {
131
- const html = await client.getRaw(jobPath, `/${buildNumber}/replay`);
132
- const match = html.match(/name="_.mainScript"[^>]*>([\s\S]*?)<\/textarea>/);
133
- if (match) {
134
- mainScript = decodeHtmlEntities(match[1]);
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
- else {
137
- return error("Could not extract current pipeline script for replay.", "Try providing the script explicitly via the mainScript parameter.");
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
- // Submit replay
141
- const formData = new URLSearchParams();
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
- return ok(`Replay triggered for ${jobPath} build #${buildNumber}.`);
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;
@@ -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,7 @@
1
+ import type { JobDescription } from "./job-xml.js";
2
+ export interface FieldChange {
3
+ path: string;
4
+ before: string;
5
+ after: string;
6
+ }
7
+ export declare function diffJobSpecs(before: JobDescription, after: JobDescription): FieldChange[];
@@ -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
+ }