Skip to content

Commit b9f346b

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

12 files changed

Lines changed: 1243 additions & 1 deletion
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+
- `list-builds` — list all Jenkins projects/jobs for a catalog entity
10+
- `get-build` — fetch details of a specific build by job name and number
11+
- `get-build-logs` — return the full console output of a build
12+
- `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 2026 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 get-build action', () => {
65+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
66+
const registration = mockActionsRegistry.register.mock.calls[0][0];
67+
expect(registration.name).toBe('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: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/*
2+
* Copyright 2026 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: '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
94+
.string()
95+
.describe('Full display name of the build.'),
96+
result: z
97+
.string()
98+
.nullable()
99+
.describe(
100+
'Build result (SUCCESS, FAILURE, UNSTABLE, ABORTED), or null if still running.',
101+
),
102+
building: z
103+
.boolean()
104+
.describe('Whether the build is currently running.'),
105+
status: z
106+
.string()
107+
.describe(
108+
'Computed status string (running, SUCCESS, FAILURE, unknown, etc.).',
109+
),
110+
timestamp: z
111+
.number()
112+
.describe('Unix timestamp (ms) of when the build started.'),
113+
duration: z
114+
.number()
115+
.describe('Duration of the build in milliseconds.'),
116+
tests: z
117+
.object({
118+
passed: z.number().describe('Number of passing tests.'),
119+
skipped: z.number().describe('Number of skipped tests.'),
120+
failed: z.number().describe('Number of failed tests.'),
121+
total: z.number().describe('Total number of tests.'),
122+
testUrl: z.string().describe('URL to the test report.'),
123+
})
124+
.optional()
125+
.describe(
126+
'Test results for the build, or undefined if no test report is available.',
127+
),
128+
source: z
129+
.object({
130+
branchName: z
131+
.string()
132+
.optional()
133+
.describe('The branch that triggered this build.'),
134+
commit: z
135+
.object({
136+
hash: z.string().describe('Short commit hash.'),
137+
})
138+
.optional()
139+
.describe('Commit information.'),
140+
url: z.string().optional().describe('URL to the source.'),
141+
displayName: z
142+
.string()
143+
.optional()
144+
.describe('Display name of the source.'),
145+
author: z.string().optional().describe('Author of the commit.'),
146+
})
147+
.optional()
148+
.describe('SCM source information for this build.'),
149+
}),
150+
},
151+
action: async ({ input, credentials, logger }) => {
152+
const entityRef = {
153+
kind: input.kind ?? 'Component',
154+
namespace: input.namespace ?? 'default',
155+
name: input.name,
156+
};
157+
158+
logger.info(
159+
`Fetching Jenkins build #${input.buildNumber} for job "${
160+
input.jobFullName
161+
}" on entity ${stringifyEntityRef(entityRef)}`,
162+
);
163+
164+
const jenkinsInfo = await jenkinsInfoProvider.getInstance({
165+
entityRef,
166+
fullJobNames: [input.jobFullName],
167+
credentials,
168+
});
169+
170+
const jobs = input.jobFullName.split('/').map(s => encodeURIComponent(s));
171+
const build = await jenkinsApi.getBuild(
172+
jenkinsInfo,
173+
jobs,
174+
input.buildNumber,
175+
);
176+
177+
if (!build) {
178+
throw new NotFoundError(
179+
`Build #${input.buildNumber} not found for job "${input.jobFullName}"`,
180+
);
181+
}
182+
183+
return {
184+
output: {
185+
number: build.number,
186+
url: build.url,
187+
displayName: build.displayName,
188+
fullDisplayName: build.fullDisplayName,
189+
result: build.result ?? null,
190+
building: build.building,
191+
status: build.status,
192+
timestamp: build.timestamp,
193+
duration: build.duration,
194+
tests: build.tests,
195+
source: build.source,
196+
},
197+
};
198+
},
199+
});
200+
}

0 commit comments

Comments
 (0)