Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/jenkins/.changeset/early-bears-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage-community/plugin-jenkins-backend': minor
---

Registered Jenkins backend routes as actions into MCP actions backend. Refactored JenkinsBuilder to delegate to JenkinsService. Fixed response status ordering bug in rebuild endpoint.
3 changes: 3 additions & 0 deletions workspaces/jenkins/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ backend:
database:
client: better-sqlite3
connection: ':memory:'
actions:
pluginSources:
- 'jenkins'

integrations:
github:
Expand Down
1 change: 1 addition & 0 deletions workspaces/jenkins/packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@backstage/plugin-auth-backend-module-guest-provider": "backstage:^",
"@backstage/plugin-catalog-backend": "backstage:^",
"@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "backstage:^",
"@backstage/plugin-mcp-actions-backend": "backstage:^",
"@backstage/plugin-permission-backend": "backstage:^",
"@backstage/plugin-permission-backend-module-allow-all-policy": "backstage:^",
"@backstage/plugin-proxy-backend": "backstage:^",
Expand Down
3 changes: 3 additions & 0 deletions workspaces/jenkins/packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ backend.add(import('@backstage/plugin-search-backend-module-techdocs'));
// Jenkins
backend.add(import('@backstage-community/plugin-jenkins-backend'));

// MCP Actions
backend.add(import('@backstage/plugin-mcp-actions-backend'));

backend.start();
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createGetBuildAction } from './createGetBuildAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { JenkinsService } from '../service/jenkinsService';

describe('createGetBuildAction', () => {
const mockJenkinsService = {
getBuild: jest.fn(),
} as unknown as jest.Mocked<JenkinsService>;

it('should return a sanitized build', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();

mockJenkinsService.getBuild.mockResolvedValueOnce({
build: {
building: false,
displayName: '#42',
duration: 5000,
fullDisplayName: 'my-job #42',
number: 42,
result: 'SUCCESS',
timestamp: 1000,
url: 'https://jenkins.example.com/job/my-job/42',
status: 'success',
source: undefined,
tests: undefined,
actions: [{ _class: 'internal' }],
},
} as any);

createGetBuildAction({
actionsRegistry: mockActionsRegistry,
jenkinsService: mockJenkinsService,
});

const result = await mockActionsRegistry.invoke({
id: 'test:get-build',
input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 42 },
});

const output = result.output as { build: Record<string, unknown> };
expect(output.build.number).toBe(42);
expect(output.build).not.toHaveProperty('actions');
});

it('should pass entity ref and build params to service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();

mockJenkinsService.getBuild.mockResolvedValueOnce({
build: {
building: false,
displayName: '#1',
duration: 0,
fullDisplayName: '',
number: 1,
result: 'SUCCESS',
timestamp: 0,
url: '',
status: 'success',
},
} as any);

createGetBuildAction({
actionsRegistry: mockActionsRegistry,
jenkinsService: mockJenkinsService,
});

await mockActionsRegistry.invoke({
id: 'test:get-build',
input: {
name: 'svc',
kind: 'Component',
namespace: 'prod',
jobFullName: 'folder/job',
buildNumber: 7,
},
});

expect(mockJenkinsService.getBuild).toHaveBeenCalledWith(
expect.objectContaining({
entityRef: { name: 'svc', kind: 'Component', namespace: 'prod' },
jobFullName: 'folder/job',
buildNumber: 7,
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { JenkinsService } from '../service/jenkinsService';
import { sanitizeBuild } from './sanitize';

export const createGetBuildAction = ({
actionsRegistry,
jenkinsService,
}: {
actionsRegistry: ActionsRegistryService;
jenkinsService: JenkinsService;
}) => {
actionsRegistry.register({
name: 'get-build',
title: 'Get Jenkins Build',
description:
'Retrieve details of a specific Jenkins build, including status, test results, and source information.',
attributes: {
readOnly: true,
idempotent: true,
destructive: false,
},
schema: {
input: z =>
z.object({
name: z.string().describe('The name of the catalog entity'),
kind: z
.string()
.optional()
.describe('The kind of the catalog entity'),
namespace: z
.string()
.optional()
.describe('The namespace of the catalog entity'),
jobFullName: z.string().describe('The full name of the Jenkins job'),
buildNumber: z.number().describe('The build number to retrieve'),
}),
output: z =>
z.object({
build: z.object({}).passthrough(),
}),
},
action: async ({ input, credentials }) => {
const result = await jenkinsService.getBuild({
entityRef: {
name: input.name,
kind: input.kind,
namespace: input.namespace,
},
jobFullName: input.jobFullName,
buildNumber: input.buildNumber,
credentials,
});
return {
output: {
build: sanitizeBuild(result.build),
},
};
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { createGetBuildConsoleTextAction } from './createGetBuildConsoleTextAction';
import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha';
import { JenkinsService } from '../service/jenkinsService';

describe('createGetBuildConsoleTextAction', () => {
const mockJenkinsService = {
getBuildConsoleText: jest.fn(),
} as unknown as jest.Mocked<JenkinsService>;

it('should return console text', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();

mockJenkinsService.getBuildConsoleText.mockResolvedValueOnce({
consoleText: 'Build started\nBuild finished',
});

createGetBuildConsoleTextAction({
actionsRegistry: mockActionsRegistry,
jenkinsService: mockJenkinsService,
});

const result = await mockActionsRegistry.invoke({
id: 'test:get-build-console-text',
input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 10 },
});

const output = result.output as { consoleText: string };
expect(output.consoleText).toBe('Build started\nBuild finished');
});

it('should pass all params to service', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();

mockJenkinsService.getBuildConsoleText.mockResolvedValueOnce({
consoleText: '',
});

createGetBuildConsoleTextAction({
actionsRegistry: mockActionsRegistry,
jenkinsService: mockJenkinsService,
});

await mockActionsRegistry.invoke({
id: 'test:get-build-console-text',
input: {
name: 'svc',
kind: 'Component',
namespace: 'ns',
jobFullName: 'folder/job',
buildNumber: 3,
},
});

expect(mockJenkinsService.getBuildConsoleText).toHaveBeenCalledWith(
expect.objectContaining({
entityRef: { name: 'svc', kind: 'Component', namespace: 'ns' },
jobFullName: 'folder/job',
buildNumber: 3,
}),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { JenkinsService } from '../service/jenkinsService';

export const createGetBuildConsoleTextAction = ({
actionsRegistry,
jenkinsService,
}: {
actionsRegistry: ActionsRegistryService;
jenkinsService: JenkinsService;
}) => {
actionsRegistry.register({
name: 'get-build-console-text',
title: 'Get Jenkins Build Console Text',
description:
'Retrieve the console output of a specific Jenkins build for log analysis and diagnostics.',
attributes: {
readOnly: true,
idempotent: true,
destructive: false,
},
schema: {
input: z =>
z.object({
name: z.string().describe('The name of the catalog entity'),
kind: z
.string()
.optional()
.describe('The kind of the catalog entity'),
namespace: z
.string()
.optional()
.describe('The namespace of the catalog entity'),
jobFullName: z.string().describe('The full name of the Jenkins job'),
buildNumber: z
.number()
.describe('The build number to retrieve console text for'),
}),
output: z =>
z.object({
consoleText: z.string(),
}),
},
action: async ({ input, credentials }) => {
const result = await jenkinsService.getBuildConsoleText({
entityRef: {
name: input.name,
kind: input.kind,
namespace: input.namespace,
},
jobFullName: input.jobFullName,
buildNumber: input.buildNumber,
credentials,
});
return { output: result };
},
});
};
Loading
Loading