diff --git a/workspaces/jenkins/.changeset/great-spies-tan.md b/workspaces/jenkins/.changeset/great-spies-tan.md new file mode 100644 index 0000000000..fbaa5e995c --- /dev/null +++ b/workspaces/jenkins/.changeset/great-spies-tan.md @@ -0,0 +1,7 @@ +--- +'@backstage-community/plugin-scaffolder-backend-module-jenkins': minor +'@backstage-community/plugin-jenkins-backend': minor +'@backstage-community/plugin-jenkins-common': minor +--- + +Replace the deprecated `jenkins` NPM package with a built-in, light-weight client. diff --git a/workspaces/jenkins/plugins/jenkins-backend/package.json b/workspaces/jenkins/plugins/jenkins-backend/package.json index ade8d1edc7..799f6b7fb2 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/package.json +++ b/workspaces/jenkins/plugins/jenkins-backend/package.json @@ -52,7 +52,6 @@ "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", - "jenkins": "^1.0.0", "node-fetch": "^2.6.7", "yn": "^4.0.0" }, @@ -61,7 +60,6 @@ "@backstage/cli": "backstage:^", "@backstage/plugin-auth-backend": "backstage:^", "@backstage/plugin-auth-backend-module-guest-provider": "backstage:^", - "@types/jenkins": "^1.0.0", "@types/node-fetch": "^2.5.12", "@types/supertest": "^6.0.0" }, diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.test.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.test.ts index 718e4a0c13..aa3e8bcd1d 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.test.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.test.ts @@ -15,25 +15,30 @@ */ import { JenkinsApiImpl } from './jenkinsApi'; -import jenkins from 'jenkins'; +import { + Jenkins, + type JenkinsBuild, +} from '@backstage-community/plugin-jenkins-common'; import { JenkinsInfo } from './jenkinsInfoProvider'; -import { JenkinsBuild, JenkinsProject } from '../types'; +import { JenkinsProject } from '../types'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import fetch, { Response } from 'node-fetch'; import { mockServices } from '@backstage/backend-test-utils'; -jest.mock('jenkins'); +jest.mock('@backstage-community/plugin-jenkins-common'); jest.mock('node-fetch'); const mockedJenkinsClient = { job: { get: jest.fn(), build: jest.fn(), + getBuilds: jest.fn(), }, build: { get: jest.fn(), + getConsoleText: jest.fn(), }, }; -const mockedJenkins = jenkins as jest.Mocked; +const mockedJenkins = Jenkins as jest.Mocked; mockedJenkins.mockReturnValue(mockedJenkinsClient); const resourceRef = 'component:default/example-component'; @@ -777,9 +782,9 @@ describe('JenkinsApi', () => { json: async () => {}, } as Response); await jenkinsApi.getJobBuilds(jenkinsInfo, jobs); - expect(mockFetch).toHaveBeenCalledWith( - 'https://jenkins.example.com/job/example-jobName/job/foo/api/json?tree=name,description,url,fullName,displayName,fullDisplayName,inQueue,builds[*]', - { headers: { headerName: 'headerValue' }, method: 'get' }, + expect(mockedJenkinsClient.job.getBuilds).toHaveBeenCalledWith( + ['example-jobName', 'foo'], + 'name,description,url,fullName,displayName,fullDisplayName,inQueue,builds[*]', ); }); @@ -790,21 +795,18 @@ describe('JenkinsApi', () => { } as Response); const fullJobName = ['test', 'folder', 'depth', 'foo']; await jenkinsApi.getJobBuilds(jenkinsInfo, fullJobName); - expect(mockFetch).toHaveBeenCalledWith( - 'https://jenkins.example.com/job/test/job/folder/job/depth/job/foo/api/json?tree=name,description,url,fullName,displayName,fullDisplayName,inQueue,builds[*]', - { headers: { headerName: 'headerValue' }, method: 'get' }, + expect(mockedJenkinsClient.job.getBuilds).toHaveBeenCalledWith( + ['test', 'folder', 'depth', 'foo'], + 'name,description,url,fullName,displayName,fullDisplayName,inQueue,builds[*]', ); }); }); describe('getBuildConsoleText', () => { it('should return the console text for a build', async () => { const mockedConsoleText = 'Build Ran'; - mockFetch.mockResolvedValueOnce({ - status: 200, - text: async () => { - return mockedConsoleText; - }, - } as unknown as Response); + mockedJenkinsClient.build.getConsoleText.mockResolvedValueOnce( + mockedConsoleText, + ); const consoleText = await jenkinsApi.getBuildConsoleText( jenkinsInfo, @@ -813,9 +815,9 @@ describe('JenkinsApi', () => { ); expect(consoleText).toBe('Build Ran'); - expect(mockFetch).toHaveBeenCalledWith( - 'https://jenkins.example.com/job/example-jobName/job/foo/19/consoleText', - { headers: { headerName: 'headerValue' }, method: 'get' }, + expect(mockedJenkinsClient.build.getConsoleText).toHaveBeenCalledWith( + ['example-jobName', 'foo'], + 19, ); }); }); diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.ts b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.ts index 9d63ff02bb..73ed4e3dc9 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -15,11 +15,13 @@ */ import type { JenkinsInfo } from './jenkinsInfoProvider'; -import Jenkins from 'jenkins'; +import { + Jenkins, + type JenkinsBuild, +} from '@backstage-community/plugin-jenkins-common'; import type { BackstageBuild, BackstageProject, - JenkinsBuild, JenkinsProject, ScmDetails, } from '../types'; @@ -225,9 +227,8 @@ export class JenkinsApiImpl { // private helper methods - private static async getClient(jenkinsInfo: JenkinsInfo) { - // The typings for the jenkins library are out of date so just cast to any - return new (Jenkins as any)({ + private static async getClient(jenkinsInfo: JenkinsInfo): Promise { + return new Jenkins({ baseUrl: jenkinsInfo.baseUrl, headers: jenkinsInfo.headers, promisify: true, @@ -379,18 +380,10 @@ export class JenkinsApiImpl { } async getJobBuilds(jenkinsInfo: JenkinsInfo, jobs: string[]) { - const response = await fetch( - `${jenkinsInfo.baseUrl}/job/${jobs.join( - '/job/', - )}/api/json?tree=${JenkinsApiImpl.jobBuildsTreeSpec.replace(/\s/g, '')}`, - { - method: 'get', - headers: jenkinsInfo.headers as HeaderInit, - }, - ); + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + const tree = JenkinsApiImpl.jobBuildsTreeSpec.replace(/\s/g, ''); - const jobBuilds = await response.json(); - return jobBuilds; + return await client.job.getBuilds(jobs, tree); } /** @@ -402,14 +395,8 @@ export class JenkinsApiImpl { jobs: string[], buildNumber: number, ) { - const buildUrl = this.getBuildUrl(jenkinsInfo, jobs, buildNumber); - - const response = await fetch(`${buildUrl}/consoleText`, { - method: 'get', - headers: jenkinsInfo.headers as HeaderInit, - }); + const client = await JenkinsApiImpl.getClient(jenkinsInfo); - const consoleText = await response.text(); - return consoleText; + return await client.build.getConsoleText(jobs, buildNumber); } } diff --git a/workspaces/jenkins/plugins/jenkins-backend/src/types.ts b/workspaces/jenkins/plugins/jenkins-backend/src/types.ts index a058087e7c..b2b864e0c1 100644 --- a/workspaces/jenkins/plugins/jenkins-backend/src/types.ts +++ b/workspaces/jenkins/plugins/jenkins-backend/src/types.ts @@ -14,29 +14,17 @@ * limitations under the License. */ +import type { + JenkinsBuild, + CommonBuild, +} from '@backstage-community/plugin-jenkins-common'; + export interface ScmDetails { url?: string; displayName?: string; author?: string; } -interface CommonBuild { - // standard Jenkins - timestamp: number; - building: boolean; - duration: number; - result?: string; - fullDisplayName: string; - displayName: string; - url: string; - number: number; -} - -export interface JenkinsBuild extends CommonBuild { - // read by us from jenkins but not passed to frontend - actions: any; -} - /** * A build as presented by this plugin to the backstage jenkins plugin */ diff --git a/workspaces/jenkins/plugins/jenkins-common/package.json b/workspaces/jenkins/plugins/jenkins-common/package.json index bf4b9a642c..f84254c4bc 100644 --- a/workspaces/jenkins/plugins/jenkins-common/package.json +++ b/workspaces/jenkins/plugins/jenkins-common/package.json @@ -39,7 +39,9 @@ }, "dependencies": { "@backstage/plugin-catalog-common": "backstage:^", - "@backstage/plugin-permission-common": "backstage:^" + "@backstage/plugin-permission-common": "backstage:^", + "form-data": "^4.0.4", + "node-fetch": "^2.6.7" }, "devDependencies": { "@backstage/cli": "backstage:^" diff --git a/workspaces/jenkins/plugins/jenkins-common/report.api.md b/workspaces/jenkins/plugins/jenkins-common/report.api.md index d91ec7612f..c57d49c8e7 100644 --- a/workspaces/jenkins/plugins/jenkins-common/report.api.md +++ b/workspaces/jenkins/plugins/jenkins-common/report.api.md @@ -5,11 +5,128 @@ ```ts import { ResourcePermission } from '@backstage/plugin-permission-common'; +// @public (undocumented) +export interface CommonBuild { + // (undocumented) + building: boolean; + // (undocumented) + displayName: string; + // (undocumented) + duration: number; + // (undocumented) + fullDisplayName: string; + // (undocumented) + number: number; + // (undocumented) + result?: string; + // (undocumented) + timestamp: number; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface CrumbData { + // (undocumented) + cookies?: string[]; + // (undocumented) + headerName: string; + // (undocumented) + headerValue: string; +} + +// @public (undocumented) +export interface CrumbDataHeaderValues { + // (undocumented) + crumb: string; + // (undocumented) + crumbRequestField: string; +} + +// @public (undocumented) +export type HeaderValue = string | string[] | undefined; + +// @public (undocumented) +export class Jenkins { + constructor(opts: JenkinsClientOptions); + // (undocumented) + build: { + get: ( + name: string | string[], + buildNumber: string | number, + ) => Promise; + getConsoleText: ( + name: string | string[], + buildNumber: string | number, + ) => Promise; + }; + // (undocumented) + job: { + get: (input: JobGetOptions) => Promise; + getBuilds: (name: string | string[], tree?: string) => Promise; + build: ( + name: string | string[], + opts?: JobBuildOptions | undefined, + ) => Promise; + copy: (name: string | string[], from: string) => Promise; + create: (name: string | string[], xml: string) => Promise; + destroy: (name: string | string[]) => Promise; + enable: (name: string | string[]) => Promise; + disable: (name: string | string[]) => Promise; + }; + // (undocumented) + readonly opts: JenkinsClientOptions; +} + +// @public (undocumented) +export interface JenkinsBuild extends CommonBuild { + // (undocumented) + actions: any; +} + +// @public (undocumented) +export interface JenkinsClientOptions { + // (undocumented) + baseUrl: string; + // (undocumented) + crumbIssuer?: boolean | ((client: any) => Promise) | undefined; + // (undocumented) + headers?: Record; + // (undocumented) + promisify?: boolean; +} + // @public export const jenkinsExecutePermission: ResourcePermission<'catalog-entity'>; +// @public (undocumented) +export type JenkinsParams = + | Record + | URLSearchParams + | undefined; + // @public export const jenkinsPermissions: ResourcePermission<'catalog-entity'>[]; +// @public (undocumented) +export interface JobBuildOptions { + // (undocumented) + delay?: string; + // (undocumented) + parameters?: JenkinsParams; + // (undocumented) + token?: string; +} + +// @public (undocumented) +export interface JobGetOptions { + // (undocumented) + depth?: number; + // (undocumented) + name: string | string[]; + // (undocumented) + tree?: string; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client.test.ts b/workspaces/jenkins/plugins/jenkins-common/src/client.test.ts new file mode 100644 index 0000000000..b2d7d2ff33 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2025 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 fetch, { Headers } from 'node-fetch'; +import { Jenkins, type JenkinsClientOptions } from './client.ts'; + +jest.mock('node-fetch', () => { + const actual = jest.requireActual('node-fetch'); + return Object.assign(jest.fn(), { + Headers: actual.Headers, + }); +}); + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +) { + const text = JSON.stringify(body); + const headers = new Headers({ + 'content-type': 'application/json', + ...(init.headers || {}), + }); + + return { + ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300, + status: init.status ?? 200, + statusText: 'OK', + headers, + text: async () => text, + json: async () => JSON.parse(text), + } as any; +} + +function textResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +) { + const text = JSON.stringify(body); + const headers = new Headers({ + 'content-type': 'text/plain', + ...(init.headers || {}), + }); + + return { + ok: (init.status ?? 200) >= 200 && (init.status ?? 200) < 300, + status: init.status ?? 200, + statusText: 'OK', + headers, + text: async () => text, + json: async () => JSON.parse(text), + } as any; +} + +const mockedFetch = fetch as unknown as jest.Mock; + +describe('Jenkins client', () => { + beforeEach(() => { + mockedFetch.mockReset(); + }); + + const baseOptions: JenkinsClientOptions = { + baseUrl: 'https://jenkins.example.com', + }; + + it('normalizes job names', () => { + const client = new Jenkins(baseOptions); + // String input + expect((client as any).normalizeJobName('a/b')).toBe('job/a/job/b'); + + // Already normalized /job/ + // Operation should be idempotent + expect((client as any).normalizeJobName('/job/a/job/b')).toBe( + 'job/a/job/b', + ); + + // Array input + expect((client as any).normalizeJobName(['folder', 'a'])).toBe( + 'job/folder/job/a', + ); + }); + + it('request builds full URL and parses JSON by default', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse({ ok: true })); + const client = new Jenkins(baseOptions); + + const result = await (client as any).request('job/a/api/json', { + query: { tree: 'x,y' }, + method: 'GET', + }); + + expect(result).toEqual({ ok: true }); + expect(mockedFetch).toHaveBeenCalledWith( + 'https://jenkins.example.com/job/a/api/json?tree=x%2Cy', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('request returns raw text when rawText=true', async () => { + mockedFetch.mockResolvedValueOnce(textResponse('hello')); + const client = new Jenkins(baseOptions); + + const out = await (client as any).request('job/main/9/consoleText', { + rawText: true, + method: 'GET', + }); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://jenkins.example.com/job/main/9/consoleText', + expect.objectContaining({ + method: 'GET', + headers: { + referer: 'https://jenkins.example.com/', + }, + body: undefined, + }), + ); + expect(out).toBe('"hello"'); + }); + + it('does not request crumb for GET/HEAD, does for POST and caches it', async () => { + const crumbCall = jest.fn().mockResolvedValue({ + headerName: 'Jenkins-Crumb', + headerValue: 'abc123', + cookies: ['JSESSIONID=xyz'], + }); + + const client = new Jenkins({ + ...baseOptions, + crumbIssuer: crumbCall, + headers: { + cookie: 'foo=bar', + }, + }); + + // GET should not fetch crumb + mockedFetch.mockResolvedValueOnce(jsonResponse({ ok: true })); + await (client as any).request('test', { method: 'GET' }); + expect(crumbCall).not.toHaveBeenCalled(); + + // First POST triggers crumb + mockedFetch.mockResolvedValueOnce(jsonResponse({ ok: true })); + await (client as any).request('build', { method: 'POST' }); + expect(crumbCall).toHaveBeenCalledTimes(1); + const postCall = mockedFetch.mock.calls.pop(); + const headers = postCall[1].headers as Record; + // cookies get merged + expect(headers.cookie).toContain('foo=bar'); + expect(headers.cookie).toContain('JSESSIONID=xyz'); + + // Second POST uses cached crumb (no new call) + mockedFetch.mockResolvedValueOnce(jsonResponse({ ok: true })); + await (client as any).request('buildAgain', { method: 'POST' }); + expect(crumbCall).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client.ts b/workspaces/jenkins/plugins/jenkins-common/src/client.ts new file mode 100644 index 0000000000..40bea99e7c --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client.ts @@ -0,0 +1,268 @@ +/* + * Copyright 2025 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 fetch from 'node-fetch'; +import { createJobApi } from './client/jobApi'; +import { createBuildApi } from './client/buildApi'; +import { CrumbData, CrumbDataHeaderValues, HeaderValue } from './client/types'; + +import { + addQueryParams, + joinUrl, + trimLeadingSlash, + ensureTrailingSlash, + safeExtractText, +} from './client/utils'; + +/** @public */ +export interface JenkinsClientOptions { + baseUrl: string; // e.g. "https://jenkins.example.com" + crumbIssuer?: boolean | ((client: any) => Promise) | undefined; + headers?: Record; + promisify?: boolean; // For compatibility with old legacy API, not used. +} + +/** @public */ +export class Jenkins { + private crumbData?: CrumbData; + public readonly opts: JenkinsClientOptions; + + constructor(opts: JenkinsClientOptions) { + if (!opts.baseUrl) { + throw new Error('Jenkins: opts.baseUrl is required'); + } + + // Legacy client behavior: set crumbIssuer to true if unset. + if (opts.crumbIssuer === undefined) { + opts.crumbIssuer = true; + } + + // Legacy client behavior: set default headers and Referer + // The referer here is the baseUrl. + const referer = ensureTrailingSlash(opts.baseUrl); + opts.headers = { referer, ...(opts.headers ?? {}) }; + + this.opts = opts; + } + + // Add APIs + job = createJobApi({ + normalizeJobName: name => this.normalizeJobName(name), + request: (path, opts) => this.request(path, opts), + }); + + build = createBuildApi({ + normalizeJobName: name => this.normalizeJobName(name), + request: (path, opts) => this.request(path, opts), + }); + + /** + * Retrieves and caches the Jenkins CSRF protection crumb. + * + * Jenkins uses a "crumb" (similar to a CSRF token) to protect write operations + * such as POST requests. This method handles retrieving that crumb based on + * the client's configuration. + * + * Behavior: + * - If `crumbIssuer` is not enabled in the client options, it returns `undefined`. + * - If a cached crumb already exists, it is returned immediately. + * - If `crumbIssuer` is a function, that function is called to obtain the crumb. + * - Otherwise, it performs a network request to + * `/crumbIssuer/api/json` to fetch the crumb from Jenkins. + * + * The result is cached in `this.crumbData` for subsequent calls. + * + * @returns A `CrumbData` object containing the header name and value, + * or `undefined` if no crumb issuer is configured or the request fails. + * @throws Any network or parsing errors that occur during the crumb fetch. + */ + private async getCrumb(): Promise { + const { crumbIssuer } = this.opts; + + if (!crumbIssuer) { + return undefined; + } + + if (this.crumbData) { + return this.crumbData; + } + + if (typeof crumbIssuer === 'function') { + this.crumbData = await crumbIssuer(this); + return this.crumbData; + } + + // Fetch crumb from Jenkins + const res = await this.fetchRaw( + `${ensureTrailingSlash(this.opts.baseUrl)}crumbIssuer/api/json`, + ); + if (!res.ok) { + return undefined; + } + const data = (await res.json()) as CrumbDataHeaderValues; + this.crumbData = { + headerName: data.crumbRequestField, + headerValue: data.crumb, + }; + + return this.crumbData; + } + + private async request( + path: string, + opts: { + method?: string; + query?: Record; + body?: any; + rawText?: boolean; + contentType?: string; + } = {}, + ): Promise { + let url = new URL( + joinUrl(ensureTrailingSlash(this.opts.baseUrl), trimLeadingSlash(path)), + ); + if (opts.query) { + url = addQueryParams(url, opts.query); + } + + const method = ( + opts?.method || (opts?.body ? 'POST' : 'GET') + ).toLocaleUpperCase('en-US'); + const headers: Record = { + ...(this.opts.headers ?? {}), + }; + + // Legacy client support: Add crumb if request is not read-only + if (method !== 'GET' && method !== 'HEAD') { + const crumb = await this.getCrumb(); + if (crumb) { + headers[crumb.headerName] = crumb.headerValue; + // If the crumb call told us to include some cookies, merge them into + // the existing cookie header + if (crumb.cookies?.length) { + const prior = + typeof headers.cookie === 'string' ? headers.cookie : ''; + const extra = crumb.cookies.join('; '); + headers.cookie = prior ? `${prior}; ${extra}` : extra; + } + } + } + + // Set Content-Type, default to undefined if not set + // Check caller-specified content-type first + let resolvedContentType: string | undefined; + if (opts?.contentType) { + resolvedContentType = opts?.contentType; + } + + // URLSearchParams -> x-www-form-urlencoded + if (!resolvedContentType && opts?.body instanceof URLSearchParams) { + resolvedContentType = 'application/x-www-form-urlencoded; charset=UTF-8'; + } + + if (resolvedContentType) { + headers['content-type'] = resolvedContentType; + } + + const res = await this.fetchRaw(url.toString(), { + method, + headers, + body: opts?.body, + }); + + if (!res.ok) { + const text = await safeExtractText(res); + throw new Error( + `Jenkins API error ${res.status} ${method} ${url.toString()}: ${text}`, + ); + } + + if (opts?.rawText) { + return res.text(); + } + + const contentType = ( + res.headers.get('content-type') || '' + ).toLocaleLowerCase('en-US'); + if (contentType.includes('application/json')) { + return res.json(); + } + + return res.text(); + } + + private async fetchRaw( + input: string, + init?: { + method?: string; + headers?: Record; + body?: any; + }, + ) { + // Flatten the values passed in "headers" + const flattened: Record = {}; + for (const [k, v] of Object.entries(init?.headers ?? {})) { + if (Array.isArray(v)) { + flattened[k] = v.join(', '); + } else if (v === undefined) { + continue; + } else { + flattened[k] = v; + } + } + + return fetch(input, { + ...(init ?? {}), + headers: flattened as any, + }); + } + + /** + * Normalizes a Jenkins job name into a fully qualified job path. + * + * Jenkins job URLs use a hierarchical format like: + * `/job/folder/job/subfolder/job/pipeline` + * + * This method takes a job name (either a string like `"folder/pipeline"` + * or an array like `["folder", "pipeline"]`) and converts it into the proper + * Jenkins API path format by inserting `job/` segments and URL-encoding + * each component. + * + * - If the input already contains `/job/` segments, it is returned as-is + * (after trimming any leading slash). + * - If the input is undefined, an error is thrown. + * + * @param name - The job name to normalize, either as a string or an array of path segments. + * @returns The normalized Jenkins job path (e.g. `"job/folder/job/pipeline"`). + * @throws If the name is undefined or empty. + */ + private normalizeJobName( + name: string | string[] | undefined, + ): string | undefined { + if (!name) { + throw new Error('Jenkins.normalizeJobName: "name" is required'); + } + + const parts = Array.isArray(name) ? name : name.split('/').filter(Boolean); + if (parts.join('/').includes('/job/')) { + return trimLeadingSlash(Array.isArray(name) ? parts.join('/') : name); + } + + return parts + .map(encodeURIComponent) + .map(s => `job/${s}`) + .join('/'); + } +} diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.test.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.test.ts new file mode 100644 index 0000000000..2020b8dc84 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.test.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2025 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 fetch from 'node-fetch'; +import { Jenkins } from '../client'; + +jest.mock('node-fetch', () => jest.fn()); +const mockedFetch = fetch as unknown as jest.Mock; + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'application/json' }, + json: async () => body, + text: async () => JSON.stringify(body), + } as any; +} + +function textResponse(text: string) { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => 'text/plain' }, + json: async () => ({ text }), + text: async () => text, + } as any; +} + +describe('buildApi', () => { + beforeEach(() => mockedFetch.mockReset()); + const client = new Jenkins({ baseUrl: 'https://jenkins.example.com' }); + + it('getLastBuild hits /lastBuild/api/json', async () => { + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({ number: 8 })); + await client.build.get('folder/proj', 8); + expect(mockedFetch).toHaveBeenCalledWith( + 'https://jenkins.example.com/job/folder/job/proj/8/api/json', + expect.objectContaining({ method: 'GET' }), + ); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({ number: 8 })); + await client.build.get(['folder', 'proj'], 8); + expect(mockedFetch).toHaveBeenCalledWith( + 'https://jenkins.example.com/job/folder/job/proj/8/api/json', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('getConsoleText returns raw string and uses rawText flag', async () => { + // String + mockedFetch.mockResolvedValueOnce(textResponse('log line 1\nlog line 2\n')); + let text = await client.build.getConsoleText(['folder', 'proj'], 7); + expect(text).toContain('log line 1'); + let [url] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/job/proj/7/consoleText', + ); + + // Text + mockedFetch.mockResolvedValueOnce(textResponse('log line 1\nlog line 2\n')); + text = await client.build.getConsoleText('folder/proj', 7); + expect(text).toContain('log line 1'); + [url] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/job/proj/7/consoleText', + ); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.ts new file mode 100644 index 0000000000..96e12a334b --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/buildApi.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2025 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 { JenkinsBuild } from '../types'; + +export interface BuildDeps { + normalizeJobName(string: string | string[] | undefined): string | undefined; + request( + path: string, + opts?: { + method?: string; + query?: Record; + body?: any; + rawText?: boolean; + contentType?: string; + }, + ): Promise; +} + +/** + * Factory for creating a Jenkins Build API interface. + * + * Provides helpers for common Jenkins job operations such as: + * - Fetching build details (`get`) + * - Fetching build console output as plain text (`getConsoleText`) + * + * This function is intended to be used by higher-level clients (e.g., `Jenkins`) + * and delegates low-level requests to the provided `request` dependency. + * + * @param deps - Dependency injection hooks for request handling and job name normalization. + * @returns An object with methods for interacting with Jenkins builds. + */ +export function createBuildApi(deps: BuildDeps) { + const { normalizeJobName, request } = deps; + + return { + /** + * Retrieves a build's JSON representation from Jenkins. + * + * @param name - A build name (string or segments). + * @param buildNumber - The build number to retrieve. + * @returns A `JenkinsBuild` object with metadata about the specified build. + */ + get: async ( + name: string | string[], + buildNumber: number | string, + ): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/${buildNumber}/api/json`); + }, + + /** + * Retrieves a build's consoleText from Jenkins. + * + * @param name - A build name (string or segments). + * @param buildNumber - The build number to retrieve logs for. + * @returns The build's console output as plain text. + */ + getConsoleText: async ( + name: string | string[], + buildNumber: number | string, + ): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/${buildNumber}/consoleText`, { + rawText: true, + }) as Promise; + }, + }; +} diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.test.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.test.ts new file mode 100644 index 0000000000..21c6eff94a --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2025 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 fetch from 'node-fetch'; +import { Jenkins } from '../client'; + +jest.mock('node-fetch', () => jest.fn()); +const mockedFetch = fetch as unknown as jest.Mock; + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { + get: () => 'application/json', + }, + json: async () => body, + text: async () => JSON.stringify(body), + } as any; +} + +describe('jobApi', () => { + beforeEach(() => mockedFetch.mockReset()); + + const client = new Jenkins({ + baseUrl: 'https://jenkins.example.com', + }); + + it('Job.get builds GET to /job//api/json with optional tree/depth', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse({ jobs: [] })); + await client.job.get({ + name: ['folder', 'main'], + tree: 'jobs[name]', + depth: 2, + }); + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://jenkins.example.com/job/folder/job/main/api/json?tree=jobs%5Bname%5D&depth=2', + expect.objectContaining({ + body: undefined, + headers: { + referer: 'https://jenkins.example.com/', + }, + method: 'GET', + }), + ); + }); + + it('Job.getBuilds requests standard tree when not provided as param', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.getBuilds(['folder', 'test']); + expect(mockedFetch.mock.calls[0][0]).toBe( + 'https://jenkins.example.com/job/folder/job/test/api/json?tree=builds%5Bnumber%2Curl%2Cresult%2Ctimestamp%2Cid%2CqueueId%2CdisplayName%2Cduration%5D', + ); + }); + + it('Job.build chooses endpoint and encodes params/token/delay', async () => { + mockedFetch.mockResolvedValue(jsonResponse({})); + + // With params -> buildWithParameters + await client.job.build('proj', { + parameters: { BRANCH: 'main', MESSAGE: 'hello world' }, + token: 't-123', + delay: '0sec', + }); + + // Note: index 0 should be crumb issuer call + let [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/proj/buildWithParameters?token=t-123&delay=0sec', + ); + expect(init.method).toBe('POST'); + // body should be URLSearchParams + expect(String(init.body)).toContain('BRANCH=main'); + expect(String(init.body)).toContain('MESSAGE=hello+world'); + + mockedFetch.mockClear(); + + // without parameters -> build + mockedFetch.mockResolvedValue(jsonResponse({})); + await client.job.build(['folder', 'proj'], { token: 'abc' }); + [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/job/proj/build?token=abc', + ); + expect(init.method).toBe('POST'); + // no body + expect(init.body).toBeUndefined(); + }); + + it('Job.copy builds URL correctly', async () => { + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.copy('pipelineACopy', 'pipelineA'); + let [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/createItem?name=pipelineACopy&mode=copy&from=pipelineA', + ); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.copy(['folder', 'proj', 'dup'], 'folder/proj'); + [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/job/proj/createItem?name=dup&mode=copy&from=folder%2Fproj', + ); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + }); + + it('Job.create builds URL correctly and accepts XML', async () => { + const xml = ` + + + + true + + + `; + + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.create('pipelineA', xml); + let [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe('https://jenkins.example.com/createItem?name=pipelineA'); + expect(init.method).toBe('POST'); + expect(init.body).toBe(xml); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.create(['folder', 'proj'], xml); + [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/createItem?name=proj', + ); + expect(init.method).toBe('POST'); + expect(init.body).toBe(xml); + }); + + it('Job.destroy builds URL correctly', async () => { + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.destroy('pipelineA'); + let [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe('https://jenkins.example.com/job/pipelineA/doDelete'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.destroy(['folder', 'proj']); + [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe( + 'https://jenkins.example.com/job/folder/job/proj/doDelete', + ); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + }); + + it('Job.enable builds URL correctly', async () => { + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.enable('pipelineA'); + let [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe('https://jenkins.example.com/job/pipelineA/enable'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.enable(['folder', 'proj']); + [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe('https://jenkins.example.com/job/folder/job/proj/enable'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + }); + + it('Job.disable builds URL correctly', async () => { + // String + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.disable('pipelineA'); + let [url, init] = mockedFetch.mock.calls[0] as [string, any]; + expect(url).toBe('https://jenkins.example.com/job/pipelineA/disable'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + + // Array + mockedFetch.mockResolvedValueOnce(jsonResponse({})); + await client.job.disable(['folder', 'proj']); + [url, init] = mockedFetch.mock.calls[1] as [string, any]; + expect(url).toBe('https://jenkins.example.com/job/folder/job/proj/disable'); + expect(init.method).toBe('POST'); + expect(init.body).toBeUndefined(); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.ts new file mode 100644 index 0000000000..a6d25426c6 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/jobApi.ts @@ -0,0 +1,297 @@ +/* + * Copyright 2025 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 { JenkinsParams, JobBuildOptions, JobGetOptions } from './types'; + +export interface JobDeps { + normalizeJobName(name: string | string[] | undefined): string | undefined; + request( + path: string, + opts?: { + method?: string; + query?: Record; + body?: any; + rawText?: boolean; + contentType?: string; + }, + ): Promise; +} + +/** + * Factory for creating a Jenkins Job API interface. + * + * Provides helpers for common Jenkins job operations such as: + * - Fetching job details (`get`) + * - Triggering builds (`build`) + * - Copying or creating jobs (`copy`, `create`) + * - Managing job state (`enable`, `disable`, `destroy`) + * + * This function is intended to be used by higher-level clients (e.g., `Jenkins`) + * and delegates low-level requests to the provided `request` dependency. + * + * @param deps - Dependency injection hooks for request handling and job name normalization. + * @returns An object with methods for interacting with Jenkins jobs. + */ +export function createJobApi(deps: JobDeps) { + const { normalizeJobName, request } = deps; + + // Helper utils + + /** + * Takes in a {@link JenkinsParams} object and returns a {@link URLSearchParams} object. + * - If the object passed is `undefined`, an empty {@link URLSearchParams} is returned. + * - If the object passed is already a {@link URLSearchParams} it gets returned as is. + * + * @param params a {@link JenkinsParams} object. + * @returns a {@link URLSearchParams} object + */ + const paramsToSearchParams = (params?: JenkinsParams): URLSearchParams => { + if (!params) { + return new URLSearchParams(); + } + if (params instanceof URLSearchParams) { + return params; + } + + const result = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null) { + continue; + } + result.set(k, String(v)); + } + return result; + }; + + /** + * Extracts the last path segment (the "leaf" job name) from a Jenkins job name. + * + * @param name - The job name, either as a slash-delimited string (e.g. "folder/job") + * or as an array of segments (e.g. ["folder", "job"]). + * @returns The last segment of the job name, or an empty string if none exists. + * + * @example + * leafSegment("folder/job"); // "job" + * leafSegment(["folder", "job"]); // "job" + * leafSegment("root"); // "root" + */ + const leafSegment = (name: string | string[]): string => { + if (Array.isArray(name)) { + return name[name.length - 1]; + } + const parts = name.split('/').filter(Boolean); + return parts[parts.length - 1] ?? ''; + }; + + /** + * Returns all parent path segments of a Jenkins job name, excluding the leaf. + * + * @param name - The job name, either as a slash-delimited string (e.g. "a/b/c") + * or as an array of segments (e.g. ["a", "b", "c"]). + * @returns An array of all parent segments, or an empty array if the job is at the root. + * + * @example + * parentSegments("a/b/c"); // ["a", "b"] + * parentSegments(["a", "b", "c"]); // ["a", "b"] + * parentSegments("job"); // [] + */ + const parentSegments = (name: string | string[] | undefined): string[] => { + if (!name) { + return []; + } + + // Return everything but leaf + if (Array.isArray(name)) { + return name.slice(0, -1); + } + + const parts = name.split('/').filter(Boolean); + if (parts.length > 1) { + return parts.slice(0, -1); + } + + return []; + }; + + return { + /** + * Retrieves a job’s JSON representation from Jenkins. + * + * @param input - A {@link JobGetOptions} object. `tree` and `depth` + * are forwarded to `/api/json` as query params. + * @returns The parsed job JSON. + */ + get: async (input: JobGetOptions) => { + const { name, tree, depth } = input; + const jobPath = normalizeJobName(name); + const query: Record = {}; + if (tree) { + query.tree = tree; + } + if (typeof depth === 'number') { + query.depth = depth; + } + return request(`${jobPath}/api/json`, { query }); + }, + + /** + * Retrieves only the builds portion of a job (server-side filtered via `tree`). + * + * @param name - The job name (string or array). + * @param tree - The Jenkins Remote API `tree` expression selecting build fields. + * Defaults to `builds[number,url,result,timestamp,id,queueId,displayName,duration]` + * @returns A JSON object containing the requested build fields. + */ + getBuilds: async ( + name: string | string[], + tree = 'builds[number,url,result,timestamp,id,queueId,displayName,duration]', + ): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/api/json`, { + query: { tree }, + }); + }, + + /** + * Triggers a Jenkins job build. + * + * Uses `/build` or `/buildWithParameters` depending on whether parameters are provided. + * Automatically URL-encodes parameters and supports legacy options like `delay` and `token`. + * + * @param name - The job name (string or array form). + * @param opts - Optional build options (parameters, token, delay). + * @returns A promise that resolves when the build request is accepted. + */ + build: async ( + name: string | string[], + opts?: JobBuildOptions, + ): Promise => { + const { parameters, token, delay } = opts ?? {}; + + const jobPath = normalizeJobName(name); + + // Check if we have search params + // This will determine the endpoint used + const hasParams = + parameters instanceof URLSearchParams + ? parameters.toString().length > 0 + : parameters && Object.keys(parameters).length > 0; + + const endpoint = hasParams ? 'buildWithParameters' : 'build'; + + const query: Record = { + ...(token ? { token } : {}), + // Legacy client support: add delay option + ...(delay !== undefined ? { delay } : {}), + }; + + const body: URLSearchParams | undefined = hasParams + ? paramsToSearchParams(parameters) + : undefined; + + return request(`${jobPath}/${endpoint}`, { + method: 'POST', + query, + body, + }); + }, + + /** + * Copies a job to a new name (optionally inside folders). + * + * **Important:** For the `from` argument, pass the *slashy* full name (e.g. `"a/b/src"`). + * Do **not** normalize it to `/job/...` form, Jenkins expects the raw slash-separated name. + * Only the *leaf* of the new job goes in the `?name=` query; parent folders are derived + * from `name` and embedded in the URL path. + * + * @param name - Target job name (string or segments). Parent parts become folders; leaf is the new job name. + * @param from - Source job’s slashy full name (e.g. `"folder/old"`). + */ + copy: async (name: string | string[], from: string): Promise => { + const segments = parentSegments(name); + const leaf = leafSegment(name); + const folderPath = segments.length + ? segments.map(normalizeJobName).join('/') + : ''; + + const url = folderPath ? `${folderPath}/createItem` : 'createItem'; + return request(url, { + method: 'POST', + query: { + name: leaf, + mode: 'copy', + from: from, // Keep slashy! + }, + }); + }, + + /** + * Creates a new job from an XML configuration payload. + * + * Only the *leaf* job name is sent in `?name=`; any parent segments become + * folder parts embedded in the URL path. + * + * @param name - The destination job name (string or segments). + * @param xml - The Jenkins job config.xml content. + */ + create: async (name: string | string[], xml: string): Promise => { + const segments = parentSegments(name); + const leaf = leafSegment(name); + const folderPath = segments.length + ? segments.map(normalizeJobName).join('/') + : ''; + + const url = folderPath ? `${folderPath}/createItem` : 'createItem'; + + return request(url, { + method: 'POST', + query: { name: leaf }, + body: xml, + contentType: 'application/xml', + }); + }, + + /** + * Permanently deletes a job. + * + * @param name - The job name (string or segments). + */ + destroy: async (name: string | string[]): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/doDelete`, { method: 'POST' }); + }, + + /** + * Enables a disabled job. + * + * @param name - The job name (string or segments). + */ + enable: async (name: string | string[]): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/enable`, { method: 'POST' }); + }, + + /** + * Disables a job (prevents builds from being scheduled). + * + * @param name - The job name (string or segments). + */ + disable: async (name: string | string[]): Promise => { + const jobPath = normalizeJobName(name); + return request(`${jobPath}/disable`, { method: 'POST' }); + }, + }; +} diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/types.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/types.ts new file mode 100644 index 0000000000..4d9052e443 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/types.ts @@ -0,0 +1,51 @@ +/* + * Copyright 2025 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. + */ + +/** @public */ +export interface CrumbData { + headerName: string; + headerValue: string; + cookies?: string[]; // optional: reuse any session cookies +} + +/** @public */ +export interface CrumbDataHeaderValues { + crumbRequestField: string; + crumb: string; +} + +/** @public */ +export type HeaderValue = string | string[] | undefined; + +/** @public */ +export type JenkinsParams = + | Record + | URLSearchParams + | undefined; + +/** @public */ +export interface JobBuildOptions { + parameters?: JenkinsParams; + token?: string; + delay?: string; // Legacy client support: allow delay option +} + +/** @public */ +export interface JobGetOptions { + name: string | string[]; + tree?: string; + depth?: number; +} diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/utils.test.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/utils.test.ts new file mode 100644 index 0000000000..13f207f220 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/utils.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2025 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 { Response } from 'node-fetch'; +import { + addQueryParams, + joinUrl, + trimLeadingSlash, + ensureTrailingSlash, + safeExtractText, +} from './utils'; + +describe('utils', () => { + it('trimLeadingSlash', () => { + expect(trimLeadingSlash('/a/b')).toBe('a/b'); + expect(trimLeadingSlash('a/b')).toBe('a/b'); + expect(trimLeadingSlash('/')).toBe(''); + }); + + it('ensureTrailingSlash', () => { + expect(ensureTrailingSlash('https://jenkins')).toBe('https://jenkins/'); + expect(ensureTrailingSlash('https://jenkins/')).toBe('https://jenkins/'); + }); + + it('addQueryParams', () => { + expect(addQueryParams('https://example.com' as any, {}).toString()).toBe( + 'https://example.com/', + ); + expect( + addQueryParams('https://example.com' as any, { depth: '1' }).toString(), + ).toBe('https://example.com/?depth=1'); + expect( + addQueryParams('https://example.com' as any, { + depth: '1', + delay: 'false', + }).toString(), + ).toBe('https://example.com/?depth=1&delay=false'); + }); + + it('joinUrl', () => { + expect(joinUrl('https://host.com', 'a/b')).toBe('https://host.com/a/b'); + expect(joinUrl('https://host.com', 'a/b?x=1')).toBe( + 'https://host.com/a/b?x=1', + ); + }); + + it('safeExtractText returns body', async () => { + const res: Partial = { + text: () => Promise.resolve('hello'), + }; + await expect(safeExtractText(res as any)).resolves.toBe('hello'); + }); + + it('safeExtractText tolerates text() failure', async () => { + const res: Partial = { + text: () => Promise.reject(new Error('uh oh')), + }; + await expect(safeExtractText(res as any)).resolves.toBe( + '', + ); + }); +}); diff --git a/workspaces/jenkins/plugins/jenkins-common/src/client/utils.ts b/workspaces/jenkins/plugins/jenkins-common/src/client/utils.ts new file mode 100644 index 0000000000..e4ca64ef7d --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/client/utils.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2025 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 Response } from 'node-fetch'; + +/** + * Copies the {@link URL} object passed, appends query params and returns the resulting {@link URL}. + * + * @param u The {@link URL} object that will be copied + * @param q The {@link Record} that stores query params + * @returns The resulting {@link URL} with query params. + */ +export function addQueryParams( + u: URL, + q: Record, +): URL { + // Create duplicate of URL, do not mutate original + const dup = new URL(u.toString()); + for (const [k, v] of Object.entries(q)) { + if (v === undefined) { + continue; + } + dup.searchParams.set(k, String(v)); + } + + return dup; +} + +/** + * Joins the base URL string with the specified path. + * Appends a `/` to the end of the `base` if it doesn't already have it. + * + * @param base The base URL string + * @param path The path that appends to the base string + * @returns A string of the full URL + */ +export function joinUrl(base: string, path: string): string { + let dupBase = base; + if (!dupBase.endsWith('/')) { + dupBase += '/'; + } + return dupBase + path; +} + +/** + * Utility function that removes the `/` from the start of a string if it exists. + * + * @param p The string to trim + * @returns The string without the leading `/` + */ +export function trimLeadingSlash(p: string): string { + return p.startsWith('/') ? p.slice(1) : p; +} + +/** + * Utility function that ensures that string ends with `/`, + * + * @param u The string to modify + * @returns The resulting string, ending with `/` + */ +export function ensureTrailingSlash(u: string): string { + return u.endsWith('/') ? u : `${u}/`; +} + +/** + * Utility function that safely extracts the text from a response. + * If the operation results in an error a default string is returned instead. + * + * @param res The {@link Response} containing the `Body.text` + * @returns The resulting `Body.text` value or a default string if the operation failed + */ +export async function safeExtractText(res: Response): Promise { + try { + return await res.text(); + } catch { + return ''; + } +} diff --git a/workspaces/jenkins/plugins/jenkins-common/src/index.ts b/workspaces/jenkins/plugins/jenkins-common/src/index.ts index aff3b136bd..81e0f75d19 100644 --- a/workspaces/jenkins/plugins/jenkins-common/src/index.ts +++ b/workspaces/jenkins/plugins/jenkins-common/src/index.ts @@ -14,3 +14,7 @@ * limitations under the License. */ export * from './permissions'; +export * from './types'; +export * from './client/types'; + +export { Jenkins, type JenkinsClientOptions } from './client'; diff --git a/workspaces/jenkins/plugins/jenkins-common/src/types.ts b/workspaces/jenkins/plugins/jenkins-common/src/types.ts new file mode 100644 index 0000000000..9c10c31177 --- /dev/null +++ b/workspaces/jenkins/plugins/jenkins-common/src/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 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. + */ + +/** @public */ +export interface CommonBuild { + // standard Jenkins + timestamp: number; + building: boolean; + duration: number; + result?: string; + fullDisplayName: string; + displayName: string; + url: string; + number: number; +} + +/** @public */ +export interface JenkinsBuild extends CommonBuild { + // read by us from jenkins but not passed to frontend + actions: any; +} diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/package.json b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/package.json index 1a97f6ed5b..0b42188d8b 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/package.json +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/package.json @@ -31,6 +31,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage-community/plugin-jenkins-common": "workspace:^", "@backstage/backend-plugin-api": "backstage:^", "@backstage/errors": "backstage:^", "@backstage/plugin-scaffolder-node": "backstage:^", diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/build.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/build.ts index 2f7dda7bc9..8295f487ee 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/build.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/copy.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/copy.ts index 0b980366e5..262c31be51 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/copy.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/copy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * This copyJob function, creates a job given a source job name diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/create.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/create.ts index 911d2a1400..91af1e89d6 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/create.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/create.ts @@ -20,7 +20,7 @@ import { import { buildJenkinsClient } from '../../config'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import fs from 'fs/promises'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * This createJob function, creates a job given a job name and own configuration file as xml format diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/destroy.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/destroy.ts index aed7ff7669..54e6a86ccc 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/destroy.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/destroy.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * This destroyJob function, deletes a job given a job name diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/disable.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/disable.ts index e204b68bb1..077a81f667 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/disable.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/disable.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * This disableJob function, disables a job given a job name diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/enable.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/enable.ts index 535a376674..400dacb57b 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/enable.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/actions/job/enable.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; /** * This enableJob function, enables a job given a job name diff --git a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/config.ts b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/config.ts index 6faff67e32..00714d5b26 100644 --- a/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/config.ts +++ b/workspaces/jenkins/plugins/scaffolder-backend-module-jenkins/src/config.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { RootConfigService } from '@backstage/backend-plugin-api'; -import Jenkins from 'jenkins'; +import { Jenkins } from '@backstage-community/plugin-jenkins-common'; export function buildJenkinsClient(config: RootConfigService) { const baseUrl = new URL(config.getString('jenkins.baseUrl')); diff --git a/workspaces/jenkins/yarn.lock b/workspaces/jenkins/yarn.lock index 113144f14f..6ec0690899 100644 --- a/workspaces/jenkins/yarn.lock +++ b/workspaces/jenkins/yarn.lock @@ -1684,12 +1684,10 @@ __metadata: "@backstage/plugin-permission-common": "backstage:^" "@backstage/plugin-permission-node": "backstage:^" "@types/express": "npm:^4.17.6" - "@types/jenkins": "npm:^1.0.0" "@types/node-fetch": "npm:^2.5.12" "@types/supertest": "npm:^6.0.0" express: "npm:^4.17.1" express-promise-router: "npm:^4.1.0" - jenkins: "npm:^1.0.0" node-fetch: "npm:^2.6.7" yn: "npm:^4.0.0" languageName: unknown @@ -1702,6 +1700,8 @@ __metadata: "@backstage/cli": "backstage:^" "@backstage/plugin-catalog-common": "backstage:^" "@backstage/plugin-permission-common": "backstage:^" + form-data: "npm:^4.0.4" + node-fetch: "npm:^2.6.7" languageName: unknown linkType: soft @@ -1745,6 +1745,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage-community/plugin-scaffolder-backend-module-jenkins@workspace:plugins/scaffolder-backend-module-jenkins" dependencies: + "@backstage-community/plugin-jenkins-common": "workspace:^" "@backstage/backend-plugin-api": "backstage:^" "@backstage/cli": "backstage:^" "@backstage/errors": "backstage:^" @@ -11124,16 +11125,6 @@ __metadata: languageName: node linkType: hard -"@types/jenkins@npm:^1.0.0": - version: 1.0.2 - resolution: "@types/jenkins@npm:1.0.2" - dependencies: - "@types/node": "npm:*" - form-data: "npm:^4.0.0" - checksum: 10/c02895642c81a74cb40740117b5fce0af768ac7985028b6fd0c68d88b350261473db8c05681fc1de0c612579f905f283f0de33efe683ea9a716ae5264287bfba - languageName: node - linkType: hard - "@types/jest@npm:^29.5.11": version: 29.5.13 resolution: "@types/jest@npm:29.5.13" @@ -17653,14 +17644,16 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.0": - version: 4.0.1 - resolution: "form-data@npm:4.0.1" +"form-data@npm:^4.0.0, form-data@npm:^4.0.4": + version: 4.0.4 + resolution: "form-data@npm:4.0.4" dependencies: asynckit: "npm:^0.4.0" combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" mime-types: "npm:^2.1.12" - checksum: 10/6adb1cff557328bc6eb8a68da205f9ae44ab0e88d4d9237aaf91eed591ffc64f77411efb9016af7d87f23d0a038c45a788aa1c6634e51175c4efa36c2bc53774 + checksum: 10/a4b62e21932f48702bc468cc26fb276d186e6b07b557e3dd7cc455872bdbb82db7db066844a64ad3cf40eaf3a753c830538183570462d3649fdfd705601cbcfb languageName: node linkType: hard @@ -20122,7 +20115,7 @@ __metadata: languageName: node linkType: hard -"jenkins@npm:^1.0.0, jenkins@npm:^1.1.0": +"jenkins@npm:^1.1.0": version: 1.1.0 resolution: "jenkins@npm:1.1.0" dependencies: