@checkstack/healthcheck-jenkins-backend 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # @checkstack/healthcheck-jenkins-backend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 97c5a6b: Add Jenkins health check strategy with 5 collectors
8
+
9
+ - **Jenkins Strategy**: Transport client for Jenkins REST API with Basic Auth (username + API token)
10
+ - **Server Info Collector**: Jenkins version, mode, executor count, job count
11
+ - **Job Status Collector**: Individual job monitoring, last build status, build duration
12
+ - **Build History Collector**: Analyze recent builds for trends (success rate, avg duration)
13
+ - **Queue Info Collector**: Monitor build queue length, wait times, stuck items
14
+ - **Node Health Collector**: Agent availability, executor utilization
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [97c5a6b]
19
+ - Updated dependencies [8e43507]
20
+ - Updated dependencies [97c5a6b]
21
+ - @checkstack/backend-api@0.2.0
22
+ - @checkstack/common@0.1.0
23
+ - @checkstack/healthcheck-common@0.2.0
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@checkstack/healthcheck-jenkins-backend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "lint": "bun run lint:code",
9
+ "lint:code": "eslint . --max-warnings 0",
10
+ "test": "bun test"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/common": "workspace:*",
15
+ "@checkstack/healthcheck-common": "workspace:*"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.0.0",
19
+ "typescript": "^5.0.0",
20
+ "@checkstack/tsconfig": "workspace:*",
21
+ "@checkstack/scripts": "workspace:*"
22
+ }
23
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { BuildHistoryCollector } from "./build-history";
3
+ import type {
4
+ JenkinsTransportClient,
5
+ JenkinsResponse,
6
+ } from "../transport-client";
7
+
8
+ describe("BuildHistoryCollector", () => {
9
+ const collector = new BuildHistoryCollector();
10
+
11
+ const createMockClient = (
12
+ response: JenkinsResponse
13
+ ): JenkinsTransportClient => ({
14
+ exec: async () => response,
15
+ });
16
+
17
+ it("should collect build history successfully", async () => {
18
+ const mockClient = createMockClient({
19
+ statusCode: 200,
20
+ data: {
21
+ builds: [
22
+ { number: 10, result: "SUCCESS", duration: 60000 },
23
+ { number: 9, result: "SUCCESS", duration: 55000 },
24
+ { number: 8, result: "FAILURE", duration: 40000 },
25
+ { number: 7, result: "UNSTABLE", duration: 70000 },
26
+ { number: 6, result: "SUCCESS", duration: 65000 },
27
+ ],
28
+ },
29
+ });
30
+
31
+ const result = await collector.execute({
32
+ config: { jobName: "my-job", buildCount: 10 },
33
+ client: mockClient,
34
+ pluginId: "healthcheck-jenkins",
35
+ });
36
+
37
+ expect(result.error).toBeUndefined();
38
+ expect(result.result.totalBuilds).toBe(5);
39
+ expect(result.result.successCount).toBe(3);
40
+ expect(result.result.failureCount).toBe(1);
41
+ expect(result.result.unstableCount).toBe(1);
42
+ expect(result.result.successRate).toBe(60);
43
+ expect(result.result.avgDurationMs).toBe(58000);
44
+ expect(result.result.minDurationMs).toBe(40000);
45
+ expect(result.result.maxDurationMs).toBe(70000);
46
+ expect(result.result.lastSuccessBuildNumber).toBe(10);
47
+ expect(result.result.lastFailureBuildNumber).toBe(8);
48
+ });
49
+
50
+ it("should handle empty builds array", async () => {
51
+ const mockClient = createMockClient({
52
+ statusCode: 200,
53
+ data: { builds: [] },
54
+ });
55
+
56
+ const result = await collector.execute({
57
+ config: { jobName: "new-job", buildCount: 10 },
58
+ client: mockClient,
59
+ pluginId: "healthcheck-jenkins",
60
+ });
61
+
62
+ expect(result.error).toBeUndefined();
63
+ expect(result.result.totalBuilds).toBe(0);
64
+ expect(result.result.successRate).toBe(0);
65
+ });
66
+
67
+ it("should aggregate correctly", () => {
68
+ const runs: Parameters<typeof collector.aggregateResult>[0] = [
69
+ {
70
+ status: "healthy" as const,
71
+ latencyMs: 100,
72
+ metadata: {
73
+ totalBuilds: 10,
74
+ successCount: 8,
75
+ failureCount: 1,
76
+ unstableCount: 1,
77
+ abortedCount: 0,
78
+ successRate: 80,
79
+ avgDurationMs: 60000,
80
+ minDurationMs: 40000,
81
+ maxDurationMs: 80000,
82
+ },
83
+ },
84
+ {
85
+ status: "healthy" as const,
86
+ latencyMs: 100,
87
+ metadata: {
88
+ totalBuilds: 10,
89
+ successCount: 6,
90
+ failureCount: 2,
91
+ unstableCount: 1,
92
+ abortedCount: 1,
93
+ successRate: 60,
94
+ avgDurationMs: 80000,
95
+ minDurationMs: 50000,
96
+ maxDurationMs: 100000,
97
+ },
98
+ },
99
+ ];
100
+
101
+ const aggregated = collector.aggregateResult(runs);
102
+
103
+ expect(aggregated.avgSuccessRate).toBe(70);
104
+ expect(aggregated.avgBuildDuration).toBe(70000);
105
+ });
106
+ });
@@ -0,0 +1,280 @@
1
+ import {
2
+ Versioned,
3
+ z,
4
+ type HealthCheckRunForAggregation,
5
+ type CollectorResult,
6
+ type CollectorStrategy,
7
+ } from "@checkstack/backend-api";
8
+ import { healthResultNumber } from "@checkstack/healthcheck-common";
9
+ import { pluginMetadata } from "../plugin-metadata";
10
+ import type { JenkinsTransportClient } from "../transport-client";
11
+
12
+ // ============================================================================
13
+ // CONFIGURATION SCHEMA
14
+ // ============================================================================
15
+
16
+ const buildHistoryConfigSchema = z.object({
17
+ jobName: z
18
+ .string()
19
+ .min(1)
20
+ .describe("Full job path (e.g., 'folder/job-name' or 'my-job')"),
21
+ buildCount: z
22
+ .number()
23
+ .int()
24
+ .min(1)
25
+ .max(100)
26
+ .default(10)
27
+ .describe("Number of recent builds to analyze"),
28
+ });
29
+
30
+ export type BuildHistoryConfig = z.infer<typeof buildHistoryConfigSchema>;
31
+
32
+ // ============================================================================
33
+ // RESULT SCHEMAS
34
+ // ============================================================================
35
+
36
+ const buildHistoryResultSchema = z.object({
37
+ totalBuilds: healthResultNumber({
38
+ "x-chart-type": "counter",
39
+ "x-chart-label": "Total Builds",
40
+ }),
41
+ successCount: healthResultNumber({
42
+ "x-chart-type": "counter",
43
+ "x-chart-label": "Successful",
44
+ }),
45
+ failureCount: healthResultNumber({
46
+ "x-chart-type": "counter",
47
+ "x-chart-label": "Failed",
48
+ }),
49
+ unstableCount: healthResultNumber({
50
+ "x-chart-type": "counter",
51
+ "x-chart-label": "Unstable",
52
+ }),
53
+ abortedCount: healthResultNumber({
54
+ "x-chart-type": "counter",
55
+ "x-chart-label": "Aborted",
56
+ }),
57
+ successRate: healthResultNumber({
58
+ "x-chart-type": "gauge",
59
+ "x-chart-label": "Success Rate",
60
+ "x-chart-unit": "%",
61
+ }),
62
+ avgDurationMs: healthResultNumber({
63
+ "x-chart-type": "line",
64
+ "x-chart-label": "Avg Duration",
65
+ "x-chart-unit": "ms",
66
+ }),
67
+ minDurationMs: healthResultNumber({
68
+ "x-chart-type": "line",
69
+ "x-chart-label": "Min Duration",
70
+ "x-chart-unit": "ms",
71
+ }),
72
+ maxDurationMs: healthResultNumber({
73
+ "x-chart-type": "line",
74
+ "x-chart-label": "Max Duration",
75
+ "x-chart-unit": "ms",
76
+ }),
77
+ lastSuccessBuildNumber: healthResultNumber({
78
+ "x-chart-type": "counter",
79
+ "x-chart-label": "Last Success #",
80
+ }).optional(),
81
+ lastFailureBuildNumber: healthResultNumber({
82
+ "x-chart-type": "counter",
83
+ "x-chart-label": "Last Failure #",
84
+ }).optional(),
85
+ });
86
+
87
+ export type BuildHistoryResult = z.infer<typeof buildHistoryResultSchema>;
88
+
89
+ const buildHistoryAggregatedSchema = z.object({
90
+ avgSuccessRate: healthResultNumber({
91
+ "x-chart-type": "gauge",
92
+ "x-chart-label": "Avg Success Rate",
93
+ "x-chart-unit": "%",
94
+ }),
95
+ avgBuildDuration: healthResultNumber({
96
+ "x-chart-type": "line",
97
+ "x-chart-label": "Avg Build Duration",
98
+ "x-chart-unit": "ms",
99
+ }),
100
+ });
101
+
102
+ export type BuildHistoryAggregatedResult = z.infer<
103
+ typeof buildHistoryAggregatedSchema
104
+ >;
105
+
106
+ // ============================================================================
107
+ // BUILD HISTORY COLLECTOR
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Collector for Jenkins build history.
112
+ * Analyzes recent builds for trends and patterns.
113
+ */
114
+ export class BuildHistoryCollector
115
+ implements
116
+ CollectorStrategy<
117
+ JenkinsTransportClient,
118
+ BuildHistoryConfig,
119
+ BuildHistoryResult,
120
+ BuildHistoryAggregatedResult
121
+ >
122
+ {
123
+ id = "build-history";
124
+ displayName = "Build History";
125
+ description = "Analyze recent build trends for a Jenkins job";
126
+
127
+ supportedPlugins = [pluginMetadata];
128
+ allowMultiple = true;
129
+
130
+ config = new Versioned({ version: 1, schema: buildHistoryConfigSchema });
131
+ result = new Versioned({ version: 1, schema: buildHistoryResultSchema });
132
+ aggregatedResult = new Versioned({
133
+ version: 1,
134
+ schema: buildHistoryAggregatedSchema,
135
+ });
136
+
137
+ async execute({
138
+ config,
139
+ client,
140
+ }: {
141
+ config: BuildHistoryConfig;
142
+ client: JenkinsTransportClient;
143
+ pluginId: string;
144
+ }): Promise<CollectorResult<BuildHistoryResult>> {
145
+ // Encode job path for URL (handle folders)
146
+ const jobPath = config.jobName
147
+ .split("/")
148
+ .map((part) => `job/${encodeURIComponent(part)}`)
149
+ .join("/");
150
+
151
+ const response = await client.exec({
152
+ path: `/${jobPath}/api/json`,
153
+ query: {
154
+ tree: `builds[number,result,duration,timestamp]{0,${config.buildCount}}`,
155
+ },
156
+ });
157
+
158
+ if (response.error) {
159
+ return {
160
+ result: {
161
+ totalBuilds: 0,
162
+ successCount: 0,
163
+ failureCount: 0,
164
+ unstableCount: 0,
165
+ abortedCount: 0,
166
+ successRate: 0,
167
+ avgDurationMs: 0,
168
+ minDurationMs: 0,
169
+ maxDurationMs: 0,
170
+ },
171
+ error: response.error,
172
+ };
173
+ }
174
+
175
+ const data = response.data as {
176
+ builds?: Array<{
177
+ number?: number;
178
+ result?: string;
179
+ duration?: number;
180
+ timestamp?: number;
181
+ }>;
182
+ };
183
+
184
+ const builds = data.builds || [];
185
+
186
+ // Count results
187
+ let successCount = 0;
188
+ let failureCount = 0;
189
+ let unstableCount = 0;
190
+ let abortedCount = 0;
191
+ let lastSuccessBuildNumber: number | undefined;
192
+ let lastFailureBuildNumber: number | undefined;
193
+
194
+ const durations: number[] = [];
195
+
196
+ for (const build of builds) {
197
+ if (build.duration !== undefined) {
198
+ durations.push(build.duration);
199
+ }
200
+
201
+ switch (build.result) {
202
+ case "SUCCESS": {
203
+ successCount++;
204
+ if (lastSuccessBuildNumber === undefined) {
205
+ lastSuccessBuildNumber = build.number;
206
+ }
207
+ break;
208
+ }
209
+ case "FAILURE": {
210
+ failureCount++;
211
+ if (lastFailureBuildNumber === undefined) {
212
+ lastFailureBuildNumber = build.number;
213
+ }
214
+ break;
215
+ }
216
+ case "UNSTABLE": {
217
+ unstableCount++;
218
+ break;
219
+ }
220
+ case "ABORTED": {
221
+ abortedCount++;
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ const totalBuilds = builds.length;
228
+ const successRate =
229
+ totalBuilds > 0 ? Math.round((successCount / totalBuilds) * 100) : 0;
230
+
231
+ const avgDurationMs =
232
+ durations.length > 0
233
+ ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
234
+ : 0;
235
+
236
+ const minDurationMs = durations.length > 0 ? Math.min(...durations) : 0;
237
+ const maxDurationMs = durations.length > 0 ? Math.max(...durations) : 0;
238
+
239
+ return {
240
+ result: {
241
+ totalBuilds,
242
+ successCount,
243
+ failureCount,
244
+ unstableCount,
245
+ abortedCount,
246
+ successRate,
247
+ avgDurationMs,
248
+ minDurationMs,
249
+ maxDurationMs,
250
+ lastSuccessBuildNumber,
251
+ lastFailureBuildNumber,
252
+ },
253
+ };
254
+ }
255
+
256
+ aggregateResult(
257
+ runs: HealthCheckRunForAggregation<BuildHistoryResult>[]
258
+ ): BuildHistoryAggregatedResult {
259
+ const successRates = runs
260
+ .map((r) => r.metadata?.successRate)
261
+ .filter((v): v is number => typeof v === "number");
262
+
263
+ const durations = runs
264
+ .map((r) => r.metadata?.avgDurationMs)
265
+ .filter((v): v is number => typeof v === "number");
266
+
267
+ return {
268
+ avgSuccessRate:
269
+ successRates.length > 0
270
+ ? Math.round(
271
+ successRates.reduce((a, b) => a + b, 0) / successRates.length
272
+ )
273
+ : 0,
274
+ avgBuildDuration:
275
+ durations.length > 0
276
+ ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)
277
+ : 0,
278
+ };
279
+ }
280
+ }
@@ -0,0 +1,5 @@
1
+ export { ServerInfoCollector } from "./server-info";
2
+ export { JobStatusCollector } from "./job-status";
3
+ export { BuildHistoryCollector } from "./build-history";
4
+ export { QueueInfoCollector } from "./queue-info";
5
+ export { NodeHealthCollector } from "./node-health";
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { JobStatusCollector } from "./job-status";
3
+ import type {
4
+ JenkinsTransportClient,
5
+ JenkinsResponse,
6
+ } from "../transport-client";
7
+
8
+ describe("JobStatusCollector", () => {
9
+ const collector = new JobStatusCollector();
10
+
11
+ const createMockClient = (
12
+ response: JenkinsResponse
13
+ ): JenkinsTransportClient => ({
14
+ exec: async () => response,
15
+ });
16
+
17
+ it("should collect job status successfully", async () => {
18
+ const mockClient = createMockClient({
19
+ statusCode: 200,
20
+ data: {
21
+ name: "my-job",
22
+ buildable: true,
23
+ color: "blue",
24
+ inQueue: false,
25
+ lastBuild: {
26
+ number: 42,
27
+ result: "SUCCESS",
28
+ duration: 60000,
29
+ timestamp: Date.now() - 3600000, // 1 hour ago
30
+ },
31
+ },
32
+ });
33
+
34
+ const result = await collector.execute({
35
+ config: { jobName: "my-job", checkLastBuild: true },
36
+ client: mockClient,
37
+ pluginId: "healthcheck-jenkins",
38
+ });
39
+
40
+ expect(result.error).toBeUndefined();
41
+ expect(result.result.jobName).toBe("my-job");
42
+ expect(result.result.buildable).toBe(true);
43
+ expect(result.result.color).toBe("blue");
44
+ expect(result.result.lastBuildNumber).toBe(42);
45
+ expect(result.result.lastBuildResult).toBe("SUCCESS");
46
+ expect(result.result.lastBuildDurationMs).toBe(60000);
47
+ expect(result.result.timeSinceLastBuildMs).toBeGreaterThan(0);
48
+ });
49
+
50
+ it("should report error for failed build", async () => {
51
+ const mockClient = createMockClient({
52
+ statusCode: 200,
53
+ data: {
54
+ name: "failing-job",
55
+ buildable: true,
56
+ color: "red",
57
+ inQueue: false,
58
+ lastBuild: {
59
+ number: 10,
60
+ result: "FAILURE",
61
+ duration: 30000,
62
+ timestamp: Date.now() - 1800000,
63
+ },
64
+ },
65
+ });
66
+
67
+ const result = await collector.execute({
68
+ config: { jobName: "failing-job", checkLastBuild: true },
69
+ client: mockClient,
70
+ pluginId: "healthcheck-jenkins",
71
+ });
72
+
73
+ expect(result.error).toBe("Last build: FAILURE");
74
+ expect(result.result.lastBuildResult).toBe("FAILURE");
75
+ });
76
+
77
+ it("should handle folder paths correctly", async () => {
78
+ let capturedPath = "";
79
+ const mockClient: JenkinsTransportClient = {
80
+ exec: async (req) => {
81
+ capturedPath = req.path;
82
+ return {
83
+ statusCode: 200,
84
+ data: { name: "nested-job", buildable: true, color: "blue" },
85
+ };
86
+ },
87
+ };
88
+
89
+ await collector.execute({
90
+ config: { jobName: "folder/subfolder/nested-job", checkLastBuild: false },
91
+ client: mockClient,
92
+ pluginId: "healthcheck-jenkins",
93
+ });
94
+
95
+ expect(capturedPath).toBe(
96
+ "/job/folder/job/subfolder/job/nested-job/api/json"
97
+ );
98
+ });
99
+
100
+ it("should aggregate success rate correctly", () => {
101
+ const runs: Parameters<typeof collector.aggregateResult>[0] = [
102
+ {
103
+ status: "healthy" as const,
104
+ latencyMs: 100,
105
+ metadata: {
106
+ jobName: "my-job",
107
+ buildable: true,
108
+ inQueue: false,
109
+ color: "blue",
110
+ lastBuildResult: "SUCCESS",
111
+ lastBuildDurationMs: 60000,
112
+ },
113
+ },
114
+ {
115
+ status: "healthy" as const,
116
+ latencyMs: 100,
117
+ metadata: {
118
+ jobName: "my-job",
119
+ buildable: true,
120
+ inQueue: false,
121
+ color: "blue",
122
+ lastBuildResult: "SUCCESS",
123
+ lastBuildDurationMs: 80000,
124
+ },
125
+ },
126
+ {
127
+ status: "unhealthy" as const,
128
+ latencyMs: 100,
129
+ metadata: {
130
+ jobName: "my-job",
131
+ buildable: true,
132
+ inQueue: false,
133
+ color: "red",
134
+ lastBuildResult: "FAILURE",
135
+ lastBuildDurationMs: 40000,
136
+ },
137
+ },
138
+ ];
139
+
140
+ const aggregated = collector.aggregateResult(runs);
141
+
142
+ expect(aggregated.successRate).toBe(67); // 2/3
143
+ expect(aggregated.avgBuildDurationMs).toBe(60000); // (60000+80000+40000)/3
144
+ expect(aggregated.buildableRate).toBe(100);
145
+ });
146
+ });