diff --git a/src/api/ci/gitlabciClient.ts b/src/api/ci/gitlabciClient.ts index bc6b68c..af87d98 100644 --- a/src/api/ci/gitlabciClient.ts +++ b/src/api/ci/gitlabciClient.ts @@ -146,23 +146,24 @@ export class GitLabCIClient { * @param jobId The job ID for which to retrieve logs * @returns A promise that resolves to the job logs as a string */ - // public async getPipelineLogs(projectPath: string, jobId: number): Promise { - // try { - // // Access the raw REST API client to make a direct request for job logs - // const gitlab = this.gitlabClient.getClient(); + //TODO: need to confirm if this is correct + public async getPipelineLogs(projectPath: string, jobId: number): Promise { + try { + // Access the raw REST API client to make a direct request for job logs + const gitlab = this.gitlabClient.getClient(); - // // GitLab API endpoint for job traces is GET /projects/:id/jobs/:job_id/trace - // const encodedProjectPath = encodeURIComponent(projectPath); - // const url = `projects/${encodedProjectPath}/jobs/${jobId}/trace`; + // GitLab API endpoint for job traces is GET /projects/:id/jobs/:job_id/trace + const encodedProjectPath = encodeURIComponent(projectPath); + const url = `projects/${encodedProjectPath}/jobs/${jobId}/trace`; - // // Make the request using the underlying requester - // const jobTrace = await gitlab.request.get(url); - // return jobTrace as string; - // } catch (error) { - // console.error(`Failed to get logs for job ${jobId} in project ${projectPath}:`, error); - // return 'Failed to retrieve job logs'; - // } - // } + // Make the request using the underlying requester + const jobTrace = await gitlab.requester.get(url); + return jobTrace as unknown as string; + } catch (error) { + console.error(`Failed to get logs for job ${jobId} in project ${projectPath}:`, error); + return 'Failed to retrieve job logs'; + } + } /** * Maps GitLab pipeline status to our standardized PipelineStatus enum diff --git a/src/api/ci/jenkins/README.md b/src/api/ci/jenkins/README.md new file mode 100644 index 0000000..e46db6a --- /dev/null +++ b/src/api/ci/jenkins/README.md @@ -0,0 +1,231 @@ +# Jenkins Client - Refactored Architecture + +This directory contains the refactored Jenkins client implementation following TypeScript best practices and design patterns. + +## Architecture Overview + +The Jenkins client has been refactored using a service-oriented architecture with the following benefits: + +- **Single Responsibility Principle**: Each class has one clear purpose +- **Type Safety**: Comprehensive TypeScript types throughout +- **Testability**: Smaller, focused classes are easier to unit test +- **Maintainability**: Changes to specific functionality affect fewer files +- **Reusability**: Utility classes can be reused across the codebase +- **Error Handling**: Consistent, typed error handling +- **Configuration**: Centralized configuration management +- **Extensibility**: Easy to add new credential types or job configurations + +## Directory Structure + +``` +jenkins/ +├── enums/ # Enums for Jenkins constants +│ └── jenkins.enums.ts +├── types/ # TypeScript type definitions +│ └── jenkins.types.ts +├── config/ # Configuration constants +│ └── jenkins.config.ts +├── errors/ # Custom error classes +│ └── jenkins.errors.ts +├── utils/ # Utility classes +│ └── jenkins.utils.ts +├── strategies/ # Strategy pattern implementations +│ └── credential.strategy.ts +├── http/ # HTTP client abstraction +│ └── jenkins-http.client.ts +├── services/ # Business logic services +│ ├── jenkins-job.service.ts +│ ├── jenkins-build.service.ts +│ └── jenkins-credential.service.ts +├── jenkins.client.ts # Main client facade +├── index.ts # Module exports +└── README.md # This file +``` + +## Usage Examples + +### Basic Usage (Backwards Compatible) + +```typescript +import { JenkinsClient } from './jenkins'; + +const client = new JenkinsClient({ + baseUrl: 'https://jenkins.example.com', + username: 'your-username', + token: 'your-api-token' +}); + +// Create a job (legacy method signature) +await client.createJob( + 'my-job', + 'https://github.com/user/repo.git', + 'my-folder', + 'main', + 'Jenkinsfile', + 'git-credentials' +); + +// Trigger a build +await client.build('my-job', 'my-folder', { PARAM1: 'value1' }); + +// Get build information +const build = await client.getBuild('my-job', 123, 'my-folder'); +``` + +### New Options-Based Usage + +```typescript +import { JenkinsClient, CreateJobOptions, BuildOptions } from './jenkins'; + +const client = new JenkinsClient({ + baseUrl: 'https://jenkins.example.com', + username: 'your-username', + token: 'your-api-token' +}); + +// Create a job (new options signature) +const jobOptions: CreateJobOptions = { + jobName: 'my-job', + repoUrl: 'https://github.com/user/repo.git', + folderName: 'my-folder', + branch: 'main', + jenkinsfilePath: 'Jenkinsfile', + credentialId: 'git-credentials' +}; +await client.createJob(jobOptions); + +// Trigger a build with options +const buildOptions: BuildOptions = { + jobName: 'my-job', + folderName: 'my-folder', + parameters: { PARAM1: 'value1' } +}; +await client.build(buildOptions); +``` + +### Direct Service Access + +```typescript +import { JenkinsClient } from './jenkins'; + +const client = new JenkinsClient(config); + +// Access individual services for advanced operations +const jobs = client.jobs; +const builds = client.builds; +const credentials = client.credentials; + +// Use services directly +const runningBuilds = await builds.getRunningBuilds('my-job', 'my-folder'); +const jobExists = await jobs.jobExists('my-job', 'my-folder'); +await credentials.createSecretTextCredential('my-folder', 'my-secret', 'secret-value'); +``` + +### Error Handling + +```typescript +import { + JenkinsClient, + JenkinsJobNotFoundError, + JenkinsBuildTimeoutError, + JenkinsAuthenticationError +} from './jenkins'; + +try { + const build = await client.getBuild('non-existent-job', 123); +} catch (error) { + if (error instanceof JenkinsJobNotFoundError) { + console.log('Job not found:', error.message); + } else if (error instanceof JenkinsAuthenticationError) { + console.log('Authentication failed:', error.message); + } else { + console.log('Unexpected error:', error); + } +} +``` + +## Design Patterns Used + +### 1. Facade Pattern +- `JenkinsClient` acts as a facade providing a simple interface to the complex subsystem + +### 2. Strategy Pattern +- `CredentialStrategy` and implementations for different credential types +- Easy to add new credential types without modifying existing code + +### 3. Service Layer Pattern +- Business logic separated into focused service classes +- Each service handles one domain (jobs, builds, credentials) + +### 4. Builder Pattern +- `JenkinsXmlBuilder` for constructing XML configurations +- `JenkinsPathBuilder` for constructing API paths + +### 5. Factory Pattern +- `CredentialStrategyFactory` for creating credential strategies + +### 6. Error Handling Pattern +- Custom error hierarchy with specific error types +- Consistent error handling across all services + +## Configuration + +All configuration constants are centralized in `JenkinsConfig`: + +```typescript +import { JenkinsConfig } from './jenkins'; + +// Access default values +const timeout = JenkinsConfig.DEFAULT_TIMEOUT_MS; +const headers = JenkinsConfig.HEADERS.JSON; +const endpoint = JenkinsConfig.ENDPOINTS.API_JSON; +``` + +## Extending the Client + +### Adding New Credential Types + +1. Add the new type to `CredentialType` enum +2. Create a new strategy class implementing `CredentialStrategy` +3. Register it in `CredentialStrategyFactory` + +### Adding New Services + +1. Create a new service class in `services/` +2. Add it to the main `JenkinsClient` constructor +3. Expose it through the facade if needed + +### Adding New Error Types + +1. Create new error classes extending `JenkinsError` +2. Export them from `errors/jenkins.errors.ts` +3. Use them in appropriate services + +## Testing + +The refactored architecture makes testing much easier: + +```typescript +// Mock individual services +const mockJobService = { + createJob: jest.fn(), + getJob: jest.fn(), +}; + +// Test services in isolation +const jobService = new JenkinsJobService(mockHttpClient); +``` + +## Performance Considerations + +- Services are lightweight and share the same HTTP client instance +- Path building and XML generation are optimized +- Error handling is consistent and efficient +- Configuration is loaded once and reused + +## Security + +- Credentials are handled through the strategy pattern +- Sensitive data is not logged +- XML escaping prevents injection attacks +- Type-safe parameter handling \ No newline at end of file diff --git a/src/api/ci/jenkins/config/jenkins.config.ts b/src/api/ci/jenkins/config/jenkins.config.ts new file mode 100644 index 0000000..8b8beeb --- /dev/null +++ b/src/api/ci/jenkins/config/jenkins.config.ts @@ -0,0 +1,44 @@ +/** + * Jenkins configuration constants and default values + */ +export class JenkinsConfig { + public static readonly DEFAULT_BRANCH = 'main'; + public static readonly DEFAULT_JENKINSFILE_PATH = 'Jenkinsfile'; + public static readonly DEFAULT_CREDENTIAL_ID = 'GITOPS_AUTH_PASSWORD'; + public static readonly DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes + public static readonly DEFAULT_POLL_INTERVAL_MS = 5000; // 5 seconds + public static readonly DEFAULT_MAX_BUILDS_TO_CHECK = 50; + + /** + * HTTP headers for different content types + */ + public static readonly HEADERS = { + XML: { 'Content-Type': 'application/xml' }, + JSON: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + PLAIN: { 'Accept': 'text/plain' }, + } as const; + + /** + * Jenkins plugin information + */ + public static readonly PLUGINS = { + WORKFLOW_JOB: 'workflow-job@2.40', + GITHUB: 'github@1.37.1', + WORKFLOW_CPS: 'workflow-cps@2.89', + GIT: 'git@4.4.5', + PLAIN_CREDENTIALS: 'plain-credentials', + } as const; + + /** + * Jenkins API endpoints + */ + public static readonly ENDPOINTS = { + CREATE_ITEM: 'createItem', + API_JSON: 'api/json', + BUILD: 'build', + BUILD_WITH_PARAMETERS: 'buildWithParameters', + LOG_TEXT: 'logText/progressiveText', + CREDENTIALS_STORE_SYSTEM: 'credentials/store/system/domain/_/createCredentials', + CREDENTIALS_STORE_FOLDER: 'credentials/store/folder/domain/_/createCredentials', + } as const; +} \ No newline at end of file diff --git a/src/api/ci/jenkins/enums/jenkins.enums.ts b/src/api/ci/jenkins/enums/jenkins.enums.ts new file mode 100644 index 0000000..d92d62c --- /dev/null +++ b/src/api/ci/jenkins/enums/jenkins.enums.ts @@ -0,0 +1,31 @@ +/** + * Jenkins build result status enum + */ +export enum JenkinsBuildResult { + SUCCESS = 'SUCCESS', + FAILURE = 'FAILURE', + UNSTABLE = 'UNSTABLE', + ABORTED = 'ABORTED', + NOT_BUILT = 'NOT_BUILT', +} + +/** + * Jenkins build trigger type enum + */ +export enum JenkinsBuildTrigger { + UNKNOWN = 'UNKNOWN', + PULL_REQUEST = 'PULL_REQUEST', + PUSH = 'PUSH', + MANUAL = 'MANUAL', + SCHEDULED = 'SCHEDULED', + API = 'API', +} + +/** + * Jenkins credential types + */ +export enum CredentialType { + SECRET_TEXT = 'Secret text', + USERNAME_PASSWORD = 'Username with password', + SSH_USERNAME_PRIVATE_KEY = 'SSH Username with private key',//TODO: need to confirm if this is correct +} \ No newline at end of file diff --git a/src/api/ci/jenkins/errors/jenkins.errors.ts b/src/api/ci/jenkins/errors/jenkins.errors.ts new file mode 100644 index 0000000..5fec107 --- /dev/null +++ b/src/api/ci/jenkins/errors/jenkins.errors.ts @@ -0,0 +1,93 @@ +/** + * Base Jenkins error class + */ +export class JenkinsError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly cause?: Error + ) { + super(message); + this.name = 'JenkinsError'; + + // Capture stack trace if available + if (Error.captureStackTrace) { + Error.captureStackTrace(this, JenkinsError); + } + } +} + +/** + * Error thrown when a Jenkins job is not found + */ +export class JenkinsJobNotFoundError extends JenkinsError { + constructor(jobName: string, folderName?: string) { + const path = folderName ? `${folderName}/${jobName}` : jobName; + super(`Jenkins job not found: ${path}`, 404); + this.name = 'JenkinsJobNotFoundError'; + } +} + +/** + * Error thrown when a Jenkins build times out + */ +export class JenkinsBuildTimeoutError extends JenkinsError { + constructor(jobName: string, buildNumber: number, timeoutMs: number) { + super(`Build #${buildNumber} for job ${jobName} did not complete within ${timeoutMs}ms`); + this.name = 'JenkinsBuildTimeoutError'; + } +} + +/** + * Error thrown when Jenkins build is not found + */ +export class JenkinsBuildNotFoundError extends JenkinsError { + constructor(jobName: string, buildNumber: number, folderName?: string) { + const path = folderName ? `${folderName}/${jobName}` : jobName; + super(`Build #${buildNumber} not found for job: ${path}`, 404); + this.name = 'JenkinsBuildNotFoundError'; + } +} + +/** + * Error thrown when Jenkins credentials creation fails + */ +export class JenkinsCredentialError extends JenkinsError { + constructor(credentialId: string, message: string, statusCode?: number) { + super(`Failed to manage credential '${credentialId}': ${message}`, statusCode); + this.name = 'JenkinsCredentialError'; + } +} + +/** + * Error thrown when Jenkins folder operations fail + */ +export class JenkinsFolderError extends JenkinsError { + constructor(folderName: string, operation: string, message: string, statusCode?: number) { + super(`Failed to ${operation} folder '${folderName}': ${message}`, statusCode); + this.name = 'JenkinsFolderError'; + } +} + +/** + * Error thrown when Jenkins authentication fails + */ +export class JenkinsAuthenticationError extends JenkinsError { + constructor(message: string = 'Authentication failed') { + super(message, 401); + this.name = 'JenkinsAuthenticationError'; + } +} + +/** + * Error thrown when Jenkins API rate limit is exceeded + */ +export class JenkinsRateLimitError extends JenkinsError { + constructor(retryAfter?: number) { + const message = retryAfter + ? `Rate limit exceeded. Retry after ${retryAfter} seconds.` + : 'Rate limit exceeded.'; + super(message, 429); + this.name = 'JenkinsRateLimitError'; + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/http/jenkins-http.client.ts b/src/api/ci/jenkins/http/jenkins-http.client.ts new file mode 100644 index 0000000..0c380e6 --- /dev/null +++ b/src/api/ci/jenkins/http/jenkins-http.client.ts @@ -0,0 +1,184 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { JenkinsClientConfig, JenkinsApiResponse } from '../types/jenkins.types'; +import { + JenkinsError, + JenkinsAuthenticationError, + JenkinsRateLimitError +} from '../errors/jenkins.errors'; + +/** + * HTTP client for Jenkins API interactions + */ +export class JenkinsHttpClient { + private client: AxiosInstance; + + constructor(config: JenkinsClientConfig) { + this.client = axios.create({ + baseURL: config.baseUrl, + auth: { + username: config.username, + password: config.token, + }, + timeout: 30000, // 30 second timeout + }); + + this.setupInterceptors(); + } + + /** + * Setup request and response interceptors + */ + private setupInterceptors(): void { + // Request interceptor + this.client.interceptors.request.use( + config => { + // Log requests in development + if (process.env.NODE_ENV === 'development') { + console.log(`Jenkins API Request: ${config.method?.toUpperCase()} ${config.url}`); + } + return config; + }, + error => { + return Promise.reject(new JenkinsError('Request setup failed', undefined, error)); + } + ); + + // Response interceptor + this.client.interceptors.response.use( + response => response, + error => { + if (error.response) { + const status = error.response.status; + const message = error.response.statusText || error.message; + + switch (status) { + case 401: + case 403: + throw new JenkinsAuthenticationError(`Authentication failed: ${message}`); + case 429: + const retryAfter = error.response.headers['retry-after']; + throw new JenkinsRateLimitError(retryAfter ? parseInt(retryAfter) : undefined); + case 404: + throw new JenkinsError(`Resource not found: ${message}`, status, error); + default: + throw new JenkinsError( + `Jenkins API request failed: ${message}`, + status, + error + ); + } + } else if (error.request) { + throw new JenkinsError('No response received from Jenkins server', undefined, error); + } else { + throw new JenkinsError('Request setup failed', undefined, error); + } + } + ); + } + + /** + * Perform GET request + */ + async get( + path: string, + headers: Record = {}, + params?: Record + ): Promise { + const config: AxiosRequestConfig = { + headers, + params, + }; + + const response: AxiosResponse = await this.client.get(path, config); + return response.data; + } + + /** + * Perform POST request + */ + async post( + path: string, + data: any, + headers: Record = {}, + params?: Record + ): Promise> { + const config: AxiosRequestConfig = { + headers, + params, + }; + + const response: AxiosResponse = await this.client.post(path, data, config); + + if (response.status !== 200 && response.status !== 201) { + throw new JenkinsError( + `Request failed with status ${response.status}: ${response.statusText}`, + response.status + ); + } + + return { + success: true, + status: response.status, + data: response.data, + location: response.headers.location, + }; + } + + /** + * Perform PUT request + */ + async put( + path: string, + data: any, + headers: Record = {} + ): Promise> { + const response: AxiosResponse = await this.client.put(path, data, { headers }); + + return { + success: true, + status: response.status, + data: response.data, + location: response.headers.location, + }; + } + + /** + * Perform DELETE request + */ + async delete( + path: string, + headers: Record = {} + ): Promise> { + const response: AxiosResponse = await this.client.delete(path, { headers }); + + return { + success: true, + status: response.status, + data: response.data, + }; + } + + /** + * Check if Jenkins server is reachable + */ + async ping(): Promise { + try { + await this.get('/api/json'); + return true; + } catch (error) { + return false; + } + } + + /** + * Get Jenkins version information + */ + async getVersion(): Promise { + try { + const response = await this.client.head('/'); + return response.headers['x-jenkins'] || null; + } catch (error) { + return null; + } + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/index.ts b/src/api/ci/jenkins/index.ts new file mode 100644 index 0000000..9fc24fe --- /dev/null +++ b/src/api/ci/jenkins/index.ts @@ -0,0 +1,28 @@ +// Export all enums +export * from './enums/jenkins.enums'; + +// Export all types +export * from './types/jenkins.types'; + +// Export configuration +export { JenkinsConfig } from './config/jenkins.config'; + +// Export all errors +export * from './errors/jenkins.errors'; + +// Export utilities +export * from './utils/jenkins.utils'; + +// Export strategies +export * from './strategies/credential.strategy'; + +// Export HTTP client +export { JenkinsHttpClient } from './http/jenkins-http.client'; + +// Export services +export { JenkinsJobService } from './services/jenkins-job.service'; +export { JenkinsBuildService } from './services/jenkins-build.service'; +export { JenkinsCredentialService } from './services/jenkins-credential.service'; + +// Export a facade for backwards compatibility and convenience +export { JenkinsClient } from './jenkins.client'; \ No newline at end of file diff --git a/src/api/ci/jenkins/jenkins.client.ts b/src/api/ci/jenkins/jenkins.client.ts new file mode 100644 index 0000000..ae4cbbf --- /dev/null +++ b/src/api/ci/jenkins/jenkins.client.ts @@ -0,0 +1,480 @@ +import { JenkinsHttpClient } from './http/jenkins-http.client'; +import { JenkinsJobService } from './services/jenkins-job.service'; +import { JenkinsBuildService } from './services/jenkins-build.service'; +import { JenkinsCredentialService } from './services/jenkins-credential.service'; +import { + JenkinsClientConfig, + JenkinsApiResponse, + FolderConfig, + CreateJobOptions, + BuildOptions, + BuildSearchOptions, + WaitForBuildOptions, + JenkinsBuild, + JenkinsJob, + JobActivityStatus, + WaitForJobsOptions +} from './types/jenkins.types'; +import { CredentialType, JenkinsBuildTrigger } from './enums/jenkins.enums'; + +/** + * Main Jenkins client that provides a facade over the service-oriented architecture + * This class maintains backwards compatibility while providing access to the new structured services + */ +export class JenkinsClient { + private httpClient: JenkinsHttpClient; + private jobService: JenkinsJobService; + private buildService: JenkinsBuildService; + private credentialService: JenkinsCredentialService; + + constructor(config: JenkinsClientConfig) { + this.httpClient = new JenkinsHttpClient(config); + this.jobService = new JenkinsJobService(this.httpClient); + this.buildService = new JenkinsBuildService(this.httpClient); + this.credentialService = new JenkinsCredentialService(this.httpClient); + } + + // =========================================== + // FOLDER OPERATIONS + // =========================================== + + /** + * Create a folder in Jenkins + */ + async createFolder(folderConfig: FolderConfig): Promise { + return this.jobService.createFolder(folderConfig); + } + + // =========================================== + // JOB OPERATIONS + // =========================================== + + /** + * Create a job in Jenkins + */ + async createJob(options: CreateJobOptions): Promise; + async createJob( + jobName: string, + repoUrl: string, + folderName?: string, + branch?: string, + jenkinsfilePath?: string, + credentialId?: string + ): Promise; + async createJob( + optionsOrJobName: CreateJobOptions | string, + repoUrl?: string, + folderName?: string, + branch?: string, + jenkinsfilePath?: string, + credentialId?: string + ): Promise { + if (typeof optionsOrJobName === 'string') { + // Legacy method signature + const options: CreateJobOptions = { + jobName: optionsOrJobName, + repoUrl: repoUrl!, + folderName, + branch, + jenkinsfilePath, + credentialId, + }; + return this.jobService.createJob(options); + } else { + // New options object signature + return this.jobService.createJob(optionsOrJobName); + } + } + + /** + * Get information about a job + */ + async getJob(jobPath: string): Promise { + return this.jobService.getJob(jobPath); + } + + /** + * Delete a job + */ + async deleteJob(jobName: string, folderName?: string): Promise { + return this.jobService.deleteJob(jobName, folderName); + } + + /** + * Check if a job exists + */ + async jobExists(jobName: string, folderName?: string): Promise { + return this.jobService.jobExists(jobName, folderName); + } + + /** + * Get all jobs in a folder + */ + async getJobs(folderName?: string): Promise { + return this.jobService.getJobs(folderName); + } + + /** + * Enable a job + */ + async enableJob(jobName: string, folderName?: string): Promise { + return this.jobService.enableJob(jobName, folderName); + } + + /** + * Disable a job + */ + async disableJob(jobName: string, folderName?: string): Promise { + return this.jobService.disableJob(jobName, folderName); + } + + // =========================================== + // CREDENTIAL OPERATIONS + // =========================================== + + /** + * Create a credential in Jenkins + */ + async createCredential( + folderName: string, + credentialId: string, + secretValue: string, + credentialType: CredentialType = CredentialType.SECRET_TEXT + ): Promise { + return this.credentialService.createCredential(folderName, credentialId, secretValue, credentialType); + } + + /** + * Create secret text credential (convenience method) + */ + async createSecretTextCredential( + folderName: string, + credentialId: string, + secretValue: string + ): Promise { + return this.credentialService.createSecretTextCredential(folderName, credentialId, secretValue); + } + + /** + * Create username/password credential (convenience method) + */ + async createUsernamePasswordCredential( + folderName: string, + credentialId: string, + username: string, + password: string + ): Promise { + return this.credentialService.createUsernamePasswordCredential(folderName, credentialId, username, password); + } + + /** + * Get credential information (without sensitive data) + */ + async getCredential(folderName: string, credentialId: string): Promise { + return this.credentialService.getCredential(folderName, credentialId); + } + + /** + * Check if a credential exists + */ + async credentialExists(folderName: string, credentialId: string): Promise { + return this.credentialService.credentialExists(folderName, credentialId); + } + + /** + * Update an existing credential + */ + async updateCredential( + folderName: string, + credentialId: string, + secretValue: string, + credentialType: CredentialType = CredentialType.SECRET_TEXT + ): Promise { + return this.credentialService.updateCredential(folderName, credentialId, secretValue, credentialType); + } + + /** + * Delete a credential + */ + async deleteCredential(folderName: string, credentialId: string): Promise { + return this.credentialService.deleteCredential(folderName, credentialId); + } + + /** + * List all credentials in a domain + */ + async listCredentials(folderName?: string): Promise { + return this.credentialService.listCredentials(folderName); + } + + /** + * Create SSH private key credential (convenience method) + */ + async createSshPrivateKeyCredential( + folderName: string, + credentialId: string, + username: string, + privateKey: string, + passphrase?: string + ): Promise { + return this.credentialService.createSshPrivateKeyCredential(folderName, credentialId, username, privateKey, passphrase); + } + + // =========================================== + // BUILD OPERATIONS + // =========================================== + + /** + * Trigger a build for a job + */ + async build(options: BuildOptions): Promise; + async build( + jobName: string, + folderName?: string, + parameters?: Record + ): Promise; + async build( + optionsOrJobName: BuildOptions | string, + folderName?: string, + parameters?: Record + ): Promise { + if (typeof optionsOrJobName === 'string') { + // Legacy method signature + const options: BuildOptions = { + jobName: optionsOrJobName, + folderName, + parameters, + }; + return this.buildService.triggerBuild(options); + } else { + // New options object signature + return this.buildService.triggerBuild(optionsOrJobName); + } + } + + /** + * Get information about a build + */ + async getBuild( + jobName: string, + buildNumber: number, + folderName?: string, + includeTriggerInfo: boolean = false + ): Promise { + return this.buildService.getBuild(jobName, buildNumber, folderName, includeTriggerInfo); + } + + /** + * Get all currently running builds for a job + */ + async getRunningBuilds(jobName: string, folderName?: string): Promise { + return this.buildService.getRunningBuilds(jobName, folderName); + } + + /** + * Get the latest build for a job + */ + async getLatestBuild(jobName: string, folderName?: string): Promise { + return this.buildService.getLatestBuild(jobName, folderName); + } + + /** + * Get the console log for a build + */ + async getBuildLog(jobName: string, buildNumber: number, folderName?: string): Promise { + return this.buildService.getBuildLog(jobName, buildNumber, folderName); + } + + /** + * Wait for a build to complete with timeout + */ + async waitForBuildCompletion(options: WaitForBuildOptions): Promise; + async waitForBuildCompletion( + jobName: string, + buildNumber: number, + folderName?: string, + timeoutMs?: number, + pollIntervalMs?: number + ): Promise; + async waitForBuildCompletion( + optionsOrJobName: WaitForBuildOptions | string, + buildNumber?: number, + folderName?: string, + timeoutMs?: number, + pollIntervalMs?: number + ): Promise { + if (typeof optionsOrJobName === 'string') { + // Legacy method signature + const options: WaitForBuildOptions = { + jobName: optionsOrJobName, + buildNumber: buildNumber!, + folderName, + timeoutMs, + pollIntervalMs, + }; + return this.buildService.waitForBuildCompletion(options); + } else { + // New options object signature + return this.buildService.waitForBuildCompletion(optionsOrJobName); + } + } + + /** + * Get the build associated with a specific git commit SHA + */ + async getBuildByCommitSha(options: BuildSearchOptions): Promise; + async getBuildByCommitSha( + jobName: string, + commitSha: string, + folderName?: string, + maxBuildsToCheck?: number + ): Promise; + async getBuildByCommitSha( + optionsOrJobName: BuildSearchOptions | string, + commitSha?: string, + folderName?: string, + maxBuildsToCheck?: number + ): Promise { + if (typeof optionsOrJobName === 'string') { + // Legacy method signature + const options: BuildSearchOptions = { + jobName: optionsOrJobName, + commitSha: commitSha!, + folderName, + maxBuildsToCheck, + }; + return this.buildService.getBuildByCommitSha(options); + } else { + // New options object signature + return this.buildService.getBuildByCommitSha(optionsOrJobName); + } + } + + /** + * Get the trigger type of a build + */ + async getBuildTriggerType( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + return this.buildService.getBuildTriggerType(jobName, buildNumber, folderName); + } + + /** + * Check if a build was triggered by a pull request + */ + async isBuildTriggeredByPullRequest( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + return this.buildService.isBuildTriggeredByPullRequest(jobName, buildNumber, folderName); + } + + /** + * Check if a build was triggered by a push event + */ + async isBuildTriggeredByPush( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + return this.buildService.isBuildTriggeredByPush(jobName, buildNumber, folderName); + } + + // =========================================== + // UTILITY METHODS + // =========================================== + + /** + * Check if Jenkins server is reachable + */ + async ping(): Promise { + return this.httpClient.ping(); + } + + /** + * Get Jenkins version + */ + async getVersion(): Promise { + return this.httpClient.getVersion(); + } + + // =========================================== + // SERVICE ACCESS (for advanced usage) + // =========================================== + + /** + * Get comprehensive activity status for a job (running builds + queue status) + */ + async getJobActivityStatus(jobName: string, folderName?: string): Promise { + return this.buildService.getJobActivityStatus(jobName, folderName); + } + + /** + * Get activity status for multiple jobs + */ + async getMultipleJobsActivityStatus(jobNames: string[], folderName?: string): Promise { + return this.buildService.getMultipleJobsActivityStatus(jobNames, folderName); + } + + /** + * Wait for multiple jobs to complete (both running builds and queued jobs) + */ + async waitForMultipleJobsToComplete(options: WaitForJobsOptions): Promise; + async waitForMultipleJobsToComplete( + jobNames: string[], + folderName?: string, + timeoutMs?: number, + pollIntervalMs?: number + ): Promise; + async waitForMultipleJobsToComplete( + optionsOrJobNames: WaitForJobsOptions | string[], + folderName?: string, + timeoutMs?: number, + pollIntervalMs?: number + ): Promise { + if (Array.isArray(optionsOrJobNames)) { + // Legacy method signature + const options: WaitForJobsOptions = { + jobNames: optionsOrJobNames, + folderName, + timeoutMs, + pollIntervalMs, + }; + return this.buildService.waitForMultipleJobsToComplete(options); + } else { + // New options object signature + return this.buildService.waitForMultipleJobsToComplete(optionsOrJobNames); + } + } + + /** + * Get the job service for advanced job operations + */ + get jobs(): JenkinsJobService { + return this.jobService; + } + + /** + * Get the build service for advanced build operations + */ + get builds(): JenkinsBuildService { + return this.buildService; + } + + /** + * Get the credential service for advanced credential operations + */ + get credentials(): JenkinsCredentialService { + return this.credentialService; + } + + /** + * Get the HTTP client for direct API access + */ + get http(): JenkinsHttpClient { + return this.httpClient; + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/services/jenkins-build.service.ts b/src/api/ci/jenkins/services/jenkins-build.service.ts new file mode 100644 index 0000000..93b5eb2 --- /dev/null +++ b/src/api/ci/jenkins/services/jenkins-build.service.ts @@ -0,0 +1,500 @@ +import { JenkinsHttpClient } from '../http/jenkins-http.client'; +import { + JenkinsApiResponse, + BuildOptions, + BuildSearchOptions, + WaitForBuildOptions, + JenkinsBuild, + JenkinsJob, + JobActivityStatus, + WaitForJobsOptions +} from '../types/jenkins.types'; +import { JenkinsBuildTrigger } from '../enums/jenkins.enums'; +import { JenkinsConfig } from '../config/jenkins.config'; +import { + JenkinsPathBuilder, + JenkinsTriggerAnalyzer, + JenkinsPollingUtils +} from '../utils/jenkins.utils'; +import { + JenkinsBuildNotFoundError, + JenkinsBuildTimeoutError, + JenkinsJobNotFoundError +} from '../errors/jenkins.errors'; + +/** + * Service for Jenkins build-related operations + */ +export class JenkinsBuildService { + constructor(private httpClient: JenkinsHttpClient) {} + + /** + * Trigger a build for a job + */ + async triggerBuild(options: BuildOptions): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(options.jobName, options.folderName); + const endpoint = options.parameters + ? `${path}/${JenkinsConfig.ENDPOINTS.BUILD_WITH_PARAMETERS}` + : `${path}/${JenkinsConfig.ENDPOINTS.BUILD}`; + + const response = await this.httpClient.post( + endpoint, + null, + JenkinsConfig.HEADERS.JSON, + options.parameters + ); + + return response; + } catch (error) { + const jobPath = options.folderName ? `${options.folderName}/${options.jobName}` : options.jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Get information about a build + */ + async getBuild( + jobName: string, + buildNumber: number, + folderName?: string, + includeTriggerInfo: boolean = false + ): Promise { + try { + const path = JenkinsPathBuilder.buildBuildPath( + jobName, + buildNumber, + folderName, + JenkinsConfig.ENDPOINTS.API_JSON + ); + + const buildInfo = await this.httpClient.get( + path, + JenkinsConfig.HEADERS.JSON + ); + + // Determine trigger type if requested + if (includeTriggerInfo) { + buildInfo.triggerType = JenkinsTriggerAnalyzer.determineBuildTrigger(buildInfo) as JenkinsBuildTrigger; + } + + return buildInfo; + } catch (error) { + throw new JenkinsBuildNotFoundError(jobName, buildNumber, folderName); + } + } + + /** + * Get all currently running builds for a job + */ + async getRunningBuilds(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + + // Get job information with build data + const response = await this.httpClient.get( + `${path}/${JenkinsConfig.ENDPOINTS.API_JSON}`, + JenkinsConfig.HEADERS.JSON, + { tree: 'builds[number,url]' } + ); + + const runningBuilds: JenkinsBuild[] = []; + + // If job has builds, check each one to see if it's running + if (response.builds && response.builds.length > 0) { + for (const build of response.builds) { + // Get detailed build information + const buildDetails = await this.getBuild(jobName, build.number, folderName, false); + + // If the build is currently running, add it to our results + if (buildDetails.building === true) { + runningBuilds.push(buildDetails); + } + } + } + + return runningBuilds; + } catch (error) { + throw new JenkinsJobNotFoundError(jobName, folderName); + } + } + + /** + * Get the latest build for a job + */ + async getLatestBuild(jobName: string, folderName?: string): Promise { + try { + // Get job info which includes lastBuild details + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const jobInfo = await this.httpClient.get( + `${path}/${JenkinsConfig.ENDPOINTS.API_JSON}`, + JenkinsConfig.HEADERS.JSON + ); + + // If there's no lastBuild, return null + if (!jobInfo.lastBuild) { + return null; + } + + // Return the build information + return await this.getBuild(jobName, jobInfo.lastBuild.number, folderName, false); + } catch (error) { + throw new JenkinsJobNotFoundError(jobName, folderName); + } + } + + /** + * Get the console log for a build + */ + async getBuildLog(jobName: string, buildNumber: number, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildBuildPath( + jobName, + buildNumber, + folderName, + JenkinsConfig.ENDPOINTS.LOG_TEXT + ); + + const log = await this.httpClient.get( + path, + JenkinsConfig.HEADERS.PLAIN, + { start: 0 } + ); + + return log; + } catch (error) { + throw new JenkinsBuildNotFoundError(jobName, buildNumber, folderName); + } + } + + /** + * Wait for a build to complete with timeout + */ + async waitForBuildCompletion(options: WaitForBuildOptions): Promise { + const { + jobName, + buildNumber, + folderName, + timeoutMs = JenkinsConfig.DEFAULT_TIMEOUT_MS, + pollIntervalMs = JenkinsConfig.DEFAULT_POLL_INTERVAL_MS + } = options; + + try { + return await JenkinsPollingUtils.pollUntil( + () => this.getBuild(jobName, buildNumber, folderName, false), + (buildInfo: JenkinsBuild) => !buildInfo.building, + timeoutMs, + pollIntervalMs + ); + } catch (error) { + if (error instanceof Error && error.message.includes('timed out')) { + throw new JenkinsBuildTimeoutError(jobName, buildNumber, timeoutMs); + } + throw error; + } + } + + /** + * Get the build associated with a specific git commit SHA + */ + async getBuildByCommitSha(options: BuildSearchOptions): Promise { + const { + jobName, + commitSha, + folderName, + maxBuildsToCheck = JenkinsConfig.DEFAULT_MAX_BUILDS_TO_CHECK + } = options; + + try { + // Normalize commitSha by trimming and lowercasing + const normalizedCommitSha = commitSha.trim().toLowerCase(); + console.log(`Looking for build with commit SHA: ${normalizedCommitSha} in job: ${jobName}`); + + // Get job info to access the builds list + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const jobInfo = await this.httpClient.get( + `${path}/${JenkinsConfig.ENDPOINTS.API_JSON}`, + JenkinsConfig.HEADERS.JSON + ); + + if (!jobInfo.builds || jobInfo.builds.length === 0) { + console.log(`No builds found for job: ${jobName}`); + return null; + } + + console.log(`Found ${jobInfo.builds.length} builds, checking up to ${maxBuildsToCheck}`); + + // Limit the number of builds to check + const buildsToCheck = jobInfo.builds.slice(0, maxBuildsToCheck); + const matchingBuilds: JenkinsBuild[] = []; + + // Check each build for the commit SHA + for (const buildRef of buildsToCheck) { + console.log(`Checking build #${buildRef.number}`); + const buildInfo = await this.getBuild(jobName, buildRef.number, folderName, false); + + if (this.buildMatchesCommit(buildInfo, normalizedCommitSha)) { + matchingBuilds.push(buildInfo); + } + } + + // If no matching build was found + if (matchingBuilds.length === 0) { + console.log( + `No builds found matching commit SHA: ${normalizedCommitSha} after checking ${buildsToCheck.length} builds` + ); + return null; + } + + // Sort matching builds by build number in descending order to get the latest one first + matchingBuilds.sort((a, b) => b.number - a.number); + + console.log( + `Found ${matchingBuilds.length} builds matching commit SHA: ${normalizedCommitSha}, returning the latest: #${matchingBuilds[0].number}` + ); + return matchingBuilds[0]; + } catch (error) { + throw new JenkinsJobNotFoundError(jobName, folderName); + } + } + + /** + * Get the trigger type of a build + */ + async getBuildTriggerType( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + const buildInfo = await this.getBuild(jobName, buildNumber, folderName, true); + return buildInfo.triggerType || JenkinsBuildTrigger.UNKNOWN; + } + + /** + * Check if a build was triggered by a pull request + */ + async isBuildTriggeredByPullRequest( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + const triggerType = await this.getBuildTriggerType(jobName, buildNumber, folderName); + return triggerType === JenkinsBuildTrigger.PULL_REQUEST; + } + + /** + * Check if a build was triggered by a push event + */ + async isBuildTriggeredByPush( + jobName: string, + buildNumber: number, + folderName?: string + ): Promise { + const triggerType = await this.getBuildTriggerType(jobName, buildNumber, folderName); + return triggerType === JenkinsBuildTrigger.PUSH; + } + + /** + * Check if a build matches a specific commit SHA + */ + private buildMatchesCommit(build: JenkinsBuild, normalizedCommitSha: string): boolean { + // Check if the build has actions containing SCM information + if (build.actions) { + for (const action of build.actions) { + // Method 1: Check lastBuiltRevision.SHA1 + if (action._class?.includes('hudson.plugins.git') && action.lastBuiltRevision?.SHA1) { + const buildSha = action.lastBuiltRevision.SHA1.toLowerCase(); + if (this.commitShasMatch(buildSha, normalizedCommitSha)) { + console.log(`Found matching commit in lastBuiltRevision: ${buildSha}`); + return true; + } + } + + // Method 2: Check buildsByBranchName + if (action.buildsByBranchName) { + for (const branch in action.buildsByBranchName) { + if (action.buildsByBranchName[branch].revision?.SHA1) { + const branchSha = action.buildsByBranchName[branch].revision.SHA1.toLowerCase(); + if (this.commitShasMatch(branchSha, normalizedCommitSha)) { + console.log(`Found matching commit in buildsByBranchName for branch ${branch}: ${branchSha}`); + return true; + } + } + } + } + + // Method 3: Check GIT_COMMIT environment variable in build parameters + if (action.parameters) { + for (const param of action.parameters) { + if ((param.name === 'GIT_COMMIT' || param.name === 'ghprbActualCommit') && param.value) { + const paramSha = param.value.toLowerCase(); + if (this.commitShasMatch(paramSha, normalizedCommitSha)) { + console.log(`Found matching commit in build parameter ${param.name}: ${paramSha}`); + return true; + } + } + } + } + + // Method 4: Check pull request related information + if (action._class?.includes('pull-request') && action.pullRequest?.source?.commit) { + const prSha = action.pullRequest.source.commit.toLowerCase(); + if (this.commitShasMatch(prSha, normalizedCommitSha)) { + console.log(`Found matching commit in pull request info: ${prSha}`); + return true; + } + } + } + } + + // Method 5: Check in build causes + if (build.causes) { + for (const cause of build.causes) { + if (cause.shortDescription && cause.shortDescription.includes(normalizedCommitSha)) { + console.log(`Found matching commit in build causes: ${cause.shortDescription}`); + return true; + } + } + } + + // Method 6: Check in build display name or description + if (build.displayName && build.displayName.includes(normalizedCommitSha)) { + console.log(`Found matching commit in build display name: ${build.displayName}`); + return true; + } else if (build.description && build.description.includes(normalizedCommitSha)) { + console.log(`Found matching commit in build description: ${build.description}`); + return true; + } + + return false; + } + + /** + * Check if two commit SHAs match (handles full and shortened SHAs) + */ + private commitShasMatch(sha1: string, sha2: string): boolean { + return sha1 === sha2 || sha1.startsWith(sha2) || sha2.startsWith(sha1); + } + + /** + * Get comprehensive activity status for a job (running builds + queue status) + */ + async getJobActivityStatus(jobName: string, folderName?: string): Promise { + try { + // Get both running builds and job info in parallel + const [runningBuilds, jobInfo] = await Promise.all([ + this.getRunningBuilds(jobName, folderName), + this.getJobInfo(jobName, folderName) + ]); + + const inQueue = jobInfo?.inQueue || false; + const isActive = runningBuilds.length > 0 || inQueue; + + return { + jobName, + folderName, + runningBuilds, + inQueue, + isActive + }; + } catch (error) { + throw new JenkinsJobNotFoundError(jobName, folderName); + } + } + + /** + * Get activity status for multiple jobs + */ + async getMultipleJobsActivityStatus(jobNames: string[], folderName?: string): Promise { + const statusPromises = jobNames.map(jobName => + this.getJobActivityStatus(jobName, folderName).catch(error => { + console.warn(`Failed to get status for job ${jobName}: ${error.message}`); + return { + jobName, + folderName, + runningBuilds: [], + inQueue: false, + isActive: false + }; + }) + ); + + return await Promise.all(statusPromises); + } + + /** + * Wait for multiple jobs to complete (both running builds and queued jobs) + */ + async waitForMultipleJobsToComplete(options: WaitForJobsOptions): Promise { + const { + jobNames, + folderName, + timeoutMs = JenkinsConfig.DEFAULT_TIMEOUT_MS, + pollIntervalMs = JenkinsConfig.DEFAULT_POLL_INTERVAL_MS + } = options; + + const startTime = Date.now(); + + console.log(`Waiting for ${jobNames.length} Jenkins jobs to complete: ${jobNames.join(', ')}`); + + while (Date.now() - startTime < timeoutMs) { + try { + const jobStatuses = await this.getMultipleJobsActivityStatus(jobNames, folderName); + + // Check if any jobs are still active (running or queued) + const activeJobs = jobStatuses.filter(status => status.isActive); + + if (activeJobs.length === 0) { + console.log(`All Jenkins jobs have completed successfully.`); + return; + } + + // Log detailed status + const statusMessages = jobStatuses.map(status => { + if (!status.isActive) return null; + + const parts = []; + if (status.runningBuilds.length > 0) { + parts.push(`${status.runningBuilds.length} running builds`); + } + if (status.inQueue) { + parts.push('queued'); + } + + return `${status.jobName}: ${parts.join(', ')}`; + }).filter(Boolean); + + console.log(`Active jobs (${activeJobs.length}/${jobNames.length}): ${statusMessages.join(' | ')}`); + + // Wait before next poll + await JenkinsPollingUtils.sleep(pollIntervalMs); + } catch (error) { + console.warn(`Error checking job statuses: ${error}. Retrying...`); + await JenkinsPollingUtils.sleep(pollIntervalMs); + } + } + + throw new Error(`Timeout waiting for Jenkins jobs to complete: ${jobNames.join(', ')}`); + } + + /** + * Get job information including queue status + * @private helper method + */ + private async getJobInfo(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const jobInfo = await this.httpClient.get( + `${path}/${JenkinsConfig.ENDPOINTS.API_JSON}`, + JenkinsConfig.HEADERS.JSON, + { tree: 'inQueue,buildable,color,lastBuild[number]' } + ); + + return jobInfo; + } catch (error) { + console.warn(`Could not get job info for ${jobName}: ${error}`); + return null; + } + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/services/jenkins-credential.service.ts b/src/api/ci/jenkins/services/jenkins-credential.service.ts new file mode 100644 index 0000000..40d4198 --- /dev/null +++ b/src/api/ci/jenkins/services/jenkins-credential.service.ts @@ -0,0 +1,220 @@ +import { JenkinsHttpClient } from '../http/jenkins-http.client'; +import { JenkinsApiResponse } from '../types/jenkins.types'; +import { CredentialType } from '../enums/jenkins.enums'; +import { JenkinsConfig } from '../config/jenkins.config'; +import { JenkinsPathBuilder } from '../utils/jenkins.utils'; +import { CredentialStrategyFactory } from '../strategies/credential.strategy'; +import { JenkinsCredentialError } from '../errors/jenkins.errors'; + +/** + * Service for Jenkins credential-related operations + */ +export class JenkinsCredentialService { + constructor(private httpClient: JenkinsHttpClient) {} + + /** + * Create a credential in Jenkins + */ + async createCredential( + folderName: string, + credentialId: string, + secretValue: string, + credentialType: CredentialType = CredentialType.SECRET_TEXT + ): Promise { + try { + // Get the appropriate strategy for the credential type + const strategy = CredentialStrategyFactory.create(credentialType); + + // Build the credential XML using the strategy + const credentialXml = strategy.buildXml(credentialId, secretValue); + + // Determine the path based on whether folder is specified + const path = JenkinsPathBuilder.buildCredentialPath(folderName); + + const response = await this.httpClient.post(path, credentialXml, JenkinsConfig.HEADERS.XML); + + return response; + } catch (error) { + throw new JenkinsCredentialError( + credentialId, + error instanceof Error ? error.message : 'Unknown error creating credential' + ); + } + } + + /** + * Update an existing credential + */ + async updateCredential( + folderName: string, + credentialId: string, + secretValue: string, + credentialType: CredentialType = CredentialType.SECRET_TEXT + ): Promise { + try { + // Get the appropriate strategy for the credential type + const strategy = CredentialStrategyFactory.create(credentialType); + + // Build the credential XML using the strategy + const credentialXml = strategy.buildXml(credentialId, secretValue); + + // Build path for updating credential + const basePath = folderName + ? `job/${encodeURIComponent(folderName)}/credentials/store/folder/domain/_/credential/${encodeURIComponent(credentialId)}` + : `credentials/store/system/domain/_/credential/${encodeURIComponent(credentialId)}`; + + const path = `${basePath}/config.xml`; + + const response = await this.httpClient.post(path, credentialXml, JenkinsConfig.HEADERS.XML); + + return response; + } catch (error) { + throw new JenkinsCredentialError( + credentialId, + error instanceof Error ? error.message : 'Unknown error updating credential' + ); + } + } + + /** + * Delete a credential + */ + async deleteCredential(folderName: string, credentialId: string): Promise { + try { + // Build path for deleting credential + const basePath = folderName + ? `job/${encodeURIComponent(folderName)}/credentials/store/folder/domain/_/credential/${encodeURIComponent(credentialId)}` + : `credentials/store/system/domain/_/credential/${encodeURIComponent(credentialId)}`; + + const path = `${basePath}/doDelete`; + + const response = await this.httpClient.post(path, '', JenkinsConfig.HEADERS.JSON); + + return response; + } catch (error) { + throw new JenkinsCredentialError( + credentialId, + error instanceof Error ? error.message : 'Unknown error deleting credential' + ); + } + } + + /** + * Check if a credential exists + */ + async credentialExists(folderName: string, credentialId: string): Promise { + try { + await this.getCredential(folderName, credentialId); + return true; + } catch (error) { + if (error instanceof JenkinsCredentialError) { + return false; + } + throw error; + } + } + + /** + * Get credential information (without sensitive data) + */ + async getCredential(folderName: string, credentialId: string): Promise { + try { + // Build path for getting credential info + const basePath = folderName + ? `job/${encodeURIComponent(folderName)}/credentials/store/folder/domain/_/credential/${encodeURIComponent(credentialId)}` + : `credentials/store/system/domain/_/credential/${encodeURIComponent(credentialId)}`; + + const path = `${basePath}/${JenkinsConfig.ENDPOINTS.API_JSON}`; + + const credential = await this.httpClient.get(path, JenkinsConfig.HEADERS.JSON); + + return credential; + } catch (error) { + throw new JenkinsCredentialError( + credentialId, + 'Credential not found or not accessible' + ); + } + } + + /** + * List all credentials in a domain + */ + async listCredentials(folderName?: string): Promise { + try { + // Build path for listing credentials + const basePath = folderName + ? `job/${encodeURIComponent(folderName)}/credentials/store/folder/domain/_` + : `credentials/store/system/domain/_`; + + const path = `${basePath}/${JenkinsConfig.ENDPOINTS.API_JSON}`; + + const response = await this.httpClient.get<{ credentials: any[] }>( + path, + JenkinsConfig.HEADERS.JSON, + { tree: 'credentials[id,description,typeName]' } + ); + + return response.credentials || []; + } catch (error) { + throw new JenkinsCredentialError( + 'list', + error instanceof Error ? error.message : 'Unknown error listing credentials' + ); + } + } + + /** + * Create secret text credential (convenience method) + */ + async createSecretTextCredential( + folderName: string, + credentialId: string, + secretValue: string + ): Promise { + return this.createCredential(folderName, credentialId, secretValue, CredentialType.SECRET_TEXT); + } + + /** + * Create username/password credential (convenience method) + */ + async createUsernamePasswordCredential( + folderName: string, + credentialId: string, + username: string, + password: string + ): Promise { + const secretValue = `${username}:${password}`; + return this.createCredential(folderName, credentialId, secretValue, CredentialType.USERNAME_PASSWORD); + } + + /** + * Create SSH private key credential (convenience method) + */ + async createSshPrivateKeyCredential( + folderName: string, + credentialId: string, + username: string, + privateKey: string, + passphrase?: string + ): Promise { + const secretValue = passphrase + ? `${username}::${privateKey}::${passphrase}` + : `${username}::${privateKey}`; + return this.createCredential(folderName, credentialId, secretValue, CredentialType.SSH_USERNAME_PRIVATE_KEY); + } + + /** + * Get supported credential types + */ + getSupportedCredentialTypes(): CredentialType[] { + return CredentialStrategyFactory.getSupportedTypes(); + } + + /** + * Check if a credential type is supported + */ + isCredentialTypeSupported(type: CredentialType): boolean { + return CredentialStrategyFactory.isSupported(type); + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/services/jenkins-job.service.ts b/src/api/ci/jenkins/services/jenkins-job.service.ts new file mode 100644 index 0000000..e6c3f3a --- /dev/null +++ b/src/api/ci/jenkins/services/jenkins-job.service.ts @@ -0,0 +1,197 @@ +import { JenkinsHttpClient } from '../http/jenkins-http.client'; +import { + JenkinsApiResponse, + FolderConfig, + CreateJobOptions, + JenkinsJob +} from '../types/jenkins.types'; +import { JenkinsConfig } from '../config/jenkins.config'; +import { JenkinsPathBuilder, JenkinsXmlBuilder } from '../utils/jenkins.utils'; +import { JenkinsFolderError, JenkinsJobNotFoundError } from '../errors/jenkins.errors'; + +/** + * Service for Jenkins job-related operations + */ +export class JenkinsJobService { + constructor(private httpClient: JenkinsHttpClient) {} + + /** + * Create a folder in Jenkins + */ + async createFolder(folderConfig: FolderConfig): Promise { + try { + const folderXml = JenkinsXmlBuilder.buildFolderXml(folderConfig.description); + const path = `${JenkinsConfig.ENDPOINTS.CREATE_ITEM}?name=${encodeURIComponent(folderConfig.name)}&mode=com.cloudbees.hudson.plugins.folder.Folder`; + + const response = await this.httpClient.post(path, folderXml, JenkinsConfig.HEADERS.XML); + + return response; + } catch (error) { + throw new JenkinsFolderError( + folderConfig.name, + 'create', + error instanceof Error ? error.message : 'Unknown error' + ); + } + } + + /** + * Create a job in Jenkins + */ + async createJob(options: CreateJobOptions): Promise { + try { + const path = JenkinsPathBuilder.buildCreateItemPath(options.folderName); + const jobXml = JenkinsXmlBuilder.buildJobXml(options); + + const response = await this.httpClient.post( + `${path}?name=${encodeURIComponent(options.jobName)}`, + jobXml, + JenkinsConfig.HEADERS.XML + ); + + return response; + } catch (error) { + const jobPath = options.folderName ? `${options.folderName}/${options.jobName}` : options.jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Get information about a job + */ + async getJob(jobPath: string): Promise { + try { + const formattedPath = JenkinsPathBuilder.buildFormattedJobPath(jobPath); + const response = await this.httpClient.get( + `${formattedPath}/${JenkinsConfig.ENDPOINTS.API_JSON}`, + JenkinsConfig.HEADERS.JSON + ); + + return response; + } catch (error) { + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Get job by name and optional folder + */ + async getJobByName(jobName: string, folderName?: string): Promise { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + return this.getJob(jobPath); + } + + /** + * Delete a job + */ + async deleteJob(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const response = await this.httpClient.post(`${path}/doDelete`, '', JenkinsConfig.HEADERS.JSON); + + return response; + } catch (error) { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Check if a job exists + */ + async jobExists(jobName: string, folderName?: string): Promise { + try { + await this.getJobByName(jobName, folderName); + return true; + } catch (error) { + if (error instanceof JenkinsJobNotFoundError) { + return false; + } + throw error; + } + } + + /** + * Get all jobs in a folder (or root if no folder specified) + */ + async getJobs(folderName?: string): Promise { + try { + const path = folderName + ? `job/${encodeURIComponent(folderName)}/${JenkinsConfig.ENDPOINTS.API_JSON}` + : JenkinsConfig.ENDPOINTS.API_JSON; + + const response = await this.httpClient.get<{ jobs: JenkinsJob[] }>( + path, + JenkinsConfig.HEADERS.JSON, + { tree: 'jobs[name,url,color,buildable]' } + ); + + return response.jobs || []; + } catch (error) { + if (folderName) { + throw new JenkinsFolderError(folderName, 'list jobs in', 'Folder not found or accessible'); + } + throw error; + } + } + + /** + * Disable a job + */ + async disableJob(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const response = await this.httpClient.post(`${path}/disable`, '', JenkinsConfig.HEADERS.JSON); + + return response; + } catch (error) { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Enable a job + */ + async enableJob(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const response = await this.httpClient.post(`${path}/enable`, '', JenkinsConfig.HEADERS.JSON); + + return response; + } catch (error) { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Update job configuration + */ + async updateJobConfig(jobName: string, configXml: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const response = await this.httpClient.post(`${path}/config.xml`, configXml, JenkinsConfig.HEADERS.XML); + + return response; + } catch (error) { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } + + /** + * Get job configuration XML + */ + async getJobConfig(jobName: string, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildJobPath(jobName, folderName); + const response = await this.httpClient.get(`${path}/config.xml`, JenkinsConfig.HEADERS.XML); + + return response; + } catch (error) { + const jobPath = folderName ? `${folderName}/${jobName}` : jobName; + throw new JenkinsJobNotFoundError(jobPath); + } + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/strategies/credential.strategy.ts b/src/api/ci/jenkins/strategies/credential.strategy.ts new file mode 100644 index 0000000..cc5848d --- /dev/null +++ b/src/api/ci/jenkins/strategies/credential.strategy.ts @@ -0,0 +1,163 @@ +import { CredentialType } from '../enums/jenkins.enums'; +import { JenkinsConfig } from '../config/jenkins.config'; + +/** + * Interface for credential creation strategies + */ +export interface CredentialStrategy { + buildXml(credentialId: string, secretValue: string): string; + getType(): CredentialType; +} + +/** + * Strategy for creating secret text credentials + */ +export class SecretTextCredentialStrategy implements CredentialStrategy { + buildXml(credentialId: string, secretValue: string): string { + return ` + GLOBAL + ${this.escapeXml(credentialId)} + Secret variable for ${this.escapeXml(credentialId)} + ${this.escapeXml(secretValue)} +`; + } + + getType(): CredentialType { + return CredentialType.SECRET_TEXT; + } + + private escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +/** + * Strategy for creating username/password credentials + */ +export class UsernamePasswordCredentialStrategy implements CredentialStrategy { + buildXml(credentialId: string, secretValue: string): string { + const [username, password] = this.parseCredentials(secretValue); + + return ` + GLOBAL + ${this.escapeXml(credentialId)} + Credentials for ${this.escapeXml(credentialId)} + ${this.escapeXml(username)} + ${this.escapeXml(password)} +`; + } + + getType(): CredentialType { + return CredentialType.USERNAME_PASSWORD; + } + + private parseCredentials(secretValue: string): [string, string] { + const parts = secretValue.split(':'); + if (parts.length < 2) { + throw new Error('Username/password credentials must be in format "username:password"'); + } + + const username = parts[0]; + const password = parts.slice(1).join(':'); // Handle passwords with colons + + return [username, password]; + } + + private escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +/** + * Strategy for creating SSH private key credentials + */ +export class SshPrivateKeyCredentialStrategy implements CredentialStrategy { + buildXml(credentialId: string, secretValue: string): string { + const [username, privateKey, passphrase] = this.parseCredentials(secretValue); + + return ` + GLOBAL + ${this.escapeXml(credentialId)} + SSH credentials for ${this.escapeXml(credentialId)} + ${this.escapeXml(username)} + + ${this.escapeXml(privateKey)} + + ${this.escapeXml(passphrase || '')} +`; + } + + getType(): CredentialType { + return CredentialType.SSH_USERNAME_PRIVATE_KEY; + } + + private parseCredentials(secretValue: string): [string, string, string?] { + const parts = secretValue.split('::'); + if (parts.length < 2) { + throw new Error('SSH credentials must be in format "username::privateKey" or "username::privateKey::passphrase"'); + } + + const username = parts[0]; + const privateKey = parts[1]; + const passphrase = parts[2]; + + return [username, privateKey, passphrase]; + } + + private escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +/** + * Factory for creating credential strategies + */ +export class CredentialStrategyFactory { + private static strategies = new Map([ + [CredentialType.SECRET_TEXT, new SecretTextCredentialStrategy()], + [CredentialType.USERNAME_PASSWORD, new UsernamePasswordCredentialStrategy()], + [CredentialType.SSH_USERNAME_PRIVATE_KEY, new SshPrivateKeyCredentialStrategy()], + ]); + + /** + * Create a credential strategy for the given type + */ + static create(type: CredentialType): CredentialStrategy { + const strategy = this.strategies.get(type); + + if (!strategy) { + throw new Error(`Unsupported credential type: ${type}`); + } + + return strategy; + } + + /** + * Get all supported credential types + */ + static getSupportedTypes(): CredentialType[] { + return Array.from(this.strategies.keys()); + } + + /** + * Check if a credential type is supported + */ + static isSupported(type: CredentialType): boolean { + return this.strategies.has(type); + } +} \ No newline at end of file diff --git a/src/api/ci/jenkins/types/jenkins.types.ts b/src/api/ci/jenkins/types/jenkins.types.ts new file mode 100644 index 0000000..0e79d1a --- /dev/null +++ b/src/api/ci/jenkins/types/jenkins.types.ts @@ -0,0 +1,179 @@ +import { JenkinsBuildResult, JenkinsBuildTrigger } from '../enums/jenkins.enums'; + +/** + * Jenkins API response wrapper + */ +export interface JenkinsApiResponse { + success: boolean; + status: number; + data: T; + location?: string; +} + +/** + * Jenkins client configuration + */ +export interface JenkinsClientConfig { + baseUrl: string; + username: string; + token: string; +} + +/** + * Folder configuration for Jenkins + */ +export interface FolderConfig { + name: string; + description?: string; +} + +/** + * Options for creating Jenkins jobs + */ +export interface CreateJobOptions { + jobName: string; + repoUrl: string; + folderName?: string; + branch?: string; + jenkinsfilePath?: string; + credentialId?: string; +} + +/** + * Options for triggering builds + */ +export interface BuildOptions { + jobName: string; + folderName?: string; + parameters?: Record; +} + +/** + * Options for searching builds by commit SHA + */ +export interface BuildSearchOptions { + jobName: string; + commitSha: string; + folderName?: string; + maxBuildsToCheck?: number; +} + +/** + * Options for waiting for build completion + */ +export interface WaitForBuildOptions { + jobName: string; + buildNumber: number; + folderName?: string; + timeoutMs?: number; + pollIntervalMs?: number; +} + +/** + * Basic interface for Jenkins build information + */ +export interface JenkinsBuild { + id: string; // Unique build identifier + number: number; // Build number + url: string; // URL to the build in Jenkins + displayName: string; // Display name of the build + fullDisplayName?: string; // Full display name (job name + build number) + + // Status + building: boolean; // Whether the build is currently running + result: JenkinsBuildResult | null; // Build result (null if building) + + // Timing + timestamp: number; // Build start time (milliseconds since epoch) + duration: number; // Build duration in milliseconds + + // Build details + actions: any[]; // Actions related to the build (contains SCM info, etc.) + causes?: Array<{ + // The causes that triggered the build + shortDescription: string; + [key: string]: any; + }>; + + // Trigger information + triggerType?: JenkinsBuildTrigger; // The type of event that triggered this build + + // Additional useful properties + description?: string; // Build description + artifacts?: Array<{ + // Build artifacts + displayPath: string; + fileName: string; + relativePath: string; + }>; +} + +/** + * Jenkins job information interface + */ +export interface JenkinsJob { + name: string; + url: string; + displayName: string; + description?: string; + buildable: boolean; + color: string; + healthReport?: Array<{ + description: string; + iconClassName?: string; + iconUrl?: string; + score: number; + }>; + builds?: Array<{ + number: number; + url: string; + }>; + firstBuild?: { number: number; url: string } | null; + lastBuild?: { number: number; url: string } | null; + lastCompletedBuild?: { number: number; url: string } | null; + lastFailedBuild?: { number: number; url: string } | null; + lastStableBuild?: { number: number; url: string } | null; + lastSuccessfulBuild?: { number: number; url: string } | null; + lastUnstableBuild?: { number: number; url: string } | null; + lastUnsuccessfulBuild?: { number: number; url: string } | null; + nextBuildNumber: number; + property?: any[]; + actions?: any[]; + queueItem?: any; + inQueue: boolean; + parameterDefinitions?: Array<{ + name: string; + type: string; + description?: string; + defaultParameterValue?: { + name: string; + value: any; + }; + }>; + concurrentBuild: boolean; + keepDependencies?: boolean; + scm?: any; + upstreamProjects?: any[]; + downstreamProjects?: any[]; +} + +/** + * Interface for job activity status + */ +export interface JobActivityStatus { + jobName: string; + folderName?: string; + runningBuilds: JenkinsBuild[]; + inQueue: boolean; + isActive: boolean; // true if either running builds exist OR job is in queue +} + +/** + * Options for waiting for multiple jobs + */ +export interface WaitForJobsOptions { + jobNames: string[]; + folderName?: string; + timeoutMs?: number; + pollIntervalMs?: number; +} \ No newline at end of file diff --git a/src/api/ci/jenkins/utils/jenkins.utils.ts b/src/api/ci/jenkins/utils/jenkins.utils.ts new file mode 100644 index 0000000..40493aa --- /dev/null +++ b/src/api/ci/jenkins/utils/jenkins.utils.ts @@ -0,0 +1,264 @@ +import { CreateJobOptions } from '../types/jenkins.types'; +import { JenkinsConfig } from '../config/jenkins.config'; + +/** + * Utility class for building Jenkins API paths + */ +export class JenkinsPathBuilder { + /** + * Build a job path for API calls + */ + static buildJobPath(jobName: string, folderName?: string): string { + return folderName + ? `job/${encodeURIComponent(folderName)}/job/${encodeURIComponent(jobName)}` + : `job/${encodeURIComponent(jobName)}`; + } + + /** + * Build a path for creating items (jobs, folders) + */ + static buildCreateItemPath(folderName?: string): string { + return folderName + ? `job/${encodeURIComponent(folderName)}/${JenkinsConfig.ENDPOINTS.CREATE_ITEM}` + : JenkinsConfig.ENDPOINTS.CREATE_ITEM; + } + + /** + * Build a path for credential operations + */ + static buildCredentialPath(folderName?: string): string { + return folderName + ? `job/${encodeURIComponent(folderName)}/${JenkinsConfig.ENDPOINTS.CREDENTIALS_STORE_FOLDER}` + : JenkinsConfig.ENDPOINTS.CREDENTIALS_STORE_SYSTEM; + } + + /** + * Build a formatted job path for API calls (handles nested folders) + */ + static buildFormattedJobPath(jobPath: string): string { + return jobPath + .split('/') + .map(segment => `job/${encodeURIComponent(segment)}`) + .join('/'); + } + + /** + * Build build-specific API path + */ + static buildBuildPath(jobName: string, buildNumber: number, folderName?: string, endpoint: string = ''): string { + const jobPath = this.buildJobPath(jobName, folderName); + return endpoint ? `${jobPath}/${buildNumber}/${endpoint}` : `${jobPath}/${buildNumber}`; + } +} + +/** + * Utility class for generating Jenkins XML configurations + */ +export class JenkinsXmlBuilder { + /** + * Build XML configuration for Jenkins folder + */ + static buildFolderXml(description: string = ''): string { + return ` + + ${this.escapeXml(description)} + + + +`; + } + + /** + * Build XML configuration for Jenkins pipeline job + */ + static buildJobXml(options: CreateJobOptions): string { + const { + repoUrl, + branch = JenkinsConfig.DEFAULT_BRANCH, + jenkinsfilePath = JenkinsConfig.DEFAULT_JENKINSFILE_PATH, + credentialId = JenkinsConfig.DEFAULT_CREDENTIAL_ID + } = options; + + return ` + + + false + + + + + + + + + + + + 2 + + + ${this.escapeXml(repoUrl)} + ${this.escapeXml(credentialId)} + + + + + */${this.escapeXml(branch)} + + + false + + + + ${this.escapeXml(jenkinsfilePath)} + true + + false +`; + } + + /** + * Escape XML special characters + */ + private static escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +/** + * Utility class for analyzing Jenkins build triggers + */ +export class JenkinsTriggerAnalyzer { + /** + * Determines the trigger type of a Jenkins build + */ + static determineBuildTrigger(build: any): string { + // Check if build has actions array + if (build.actions && Array.isArray(build.actions)) { + // Look for pull request related information in actions + for (const action of build.actions) { + if (this.isPullRequestTrigger(action)) { + return 'PULL_REQUEST'; + } + } + } + + // Check causes for trigger information + if (build.causes && Array.isArray(build.causes)) { + for (const cause of build.causes) { + const triggerType = this.analyzeCause(cause); + if (triggerType !== 'UNKNOWN') { + return triggerType; + } + } + } + + // Default to PUSH if we have git information but couldn't identify as PR + if (this.hasGitInformation(build)) { + return 'PUSH'; + } + + return 'UNKNOWN'; + } + + /** + * Check if action indicates pull request trigger + */ + private static isPullRequestTrigger(action: any): boolean { + return action._class?.includes('pull-request') || + action._class?.includes('PullRequestAction') || + action.pullRequest || + (action.parameters && action.parameters.some((p: any) => + p.name?.includes('ghpr') || p.name?.includes('pull') || p.name?.includes('PR'))); + } + + /** + * Analyze build cause to determine trigger type + */ + private static analyzeCause(cause: any): string { + if (!cause.shortDescription) { + return 'UNKNOWN'; + } + + const description = cause.shortDescription.toLowerCase(); + + if (description.includes('pull request') || description.includes('pr ') || + cause._class?.toLowerCase().includes('pullrequest')) { + return 'PULL_REQUEST'; + } + + if (description.includes('push') || + cause._class?.includes('GitHubPushCause') || + cause._class?.includes('GitLabWebHookCause')) { + return 'PUSH'; + } + + if (description.includes('started by user') || + cause._class?.includes('UserIdCause')) { + return 'MANUAL'; + } + + if (description.includes('timer') || + cause._class?.includes('TimerTrigger')) { + return 'SCHEDULED'; + } + + if (description.includes('remote') || + cause._class?.includes('RemoteCause')) { + return 'API'; + } + + return 'UNKNOWN'; + } + + /** + * Check if build has git information + */ + private static hasGitInformation(build: any): boolean { + return build.actions && build.actions.some((action: any) => + action._class?.includes('git') || action.lastBuiltRevision || action.buildsByBranchName); + } +} + +/** + * Utility class for waiting and polling operations + */ +export class JenkinsPollingUtils { + /** + * Generic polling utility with timeout + */ + static async pollUntil( + pollFn: () => Promise, + conditionFn: (result: T) => boolean, + timeoutMs: number = JenkinsConfig.DEFAULT_TIMEOUT_MS, + intervalMs: number = JenkinsConfig.DEFAULT_POLL_INTERVAL_MS + ): Promise { + const startTime = Date.now(); + + while (true) { + const result = await pollFn(); + + if (conditionFn(result)) { + return result; + } + + if (Date.now() - startTime > timeoutMs) { + throw new Error(`Polling timed out after ${timeoutMs}ms`); + } + + await this.sleep(intervalMs); + } + } + + /** + * Sleep utility + */ + static sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} \ No newline at end of file diff --git a/src/rhtap/core/integration/ci/providers/gitlabCI.ts b/src/rhtap/core/integration/ci/providers/gitlabCI.ts index 2809cb4..ab289c0 100644 --- a/src/rhtap/core/integration/ci/providers/gitlabCI.ts +++ b/src/rhtap/core/integration/ci/providers/gitlabCI.ts @@ -275,11 +275,11 @@ export class GitLabCI extends BaseCI { } public override getIntegrationSecret(): Promise> { - return this.secret; + return Promise.resolve(this.secret); } - public getPipelineLogs(pipeline: Pipeline): Promise { - return this.gitlabCIClient.getPipelineLogs(pipeline.id); + public override async getPipelineLogs(pipeline: Pipeline): Promise { + throw new Error('GitLab does not support getting pipeline logs.'); } public override async getCIFilePathInRepo(): Promise { diff --git a/src/rhtap/core/integration/ci/providers/jenkinsCI.ts b/src/rhtap/core/integration/ci/providers/jenkinsCI.ts index 103b0d3..ed1def0 100644 --- a/src/rhtap/core/integration/ci/providers/jenkinsCI.ts +++ b/src/rhtap/core/integration/ci/providers/jenkinsCI.ts @@ -4,12 +4,14 @@ import { JenkinsBuildResult, JenkinsBuildTrigger, JenkinsClient, -} from '../../../../../../src/api/ci/jenkinsClient'; +} from '../../../../../../src/api/ci/jenkins'; +// ... existing code ... import { KubeClient } from '../../../../../../src/api/ocp/kubeClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; import retry from 'async-retry'; +import { JobActivityStatus } from '../../../../../../src/api/ci/jenkins'; export class JenkinsCI extends BaseCI { private jenkinsClient!: JenkinsClient; @@ -27,6 +29,60 @@ export class JenkinsCI extends BaseCI { this.componentName = componentName; } + /** + * Convert a JenkinsBuild to a Pipeline object + * Helper method to transform a Jenkins build into the standardized Pipeline format + */ + private convertBuildToPipeline( + build: JenkinsBuild, + jobName: string, + repositoryName: string, + logs: string = '', + sha?: string + ): Pipeline { + // Map Jenkins build status to standardized PipelineStatus + let status = PipelineStatus.UNKNOWN; + + if (build.building) { + status = PipelineStatus.RUNNING; + } else if (build.result) { + switch (build.result) { + case JenkinsBuildResult.SUCCESS: + status = PipelineStatus.SUCCESS; + break; + case JenkinsBuildResult.FAILURE: + status = PipelineStatus.FAILURE; + break; + case JenkinsBuildResult.UNSTABLE: + status = PipelineStatus.FAILURE; // Map unstable to failure + break; + case JenkinsBuildResult.ABORTED: + status = PipelineStatus.FAILURE; // Map aborted to failure + break; + case JenkinsBuildResult.NOT_BUILT: + status = PipelineStatus.PENDING; + break; + default: + status = PipelineStatus.UNKNOWN; + } + } + + // Create a results string from build actions + const results = JSON.stringify(build.actions || {}); + + // Create and return a Pipeline object + return Pipeline.createJenkinsPipeline( + jobName, + build.number, + status, + repositoryName, + logs, + results, + build.url, + sha + ); + } + private async loadSecret(): Promise> { const secret = await this.kubeClient.getSecret('tssc-jenkins-integration', 'tssc'); if (!secret) { @@ -115,8 +171,24 @@ export class JenkinsCI extends BaseCI { credentialType: CredentialType = CredentialType.SECRET_TEXT ): Promise { try { - await this.jenkinsClient.createCredential(folderName, key, value, credentialType); - console.log(`Credential ${key} added to folder ${folderName}`); + // Check if credential already exists + const credentialExists = await this.jenkinsClient.credentialExists(folderName, key); + + if (credentialExists) { + console.log(`Credential ${key} already exists in folder ${folderName}. Updating...`); + await this.jenkinsClient.updateCredential(folderName, key, value, credentialType); + } else { + console.log(`Creating new credential ${key} in folder ${folderName}...`); + await this.jenkinsClient.createCredential(folderName, key, value, credentialType); + } + + // Verify the credential was created/updated successfully + const credential = await this.jenkinsClient.getCredential(folderName, key); + if (!credential) { + throw new Error(`Failed to verify credential ${key} after creation/update`); + } + + console.log(`Credential ${key} successfully added/updated in folder ${folderName}`); } catch (error) { console.error(`Failed to apply credentials in folder ${folderName}:`, error); throw error; @@ -360,37 +432,50 @@ export class JenkinsCI extends BaseCI { } /** - * Wait for all Jenkins jobs to finish + * Enhanced method to wait for all Jenkins jobs to finish (both running and queued) */ public override async waitForAllPipelinesToFinish(timeoutMs: number = 600000, pollIntervalMs: number = 5000): Promise { const folderName = this.componentName; const sourceRepoJobName = this.componentName; const gitopsRepoJobName = `${this.componentName}-gitops`; - const startTime = Date.now(); - + console.log(`Waiting for all Jenkins jobs to finish in folder ${folderName} for both source (${sourceRepoJobName}) and gitops (${gitopsRepoJobName}) repositories`); - while (Date.now() - startTime < timeoutMs) { - // Check running builds for both source and gitops repositories - const [sourceRunningBuilds, gitopsRunningBuilds] = await Promise.all([ - this.jenkinsClient.getRunningBuilds(sourceRepoJobName, folderName), - this.jenkinsClient.getRunningBuilds(gitopsRepoJobName, folderName) - ]); - - const totalRunningBuilds = sourceRunningBuilds.length + gitopsRunningBuilds.length; + try { + // Use the enhanced Jenkins client method to wait for multiple jobs + await this.jenkinsClient.waitForMultipleJobsToComplete({ + jobNames: [sourceRepoJobName, gitopsRepoJobName], + folderName: folderName, + timeoutMs: timeoutMs, + pollIntervalMs: pollIntervalMs + }); + + console.log(`All Jenkins jobs have completed successfully in folder ${folderName}`); + } catch (error) { + if (error instanceof Error && error.message.includes('Timeout')) { + throw new Error(`Timeout waiting for Jenkins pipelines to finish in folder ${folderName} for both source and gitops repositories after ${timeoutMs}ms`); + } + throw error; + } + } - if (totalRunningBuilds === 0) { - console.log(`No running Jenkins builds found in folder ${folderName} for both repositories. Exiting.`); - return; - } + /** + * Get detailed activity status for all jobs in this component + */ + public async getJobsActivityStatus(): Promise { + const folderName = this.componentName; + const sourceRepoJobName = this.componentName; + const gitopsRepoJobName = `${this.componentName}-gitops`; - console.log(`Found ${sourceRunningBuilds.length} running builds in source repo (${sourceRepoJobName}) and ${gitopsRunningBuilds.length} running builds in gitops repo (${gitopsRepoJobName}). Total: ${totalRunningBuilds}. Waiting...`); - - // Wait for the poll interval before checking again - await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + try { + return await this.jenkinsClient.getMultipleJobsActivityStatus( + [sourceRepoJobName, gitopsRepoJobName], + folderName + ); + } catch (error) { + console.error(`Failed to get jobs activity status:`, error); + throw error; } - - throw new Error(`Timeout waiting for Jenkins pipelines to finish in folder ${folderName} for both source and gitops repositories`); } public override async getWebhookUrl(): Promise { @@ -482,64 +567,4 @@ export class JenkinsCI extends BaseCI { throw error; } } - - /** - * Convert a JenkinsBuild to a Pipeline object - * This helper method makes it easier to transform a Jenkins build into the standardized Pipeline format - * - * @param build The Jenkins build to convert - * @param repositoryName The name of the repository associated with this build - * @param logs Optional build logs - * @param sha Optional git commit SHA that triggered this build - * @returns A standardized Pipeline object - */ - private convertBuildToPipeline( - build: JenkinsBuild, - jobName: string, - repositoryName: string, - logs: string = '', - sha?: string - ): Pipeline { - // Map Jenkins build status to standardized PipelineStatus - let status = PipelineStatus.UNKNOWN; - - if (build.building) { - status = PipelineStatus.RUNNING; - } else if (build.result) { - switch (build.result) { - case JenkinsBuildResult.SUCCESS: - status = PipelineStatus.SUCCESS; - break; - case JenkinsBuildResult.FAILURE: - status = PipelineStatus.FAILURE; - break; - case JenkinsBuildResult.UNSTABLE: - status = PipelineStatus.FAILURE; // Map unstable to failure - break; - case JenkinsBuildResult.ABORTED: - status = PipelineStatus.FAILURE; // Map aborted to failure - break; - case JenkinsBuildResult.NOT_BUILT: - status = PipelineStatus.PENDING; - break; - default: - status = PipelineStatus.UNKNOWN; - } - } - - // Create a results string from build actions - const results = JSON.stringify(build.actions || {}); - - // Create and return a Pipeline object - return Pipeline.createJenkinsPipeline( - jobName, - build.number, - status, - repositoryName, - logs, - results, - build.url, - sha - ); - } } \ No newline at end of file diff --git a/src/rhtap/postcreation/strategies/commands/addJenkinsSecretsCommand.ts b/src/rhtap/postcreation/strategies/commands/addJenkinsSecretsCommand.ts index 9d77d69..09771fc 100644 --- a/src/rhtap/postcreation/strategies/commands/addJenkinsSecretsCommand.ts +++ b/src/rhtap/postcreation/strategies/commands/addJenkinsSecretsCommand.ts @@ -1,4 +1,4 @@ -import { CredentialType } from '../../../../api/ci/jenkinsClient'; +import { CredentialType } from '../../../../api/ci/jenkins'; import { Component } from '../../../core/component'; import { JenkinsCI } from '../../../core/integration/ci'; import { GitType } from '../../../core/integration/git'; diff --git a/tests/api/ci/jenkinsClient.test.ts b/tests/api/ci/jenkinsClient.test.ts deleted file mode 100644 index 558d179..0000000 --- a/tests/api/ci/jenkinsClient.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - CredentialType, - JenkinsBuildTrigger, - JenkinsClient, -} from '../../../src/api/ci/jenkinsClient'; -import { expect, test } from '@playwright/test'; -import * as dotenv from 'dotenv'; - -// Load environment variables from .env file -dotenv.config(); - -// Test configuration - use environment variables -const JENKINS_URL = - process.env.JENKINS_URL || - 'https://jenkins-jenkins.apps.rosa.rhtap-services.xmdt.p3.openshiftapps.com'; -const JENKINS_USERNAME = 'cluster-admin-admin-edit-view'; -const JENKINS_TOKEN = ''; - -// Test constants - change these as needed -const TEST_FOLDER_NAME = 'test-folder-xjiang'; -const TEST_JOB_NAME = 'test-job'; -const TEST_REPO_URL = 'https://github.com/username/repo.git'; -const TEST_CREDENTIAL_ID = 'test-credential'; - -// Initialize the Jenkins client -const jenkins = new JenkinsClient({ - baseUrl: JENKINS_URL, - username: JENKINS_USERNAME, - token: JENKINS_TOKEN, -}); - -test.describe('JenkinsClient Integration Tests', () => { - // Set timeout for tests (Jenkins operations can be slow) - test.setTimeout(30000); - - // test('Should create a folder successfully', async () => { - // const result = await jenkins.createFolder({ - // name: TEST_FOLDER_NAME, - // description: 'A test subfolder', - // }); - // console.log('Folder creation result:', result); - - // expect(result.success).toBe(true); - // expect(result.status).toBeGreaterThanOrEqual(200); - // expect(result.status).toBeLessThan(300); - // }); - - // test('Should create a job under a folder', async () => { - // const result = await jenkins.createJob(TEST_JOB_NAME, TEST_FOLDER_NAME, TEST_REPO_URL); - // console.log('Job creation result:', result); - // expect(result.success).toBe(true); - // expect(result.status).toBeGreaterThanOrEqual(200); - // expect(result.status).toBeLessThan(300); - // }); - - // test('Should create a secret text credential', async () => { - // const result = await jenkins.createCredential( - // TEST_FOLDER_NAME, - // TEST_CREDENTIAL_ID, - // 'test-secret-value', - // CredentialType.SECRET_TEXT - // ); - - // expect(result.success).toBe(true); - // expect(result.status).toBeGreaterThanOrEqual(200); - // expect(result.status).toBeLessThan(300); - // }); - - // test('Should create a username-password credential', async () => { - // const credentialId = `${TEST_CREDENTIAL_ID}-userpass`; - // const result = await jenkins.createCredential( - // TEST_FOLDER_NAME, - // credentialId, - // 'testuser:testpassword', - // CredentialType.USERNAME_PASSWORD - // ); - - // expect(result.success).toBe(true); - // expect(result.status).toBeGreaterThanOrEqual(200); - // expect(result.status).toBeLessThan(300); - // }); - - // test('Should get job information', async () => { - // // const jobPath = `${TEST_FOLDER_NAME}/${TEST_JOB_NAME}`; - // const testFolderName = 'b6cybvqqx-dotnet-basic'; // Adjusted for root folder - // const testJobName = 'b6cybvqqx-dotnet-basic'; - // const jobPath = `${testFolderName}/${testJobName}`; // Adjusted for root folder - // const jobInfo = await jenkins.getJob(jobPath); - // console.log('Job information:', jobInfo); - // console.log('builds:', jobInfo.builds); - // console.log('lastBuild:', jobInfo.lastBuild); - // expect(jobInfo).toBeDefined(); - // expect(jobInfo.name).toBe(testJobName); - // }); - - test.only('Should trigger a build and get build information', async () => { - // Trigger a build - // const buildResult = await jenkins.build(TEST_JOB_NAME, TEST_FOLDER_NAME); - // expect(buildResult.success).toBe(true); - - // // Wait a bit for the build to start - // await new Promise(resolve => setTimeout(resolve, 5000)); - - // Get the latest build number (assume it's 1 for the first build) - const buildNumber = 1; - const buildInfo = await jenkins.getBuild('b6cybvqqx-dotnet-basic', 1, 'b6cybvqqx-dotnet-basic'); - - expect(buildInfo).toBeDefined(); - expect(buildInfo.number).toBe(buildNumber); - }); - - test('Should detect build trigger type', async () => { - // Get a build with trigger detection - const buildInfo = await jenkins.getBuild( - 'b6cybvqqx-dotnet-basic', - 1, - 'b6cybvqqx-dotnet-basic', - true - ); - - // Verify trigger type is populated - expect(buildInfo.triggerType).toBeDefined(); - - // Log the detected trigger type - console.log('Detected build trigger type:', buildInfo.triggerType); - - // Directly use the convenience methods - const isPR = await jenkins.isBuildTriggeredByPullRequest( - 'b6cybvqqx-dotnet-basic', - 1, - 'b6cybvqqx-dotnet-basic' - ); - const isPush = await jenkins.isBuildTriggeredByPush( - 'b6cybvqqx-dotnet-basic', - 1, - 'b6cybvqqx-dotnet-basic' - ); - - console.log('Is PR build?', isPR); - console.log('Is Push build?', isPush); - - // We can't make specific assertions about the trigger type in this test - // as it depends on how the build was actually triggered in Jenkins - expect([ - JenkinsBuildTrigger.PUSH, - JenkinsBuildTrigger.PULL_REQUEST, - JenkinsBuildTrigger.MANUAL, - JenkinsBuildTrigger.API, - JenkinsBuildTrigger.SCHEDULED, - JenkinsBuildTrigger.UNKNOWN, - ]).toContain(buildInfo.triggerType); - }); -});