From caca4c2295a4ab6e0cda0f69e091c99f1e44c7d0 Mon Sep 17 00:00:00 2001 From: Mattia Dell'Oca Date: Tue, 14 Apr 2026 16:50:55 +0200 Subject: [PATCH 1/2] feat(jenkins-backend): regiser MCP actions for Jenkins Signed-off-by: Mattia Dell'Oca --- .../jenkins/.changeset/early-bears-hide.md | 5 + workspaces/jenkins/app-config.yaml | 3 + .../jenkins/packages/backend/package.json | 1 + .../jenkins/packages/backend/src/index.ts | 3 + .../src/actions/createGetBuildAction.test.ts | 102 ++++++ .../src/actions/createGetBuildAction.ts | 76 ++++ .../createGetBuildConsoleTextAction.test.ts | 78 ++++ .../createGetBuildConsoleTextAction.ts | 73 ++++ .../actions/createGetJobBuildsAction.test.ts | 81 +++++ .../src/actions/createGetJobBuildsAction.ts | 77 ++++ .../actions/createGetProjectsAction.test.ts | 95 +++++ .../src/actions/createGetProjectsAction.ts | 77 ++++ .../createRebuildProjectAction.test.ts | 71 ++++ .../src/actions/createRebuildProjectAction.ts | 72 ++++ .../jenkins-backend/src/actions/index.ts | 34 ++ .../src/actions/sanitize.test.ts | 122 +++++++ .../jenkins-backend/src/actions/sanitize.ts | 53 +++ .../plugins/jenkins-backend/src/plugin.ts | 14 + .../src/service/JenkinsBuilder.test.ts | 286 +++++++++++++++ .../src/service/JenkinsBuilder.ts | 170 +++------ .../jenkins-backend/src/service/index.ts | 2 + .../src/service/jenkinsInfoProvider.ts | 15 +- .../src/service/jenkinsService.test.ts | 250 +++++++++++++ .../src/service/jenkinsService.ts | 220 ++++++++++++ workspaces/jenkins/yarn.lock | 333 +++++++++++++++++- 25 files changed, 2165 insertions(+), 148 deletions(-) create mode 100644 workspaces/jenkins/.changeset/early-bears-hide.md create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/index.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.test.ts create mode 100644 workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.ts diff --git a/workspaces/jenkins/.changeset/early-bears-hide.md b/workspaces/jenkins/.changeset/early-bears-hide.md new file mode 100644 index 00000000000..ea4839af2d1 --- /dev/null +++ b/workspaces/jenkins/.changeset/early-bears-hide.md @@ -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. diff --git a/workspaces/jenkins/app-config.yaml b/workspaces/jenkins/app-config.yaml index 78480a1fe0f..e4186f93555 100644 --- a/workspaces/jenkins/app-config.yaml +++ b/workspaces/jenkins/app-config.yaml @@ -26,6 +26,9 @@ backend: database: client: better-sqlite3 connection: ':memory:' + actions: + pluginSources: + - 'jenkins' integrations: github: diff --git a/workspaces/jenkins/packages/backend/package.json b/workspaces/jenkins/packages/backend/package.json index 786758fb264..2527a81d4ca 100644 --- a/workspaces/jenkins/packages/backend/package.json +++ b/workspaces/jenkins/packages/backend/package.json @@ -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:^", diff --git a/workspaces/jenkins/packages/backend/src/index.ts b/workspaces/jenkins/packages/backend/src/index.ts index fe39bf44167..b10fc1ec9c8 100644 --- a/workspaces/jenkins/packages/backend/src/index.ts +++ b/workspaces/jenkins/packages/backend/src/index.ts @@ -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(); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.test.ts new file mode 100644 index 00000000000..07556075f5d --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.test.ts @@ -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; + + 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 }; + 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, + }), + ); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.ts new file mode 100644 index 00000000000..4231a36d42b --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildAction.ts @@ -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), + }, + }; + }, + }); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.test.ts new file mode 100644 index 00000000000..fa320bf2be4 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.test.ts @@ -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; + + 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, + }), + ); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.ts new file mode 100644 index 00000000000..1d540b68a08 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetBuildConsoleTextAction.ts @@ -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 }; + }, + }); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.test.ts new file mode 100644 index 00000000000..dfa7d6ce519 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { createGetJobBuildsAction } from './createGetJobBuildsAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { JenkinsService } from '../service/jenkinsService'; + +const makeBuild = (number: number) => ({ + building: false, + displayName: `#${number}`, + duration: 100, + fullDisplayName: `my-job #${number}`, + number, + result: 'SUCCESS', + timestamp: 1000, + url: `https://jenkins.example.com/job/my-job/${number}`, + status: 'success', +}); + +describe('createGetJobBuildsAction', () => { + const mockJenkinsService = { + getJobBuilds: jest.fn(), + } as unknown as jest.Mocked; + + it('should return sanitized builds when result is an array', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.getJobBuilds.mockResolvedValueOnce({ + builds: [makeBuild(1), makeBuild(2)], + } as any); + + createGetJobBuildsAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-job-builds', + input: { name: 'my-entity', jobFullName: 'my-job' }, + }); + + const output = result.output as { builds: Record[] }; + expect(output.builds).toHaveLength(2); + expect(output.builds[0].number).toBe(1); + }); + + it('should handle nested builds object', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.getJobBuilds.mockResolvedValueOnce({ + builds: { builds: [makeBuild(3)] }, + } as any); + + createGetJobBuildsAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-job-builds', + input: { name: 'my-entity', jobFullName: 'my-job' }, + }); + + const output = result.output as { builds: Record[] }; + expect(output.builds).toHaveLength(1); + expect(output.builds[0].number).toBe(3); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.ts new file mode 100644 index 00000000000..5816ef7a8ea --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetJobBuildsAction.ts @@ -0,0 +1,77 @@ +/* + * 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 createGetJobBuildsAction = ({ + actionsRegistry, + jenkinsService, +}: { + actionsRegistry: ActionsRegistryService; + jenkinsService: JenkinsService; +}) => { + actionsRegistry.register({ + name: 'get-job-builds', + title: 'Get Jenkins Job Builds', + description: + 'Retrieve all builds for a specific Jenkins job to analyze build history and trends.', + 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'), + }), + output: z => + z.object({ + builds: z.array(z.object({}).passthrough()), + }), + }, + action: async ({ input, credentials }) => { + const result = await jenkinsService.getJobBuilds({ + entityRef: { + name: input.name, + kind: input.kind, + namespace: input.namespace, + }, + jobFullName: input.jobFullName, + credentials, + }); + const rawBuilds = Array.isArray(result.builds) + ? result.builds + : result.builds?.builds ?? []; + return { + output: { + builds: rawBuilds.map((b: any) => sanitizeBuild(b)), + }, + }; + }, + }); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.test.ts new file mode 100644 index 00000000000..7bd0adc4bb5 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { createGetProjectsAction } from './createGetProjectsAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { JenkinsService } from '../service/jenkinsService'; + +describe('createGetProjectsAction', () => { + const mockJenkinsService = { + getProjects: jest.fn(), + } as unknown as jest.Mocked; + + it('should return sanitized projects', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.getProjects.mockResolvedValueOnce({ + projects: [ + { + displayName: 'my-build', + fullDisplayName: 'my-job » my-build', + fullName: 'my-job/my-build', + inQueue: false, + status: 'success', + actions: [{ _class: 'internal' }], + lastBuild: { + building: false, + displayName: '#1', + duration: 100, + fullDisplayName: 'my-job » my-build #1', + number: 1, + result: 'SUCCESS', + timestamp: 1000, + url: 'https://jenkins.example.com/job/my-job/1', + status: 'success', + source: undefined, + tests: undefined, + actions: [], + }, + }, + ], + } as any); + + createGetProjectsAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-projects', + input: { name: 'my-entity' }, + }); + + const output = result.output as { projects: Record[] }; + expect(output.projects).toHaveLength(1); + expect(output.projects[0]).not.toHaveProperty('actions'); + expect(output.projects[0].displayName).toBe('my-build'); + }); + + it('should split comma-separated branches', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.getProjects.mockResolvedValueOnce({ + projects: [], + } as any); + + createGetProjectsAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + await mockActionsRegistry.invoke({ + id: 'test:get-projects', + input: { name: 'my-entity', branches: 'main,develop' }, + }); + + expect(mockJenkinsService.getProjects).toHaveBeenCalledWith( + expect.objectContaining({ + branches: ['main', 'develop'], + }), + ); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.ts new file mode 100644 index 00000000000..919f48c89d4 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createGetProjectsAction.ts @@ -0,0 +1,77 @@ +/* + * 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 { sanitizeProject } from './sanitize'; + +export const createGetProjectsAction = ({ + actionsRegistry, + jenkinsService, +}: { + actionsRegistry: ActionsRegistryService; + jenkinsService: JenkinsService; +}) => { + actionsRegistry.register({ + name: 'get-projects', + title: 'Get Jenkins Projects', + description: + 'List Jenkins projects for a Backstage catalog entity, optionally filtered by branch name.', + 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'), + branches: z + .string() + .optional() + .describe('Comma-separated branch names to filter by'), + }), + output: z => + z.object({ + projects: z.array(z.object({}).passthrough()), + }), + }, + action: async ({ input, credentials }) => { + const result = await jenkinsService.getProjects({ + entityRef: { + name: input.name, + kind: input.kind, + namespace: input.namespace, + }, + branches: input.branches?.split(','), + credentials, + }); + return { + output: { + projects: result.projects.map(p => sanitizeProject(p)), + }, + }; + }, + }); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.test.ts new file mode 100644 index 00000000000..71786f1338a --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.test.ts @@ -0,0 +1,71 @@ +/* + * 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 { createRebuildProjectAction } from './createRebuildProjectAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { JenkinsService } from '../service/jenkinsService'; + +describe('createRebuildProjectAction', () => { + const mockJenkinsService = { + rebuildProject: jest.fn(), + } as unknown as jest.Mocked; + + it('should return success on rebuild', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.rebuildProject.mockResolvedValueOnce({ + status: 'success', + message: 'Rebuild triggered', + }); + + createRebuildProjectAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:rebuild-project', + input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 5 }, + }); + + expect(result.output).toEqual({ + status: 'success', + message: 'Rebuild triggered', + }); + }); + + it('should return denied when permission is denied', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + + mockJenkinsService.rebuildProject.mockResolvedValueOnce({ + status: 'denied', + message: 'Not allowed', + }); + + createRebuildProjectAction({ + actionsRegistry: mockActionsRegistry, + jenkinsService: mockJenkinsService, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:rebuild-project', + input: { name: 'my-entity', jobFullName: 'my-job', buildNumber: 5 }, + }); + + const output = result.output as { status: string; message: string }; + expect(output.status).toBe('denied'); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.ts new file mode 100644 index 00000000000..2ddad5771b8 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/createRebuildProjectAction.ts @@ -0,0 +1,72 @@ +/* + * 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 createRebuildProjectAction = ({ + actionsRegistry, + jenkinsService, +}: { + actionsRegistry: ActionsRegistryService; + jenkinsService: JenkinsService; +}) => { + actionsRegistry.register({ + name: 'rebuild-project', + title: 'Rebuild Jenkins Project', + description: + 'Trigger a rebuild of a specific Jenkins build. Requires jenkinsExecutePermission.', + attributes: { + destructive: true, + readOnly: false, + idempotent: 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 rebuild'), + }), + output: z => + z.object({ + status: z.enum(['success', 'denied']), + message: z.string(), + }), + }, + action: async ({ input, credentials }) => { + const result = await jenkinsService.rebuildProject({ + entityRef: { + name: input.name, + kind: input.kind, + namespace: input.namespace, + }, + jobFullName: input.jobFullName, + buildNumber: input.buildNumber, + credentials, + }); + return { output: result }; + }, + }); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/index.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/index.ts new file mode 100644 index 00000000000..71ba4f8645d --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { createGetProjectsAction } from './createGetProjectsAction'; +import { createGetBuildAction } from './createGetBuildAction'; +import { createGetJobBuildsAction } from './createGetJobBuildsAction'; +import { createRebuildProjectAction } from './createRebuildProjectAction'; +import { createGetBuildConsoleTextAction } from './createGetBuildConsoleTextAction'; + +export const createJenkinsActions = (options: { + actionsRegistry: ActionsRegistryService; + jenkinsService: JenkinsService; +}) => { + createGetProjectsAction(options); + createGetBuildAction(options); + createGetJobBuildsAction(options); + createRebuildProjectAction(options); + createGetBuildConsoleTextAction(options); +}; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.test.ts new file mode 100644 index 00000000000..050e3d7d325 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { sanitizeBuild, sanitizeProject } from './sanitize'; +import type { BackstageBuild, BackstageProject } from '../types'; + +const baseBuild: BackstageBuild = { + building: false, + displayName: '#7', + duration: 5000, + fullDisplayName: 'my-job #7', + number: 7, + result: 'SUCCESS', + timestamp: 1700000000, + url: 'https://jenkins.example.com/job/my-job/7', + status: 'success', + source: { + branchName: 'main', + displayName: 'feat: stuff', + url: 'https://github.com/org/repo/pull/1', + commit: { hash: 'abc123' }, + author: 'dev', + }, + tests: { + passed: 10, + skipped: 1, + failed: 0, + total: 11, + testUrl: 'https://jenkins.example.com/job/my-job/7/testReport/', + }, + // fields that should be stripped + actions: [{ _class: 'hudson.model.CauseAction' }], +} as any; + +describe('sanitizeBuild', () => { + it('should keep only the expected fields', () => { + const result = sanitizeBuild(baseBuild); + + expect(result).toEqual({ + building: false, + displayName: '#7', + duration: 5000, + fullDisplayName: 'my-job #7', + number: 7, + result: 'SUCCESS', + timestamp: 1700000000, + url: 'https://jenkins.example.com/job/my-job/7', + status: 'success', + source: baseBuild.source, + }); + }); + + it('should strip actions and tests', () => { + const result = sanitizeBuild(baseBuild); + + expect(result).not.toHaveProperty('actions'); + expect(result).not.toHaveProperty('tests'); + }); +}); + +describe('sanitizeProject', () => { + const baseProject: BackstageProject = { + displayName: 'my-build', + fullDisplayName: 'my-job » my-build', + fullName: 'my-job/my-build', + inQueue: false, + status: 'success', + lastBuild: baseBuild, + // fields that should be stripped + actions: [{ _class: 'internal' }], + } as any; + + it('should keep only the expected fields', () => { + const result = sanitizeProject(baseProject); + + expect(result).toEqual({ + name: undefined, + displayName: 'my-build', + fullDisplayName: 'my-job » my-build', + fullName: 'my-job/my-build', + url: undefined, + inQueue: false, + status: 'success', + lastBuild: sanitizeBuild(baseBuild), + }); + }); + + it('should strip actions from project', () => { + const result = sanitizeProject(baseProject); + expect(result).not.toHaveProperty('actions'); + }); + + it('should handle null lastBuild', () => { + const result = sanitizeProject({ ...baseProject, lastBuild: null }); + expect(result.lastBuild).toBeNull(); + }); + + it('should pass through name and url when present', () => { + const withExtras = { + ...baseProject, + name: 'proj-name', + url: 'https://jenkins.example.com/job/my-job', + } as any; + + const result = sanitizeProject(withExtras); + expect(result.name).toBe('proj-name'); + expect(result.url).toBe('https://jenkins.example.com/job/my-job'); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.ts b/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.ts new file mode 100644 index 00000000000..bbfdf36e0aa --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/actions/sanitize.ts @@ -0,0 +1,53 @@ +/* + * 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 type { BackstageBuild, BackstageProject } from '../types'; + +/** + * Strips internal Jenkins fields (actions, _class, etc.) from a build, + * keeping only the fields useful for MCP consumers. + */ +export function sanitizeBuild(build: BackstageBuild) { + return { + building: build.building, + displayName: build.displayName, + duration: build.duration, + fullDisplayName: build.fullDisplayName, + number: build.number, + result: build.result, + timestamp: build.timestamp, + url: build.url, + status: build.status, + source: build.source, + }; +} + +/** + * Strips internal Jenkins fields from a project, + * keeping only the fields useful for MCP consumers. + */ +export function sanitizeProject(project: BackstageProject) { + return { + name: (project as any).name, + displayName: project.displayName, + fullDisplayName: project.fullDisplayName, + fullName: project.fullName, + url: (project as any).url, + inQueue: project.inQueue, + status: project.status, + lastBuild: project.lastBuild ? sanitizeBuild(project.lastBuild) : null, + }; +} diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/plugin.ts b/workspaces/jenkins/plugins/jenkins-backend/src/plugin.ts index 76bba9323a2..1e484f0561c 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/plugin.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/plugin.ts @@ -18,10 +18,13 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { catalogServiceRef } from '@backstage/plugin-catalog-node'; import { DefaultJenkinsInfoProvider } from './service/jenkinsInfoProvider'; import { JenkinsBuilder } from './service/JenkinsBuilder'; +import { JenkinsService } from './service/jenkinsService'; +import { createJenkinsActions } from './actions'; import { jenkinsPermissions } from '@backstage-community/plugin-jenkins-common'; /** @@ -43,6 +46,7 @@ export const jenkinsPlugin = createBackendPlugin({ discovery: coreServices.discovery, auth: coreServices.auth, httpAuth: coreServices.httpAuth, + actionsRegistry: actionsRegistryServiceRef, }, async init({ logger, @@ -54,6 +58,7 @@ export const jenkinsPlugin = createBackendPlugin({ discovery, auth, httpAuth, + actionsRegistry, }) { permissionsRegistry.addPermissions(jenkinsPermissions); @@ -66,6 +71,12 @@ export const jenkinsPlugin = createBackendPlugin({ logger, }); + const jenkinsService = JenkinsService.createService({ + permissions, + logger, + jenkinsInfoProvider, + }); + const builder = JenkinsBuilder.createBuilder({ logger, jenkinsInfoProvider, @@ -74,8 +85,11 @@ export const jenkinsPlugin = createBackendPlugin({ discovery, auth, httpAuth, + jenkinsService, }); + createJenkinsActions({ actionsRegistry, jenkinsService }); + const { router } = await builder.build(); httpRouter.use(router); }, diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.test.ts new file mode 100644 index 00000000000..6eff413ef57 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.test.ts @@ -0,0 +1,286 @@ +/* + * 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 express from 'express'; +import http from 'http'; +import fetch from 'node-fetch'; +import { JenkinsBuilder, JenkinsEnvironment } from './JenkinsBuilder'; +import { JenkinsService } from './jenkinsService'; +import { ConfigReader } from '@backstage/config'; +import { mockServices } from '@backstage/backend-test-utils'; + +describe('JenkinsBuilder', () => { + let mockJenkinsService: jest.Mocked; + + beforeEach(() => { + mockJenkinsService = { + getProjects: jest.fn(), + getBuild: jest.fn(), + getJobBuilds: jest.fn(), + rebuildProject: jest.fn(), + getBuildConsoleText: jest.fn(), + } as unknown as jest.Mocked; + }); + + function createEnv(configData: Record = {}): JenkinsEnvironment { + return { + permissions: { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }, + config: new ConfigReader(configData), + logger: mockServices.rootLogger(), + jenkinsInfoProvider: {} as any, + discovery: mockServices.discovery(), + httpAuth: mockServices.httpAuth(), + jenkinsService: mockJenkinsService, + }; + } + + describe('build', () => { + it('should throw if jenkins config is missing and not in development', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + + try { + const env = createEnv({}); + const builder = JenkinsBuilder.createBuilder(env); + await expect(builder.build()).rejects.toThrow( + 'Jenkins configuration is missing', + ); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it('should return an empty router if jenkins config is missing in development', async () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const env = createEnv({}); + const builder = JenkinsBuilder.createBuilder(env); + const { router } = await builder.build(); + expect(router).toBeDefined(); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + it('should return a router when jenkins config is present', async () => { + const env = createEnv({ + jenkins: { baseUrl: 'https://jenkins.example.com' }, + }); + const builder = JenkinsBuilder.createBuilder(env); + const { router } = await builder.build(); + expect(router).toBeDefined(); + }); + }); + + describe('router', () => { + let server: http.Server; + let baseUrl: string; + + beforeEach(async () => { + const env = createEnv({ + jenkins: { baseUrl: 'https://jenkins.example.com' }, + }); + const builder = JenkinsBuilder.createBuilder(env); + const { router } = await builder.build(); + const app = express(); + app.use(router); + server = http.createServer(app); + await new Promise(resolve => server.listen(0, resolve)); + const addr = server.address(); + if (addr && typeof addr !== 'string') { + baseUrl = `http://127.0.0.1:${addr.port}`; + } + }); + + afterEach(async () => { + await new Promise((resolve, reject) => + server.close(err => (err ? reject(err) : resolve())), + ); + }); + + describe('GET /v1/entity/:namespace/:kind/:name/projects', () => { + it('should return projects', async () => { + mockJenkinsService.getProjects.mockResolvedValueOnce({ + projects: [{ fullName: 'test-project' }], + } as any); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/projects`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + projects: [{ fullName: 'test-project' }], + }); + expect(mockJenkinsService.getProjects).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-service', + }, + branches: undefined, + }), + ); + }); + + it('should pass branch filter as array', async () => { + mockJenkinsService.getProjects.mockResolvedValueOnce({ + projects: [], + } as any); + + await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/projects?branch=main,develop`, + ); + + expect(mockJenkinsService.getProjects).toHaveBeenCalledWith( + expect.objectContaining({ + branches: ['main', 'develop'], + }), + ); + }); + }); + + describe('GET /v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', () => { + it('should return a build', async () => { + mockJenkinsService.getBuild.mockResolvedValueOnce({ + build: { number: 42, result: 'SUCCESS' }, + } as any); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/job/my-job/42`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + build: { number: 42, result: 'SUCCESS' }, + }); + expect(mockJenkinsService.getBuild).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-service', + }, + jobFullName: 'my-job', + buildNumber: 42, + }), + ); + }); + }); + + describe('GET /v1/entity/:namespace/:kind/:name/job/:jobFullName', () => { + it('should return job builds', async () => { + mockJenkinsService.getJobBuilds.mockResolvedValueOnce({ + builds: [{ number: 1 }, { number: 2 }], + } as any); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/job/my-job`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + build: [{ number: 1 }, { number: 2 }], + }); + expect(mockJenkinsService.getJobBuilds).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-service', + }, + jobFullName: 'my-job', + }), + ); + }); + }); + + describe('POST /v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', () => { + it('should return 200 on successful rebuild', async () => { + mockJenkinsService.rebuildProject.mockResolvedValueOnce({ + status: 'success', + message: 'ok', + }); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/job/my-job/42`, + { method: 'POST' }, + ); + + expect(res.status).toBe(200); + expect(mockJenkinsService.rebuildProject).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-service', + }, + jobFullName: 'my-job', + buildNumber: 42, + }), + ); + }); + + it('should return 401 on denied rebuild', async () => { + mockJenkinsService.rebuildProject.mockResolvedValueOnce({ + status: 'denied', + message: 'not allowed', + }); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/job/my-job/42`, + { method: 'POST' }, + ); + + expect(res.status).toBe(401); + }); + }); + + describe('GET /v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber/consoleText', () => { + it('should return console text', async () => { + mockJenkinsService.getBuildConsoleText.mockResolvedValueOnce({ + consoleText: 'Build output here', + } as any); + + const res = await fetch( + `${baseUrl}/v1/entity/default/Component/my-service/job/my-job/42/consoleText`, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + consoleText: 'Build output here', + }); + expect(mockJenkinsService.getBuildConsoleText).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-service', + }, + jobFullName: 'my-job', + buildNumber: 42, + }), + ); + }); + }); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.ts index 46c6ec2f66f..b258c876937 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/JenkinsBuilder.ts @@ -17,13 +17,8 @@ import express from 'express'; import Router from 'express-promise-router'; import { JenkinsInfoProvider } from './jenkinsInfoProvider'; -import { JenkinsApiImpl } from './jenkinsApi'; -import { - PermissionEvaluator, - toPermissionEvaluator, -} from '@backstage/plugin-permission-common'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { stringifyError } from '@backstage/errors'; +import { JenkinsService } from './jenkinsService'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { AuthService, DiscoveryService, @@ -47,6 +42,7 @@ export interface JenkinsEnvironment { discovery: DiscoveryService; auth?: AuthService; httpAuth: HttpAuthService; + jenkinsService: JenkinsService; } /** @public */ @@ -60,9 +56,6 @@ export class JenkinsBuilder { public async build() { const logger = this.env.logger; const config = this.env.config; - const httpAuth = this.env.httpAuth; - const permissions = this.env.permissions; - const jenkinsInfoProvider = this.env.jenkinsInfoProvider; logger.info('Initializing Jenkins backend'); @@ -78,34 +71,15 @@ export class JenkinsBuilder { } as unknown as JenkinsBuilderReturn; } - const router = this.buildRouter(jenkinsInfoProvider, permissions, httpAuth); + const router = this.buildRouter(); return { router, } as unknown as JenkinsBuilderReturn; } - protected buildRouter( - jenkinsInfoProvider: JenkinsInfoProvider, - permissionApi: PermissionEvaluator, - httpAuth: HttpAuthService, - ): express.Router { - const logger = this.env.logger; - - let permissionEvaluator: PermissionEvaluator | undefined; - if (permissionApi && 'authorizeConditional' in permissionApi) { - permissionEvaluator = permissionApi as PermissionEvaluator; - } else { - logger.warn( - 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', - ); - permissionEvaluator = permissionApi - ? toPermissionEvaluator(permissionApi) - : undefined; - } - - const jenkinsApi = new JenkinsApiImpl(permissionEvaluator); - + protected buildRouter(): express.Router { + const { jenkinsService, httpAuth } = this.env; const router = Router(); router.use(express.json()); @@ -130,33 +104,14 @@ export class JenkinsBuilder { return; } - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ - entityRef: { - kind, - namespace, - name, - }, - credentials: await httpAuth.credentials(request), + const credentials = await httpAuth.credentials(request); + const result = await jenkinsService.getProjects({ + entityRef: { kind, namespace, name }, + branches, + credentials, }); - try { - const projects = await jenkinsApi.getProjects(jenkinsInfo, branches); - - response.json({ - projects: projects, - }); - } catch (err) { - // Promise.any, used in the getProjects call returns an Aggregate error message with a useless error message 'AggregateError: All promises were rejected' - // extract useful information ourselves - if (err.errors) { - throw new Error( - `Unable to fetch projects, for ${ - jenkinsInfo.fullJobNames - }: ${stringifyError(err.errors)}`, - ); - } - throw err; - } + response.json(result); }, ); @@ -165,27 +120,16 @@ export class JenkinsBuilder { async (request, response) => { const { namespace, kind, name, jobFullName, buildNumber } = request.params; - const jobs = this.jobFullNameParamToJobs(jobFullName); + const credentials = await httpAuth.credentials(request); - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ - entityRef: { - kind, - namespace, - name, - }, - fullJobNames: [jobFullName], - credentials: await httpAuth.credentials(request), + const result = await jenkinsService.getBuild({ + entityRef: { kind, namespace, name }, + jobFullName, + buildNumber: parseInt(buildNumber, 10), + credentials, }); - const build = await jenkinsApi.getBuild( - jenkinsInfo, - jobs, - parseInt(buildNumber, 10), - ); - - response.json({ - build: build, - }); + response.json(result); }, ); @@ -193,22 +137,16 @@ export class JenkinsBuilder { '/v1/entity/:namespace/:kind/:name/job/:jobFullName', async (request, response) => { const { namespace, kind, name, jobFullName } = request.params; - const jobs = this.jobFullNameParamToJobs(jobFullName); + const credentials = await httpAuth.credentials(request); - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ - entityRef: { - kind, - namespace, - name, - }, - fullJobNames: [jobFullName], - credentials: await httpAuth.credentials(request), + const result = await jenkinsService.getJobBuilds({ + entityRef: { kind, namespace, name }, + jobFullName, + credentials, }); - const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobs); - response.json({ - build: build, + build: result.builds, }); }, ); @@ -218,29 +156,17 @@ export class JenkinsBuilder { async (request, response) => { const { namespace, kind, name, jobFullName, buildNumber } = request.params; - const jobs = this.jobFullNameParamToJobs(jobFullName); + const credentials = await httpAuth.credentials(request); - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ - entityRef: { - kind, - namespace, - name, - }, - fullJobNames: [jobFullName], - credentials: await httpAuth.credentials(request), + const result = await jenkinsService.rebuildProject({ + entityRef: { kind, namespace, name }, + jobFullName, + buildNumber: parseInt(buildNumber, 10), + credentials, }); - const resourceRef = stringifyEntityRef({ kind, namespace, name }); - const status = await jenkinsApi.rebuildProject( - jenkinsInfo, - jobs, - parseInt(buildNumber, 10), - resourceRef, - { - credentials: await httpAuth.credentials(request), - }, - ); - response.json({}).status(status); + const statusCode = result.status === 'success' ? 200 : 401; + response.status(statusCode).json({}); }, ); @@ -249,35 +175,19 @@ export class JenkinsBuilder { async (request, response) => { const { namespace, kind, name, jobFullName, buildNumber } = request.params; - const jobs = this.jobFullNameParamToJobs(jobFullName); + const credentials = await httpAuth.credentials(request); - const jenkinsInfo = await jenkinsInfoProvider.getInstance({ - entityRef: { - kind, - namespace, - name, - }, - fullJobNames: [jobFullName], - credentials: await httpAuth.credentials(request), + const result = await jenkinsService.getBuildConsoleText({ + entityRef: { kind, namespace, name }, + jobFullName, + buildNumber: parseInt(buildNumber, 10), + credentials, }); - const consoleText = await jenkinsApi.getBuildConsoleText( - jenkinsInfo, - jobs, - parseInt(buildNumber, 10), - ); - - response.json({ - consoleText: consoleText, - }); + response.json(result); }, ); return router; } - - private jobFullNameParamToJobs(jobFullName: string): string[] { - // jobFullName may contain a list of job names separated by '/' - return jobFullName.split('/').map((s: string) => encodeURIComponent(s)); - } } diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/index.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/index.ts index 5b4bc880573..a207eab64a5 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/service/index.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/index.ts @@ -24,3 +24,5 @@ export type { JenkinsInstanceConfig, } from './jenkinsInfoProvider'; export * from './JenkinsBuilder'; +export { JenkinsService } from './jenkinsService'; +export type { EntityRef, RebuildResult } from './jenkinsService'; diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts index 6ea8c3dc838..5873732121c 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsInfoProvider.ts @@ -28,6 +28,7 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { InputError, NotFoundError } from '@backstage/errors'; /** @public */ export interface JenkinsInfoProvider { @@ -122,7 +123,7 @@ export class JenkinsConfig { ); if (hasNamedDefault && (baseUrl || username || apiKey)) { - throw new Error( + throw new InputError( `Found both a named jenkins instance with name ${DEFAULT_JENKINS_NAME} and top level baseUrl, username or apiKey config. Use only one style of config.`, ); } @@ -130,7 +131,7 @@ export class JenkinsConfig { const unnamedNonePresent = !baseUrl && !username && !apiKey; const unnamedAllPresent = baseUrl && username && apiKey; if (!(unnamedAllPresent || unnamedNonePresent)) { - throw new Error( + throw new InputError( `Found partial default jenkins config. All (or none) of baseUrl, username and apiKey must be provided.`, ); } @@ -169,7 +170,7 @@ export class JenkinsConfig { ); if (!instanceConfig) { - throw new Error( + throw new NotFoundError( `Couldn't find a default jenkins instance in the config. Either configure an instance with name ${DEFAULT_JENKINS_NAME} or add a prefix to your annotation value.`, ); } @@ -181,7 +182,7 @@ export class JenkinsConfig { const instanceConfig = this.instances.find(c => c.name === jenkinsName); if (!instanceConfig) { - throw new Error( + throw new NotFoundError( `Couldn't find a jenkins instance in the config with name ${jenkinsName}`, ); } @@ -238,7 +239,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { credentials, }); if (!entity) { - throw new Error( + throw new NotFoundError( `Couldn't find entity with name: ${stringifyEntityRef(opt.entityRef)}`, ); } @@ -247,7 +248,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { const jenkinsAndJobNames = DefaultJenkinsInfoProvider.getEntityAnnotationValue(entity); if (!jenkinsAndJobNames || jenkinsAndJobNames.length === 0) { - throw new Error( + throw new NotFoundError( `Couldn't find jenkins annotation (${ DefaultJenkinsInfoProvider.NEW_JENKINS_ANNOTATION }) on entity with name: ${stringifyEntityRef(opt.entityRef)}`, @@ -279,7 +280,7 @@ export class DefaultJenkinsInfoProvider implements JenkinsInfoProvider { // Ensure that all jobs belong to a single Jenkins instance. const instancesFound: string[] = Object.keys(jobsByInstance); if (instancesFound.length > 1) { - throw new Error( + throw new InputError( `More than one Jenkins instance found: (${instancesFound}) ` + `on entity with name: ${stringifyEntityRef(opt.entityRef)}. ` + `Please use the same instance for all jobs.`, diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.test.ts new file mode 100644 index 00000000000..25e04a70bb9 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.test.ts @@ -0,0 +1,250 @@ +/* + * 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 { JenkinsService } from './jenkinsService'; +import { JenkinsApiImpl } from './jenkinsApi'; +import { JenkinsInfoProvider, JenkinsInfo } from './jenkinsInfoProvider'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { mockServices } from '@backstage/backend-test-utils'; + +jest.mock('./jenkinsApi'); + +const MockedJenkinsApiImpl = JenkinsApiImpl as jest.MockedClass< + typeof JenkinsApiImpl +>; + +const mockJenkinsApi = { + getProjects: jest.fn(), + getBuild: jest.fn(), + rebuildProject: jest.fn(), + getJobBuilds: jest.fn(), + getBuildConsoleText: jest.fn(), +}; + +MockedJenkinsApiImpl.mockImplementation(() => mockJenkinsApi as any); + +const jenkinsInfo: JenkinsInfo = { + baseUrl: 'https://jenkins.example.com', + headers: { Authorization: 'Basic abc' }, + fullJobNames: ['my-job'], + projectCountLimit: 50, +}; + +const mockInfoProvider: jest.Mocked = { + getInstance: jest.fn().mockResolvedValue(jenkinsInfo), +}; + +const fakePermissionApi = { + authorize: jest.fn().mockResolvedValue([{ result: AuthorizeResult.ALLOW }]), + authorizeConditional: jest.fn(), +}; + +describe('JenkinsService', () => { + let service: JenkinsService; + const auth = mockServices.auth(); + let credentials: any; + + beforeAll(async () => { + credentials = await auth.getOwnServiceCredentials(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockInfoProvider.getInstance.mockResolvedValue(jenkinsInfo); + + service = JenkinsService.createService({ + permissions: fakePermissionApi, + logger: mockServices.rootLogger(), + jenkinsInfoProvider: mockInfoProvider, + }); + }); + + describe('getProjects', () => { + it('should return projects from the jenkins api', async () => { + const projects = [{ fullName: 'my-job/main', status: 'success' }]; + mockJenkinsApi.getProjects.mockResolvedValueOnce(projects); + + const result = await service.getProjects({ + entityRef: { kind: 'Component', namespace: 'default', name: 'my-svc' }, + credentials, + }); + + expect(result).toEqual({ projects }); + expect(mockInfoProvider.getInstance).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-svc', + }, + }), + ); + expect(mockJenkinsApi.getProjects).toHaveBeenCalledWith( + jenkinsInfo, + undefined, + ); + }); + + it('should pass branches through', async () => { + mockJenkinsApi.getProjects.mockResolvedValueOnce([]); + + await service.getProjects({ + entityRef: { name: 'my-svc' }, + branches: ['main', 'develop'], + credentials, + }); + + expect(mockJenkinsApi.getProjects).toHaveBeenCalledWith(jenkinsInfo, [ + 'main', + 'develop', + ]); + }); + + it('should default kind and namespace', async () => { + mockJenkinsApi.getProjects.mockResolvedValueOnce([]); + + await service.getProjects({ + entityRef: { name: 'my-svc' }, + credentials, + }); + + expect(mockInfoProvider.getInstance).toHaveBeenCalledWith( + expect.objectContaining({ + entityRef: { + kind: 'Component', + namespace: 'default', + name: 'my-svc', + }, + }), + ); + }); + + it('should wrap AggregateError with useful message', async () => { + const aggError: any = new Error('All promises were rejected'); + aggError.errors = [new Error('connection refused')]; + mockJenkinsApi.getProjects.mockRejectedValueOnce(aggError); + + await expect( + service.getProjects({ + entityRef: { name: 'my-svc' }, + credentials, + }), + ).rejects.toThrow(/Unable to fetch projects/); + }); + + it('should rethrow non-aggregate errors as-is', async () => { + mockJenkinsApi.getProjects.mockRejectedValueOnce( + new Error('some other error'), + ); + + await expect( + service.getProjects({ + entityRef: { name: 'my-svc' }, + credentials, + }), + ).rejects.toThrow('some other error'); + }); + }); + + describe('getBuild', () => { + it('should return a build', async () => { + const build = { number: 42, result: 'SUCCESS' }; + mockJenkinsApi.getBuild.mockResolvedValueOnce(build); + + const result = await service.getBuild({ + entityRef: { name: 'my-svc' }, + jobFullName: 'folder/my-job', + buildNumber: 42, + credentials, + }); + + expect(result).toEqual({ build }); + expect(mockInfoProvider.getInstance).toHaveBeenCalledWith( + expect.objectContaining({ + fullJobNames: ['folder/my-job'], + }), + ); + expect(mockJenkinsApi.getBuild).toHaveBeenCalledWith( + jenkinsInfo, + ['folder', 'my-job'], + 42, + ); + }); + }); + + describe('getJobBuilds', () => { + it('should return builds for a job', async () => { + const builds = [{ number: 1 }, { number: 2 }]; + mockJenkinsApi.getJobBuilds.mockResolvedValueOnce(builds); + + const result = await service.getJobBuilds({ + entityRef: { name: 'my-svc' }, + jobFullName: 'my-job', + credentials, + }); + + expect(result).toEqual({ builds }); + expect(mockJenkinsApi.getJobBuilds).toHaveBeenCalledWith(jenkinsInfo, [ + 'my-job', + ]); + }); + }); + + describe('rebuildProject', () => { + it('should return success on rebuild', async () => { + mockJenkinsApi.rebuildProject.mockResolvedValueOnce(200); + + const result = await service.rebuildProject({ + entityRef: { kind: 'Component', namespace: 'default', name: 'my-svc' }, + jobFullName: 'my-job', + buildNumber: 7, + credentials, + }); + + expect(result).toEqual({ + status: 'success', + message: 'Successfully triggered rebuild of my-job #7', + }); + expect(mockJenkinsApi.rebuildProject).toHaveBeenCalledWith( + jenkinsInfo, + ['my-job'], + 7, + 'component:default/my-svc', + { credentials }, + ); + }); + }); + + describe('getBuildConsoleText', () => { + it('should return console text', async () => { + mockJenkinsApi.getBuildConsoleText.mockResolvedValueOnce('Build output'); + + const result = await service.getBuildConsoleText({ + entityRef: { name: 'my-svc' }, + jobFullName: 'my-job', + buildNumber: 5, + credentials, + }); + + expect(result).toEqual({ consoleText: 'Build output' }); + expect(mockJenkinsApi.getBuildConsoleText).toHaveBeenCalledWith( + jenkinsInfo, + ['my-job'], + 5, + ); + }); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.ts new file mode 100644 index 00000000000..161f9376454 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsService.ts @@ -0,0 +1,220 @@ +/* + * 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 { + BackstageCredentials, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { stringifyError } from '@backstage/errors'; +import { + PermissionEvaluator, + toPermissionEvaluator, +} from '@backstage/plugin-permission-common'; +import { JenkinsInfoProvider } from './jenkinsInfoProvider'; +import { JenkinsApiImpl } from './jenkinsApi'; +import type { BackstageBuild, BackstageProject } from '../types'; + +/** @public */ +export interface EntityRef { + kind?: string; + namespace?: string; + name: string; +} + +/** @public */ +export interface RebuildResult { + status: 'success' | 'denied'; + message: string; +} + +/** @public */ +export interface JenkinsServiceEnvironment { + permissions: PermissionEvaluator; + logger: LoggerService; + jenkinsInfoProvider: JenkinsInfoProvider; +} + +/** + * Splits a jobFullName on `/` and URL-encodes each segment. + */ +function jobFullNameToJobs(jobFullName: string): string[] { + return jobFullName.split('/').map(s => encodeURIComponent(s)); +} + +function toCompoundEntityRef(entityRef: EntityRef) { + return { + kind: entityRef.kind ?? 'Component', + namespace: entityRef.namespace ?? 'default', + name: entityRef.name, + }; +} + +/** @public */ +export class JenkinsService { + private readonly jenkinsApi: JenkinsApiImpl; + private readonly jenkinsInfoProvider: JenkinsInfoProvider; + + static createService(env: JenkinsServiceEnvironment) { + return new JenkinsService(env); + } + + constructor(protected readonly env: JenkinsServiceEnvironment) { + const { logger, permissions: permissionApi, jenkinsInfoProvider } = env; + this.jenkinsInfoProvider = jenkinsInfoProvider; + + let permissionEvaluator: PermissionEvaluator | undefined; + if (permissionApi && 'authorizeConditional' in permissionApi) { + permissionEvaluator = permissionApi as PermissionEvaluator; + } else { + logger.warn( + 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', + ); + permissionEvaluator = permissionApi + ? toPermissionEvaluator(permissionApi) + : undefined; + } + + this.jenkinsApi = new JenkinsApiImpl(permissionEvaluator); + } + + async getProjects(options: { + entityRef: EntityRef; + branches?: string[]; + credentials: BackstageCredentials; + }): Promise<{ projects: BackstageProject[] }> { + const { entityRef, branches, credentials } = options; + + const jenkinsInfo = await this.jenkinsInfoProvider.getInstance({ + entityRef: toCompoundEntityRef(entityRef), + credentials, + }); + + try { + const projects = await this.jenkinsApi.getProjects(jenkinsInfo, branches); + return { projects }; + } catch (err: any) { + // Promise.any, used in the getProjects call returns an Aggregate error message with a useless error message 'AggregateError: All promises were rejected' + // extract useful information ourselves + if (err.errors) { + throw new Error( + `Unable to fetch projects, for ${ + jenkinsInfo.fullJobNames + }: ${stringifyError(err.errors)}`, + ); + } + throw err; + } + } + + async getBuild(options: { + entityRef: EntityRef; + jobFullName: string; + buildNumber: number; + credentials: BackstageCredentials; + }): Promise<{ build: BackstageBuild }> { + const { entityRef, jobFullName, buildNumber, credentials } = options; + const jobs = jobFullNameToJobs(jobFullName); + + const jenkinsInfo = await this.jenkinsInfoProvider.getInstance({ + entityRef: toCompoundEntityRef(entityRef), + fullJobNames: [jobFullName], + credentials, + }); + + const build = await this.jenkinsApi.getBuild( + jenkinsInfo, + jobs, + buildNumber, + ); + return { build }; + } + + async getJobBuilds(options: { + entityRef: EntityRef; + jobFullName: string; + credentials: BackstageCredentials; + }): Promise<{ builds: any }> { + const { entityRef, jobFullName, credentials } = options; + const jobs = jobFullNameToJobs(jobFullName); + + const jenkinsInfo = await this.jenkinsInfoProvider.getInstance({ + entityRef: toCompoundEntityRef(entityRef), + fullJobNames: [jobFullName], + credentials, + }); + + const builds = await this.jenkinsApi.getJobBuilds(jenkinsInfo, jobs); + return { builds }; + } + + async rebuildProject(options: { + entityRef: EntityRef; + jobFullName: string; + buildNumber: number; + credentials: BackstageCredentials; + }): Promise { + const { entityRef, jobFullName, buildNumber, credentials } = options; + + const jobs = jobFullNameToJobs(jobFullName); + + const jenkinsInfo = await this.jenkinsInfoProvider.getInstance({ + entityRef: toCompoundEntityRef(entityRef), + fullJobNames: [jobFullName], + credentials, + }); + + const resourceRef = stringifyEntityRef(toCompoundEntityRef(entityRef)); + + await this.jenkinsApi.rebuildProject( + jenkinsInfo, + jobs, + buildNumber, + resourceRef, + { + credentials, + }, + ); + + return { + status: 'success', + message: `Successfully triggered rebuild of ${jobFullName} #${buildNumber}`, + }; + } + + async getBuildConsoleText(options: { + entityRef: EntityRef; + jobFullName: string; + buildNumber: number; + credentials: BackstageCredentials; + }): Promise<{ consoleText: string }> { + const { entityRef, jobFullName, buildNumber, credentials } = options; + const jobs = jobFullNameToJobs(jobFullName); + + const jenkinsInfo = await this.jenkinsInfoProvider.getInstance({ + entityRef: toCompoundEntityRef(entityRef), + fullJobNames: [jobFullName], + credentials, + }); + + const consoleText = await this.jenkinsApi.getBuildConsoleText( + jenkinsInfo, + jobs, + buildNumber, + ); + return { consoleText }; + } +} diff --git a/workspaces/jenkins/yarn.lock b/workspaces/jenkins/yarn.lock index e9326c396fe..d1f665eea18 100644 --- a/workspaces/jenkins/yarn.lock +++ b/workspaces/jenkins/yarn.lock @@ -3023,6 +3023,26 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-mcp-actions-backend@backstage:^::backstage=1.49.2&npm=0.1.10, @backstage/plugin-mcp-actions-backend@npm:^0.1.10": + version: 0.1.11 + resolution: "@backstage/plugin-mcp-actions-backend@npm:0.1.11" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.8.0" + "@backstage/catalog-client": "npm:^1.14.0" + "@backstage/config": "npm:^1.3.6" + "@backstage/errors": "npm:^1.2.7" + "@backstage/plugin-catalog-node": "npm:^2.1.0" + "@backstage/types": "npm:^1.2.2" + "@cfworker/json-schema": "npm:^4.1.1" + "@modelcontextprotocol/sdk": "npm:^1.25.2" + express: "npm:^4.22.0" + express-promise-router: "npm:^4.1.0" + minimatch: "npm:^10.2.1" + zod: "npm:^3.25.76 || ^4.0.0" + checksum: 10/ef3fca1a81a6e0829d5890ba0236a8cc35fd4fdcd8ecaa49fdc47204dc282799c1598f9713c8f4ecd3acc93b2b882e1a322954492c87b1fe8016f164d5f80403 + languageName: node + linkType: hard + "@backstage/plugin-org@backstage:^::backstage=1.49.2&npm=0.7.0, @backstage/plugin-org@npm:^0.7.0": version: 0.7.0 resolution: "@backstage/plugin-org@npm:0.7.0" @@ -3933,6 +3953,13 @@ __metadata: languageName: node linkType: hard +"@cfworker/json-schema@npm:^4.1.1": + version: 4.1.1 + resolution: "@cfworker/json-schema@npm:4.1.1" + checksum: 10/62fd08bb2e6b4f0fe7c2b8f8c19f17f94b6a34feba7f455f228898ab435eda8aae082fcf6b0fe8a235a72e0ec0041922fdcd4c526acc32d45084272f000c1af9 + languageName: node + linkType: hard + "@changesets/apply-release-plan@npm:^7.0.5": version: 7.0.5 resolution: "@changesets/apply-release-plan@npm:7.0.5" @@ -5067,6 +5094,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.14 + resolution: "@hono/node-server@npm:1.19.14" + peerDependencies: + hono: ^4 + checksum: 10/618dd95feeb3fd11ec8502e088879cd86529523788de19602edebd16892dd61899e73564d6e3d00875cc5a49488a908ddb2aa425d28f9cdeb7f22cfecabf022c + languageName: node + linkType: hard + "@httptoolkit/httpolyglot@npm:^2.2.1": version: 2.2.2 resolution: "@httptoolkit/httpolyglot@npm:2.2.2" @@ -5892,6 +5928,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.25.2": + version: 1.29.0 + resolution: "@modelcontextprotocol/sdk@npm:1.29.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10/ff551b97e06b661f95fec8fd34e112c446e69894a84a9979cdac369fb5de27f0a1a5c1f4e2a1f270cc60f93e54c28a8059a94ca51c3d528d2670ade874b244f9 + languageName: node + linkType: hard + "@module-federation/bridge-react-webpack-plugin@npm:0.21.6": version: 0.21.6 resolution: "@module-federation/bridge-react-webpack-plugin@npm:0.21.6" @@ -13225,6 +13294,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: "npm:^3.0.0" + negotiator: "npm:^1.0.0" + checksum: 10/ea1343992b40b2bfb3a3113fa9c3c2f918ba0f9197ae565c48d3f84d44b174f6b1d5cd9989decd7655963eb03a272abc36968cc439c2907f999bd5ef8653d5a7 + languageName: node + linkType: hard + "acorn-import-phases@npm:^1.0.3": version: 1.0.4 resolution: "acorn-import-phases@npm:1.0.4" @@ -14124,6 +14203,7 @@ __metadata: "@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:^" @@ -14423,6 +14503,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.2.2 + resolution: "body-parser@npm:2.2.2" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^1.0.5" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.0" + iconv-lite: "npm:^0.7.0" + on-finished: "npm:^2.4.1" + qs: "npm:^6.14.1" + raw-body: "npm:^3.0.1" + type-is: "npm:^2.0.1" + checksum: 10/69671f67d4d5ae5974593901a92d639757231da1725ed6de4d35e86cde9ce7650afdf1cd28df9b6f7892ea7f9eb03ccb30c70fe27d679275ae4cb4aae5ce1b21 + languageName: node + linkType: hard + "bonjour-service@npm:^1.2.1": version: 1.2.1 resolution: "bonjour-service@npm:1.2.1" @@ -14733,7 +14830,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2, bytes@npm:~3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -15560,6 +15657,13 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^1.0.0": + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 10/c4f65e3c001a4a8eb87d0d24c0f112abb139836fb13b8ea67276715e7dce09570ef666ba7848ee8b660d467e6588d030c8ed7e8d0128db6ca78a0800dcd8c7a8 + languageName: node + linkType: hard + "content-disposition@npm:~0.5.4": version: 0.5.4 resolution: "content-disposition@npm:0.5.4" @@ -15607,7 +15711,14 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:~0.7.1": +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10/be44a3c9a56f3771aea3a8bd8ad8f0a8e2679bcb967478267f41a510b4eb5ec55085386ba79c706c4ac21605ca76f4251973444b90283e0eb3eeafe8a92c7708 + languageName: node + linkType: hard + +"cookie@npm:0.7.2, cookie@npm:^0.7.0, cookie@npm:^0.7.1, cookie@npm:~0.7.1": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f @@ -15859,7 +15970,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -16305,7 +16416,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.3": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -16520,7 +16631,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:~2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10/c0c8ff36079ce5ada64f46cc9d6fd47ebcf38241105b6e0c98f412e8ad91f084bcf906ff644cc3a4bd876ca27a62accb8b0fff72ea6ed1a414b89d8506f4a5ca @@ -17838,7 +17949,7 @@ __metadata: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 10/571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff @@ -17882,6 +17993,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.0.6 + resolution: "eventsource-parser@npm:3.0.6" + checksum: 10/febf7058b9c2168ecbb33e92711a1646e06bd1568f60b6eb6a01a8bf9f8fcd29cc8320d57247059cacf657a296280159f21306d2e3ff33309a9552b2ef889387 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10/e034915bc97068d1d38617951afd798e6776d6a3a78e36a7569c235b177c7afc2625c9fe82656f7341ab72c7eeecb3fd507b7f88e9328f2448872ff9c4742bb6 + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -17957,6 +18084,17 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.3.2 + resolution: "express-rate-limit@npm:8.3.2" + dependencies: + ip-address: "npm:10.1.0" + peerDependencies: + express: ">= 4.11" + checksum: 10/71afac0fff29bf03117a89902953e7feafc67402332910cfb601b27da079b98f04ba551a1ef4f0ad5cf2538d9599c2649450af6d3f7505bcc7d24f7eaf765a4e + languageName: node + linkType: hard + "express-rate-limit@npm:^8.2.2": version: 8.3.1 resolution: "express-rate-limit@npm:8.3.1" @@ -18023,6 +18161,42 @@ __metadata: languageName: node linkType: hard +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10/4aa545d89702ac83f645c77abda1b57bcabe288f0b380fb5580fac4e323ea0eb533005c8e666b4e19152fb16d4abf11ba87b22aa9a10857a0485cd86b94639bd + languageName: node + linkType: hard + "extend@npm:3.0.2, extend@npm:^3.0.0, extend@npm:^3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -18294,6 +18468,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10/f4ba75c23408d8f9d393c3e875b9452e84d68c925411a6e67b7efa678b0bed5075ef33def4bb65ed8e0dd37c92a3ea354bcbde07303cd4dc2550e12b95885067 + languageName: node + linkType: hard + "finalhandler@npm:~1.3.1": version: 1.3.2 resolution: "finalhandler@npm:1.3.2" @@ -18605,6 +18793,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10/44e1468488363074641991c1340d2a10c5a6f6d7c353d89fd161c49d120c58ebf9890720f7584f509058385836e3ce50ddb60e9f017315a4ba8c6c3461813bfc + languageName: node + linkType: hard + "fresh@npm:~0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -19624,6 +19819,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.12 + resolution: "hono@npm:4.12.12" + checksum: 10/760617935cbc40fb20f2ffb4cbd5d59c3578a6838700be5f89a636a287bce59195d08f8e8e3551ca907796110457fb276327c155289d3776c151ed74ee02270f + languageName: node + linkType: hard + "hoopy@npm:^0.1.4": version: 0.1.4 resolution: "hoopy@npm:0.1.4" @@ -19758,7 +19960,7 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:^2.0.0, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.0, http-errors@npm:~2.0.1": version: 2.0.1 resolution: "http-errors@npm:2.0.1" dependencies: @@ -19954,6 +20156,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10/24c937b532f868e938386b62410b303b7c767ce3d08dc2829cbe59464d5a26ef86ae5ad1af6b34eec43ddfea39e7d101638644b0178d67262fa87015d59f983a + languageName: node + linkType: hard + "icss-replace-symbols@npm:^1.1.0": version: 1.1.0 resolution: "icss-replace-symbols@npm:1.1.0" @@ -21007,6 +21218,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.2 + resolution: "jose@npm:6.2.2" + checksum: 10/fd66f24916d2507dbde0ca77ede86377e7d7eae42c7f94db0dfd014b0c6ce5041365dc8e757a44e54cd7606ea5e9c757aa544b51e55cb8a736e83320db1b8258 + languageName: node + linkType: hard + "js-base64@npm:^3.6.0": version: 3.7.7 resolution: "js-base64@npm:3.7.7" @@ -21217,6 +21435,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10/fa866d1fe91e3a94aa4fe007861475cd03dcaf47b719861cab171ef2f8598478007c634d29ae45de94ee34ddff4e13414c63ea5ff06c5b868b613142c699d511 + languageName: node + linkType: hard + "json-schema@npm:^0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" @@ -22623,6 +22848,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10/e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -23030,7 +23262,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1, mime-types@npm:^3.0.2": version: 3.0.2 resolution: "mime-types@npm:3.0.2" dependencies: @@ -23643,6 +23875,13 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:^1.0.0": + version: 1.0.0 + resolution: "negotiator@npm:1.0.0" + checksum: 10/b5734e87295324fabf868e36fb97c84b7d7f3156ec5f4ee5bf6e488079c11054f818290fc33804cef7b1ee21f55eeb14caea83e7dafae6492a409b3e573153e5 + languageName: node + linkType: hard + "neo-async@npm:^2.6.2": version: 2.6.2 resolution: "neo-async@npm:2.6.2" @@ -25003,6 +25242,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10/51d11f68d5a78617cfb2e9c2706dadcc2cbe55ffb55b21d42a6ed848ac5159db2657bf6c966a5a414119aa839ceb64240afea35e9e1c06946b57606ed0b43789 + languageName: node + linkType: hard + "pkg-dir@npm:^5.0.0": version: 5.0.0 resolution: "pkg-dir@npm:5.0.0" @@ -25789,7 +26035,7 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:~2.0.7": +"proxy-addr@npm:^2.0.7, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" dependencies: @@ -25883,6 +26129,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.14.1": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac + languageName: node + linkType: hard + "querystring-es3@npm:^0.2.1": version: 0.2.1 resolution: "querystring-es3@npm:0.2.1" @@ -26029,6 +26284,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10/4168c82157bd69175d5bd960e59b74e253e237b358213694946a427a6f750a18b8e150f036fed3421b3e83294b071a4e2bb01037a79ccacdac05360c63d3ebba + languageName: node + linkType: hard + "raw-loader@npm:^4.0.2": version: 4.0.2 resolution: "raw-loader@npm:4.0.2" @@ -27498,6 +27765,19 @@ __metadata: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10/8949bd1d3da5403cc024e2989fee58d7fda0f3ffe9f2dc5b8a192f295f400b3cde307b0b554f7d44851077640f36962ca469a766b3d57410d7d96245a7ba6c91 + languageName: node + linkType: hard + "rrweb-cssom@npm:^0.7.1": version: 0.7.1 resolution: "rrweb-cssom@npm:0.7.1" @@ -27785,6 +28065,25 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: "npm:^4.4.3" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.2" + checksum: 10/274f842d69ccfa49d4940a85598c6825da58dee6cb8ea33b08d5bd3988e6a82267c4d7c32b23d0e4706aad076ee95b1edfa13f859877db9b589829019397e355 + languageName: node + linkType: hard + "send@npm:~0.19.0, send@npm:~0.19.1": version: 0.19.2 resolution: "send@npm:0.19.2" @@ -27846,6 +28145,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10/71500fe80cc7163fec04e4297de7591ad1cb682d137fc030e7a53e57040fda5187e8082a9c1b2ef37f1d3f9c27c9a94d4ba61806ebc28938ba4a7c8947c9f71e + languageName: node + linkType: hard + "serve-static@npm:~1.16.2": version: 1.16.3 resolution: "serve-static@npm:1.16.3" @@ -28445,7 +28756,7 @@ __metadata: languageName: node linkType: hard -"statuses@npm:^2.0.1, statuses@npm:~2.0.1, statuses@npm:~2.0.2": +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.1, statuses@npm:~2.0.2": version: 2.0.2 resolution: "statuses@npm:2.0.2" checksum: 10/6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 @@ -31357,7 +31668,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.76 || ^4.0.0": +"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.76 || ^4.0.0": version: 4.3.6 resolution: "zod@npm:4.3.6" checksum: 10/25fc0f62e01b557b4644bf0b393bbaf47542ab30877c37837ea8caf314a8713d220c7d7fe51f68ffa72f0e1018ddfa34d96f1973d23033f5a2a5a9b6b9d9da01 From 5bcf24891de6e48d3e6711dd4b61824b4bde839e Mon Sep 17 00:00:00 2001 From: Mattia Dell'Oca Date: Wed, 15 Apr 2026 09:39:17 +0200 Subject: [PATCH 2/2] Dedube lockfile Signed-off-by: Mattia Dell'Oca --- workspaces/jenkins/yarn.lock | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/workspaces/jenkins/yarn.lock b/workspaces/jenkins/yarn.lock index d1f665eea18..4b4a272c868 100644 --- a/workspaces/jenkins/yarn.lock +++ b/workspaces/jenkins/yarn.lock @@ -18084,7 +18084,7 @@ __metadata: languageName: node linkType: hard -"express-rate-limit@npm:^8.2.1": +"express-rate-limit@npm:^8.2.1, express-rate-limit@npm:^8.2.2": version: 8.3.2 resolution: "express-rate-limit@npm:8.3.2" dependencies: @@ -18095,17 +18095,6 @@ __metadata: languageName: node linkType: hard -"express-rate-limit@npm:^8.2.2": - version: 8.3.1 - resolution: "express-rate-limit@npm:8.3.1" - dependencies: - ip-address: "npm:10.1.0" - peerDependencies: - express: ">= 4.11" - checksum: 10/dd97bfc48c01a6d4c5433203232b5e7a1e55e21322bde49033e5f8c4339584fe671a94096144a0810f4ea21dcec8aaaf15823109627e609f8ed1bc5912a345cf - languageName: node - linkType: hard - "express-session@npm:^1.17.1": version: 1.18.1 resolution: "express-session@npm:1.18.1" @@ -26120,21 +26109,21 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.10.1, qs@npm:^6.11.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.9.4, qs@npm:~6.14.0": - version: 6.14.1 - resolution: "qs@npm:6.14.1" +"qs@npm:^6.10.1, qs@npm:^6.11.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1, qs@npm:^6.9.4": + version: 6.15.1 + resolution: "qs@npm:6.15.1" dependencies: side-channel: "npm:^1.1.0" - checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5 + checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac languageName: node linkType: hard -"qs@npm:^6.14.1": - version: 6.15.1 - resolution: "qs@npm:6.15.1" +"qs@npm:~6.14.0": + version: 6.14.1 + resolution: "qs@npm:6.14.1" dependencies: side-channel: "npm:^1.1.0" - checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac + checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5 languageName: node linkType: hard