Skip to content

Commit 708907e

Browse files
committed
feat(jenkins): register actions for builds
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
1 parent c665f7c commit 708907e

11 files changed

Lines changed: 1219 additions & 0 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@backstage-community/plugin-jenkins-backend': patch
3+
---
4+
5+
Added new actions for Jenkins build management.
6+
7+
Added four actions that expose Jenkins backend capabilities via MCP and scaffolder templates:
8+
9+
- `jenkins:list-builds` — list all Jenkins projects/jobs for a catalog entity
10+
- `jenkins:get-build` — fetch details of a specific build by job name and number
11+
- `jenkins:get-build-logs` — return the full console output of a build
12+
- `jenkins:trigger-build` — replay a build (enforces `jenkins.execute` permission)
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
18+
import { NotFoundError } from '@backstage/errors';
19+
import { createGetBuildAction } from './createGetBuildAction';
20+
import { JenkinsInfoProvider } from '../service/jenkinsInfoProvider';
21+
import { JenkinsApiImpl } from '../service/jenkinsApi';
22+
23+
const mockBuild = {
24+
number: 42,
25+
url: 'http://jenkins/job/my-pipeline/42/',
26+
displayName: '#42',
27+
fullDisplayName: 'my-pipeline #42',
28+
result: 'SUCCESS',
29+
building: false,
30+
status: 'SUCCESS',
31+
timestamp: 1700000000000,
32+
duration: 120000,
33+
tests: { passed: 10, skipped: 0, failed: 0, total: 10, testUrl: '' },
34+
source: { branchName: 'main', commit: { hash: 'abc1234' } },
35+
};
36+
37+
describe('createGetBuildAction', () => {
38+
let mockActionsRegistry: { register: jest.Mock };
39+
let mockJenkinsInfoProvider: jest.Mocked<JenkinsInfoProvider>;
40+
let mockJenkinsApi: jest.Mocked<JenkinsApiImpl>;
41+
42+
beforeEach(() => {
43+
mockActionsRegistry = { register: jest.fn() };
44+
45+
mockJenkinsInfoProvider = {
46+
getInstance: jest.fn().mockResolvedValue({
47+
baseUrl: 'http://jenkins',
48+
fullJobNames: ['my-pipeline'],
49+
projectCountLimit: 50,
50+
}),
51+
} as any;
52+
53+
mockJenkinsApi = {
54+
getBuild: jest.fn().mockResolvedValue(mockBuild),
55+
} as any;
56+
57+
createGetBuildAction({
58+
actionsRegistry: mockActionsRegistry as any,
59+
jenkinsInfoProvider: mockJenkinsInfoProvider,
60+
jenkinsApi: mockJenkinsApi,
61+
});
62+
});
63+
64+
it('registers the jenkins:get-build action', () => {
65+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
66+
const registration = mockActionsRegistry.register.mock.calls[0][0];
67+
expect(registration.name).toBe('jenkins:get-build');
68+
expect(registration.attributes.readOnly).toBe(true);
69+
});
70+
71+
it('returns build details', async () => {
72+
const registration = mockActionsRegistry.register.mock.calls[0][0];
73+
const credentials = mockCredentials.user();
74+
75+
const result = await registration.action({
76+
input: {
77+
name: 'my-service',
78+
kind: 'Component',
79+
namespace: 'default',
80+
jobFullName: 'my-folder/my-pipeline',
81+
buildNumber: 42,
82+
},
83+
credentials,
84+
logger: mockServices.logger.mock(),
85+
});
86+
87+
expect(mockJenkinsApi.getBuild).toHaveBeenCalledWith(
88+
expect.any(Object),
89+
['my-folder', 'my-pipeline'],
90+
42,
91+
);
92+
expect(result.output).toMatchObject({
93+
number: 42,
94+
result: 'SUCCESS',
95+
building: false,
96+
status: 'SUCCESS',
97+
});
98+
});
99+
100+
it('splits jobFullName on "/" when passing to API', async () => {
101+
const registration = mockActionsRegistry.register.mock.calls[0][0];
102+
const credentials = mockCredentials.user();
103+
104+
await registration.action({
105+
input: {
106+
name: 'my-service',
107+
jobFullName: 'folder/sub-folder/pipeline/main',
108+
buildNumber: 1,
109+
},
110+
credentials,
111+
logger: mockServices.logger.mock(),
112+
});
113+
114+
expect(mockJenkinsApi.getBuild).toHaveBeenCalledWith(
115+
expect.any(Object),
116+
['folder', 'sub-folder', 'pipeline', 'main'],
117+
1,
118+
);
119+
});
120+
121+
it('throws NotFoundError when build is not found', async () => {
122+
mockJenkinsApi.getBuild.mockResolvedValue(undefined as any);
123+
const registration = mockActionsRegistry.register.mock.calls[0][0];
124+
const credentials = mockCredentials.user();
125+
126+
await expect(
127+
registration.action({
128+
input: {
129+
name: 'my-service',
130+
jobFullName: 'my-pipeline',
131+
buildNumber: 999,
132+
},
133+
credentials,
134+
logger: mockServices.logger.mock(),
135+
}),
136+
).rejects.toThrow(NotFoundError);
137+
});
138+
});
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
18+
import { stringifyEntityRef } from '@backstage/catalog-model';
19+
import { NotFoundError } from '@backstage/errors';
20+
import { JenkinsInfoProvider } from '../service/jenkinsInfoProvider';
21+
import { JenkinsApiImpl } from '../service/jenkinsApi';
22+
23+
/**
24+
* Registers the `jenkins:get-build` action with the ActionsRegistryService.
25+
*
26+
* @public
27+
*/
28+
export function createGetBuildAction(options: {
29+
actionsRegistry: ActionsRegistryService;
30+
jenkinsInfoProvider: JenkinsInfoProvider;
31+
jenkinsApi: JenkinsApiImpl;
32+
}) {
33+
const { actionsRegistry, jenkinsInfoProvider, jenkinsApi } = options;
34+
35+
actionsRegistry.register({
36+
name: 'jenkins:get-build',
37+
title: 'Get Jenkins Build',
38+
description: `
39+
Fetches details for a specific Jenkins build identified by job full name and build number.
40+
41+
The entity must have the \`jenkins.io/job-full-name\` annotation.
42+
Use \`jenkins:list-builds\` to discover available job names and build numbers.
43+
44+
## Job full name format
45+
46+
Jobs are specified as path components separated by "/". Examples:
47+
- \`my-pipeline\` — a top-level pipeline job
48+
- \`my-folder/my-pipeline\` — a pipeline inside a folder
49+
- \`my-folder/my-pipeline/main\` — a specific branch in a multi-branch pipeline
50+
51+
## When to use
52+
53+
Use this action to inspect the details of a particular build, including test results and SCM information.
54+
For console output use \`jenkins:get-build-logs\`.
55+
`,
56+
attributes: {
57+
readOnly: true,
58+
destructive: false,
59+
idempotent: true,
60+
},
61+
schema: {
62+
input: z =>
63+
z.object({
64+
name: z.string().describe('The name of the catalog entity.'),
65+
kind: z
66+
.string()
67+
.optional()
68+
.describe(
69+
'The kind of the catalog entity, e.g. "Component". Defaults to "Component" if omitted.',
70+
),
71+
namespace: z
72+
.string()
73+
.optional()
74+
.describe(
75+
'The namespace of the catalog entity. Defaults to "default" if omitted.',
76+
),
77+
jobFullName: z
78+
.string()
79+
.describe(
80+
'Full name of the Jenkins job, e.g. "my-folder/my-pipeline" or "my-folder/my-pipeline/main".',
81+
),
82+
buildNumber: z
83+
.number()
84+
.int()
85+
.positive()
86+
.describe('The Jenkins build number to fetch.'),
87+
}),
88+
output: z =>
89+
z.object({
90+
number: z.number().describe('The build number.'),
91+
url: z.string().describe('URL to the build in Jenkins.'),
92+
displayName: z.string().describe('Display name of the build.'),
93+
fullDisplayName: z.string().describe('Full display name of the build.'),
94+
result: z
95+
.string()
96+
.nullable()
97+
.describe(
98+
'Build result (SUCCESS, FAILURE, UNSTABLE, ABORTED), or null if still running.',
99+
),
100+
building: z
101+
.boolean()
102+
.describe('Whether the build is currently running.'),
103+
status: z
104+
.string()
105+
.describe(
106+
'Computed status string (running, SUCCESS, FAILURE, unknown, etc.).',
107+
),
108+
timestamp: z
109+
.number()
110+
.describe('Unix timestamp (ms) of when the build started.'),
111+
duration: z
112+
.number()
113+
.describe('Duration of the build in milliseconds.'),
114+
tests: z
115+
.object({
116+
passed: z.number().describe('Number of passing tests.'),
117+
skipped: z.number().describe('Number of skipped tests.'),
118+
failed: z.number().describe('Number of failed tests.'),
119+
total: z.number().describe('Total number of tests.'),
120+
testUrl: z.string().describe('URL to the test report.'),
121+
})
122+
.describe('Test results for the build.'),
123+
source: z
124+
.object({
125+
branchName: z
126+
.string()
127+
.optional()
128+
.describe('The branch that triggered this build.'),
129+
commit: z
130+
.object({
131+
hash: z.string().describe('Short commit hash.'),
132+
})
133+
.optional()
134+
.describe('Commit information.'),
135+
url: z.string().optional().describe('URL to the source.'),
136+
displayName: z
137+
.string()
138+
.optional()
139+
.describe('Display name of the source.'),
140+
author: z.string().optional().describe('Author of the commit.'),
141+
})
142+
.optional()
143+
.describe('SCM source information for this build.'),
144+
}),
145+
},
146+
action: async ({ input, credentials, logger }) => {
147+
const entityRef = {
148+
kind: input.kind ?? 'Component',
149+
namespace: input.namespace ?? 'default',
150+
name: input.name,
151+
};
152+
153+
logger.info(
154+
`Fetching Jenkins build #${input.buildNumber} for job "${input.jobFullName}" on entity ${stringifyEntityRef(entityRef)}`,
155+
);
156+
157+
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
158+
entityRef,
159+
credentials,
160+
});
161+
162+
const jobs = input.jobFullName.split('/');
163+
const build = await jenkinsApi.getBuild(
164+
jenkinsInfo,
165+
jobs,
166+
input.buildNumber,
167+
);
168+
169+
if (!build) {
170+
throw new NotFoundError(
171+
`Build #${input.buildNumber} not found for job "${input.jobFullName}"`,
172+
);
173+
}
174+
175+
return {
176+
output: {
177+
number: build.number,
178+
url: build.url,
179+
displayName: build.displayName,
180+
fullDisplayName: build.fullDisplayName,
181+
result: build.result ?? null,
182+
building: build.building,
183+
status: build.status,
184+
timestamp: build.timestamp,
185+
duration: build.duration,
186+
tests: build.tests,
187+
source: build.source,
188+
},
189+
};
190+
},
191+
});
192+
}

0 commit comments

Comments
 (0)