Skip to content

refactor jenkins client #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions src/api/ci/gitlabciClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
// 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<string> {
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';
}
}
Comment on lines +149 to +166
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Address the TODO and improve error handling.

The method implementation needs verification and better error handling:

  1. The TODO comment indicates uncertainty about the implementation
  2. Returning a generic failure message instead of proper error propagation may hide important debugging information

Consider this refactor for better error handling:

  public async getPipelineLogs(projectPath: string, jobId: number): Promise<string> {
    try {
      const gitlab = this.gitlabClient.getClient();
      const encodedProjectPath = encodeURIComponent(projectPath);
      const url = `projects/${encodedProjectPath}/jobs/${jobId}/trace`;
      
      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';
+     throw new Error(`Failed to retrieve job logs for job ${jobId}: ${error instanceof Error ? error.message : String(error)}`);
    }
  }

🏁 Script executed:

#!/bin/bash
# Description: Verify GitLab API endpoint for job traces
# Expected: Confirm the correct API path and method

echo "=== Checking GitLab API documentation reference ==="
echo "Searching for GitLab API trace endpoint usage in codebase..."
rg -A 3 -B 3 "jobs.*trace|trace.*jobs" --type ts

Length of output: 1022


Refine getPipelineLogs implementation and error handling

Verified that GitLab’s API for raw job logs is indeed
GET /projects/:id/jobs/:job_id/trace
and returns the full log as text. Please remove the lingering TODO and improve error propagation so consumers can handle failures more granularly.

• File: src/api/ci/gitlabciClient.ts (lines 149–166)

  1. Remove the //TODO comment now that the endpoint has been confirmed.
  2. Replace the generic failure return with a thrown Error, preserving the original message.
  3. (Optional) Explicitly set Accept: text/plain in the request if your client doesn’t default to it.

Suggested diff:

   public async getPipelineLogs(projectPath: string, jobId: number): Promise<string> {
     try {
       const gitlab = this.gitlabClient.getClient();
       const encodedProjectPath = encodeURIComponent(projectPath);
       const url = `projects/${encodedProjectPath}/jobs/${jobId}/trace`;
-      // Make the request using the underlying requester
+      // Fetch raw job logs as plain text
       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';
+      console.error(`Failed to retrieve logs for job ${jobId} in project ${projectPath}:`, error);
+      throw new Error(
+        `getPipelineLogs error for project "${projectPath}", job ${jobId}: ` +
+        (error instanceof Error ? error.message : String(error))
+      );
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
//TODO: need to confirm if this is correct
public async getPipelineLogs(projectPath: string, jobId: number): Promise<string> {
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';
}
}
//TODO: need to confirm if this is correct
public async getPipelineLogs(projectPath: string, jobId: number): Promise<string> {
try {
const gitlab = this.gitlabClient.getClient();
const encodedProjectPath = encodeURIComponent(projectPath);
const url = `projects/${encodedProjectPath}/jobs/${jobId}/trace`;
// Fetch raw job logs as plain text
const jobTrace = await gitlab.requester.get(url);
return jobTrace as unknown as string;
} catch (error) {
console.error(`Failed to retrieve logs for job ${jobId} in project ${projectPath}:`, error);
throw new Error(
`getPipelineLogs error for project "${projectPath}", job ${jobId}: ` +
(error instanceof Error ? error.message : String(error))
);
}
}
🤖 Prompt for AI Agents
In src/api/ci/gitlabciClient.ts lines 149 to 166, remove the TODO comment since
the endpoint is confirmed correct. Modify the error handling by throwing a new
Error with the failure message instead of returning a generic string, so callers
can handle errors properly. Optionally, add an 'Accept: text/plain' header to
the request to explicitly request plain text logs if not already set by the
client.


/**
* Maps GitLab pipeline status to our standardized PipelineStatus enum
Expand Down
231 changes: 231 additions & 0 deletions src/api/ci/jenkins/README.md
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions src/api/ci/jenkins/config/jenkins.config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +4 to +44
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Refactor to avoid static-only class anti-pattern.

Using a class with only static members is an anti-pattern. Consider using a namespace, const object, or individual exports instead.

-export class JenkinsConfig {
-  public static readonly DEFAULT_BRANCH = 'main';
-  public static readonly DEFAULT_JENKINSFILE_PATH = 'Jenkinsfile';
-  // ... other static members
-}
+export const JenkinsConfig = {
+  DEFAULT_BRANCH: 'main',
+  DEFAULT_JENKINSFILE_PATH: 'Jenkinsfile',
+  DEFAULT_CREDENTIAL_ID: 'GITOPS_AUTH_PASSWORD',
+  DEFAULT_TIMEOUT_MS: 10 * 60 * 1000, // 10 minutes
+  DEFAULT_POLL_INTERVAL_MS: 5000, // 5 seconds
+  DEFAULT_MAX_BUILDS_TO_CHECK: 50,
+  
+  HEADERS: {
+    XML: { 'Content-Type': 'application/xml' },
+    JSON: { 'Content-Type': 'application/json', Accept: 'application/json' },
+    PLAIN: { Accept: 'text/plain' },
+  } as const,
+
+  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,
+
+  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,
+} as const;

This also fixes the prettier formatting issues with quotes and spacing.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
export const JenkinsConfig = {
DEFAULT_BRANCH: 'main',
DEFAULT_JENKINSFILE_PATH: 'Jenkinsfile',
DEFAULT_CREDENTIAL_ID: 'GITOPS_AUTH_PASSWORD',
DEFAULT_TIMEOUT_MS: 10 * 60 * 1000, // 10 minutes
DEFAULT_POLL_INTERVAL_MS: 5000, // 5 seconds
DEFAULT_MAX_BUILDS_TO_CHECK: 50,
/**
* HTTP headers for different content types
*/
HEADERS: {
XML: { 'Content-Type': 'application/xml' },
JSON: { 'Content-Type': 'application/json', Accept: 'application/json' },
PLAIN: { Accept: 'text/plain' },
} as const,
/**
* Jenkins plugin information
*/
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
*/
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,
} as const;
🧰 Tools
🪛 Biome (1.9.4)

[error] 4-44: Avoid classes that contain only static members.

Prefer using simple functions instead of classes with only static members.

(lint/complexity/noStaticOnlyClass)

🪛 ESLint

[error] 11-11: Delete ··

(prettier/prettier)


[error] 17-17: Replace 'Accept' with Accept

(prettier/prettier)


[error] 18-18: Replace 'Accept' with Accept

(prettier/prettier)


[error] 44-44: Replace · with

(prettier/prettier)

🤖 Prompt for AI Agents
In src/api/ci/jenkins/config/jenkins.config.ts from lines 4 to 44, the
JenkinsConfig class contains only static members, which is an anti-pattern.
Refactor this by replacing the class with either a namespace or a set of
individual exported constants or objects that hold the same data. This will also
help fix any prettier formatting issues related to quotes and spacing by
allowing more straightforward formatting of the exported constants.

31 changes: 31 additions & 0 deletions src/api/ci/jenkins/enums/jenkins.enums.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading