From e934eec89f05090d04ed962578e2ace3c1551523 Mon Sep 17 00:00:00 2001 From: Xin Jiang Date: Thu, 23 Oct 2025 22:29:43 +0800 Subject: [PATCH] feat: implement cancelAllPipelines for CI providers Assisted-by: Claude Code --- .../azure/services/azure-pipeline.service.ts | 35 ++ .../github/services/github-actions.service.ts | 70 ++++ .../jenkins/services/jenkins-build.service.ts | 19 + src/api/ocp/kubeClient.ts | 31 ++ .../services/tekton-pipelinerun.service.ts | 30 ++ src/api/tekton/tekton.client.ts | 4 + src/rhtap/core/integration/ci/baseCI.ts | 21 +- src/rhtap/core/integration/ci/ciInterface.ts | 175 ++++++++- .../core/integration/ci/providers/azureCI.ts | 349 ++++++++++++++++- .../ci/providers/githubActionsCI.ts | 363 +++++++++++++++++- .../core/integration/ci/providers/gitlabCI.ts | 358 ++++++++++++++++- .../integration/ci/providers/jenkinsCI.ts | 345 ++++++++++++++++- .../core/integration/ci/providers/tektonCI.ts | 356 ++++++++++++++++- src/utils/test/common.ts | 13 - tests/api/git/github.test.ts | 117 ------ tests/tssc/full_workflow.test.ts | 8 +- 16 files changed, 2118 insertions(+), 176 deletions(-) delete mode 100644 tests/api/git/github.test.ts diff --git a/src/api/azure/services/azure-pipeline.service.ts b/src/api/azure/services/azure-pipeline.service.ts index 0acb96ff..473b6f59 100644 --- a/src/api/azure/services/azure-pipeline.service.ts +++ b/src/api/azure/services/azure-pipeline.service.ts @@ -168,6 +168,41 @@ export class AzurePipelineService { } } + public async cancelBuild(buildId: number): Promise { + try { + await retry( + async () => { + await this.client.patch( + `${this.project}/_apis/build/builds/${buildId}?${this.getApiVersionParam()}`, + { status: 'cancelling' } + ); + }, + { + retries: 3, + minTimeout: 1000, + maxTimeout: 5000, + onRetry: (error: Error, attempt: number) => { + console.log(`[Azure] Retry ${attempt}/3 - Cancelling build ${buildId}: ${error.message}`); + }, + } + ); + + console.log(`[Azure] Successfully cancelled build ${buildId}`); + } catch (error: any) { + // Handle specific error cases + if (error.response?.status === 404) { + throw new Error(`Build ${buildId} not found`); + } + if (error.response?.status === 403) { + throw new Error(`Insufficient permissions to cancel build ${buildId}`); + } + if (error.response?.status === 400) { + throw new Error(`Build ${buildId} cannot be cancelled (already completed or not cancellable)`); + } + throw new Error(`Failed to cancel build ${buildId}: ${error.message}`); + } + } + public async getAllPipelines(): Promise { try { const pipelines = await this.client.get<{ value: AzurePipelineDefinition[] }>( diff --git a/src/api/github/services/github-actions.service.ts b/src/api/github/services/github-actions.service.ts index 8afe46d5..cafb6d8f 100644 --- a/src/api/github/services/github-actions.service.ts +++ b/src/api/github/services/github-actions.service.ts @@ -590,4 +590,74 @@ export class GithubActionsService { throw new GithubApiError(`Error finding workflow run for commit ${sha.substring(0, 7)}`, error.status, error); } } + + /** + * Cancel a workflow run + * + * @param owner Repository owner + * @param repo Repository name + * @param runId Workflow run ID to cancel + * @returns Promise resolving when the workflow run is cancelled + * @throws GithubNotFoundError if the workflow run is not found + * @throws GithubApiError for other API errors + */ + async cancelWorkflowRun( + owner: string, + repo: string, + runId: number + ): Promise { + try { + await retry( + async () => { + await this.octokit.actions.cancelWorkflowRun({ + owner, + repo, + run_id: runId, + }); + }, + { + retries: this.maxRetries, + minTimeout: this.minTimeout, + maxTimeout: this.maxTimeout, + factor: this.factor, + onRetry: (error: Error, attempt: number) => { + console.log( + `[GitHub Actions] Retry ${attempt}/${this.maxRetries} - Cancelling workflow run ${runId}: ${error.message}` + ); + }, + } + ); + + console.log(`[GitHub Actions] Successfully cancelled workflow run ${runId}`); + } catch (error: any) { + // Handle specific error cases + if (error.status === 404) { + throw new GithubNotFoundError( + 'workflow run', + `${runId} in repository ${owner}/${repo}` + ); + } + + if (error.status === 403) { + throw new GithubApiError( + `Insufficient permissions to cancel workflow run ${runId}`, + error.status + ); + } + + if (error.status === 409) { + throw new GithubApiError( + `Workflow run ${runId} cannot be cancelled (already completed or not cancellable)`, + error.status + ); + } + + // Re-throw with more context + throw new GithubApiError( + `Failed to cancel workflow run ${runId}: ${error.message}`, + error.status || 500, + error + ); + } + } } diff --git a/src/api/jenkins/services/jenkins-build.service.ts b/src/api/jenkins/services/jenkins-build.service.ts index b6f5b974..11b62555 100644 --- a/src/api/jenkins/services/jenkins-build.service.ts +++ b/src/api/jenkins/services/jenkins-build.service.ts @@ -165,6 +165,25 @@ export class JenkinsBuildService { } } + /** + * Stop/abort a running build + */ + async stopBuild(jobName: string, buildNumber: number, folderName?: string): Promise { + try { + const path = JenkinsPathBuilder.buildBuildPath( + jobName, + buildNumber, + folderName, + 'stop' + ); + + await this.httpClient.post(path, null); + } catch (error) { + // If build is not found or already stopped, throw specific error + throw new JenkinsBuildNotFoundError(jobName, buildNumber, folderName); + } + } + /** * Wait for a build to complete with timeout */ diff --git a/src/api/ocp/kubeClient.ts b/src/api/ocp/kubeClient.ts index 054fd3b9..e98e2677 100644 --- a/src/api/ocp/kubeClient.ts +++ b/src/api/ocp/kubeClient.ts @@ -265,6 +265,37 @@ export class KubeClient { } } + /** + * Generic method to patch a resource with proper error handling + * + * @template T The resource type to return + * @param {K8sApiOptions} options - API options for the request (must include name) + * @param {any} patchData - The patch data to apply + * @returns {Promise} The patched resource of type T + */ + public async patchResource(options: K8sApiOptions, patchData: any): Promise { + try { + if (!options.name) { + throw new Error('Resource name is required for patchResource'); + } + + const response = await this.customApi.patchNamespacedCustomObject({ + group: options.group, + version: options.version, + namespace: options.namespace, + plural: options.plural, + name: options.name, + body: patchData, + }); + return response as T; + } catch (error) { + console.error( + `Error patching resource '${options.name}' in namespace '${options.namespace}': ${error}` + ); + throw new Error(`Failed to patch resource '${options.name}': ${error}`); + } + } + /** * Retrieves logs from a pod or specific containers within a pod * @param podName The name of the pod diff --git a/src/api/tekton/services/tekton-pipelinerun.service.ts b/src/api/tekton/services/tekton-pipelinerun.service.ts index b5dbdca9..ff9dc5af 100644 --- a/src/api/tekton/services/tekton-pipelinerun.service.ts +++ b/src/api/tekton/services/tekton-pipelinerun.service.ts @@ -191,6 +191,36 @@ export class TektonPipelineRunService { } } + /** + * Cancel a running PipelineRun by patching its spec + */ + public async cancelPipelineRun(namespace: string, name: string): Promise { + try { + const options = this.kubeClient.createApiOptions( + this.API_GROUP, + this.API_VERSION, + this.PIPELINE_RUNS_PLURAL, + namespace, + { name } + ); + + // Patch the PipelineRun to set status to cancelled + const patchData = { + spec: { + status: 'PipelineRunCancelled' + } + }; + + await this.kubeClient.patchResource(options, patchData); + + console.log(`Successfully cancelled PipelineRun: ${name} in namespace: ${namespace}`); + } catch (error: unknown) { + const errorMessage = (error as Error).message; + console.error(`Failed to cancel PipelineRun ${name}: ${errorMessage}`); + throw new Error(`Failed to cancel PipelineRun ${name}: ${errorMessage}`); + } + } + private findPipelineRunByEventType( pipelineRuns: PipelineRunKind[], eventType: string, diff --git a/src/api/tekton/tekton.client.ts b/src/api/tekton/tekton.client.ts index 42dead22..0e573efa 100644 --- a/src/api/tekton/tekton.client.ts +++ b/src/api/tekton/tekton.client.ts @@ -45,6 +45,10 @@ export class TektonClient { return this.pipelineRunService.getPipelineRunLogs(namespace, pipelineRunName); } + public async cancelPipelineRun(namespace: string, name: string): Promise { + return this.pipelineRunService.cancelPipelineRun(namespace, name); + } + public get pipelineRuns(): TektonPipelineRunService { return this.pipelineRunService; } diff --git a/src/rhtap/core/integration/ci/baseCI.ts b/src/rhtap/core/integration/ci/baseCI.ts index ef753550..6c5d4bdf 100644 --- a/src/rhtap/core/integration/ci/baseCI.ts +++ b/src/rhtap/core/integration/ci/baseCI.ts @@ -1,6 +1,14 @@ import { PullRequest } from '../git/models'; import { KubeClient } from './../../../../../src/api/ocp/kubeClient'; -import { CI, CIType, EventType, Pipeline, PipelineStatus } from './ciInterface'; +import { + CI, + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, +} from './ciInterface'; import retry from 'async-retry'; /** @@ -118,7 +126,16 @@ export abstract class BaseCI implements CI { public abstract waitForAllPipelineRunsToFinish(): Promise; - public abstract cancelAllInitialPipelines(): Promise; + /** + * Abstract method for cancelling all pipelines + * Must be implemented by each provider + * + * @param options Optional configuration for filtering and behavior + * @returns Promise resolving to detailed cancellation results + */ + public abstract cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise; public abstract getWebhookUrl(): Promise; diff --git a/src/rhtap/core/integration/ci/ciInterface.ts b/src/rhtap/core/integration/ci/ciInterface.ts index c89c13e7..421494b6 100644 --- a/src/rhtap/core/integration/ci/ciInterface.ts +++ b/src/rhtap/core/integration/ci/ciInterface.ts @@ -23,6 +23,153 @@ export enum EventType { // MERGE_REQUEST="Merge_Request" } +/** + * Options for configuring pipeline cancellation behavior + */ +export interface CancelPipelineOptions { + /** + * Regular expression patterns to exclude pipelines from cancellation + * Matches against pipeline name, ID, or branch name (provider-specific) + * @example [/^prod-/, /^release\//] - excludes production and release pipelines + */ + excludePatterns?: RegExp[]; + + /** + * Whether to cancel pipelines in all states or only active ones + * @default false (only cancel running/pending pipelines) + */ + includeCompleted?: boolean; + + /** + * Optional event type filter (PULL_REQUEST, PUSH, etc.) + * If specified, only pipelines matching this event type will be cancelled + */ + eventType?: EventType; + + /** + * Optional branch filter + * If specified, only pipelines for this branch will be cancelled + */ + branch?: string; + + /** + * Maximum number of pipelines to cancel in parallel + * @default 10 + */ + concurrency?: number; + + /** + * Dry run mode - don't actually cancel, just return what would be cancelled + * @default false + */ + dryRun?: boolean; +} + +/** + * Result of pipeline cancellation operation + */ +export interface CancelResult { + /** + * Total number of pipelines found + */ + total: number; + + /** + * Number of pipelines successfully cancelled + */ + cancelled: number; + + /** + * Number of pipelines that failed to cancel + */ + failed: number; + + /** + * Number of pipelines skipped (due to filters or already completed) + */ + skipped: number; + + /** + * Detailed information about each pipeline operation + */ + details: PipelineCancelDetail[]; + + /** + * Any errors encountered during cancellation + */ + errors: CancelError[]; +} + +/** + * Details about individual pipeline cancellation attempt + */ +export interface PipelineCancelDetail { + /** + * Pipeline identifier (provider-specific) + */ + pipelineId: string | number; + + /** + * Pipeline name or display name + */ + name: string; + + /** + * Pipeline status before cancellation attempt + */ + status: PipelineStatus; + + /** + * Operation result + */ + result: 'cancelled' | 'failed' | 'skipped'; + + /** + * Reason for skip or failure + */ + reason?: string; + + /** + * Branch name (if available) + */ + branch?: string; + + /** + * Event type (if available) + */ + eventType?: EventType; +} + +/** + * Error information for failed cancellations + */ +export interface CancelError { + /** + * Pipeline identifier that failed + */ + pipelineId: string | number; + + /** + * Error message + */ + message: string; + + /** + * Original error object (if available) + */ + error?: Error; + + /** + * HTTP status code (if applicable) + */ + statusCode?: number; + + /** + * Provider-specific error code + */ + providerErrorCode?: string; +} + export interface CI extends IntegrationSecret { //TODO: it should wait for all pipeines to finish triggered from both source and gitops repos waitForAllPipelineRunsToFinish(): Promise; @@ -47,6 +194,32 @@ export interface CI extends IntegrationSecret { getWebhookUrl(): Promise; getCIFilePathInRepo(): Promise; - cancelAllInitialPipelines(): Promise; + + /** + * Cancel all pipelines for this component with optional filtering + * + * This method cancels all active pipelines (running/pending by default). + * Completed pipelines (success/failed/cancelled) are skipped unless + * includeCompleted option is set to true. + * + * @param options Optional configuration for filtering and behavior + * @returns Promise resolving to detailed cancellation results + * + * @example + * // Cancel all active pipelines + * const result = await ci.cancelAllPipelines(); + * + * @example + * // Cancel all pipelines except production ones + * const result = await ci.cancelAllPipelines({ + * excludePatterns: [/^prod-/, /^release\//] + * }); + * + * @example + * // Dry run to see what would be cancelled + * const result = await ci.cancelAllPipelines({ dryRun: true }); + * console.log(`Would cancel ${result.cancelled} pipelines`); + */ + cancelAllPipelines(options?: CancelPipelineOptions): Promise; } export { PipelineStatus, Pipeline }; diff --git a/src/rhtap/core/integration/ci/providers/azureCI.ts b/src/rhtap/core/integration/ci/providers/azureCI.ts index 8bcbd09a..bdb25c73 100644 --- a/src/rhtap/core/integration/ci/providers/azureCI.ts +++ b/src/rhtap/core/integration/ci/providers/azureCI.ts @@ -11,7 +11,16 @@ import { import { KubeClient } from '../../../../../api/ocp/kubeClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; -import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; +import { + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, + PipelineCancelDetail, + CancelError, +} from '../ciInterface'; import retry from 'async-retry'; export interface Variable { @@ -451,8 +460,340 @@ export class AzureCI extends BaseCI { await this.azureClient.serviceEndpoints.deleteServiceEndpoint(endpoint.id, projectId); } - public async cancelAllInitialPipelines(): Promise { - // TODO: Implement Azure pipeline cancellation logic - console.log(`Azure CI: cancelAllInitialPipelines not yet implemented for ${this.componentName}`); + + + /** + * Cancel all pipelines for this component with optional filtering + */ + public override async cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise { + // 1. Normalize options with defaults + const opts = this.normalizeOptions(options); + + // 2. Initialize result object + const result: CancelResult = { + total: 0, + cancelled: 0, + failed: 0, + skipped: 0, + details: [], + errors: [], + }; + + console.log(`[Azure] Starting build cancellation for ${this.componentName}`); + + try { + // 3. Fetch all builds from Azure API + const allBuilds = await this.fetchAllBuilds(); + result.total = allBuilds.length; + + if (allBuilds.length === 0) { + console.log(`[Azure] No builds found for ${this.componentName}`); + return result; + } + + console.log(`[Azure] Found ${allBuilds.length} total builds`); + + // 4. Apply filters + const buildsToCancel = this.filterBuilds(allBuilds, opts); + + console.log(`[Azure] ${buildsToCancel.length} builds match filters`); + console.log(`[Azure] ${allBuilds.length - buildsToCancel.length} builds filtered out`); + + // 5. Cancel builds in batches + await this.cancelBuildsInBatches(buildsToCancel, opts, result); + + // 6. Log summary + console.log(`[Azure] Cancellation complete:`, { + total: result.total, + cancelled: result.cancelled, + failed: result.failed, + skipped: result.skipped, + }); + + } catch (error: any) { + console.error(`[Azure] Error in cancelAllPipelines: ${error.message}`); + throw new Error(`Failed to cancel pipelines: ${error.message}`); + } + + return result; + } + + /** + * Normalize options with defaults + */ + private normalizeOptions( + options?: CancelPipelineOptions + ): Required> & Pick { + return { + excludePatterns: options?.excludePatterns || [], + includeCompleted: options?.includeCompleted || false, + eventType: options?.eventType, + branch: options?.branch, + concurrency: options?.concurrency || 10, + dryRun: options?.dryRun || false, + }; + } + + /** + * Fetch all builds from Azure API + */ + private async fetchAllBuilds(): Promise { + try { + // Get pipeline definitions for both source and gitops repos + const pipelineDefSource = await this.azureClient.pipelines.getPipelineDefinition(this.componentName); + const pipelineDefGitops = await this.azureClient.pipelines.getPipelineDefinition( + this.componentName + '-gitops' + ); + + const builds: AzureBuild[] = []; + + // Fetch builds from source pipeline if it exists + if (pipelineDefSource) { + const runsSource = await this.azureClient.pipelines.listPipelineRuns(pipelineDefSource.id); + const buildsSource = await Promise.all( + runsSource.map(run => this.azureClient.pipelines.getBuild(run.id)) + ); + + // Tag builds with their pipeline name for later cancellation logging + const taggedSourceBuilds = buildsSource.map(build => ({ + ...build, + _pipelineName: this.componentName + })); + builds.push(...taggedSourceBuilds); + } + + // Fetch builds from gitops pipeline if it exists + if (pipelineDefGitops) { + const runsGitops = await this.azureClient.pipelines.listPipelineRuns(pipelineDefGitops.id); + const buildsGitops = await Promise.all( + runsGitops.map(run => this.azureClient.pipelines.getBuild(run.id)) + ); + + // Tag builds with their pipeline name for later cancellation logging + const taggedGitopsBuilds = buildsGitops.map(build => ({ + ...build, + _pipelineName: `${this.componentName}-gitops` + })); + builds.push(...taggedGitopsBuilds); + } + + return builds; + + } catch (error: any) { + console.error(`[Azure] Failed to fetch builds: ${error.message}`); + throw error; + } + } + + /** + * Filter builds based on cancellation options + */ + private filterBuilds( + builds: AzureBuild[], + options: Required> & Pick + ): AzureBuild[] { + return builds.filter(build => { + // Filter 1: Skip completed builds unless includeCompleted is true + if (!options.includeCompleted && this.isCompletedStatus(build)) { + console.log(`[Filter] Skipping completed build ${build.id} (${build.status})`); + return false; + } + + // Filter 2: Check exclusion patterns + if (this.matchesExclusionPattern(build, options.excludePatterns)) { + console.log(`[Filter] Excluding build ${build.id} by pattern`); + return false; + } + + // Filter 3: Filter by event type if specified + if (options.eventType && !this.matchesEventType(build, options.eventType)) { + console.log(`[Filter] Skipping build ${build.id} (event type mismatch)`); + return false; + } + + // Note: Azure builds don't have branch information directly, + // so we skip branch filtering for Azure + if (options.branch) { + console.log(`[Filter] Branch filtering not supported for Azure DevOps, ignoring branch filter`); + } + + return true; // Include this build for cancellation + }); + } + + /** + * Check if build status is completed + */ + private isCompletedStatus(build: AzureBuild): boolean { + const completedStatuses = ['succeeded', 'failed', 'stopped']; + return completedStatuses.includes(build.status); + } + + /** + * Check if build matches any exclusion pattern + */ + private matchesExclusionPattern(build: AzureBuild, patterns: RegExp[]): boolean { + if (patterns.length === 0) { + return false; + } + + const buildName = build.buildNumber || `Build-${build.id}`; + + return patterns.some(pattern => pattern.test(buildName)); + } + + /** + * Check if build matches the event type + * Azure uses 'reason' field to indicate trigger type + */ + private matchesEventType(build: AzureBuild, eventType: EventType): boolean { + switch (eventType) { + case EventType.PUSH: + return build.reason === AzurePipelineTriggerReason.INDIVIDUAL_CI || + build.reason === AzurePipelineTriggerReason.BATCH_CI; + case EventType.PULL_REQUEST: + // PR Automated shows as manual build in Azure + return build.reason === AzurePipelineTriggerReason.MANUAL || + build.reason === AzurePipelineTriggerReason.PULL_REQUEST; + default: + return false; + } + } + + /** + * Cancel builds in batches with concurrency control + */ + private async cancelBuildsInBatches( + builds: AzureBuild[], + options: Required> & Pick, + result: CancelResult + ): Promise { + // Split into batches + const batches = this.chunkArray(builds, options.concurrency); + + console.log(`[Azure] Processing ${batches.length} batches with concurrency ${options.concurrency}`); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log(`[Azure] Processing batch ${i + 1}/${batches.length} (${batch.length} builds)`); + + // Create promises for all builds in this batch + const promises = batch.map(build => + this.cancelSingleBuild(build, options, result) + ); + + // Wait for all in batch to complete (don't stop on errors) + await Promise.allSettled(promises); + } + } + + /** + * Cancel a single build and update results + */ + private async cancelSingleBuild( + build: AzureBuild, + options: Required> & Pick, + result: CancelResult + ): Promise { + // Initialize detail object + const detail: PipelineCancelDetail = { + pipelineId: build.id, + name: build.buildNumber || `Build-${build.id}`, + status: this.mapAzureBuildStatusToPipelineStatus(build), + result: 'skipped', + eventType: this.mapAzureEventType(build), + }; + + try { + if (options.dryRun) { + // Dry run mode - don't actually cancel + detail.result = 'skipped'; + detail.reason = 'Dry run mode'; + result.skipped++; + console.log(`[DryRun] Would cancel build ${build.id}`); + + } else { + // Extract pipeline name from tagged build (added in fetchAllBuilds) + const pipelineName = (build as any)._pipelineName || this.componentName; + + // Actually cancel the build via Azure API + await this.cancelBuildViaAPI(build.id); + + detail.result = 'cancelled'; + result.cancelled++; + console.log(`✅ [Azure] Cancelled build ${build.id} in ${pipelineName} (status: ${build.status})`); + } + + } catch (error: any) { + // Cancellation failed + detail.result = 'failed'; + detail.reason = error.message; + result.failed++; + + // Add to errors array + const cancelError: CancelError = { + pipelineId: build.id, + message: error.message, + error: error, + }; + + // Add status code if available + if (error.response?.status) { + cancelError.statusCode = error.response.status; + } + + // Add provider error code if available + if (error.response?.data?.message) { + cancelError.providerErrorCode = error.response.data.message; + } + + result.errors.push(cancelError); + + console.error(`❌ [Azure] Failed to cancel build ${build.id}: ${error.message}`); + } + + // Add detail to results + result.details.push(detail); + } + + /** + * Actually cancel the build via Azure API + */ + private async cancelBuildViaAPI(buildId: number): Promise { + try { + await this.azureClient.pipelines.cancelBuild(buildId); + + } catch (error: any) { + // Re-throw - the azureClient.pipelines.cancelBuild already has detailed error handling + throw error; + } + } + + /** + * Map Azure build to EventType + */ + private mapAzureEventType(build: AzureBuild): EventType | undefined { + if (build.reason === AzurePipelineTriggerReason.INDIVIDUAL_CI || + build.reason === AzurePipelineTriggerReason.BATCH_CI) { + return EventType.PUSH; + } + if (build.reason === AzurePipelineTriggerReason.MANUAL || + build.reason === AzurePipelineTriggerReason.PULL_REQUEST) { + return EventType.PULL_REQUEST; + } + return undefined; + } + + /** + * Utility: Split array into chunks + */ + private chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; } } diff --git a/src/rhtap/core/integration/ci/providers/githubActionsCI.ts b/src/rhtap/core/integration/ci/providers/githubActionsCI.ts index c09a750c..865dfb08 100644 --- a/src/rhtap/core/integration/ci/providers/githubActionsCI.ts +++ b/src/rhtap/core/integration/ci/providers/githubActionsCI.ts @@ -6,7 +6,16 @@ import { GithubClient } from '../../../../../api/github'; import { KubeClient } from '../../../../../api/ocp/kubeClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; -import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; +import { + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, + PipelineCancelDetail, + CancelError, +} from '../ciInterface'; export class GitHubActionsCI extends BaseCI { private githubClient!: GithubClient; @@ -353,12 +362,358 @@ export class GitHubActionsCI extends BaseCI { } } - public override async cancelAllInitialPipelines(): Promise { - throw new Error( - 'GitHub Actions does not support cancelling initial pipeline runs.' + + + /** + * Cancel all pipelines for this component with optional filtering + */ + public override async cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise { + // 1. Normalize options with defaults + const opts = this.normalizeOptions(options); + + // 2. Initialize result object + const result: CancelResult = { + total: 0, + cancelled: 0, + failed: 0, + skipped: 0, + details: [], + errors: [], + }; + + console.log(`[GitHubActions] Starting workflow cancellation for ${this.componentName}`); + + try { + // 3. Fetch all workflow runs from GitHub API + const allWorkflowRuns = await this.fetchAllWorkflowRuns(); + result.total = allWorkflowRuns.length; + + if (allWorkflowRuns.length === 0) { + console.log(`[GitHubActions] No workflow runs found for ${this.componentName}`); + return result; + } + + console.log(`[GitHubActions] Found ${allWorkflowRuns.length} total workflow runs`); + + // 4. Apply filters + const workflowRunsToCancel = this.filterWorkflowRuns(allWorkflowRuns, opts); + + console.log(`[GitHubActions] ${workflowRunsToCancel.length} workflow runs match filters`); + console.log(`[GitHubActions] ${allWorkflowRuns.length - workflowRunsToCancel.length} workflow runs filtered out`); + + // 5. Cancel workflow runs in batches + await this.cancelWorkflowRunsInBatches(workflowRunsToCancel, opts, result); + + // 6. Log summary + console.log(`[GitHubActions] Cancellation complete:`, { + total: result.total, + cancelled: result.cancelled, + failed: result.failed, + skipped: result.skipped, + }); + + } catch (error: any) { + console.error(`[GitHubActions] Error in cancelAllPipelines: ${error.message}`); + throw new Error(`Failed to cancel pipelines: ${error.message}`); + } + + return result; + } + + /** + * Normalize options with defaults + */ + private normalizeOptions( + options?: CancelPipelineOptions + ): Required> & Pick { + return { + excludePatterns: options?.excludePatterns || [], + includeCompleted: options?.includeCompleted || false, + eventType: options?.eventType, + branch: options?.branch, + concurrency: options?.concurrency || 10, + dryRun: options?.dryRun || false, + }; + } + + /** + * Fetch all workflow runs from GitHub API (both source and gitops repos) + */ + private async fetchAllWorkflowRuns(): Promise { + try { + const allWorkflowRuns: WorkflowRun[] = []; + + // Fetch from source repository + const responseSource = await this.githubClient.actions.getWorkflowRuns( + this.getRepoOwner(), + this.componentName, + { per_page: 100 } + ); + + // Tag workflow runs with their repository name for later cancellation + const taggedSourceRuns = (responseSource.data?.workflow_runs || []).map(run => ({ + ...run, + _repositoryName: this.componentName + })); + allWorkflowRuns.push(...taggedSourceRuns); + + // Fetch from gitops repository + const gitopsRepoName = `${this.componentName}-gitops`; + try { + const responseGitops = await this.githubClient.actions.getWorkflowRuns( + this.getRepoOwner(), + gitopsRepoName, + { per_page: 100 } + ); + + // Tag workflow runs with their repository name for later cancellation + const taggedGitopsRuns = (responseGitops.data?.workflow_runs || []).map(run => ({ + ...run, + _repositoryName: gitopsRepoName + })); + allWorkflowRuns.push(...taggedGitopsRuns); + } catch (gitopsError: any) { + // Gitops repo might not exist, log but don't fail + console.log(`[GitHubActions] Gitops repository ${gitopsRepoName} not found or no workflows: ${gitopsError.message}`); + } + + return allWorkflowRuns; + + } catch (error: any) { + console.error(`[GitHubActions] Failed to fetch workflow runs: ${error.message}`); + throw error; + } + } + + /** + * Filter workflow runs based on cancellation options + */ + private filterWorkflowRuns( + workflowRuns: WorkflowRun[], + options: Required> & Pick + ): WorkflowRun[] { + return workflowRuns.filter(workflowRun => { + // Filter 1: Skip completed workflow runs unless includeCompleted is true + if (!options.includeCompleted && this.isCompletedStatus(workflowRun)) { + console.log(`[Filter] Skipping completed workflow run ${workflowRun.id} (${workflowRun.status}/${workflowRun.conclusion || 'none'})`); + return false; + } + + // Filter 2: Check exclusion patterns + if (this.matchesExclusionPattern(workflowRun, options.excludePatterns)) { + console.log(`[Filter] Excluding workflow run ${workflowRun.id} by pattern`); + return false; + } + + // Filter 3: Filter by event type if specified + if (options.eventType && !this.matchesEventType(workflowRun, options.eventType)) { + console.log(`[Filter] Skipping workflow run ${workflowRun.id} (event type mismatch)`); + return false; + } + + // Filter 4: Filter by branch if specified + if (options.branch && workflowRun.head_branch !== options.branch) { + console.log(`[Filter] Skipping workflow run ${workflowRun.id} (branch mismatch)`); + return false; + } + + return true; // Include this workflow run for cancellation + }); + } + + /** + * Check if workflow run status is completed + * GitHub has two-level status: status + conclusion + */ + private isCompletedStatus(workflowRun: WorkflowRun): boolean { + // A workflow is completed if status is 'completed' + return workflowRun.status === 'completed'; + } + + /** + * Check if workflow run matches any exclusion pattern + */ + private matchesExclusionPattern(workflowRun: WorkflowRun, patterns: RegExp[]): boolean { + if (patterns.length === 0) { + return false; + } + + const workflowName = workflowRun.name || `Workflow-${workflowRun.id}`; + const branch = workflowRun.head_branch || ''; + + return patterns.some(pattern => + pattern.test(workflowName) || pattern.test(branch) ); } + /** + * Check if workflow run matches the event type + */ + private matchesEventType(workflowRun: WorkflowRun, eventType: EventType): boolean { + // GitHub uses 'event' field to indicate trigger type + switch (eventType) { + case EventType.PUSH: + return workflowRun.event === 'push'; + case EventType.PULL_REQUEST: + return workflowRun.event === 'pull_request'; + default: + return false; + } + } + + /** + * Cancel workflow runs in batches with concurrency control + */ + private async cancelWorkflowRunsInBatches( + workflowRuns: WorkflowRun[], + options: Required> & Pick, + result: CancelResult + ): Promise { + // Split into batches + const batches = this.chunkArray(workflowRuns, options.concurrency); + + console.log(`[GitHubActions] Processing ${batches.length} batches with concurrency ${options.concurrency}`); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log(`[GitHubActions] Processing batch ${i + 1}/${batches.length} (${batch.length} workflow runs)`); + + // Create promises for all workflow runs in this batch + const promises = batch.map(workflowRun => + this.cancelSingleWorkflowRun(workflowRun, options, result) + ); + + // Wait for all in batch to complete (don't stop on errors) + await Promise.allSettled(promises); + } + } + + /** + * Cancel a single workflow run and update results + */ + private async cancelSingleWorkflowRun( + workflowRun: WorkflowRun, + options: Required> & Pick, + result: CancelResult + ): Promise { + // Initialize detail object + const detail: PipelineCancelDetail = { + pipelineId: workflowRun.id, + name: workflowRun.name || `Workflow-${workflowRun.id}`, + status: this.mapGitHubWorkflowStatusToPipelineStatus(workflowRun), + result: 'skipped', + branch: workflowRun.head_branch || undefined, + eventType: this.mapGitHubEventType(workflowRun), + }; + + try { + if (options.dryRun) { + // Dry run mode - don't actually cancel + detail.result = 'skipped'; + detail.reason = 'Dry run mode'; + result.skipped++; + console.log(`[DryRun] Would cancel workflow run ${workflowRun.id}`); + + } else { + // Extract repository name from tagged workflow run (added in fetchAllWorkflowRuns) + const repositoryName = (workflowRun as any)._repositoryName || this.componentName; + + // Actually cancel the workflow run via GitHub API + await this.cancelWorkflowRunViaAPI(workflowRun.id, repositoryName); + + detail.result = 'cancelled'; + result.cancelled++; + console.log(`✅ [GitHubActions] Cancelled workflow run ${workflowRun.id} in ${repositoryName} (status: ${workflowRun.status})`); + } + + } catch (error: any) { + // Cancellation failed + detail.result = 'failed'; + detail.reason = error.message; + result.failed++; + + // Add to errors array + const cancelError: CancelError = { + pipelineId: workflowRun.id, + message: error.message, + error: error, + }; + + // Add status code if available + if (error.response?.status) { + cancelError.statusCode = error.response.status; + } + + // Add provider error code if available + if (error.response?.data?.message) { + cancelError.providerErrorCode = error.response.data.message; + } + + result.errors.push(cancelError); + + console.error(`❌ [GitHubActions] Failed to cancel workflow run ${workflowRun.id}: ${error.message}`); + } + + // Add detail to results + result.details.push(detail); + } + + /** + * Actually cancel the workflow run via GitHub API + */ + private async cancelWorkflowRunViaAPI(workflowRunId: number, repositoryName: string): Promise { + try { + await this.githubClient.actions.cancelWorkflowRun( + this.getRepoOwner(), + repositoryName, + workflowRunId + ); + + } catch (error: any) { + // Handle GitHub-specific errors + if (error.response?.status === 404) { + throw new Error('Workflow run not found (may have been deleted)'); + } + if (error.response?.status === 403) { + throw new Error('Insufficient permissions to cancel workflow run'); + } + if (error.response?.status === 409) { + throw new Error('Workflow run cannot be cancelled (already completed or not cancellable)'); + } + if (error.response?.data?.message) { + throw new Error(`GitHub API error: ${error.response.data.message}`); + } + + throw error; + } + } + + /** + * Map GitHub workflow run to EventType + */ + private mapGitHubEventType(workflowRun: WorkflowRun): EventType | undefined { + if (workflowRun.event === 'push') { + return EventType.PUSH; + } + if (workflowRun.event === 'pull_request') { + return EventType.PULL_REQUEST; + } + return undefined; + } + + /** + * Utility: Split array into chunks + */ + private chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } + public override async waitForAllPipelineRunsToFinish( timeoutMs = 5 * 60 * 1000, pollIntervalMs = 5000 diff --git a/src/rhtap/core/integration/ci/providers/gitlabCI.ts b/src/rhtap/core/integration/ci/providers/gitlabCI.ts index 8ad0e5aa..82f8e031 100644 --- a/src/rhtap/core/integration/ci/providers/gitlabCI.ts +++ b/src/rhtap/core/integration/ci/providers/gitlabCI.ts @@ -3,7 +3,16 @@ import { GitLabClient, GitLabConfigBuilder } from '../../../../../api/gitlab'; // import { GitLabClient } from '../../../../../api/git/gitlabClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; -import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; +import { + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, + PipelineCancelDetail, + CancelError, +} from '../ciInterface'; import retry from 'async-retry'; export class GitLabCI extends BaseCI { @@ -273,32 +282,341 @@ export class GitLabCI extends BaseCI { } } - public override async cancelAllInitialPipelines(): Promise { - try{ - const allPipelines = await this.gitlabCIClient.pipelines.getAllPipelines( - `${this.getGroup()}/${this.sourceRepoName}` - ); - if (!allPipelines || allPipelines.length === 0) { - throw new Error(`No pipelines found for component ${this.componentName}`); + + + /** + * Cancel all pipelines for this component with optional filtering + */ + public override async cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise { + // 1. Normalize options with defaults + const opts = this.normalizeOptions(options); + + // 2. Initialize result object + const result: CancelResult = { + total: 0, + cancelled: 0, + failed: 0, + skipped: 0, + details: [], + errors: [], + }; + + console.log(`[GitLabCI] Starting pipeline cancellation for ${this.componentName}`); + + try { + // 3. Fetch all pipelines from GitLab API + const allPipelines = await this.fetchAllPipelines(); + result.total = allPipelines.length; + + if (allPipelines.length === 0) { + console.log(`[GitLabCI] No pipelines found for ${this.componentName}`); + return result; } - console.log(`Found ${allPipelines.length} pipelines for ${this.componentName}`); + console.log(`[GitLabCI] Found ${allPipelines.length} total pipelines`); - await Promise.all( - allPipelines - .filter(pipeline => pipeline.status !== 'failed') - .map(pipeline => { - console.log(`Cancelling initial pipeline ${pipeline.id} with status ${pipeline.status}`); - return this.gitlabCIClient.pipelines.cancelPipeline( - `${this.getGroup()}/${this.sourceRepoName}`, - pipeline.id - ); - }) + // 4. Apply filters + const pipelinesToCancel = this.filterPipelines(allPipelines, opts); + + console.log(`[GitLabCI] ${pipelinesToCancel.length} pipelines match filters`); + console.log(`[GitLabCI] ${allPipelines.length - pipelinesToCancel.length} pipelines filtered out`); + + // 5. Cancel pipelines in batches + await this.cancelPipelinesInBatches(pipelinesToCancel, opts, result); + + // 6. Log summary + console.log(`[GitLabCI] Cancellation complete:`, { + total: result.total, + cancelled: result.cancelled, + failed: result.failed, + skipped: result.skipped, + }); + + } catch (error: any) { + console.error(`[GitLabCI] Error in cancelAllPipelines: ${error.message}`); + throw new Error(`Failed to cancel pipelines: ${error.message}`); + } + + return result; + } + + /** + * Normalize options with defaults + */ + private normalizeOptions( + options?: CancelPipelineOptions + ): Required> & Pick { + return { + excludePatterns: options?.excludePatterns || [], + includeCompleted: options?.includeCompleted || false, + eventType: options?.eventType, + branch: options?.branch, + concurrency: options?.concurrency || 10, + dryRun: options?.dryRun || false, + }; + } + + /** + * Fetch all pipelines from GitLab API (both source and gitops repos) + */ + private async fetchAllPipelines(): Promise { + try { + const allPipelines: any[] = []; + + // Fetch from source repository + const sourceProjectPath = `${this.getGroup()}/${this.sourceRepoName}`; + const sourcePipelines = await this.gitlabCIClient.pipelines.getAllPipelines(sourceProjectPath); + + // Tag pipelines with their project path for later cancellation + const taggedSourcePipelines = (sourcePipelines || []).map(p => ({ + ...p, + _projectPath: sourceProjectPath + })); + allPipelines.push(...taggedSourcePipelines); + + // Fetch from gitops repository + const gitopsProjectPath = `${this.getGroup()}/${this.gitOpsRepoName}`; + try { + const gitopsPipelines = await this.gitlabCIClient.pipelines.getAllPipelines(gitopsProjectPath); + + // Tag pipelines with their project path for later cancellation + const taggedGitopsPipelines = (gitopsPipelines || []).map(p => ({ + ...p, + _projectPath: gitopsProjectPath + })); + allPipelines.push(...taggedGitopsPipelines); + } catch (gitopsError: any) { + // Gitops repo might not exist, log but don't fail + console.log(`[GitLabCI] Gitops repository ${gitopsProjectPath} not found or no pipelines: ${gitopsError.message}`); + } + + return allPipelines; + + } catch (error: any) { + console.error(`[GitLabCI] Failed to fetch pipelines: ${error.message}`); + throw error; + } + } + + /** + * Filter pipelines based on cancellation options + */ + private filterPipelines( + pipelines: any[], + options: Required> & Pick + ): any[] { + return pipelines.filter(pipeline => { + // Filter 1: Skip completed pipelines unless includeCompleted is true + if (!options.includeCompleted && this.isCompletedStatus(pipeline.status)) { + console.log(`[Filter] Skipping completed pipeline ${pipeline.id} (${pipeline.status})`); + return false; + } + + // Filter 2: Check exclusion patterns + if (this.matchesExclusionPattern(pipeline, options.excludePatterns)) { + console.log(`[Filter] Excluding pipeline ${pipeline.id} by pattern`); + return false; + } + + // Filter 3: Filter by event type if specified + if (options.eventType && !this.matchesEventType(pipeline, options.eventType)) { + console.log(`[Filter] Skipping pipeline ${pipeline.id} (event type mismatch)`); + return false; + } + + // Filter 4: Filter by branch if specified + if (options.branch && pipeline.ref !== options.branch) { + console.log(`[Filter] Skipping pipeline ${pipeline.id} (branch mismatch)`); + return false; + } + + return true; // Include this pipeline for cancellation + }); + } + + /** + * Check if pipeline status is completed + */ + private isCompletedStatus(status: string): boolean { + const completedStatuses = ['success', 'failed', 'canceled', 'skipped', 'manual']; + return completedStatuses.includes(status.toLowerCase()); + } + + /** + * Check if pipeline matches any exclusion pattern + */ + private matchesExclusionPattern(pipeline: any, patterns: RegExp[]): boolean { + if (patterns.length === 0) { + return false; + } + + const pipelineName = `Pipeline-${pipeline.id}`; + const branch = pipeline.ref || ''; + + return patterns.some(pattern => + pattern.test(pipelineName) || pattern.test(branch) + ); + } + + /** + * Check if pipeline matches the event type + */ + private matchesEventType(pipeline: any, eventType: EventType): boolean { + // GitLab uses 'source' field to indicate trigger type + switch (eventType) { + case EventType.PUSH: + return pipeline.source === 'push'; + case EventType.PULL_REQUEST: + return pipeline.source === 'merge_request_event'; + default: + return false; + } + } + + /** + * Cancel pipelines in batches with concurrency control + */ + private async cancelPipelinesInBatches( + pipelines: any[], + options: Required> & Pick, + result: CancelResult + ): Promise { + // Split into batches + const batches = this.chunkArray(pipelines, options.concurrency); + + console.log(`[GitLabCI] Processing ${batches.length} batches with concurrency ${options.concurrency}`); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log(`[GitLabCI] Processing batch ${i + 1}/${batches.length} (${batch.length} pipelines)`); + + // Create promises for all pipelines in this batch + const promises = batch.map(pipeline => + this.cancelSinglePipeline(pipeline, options, result) ); + // Wait for all in batch to complete (don't stop on errors) + await Promise.allSettled(promises); + } + } + + /** + * Cancel a single pipeline and update results + */ + private async cancelSinglePipeline( + pipeline: any, + options: Required> & Pick, + result: CancelResult + ): Promise { + // Initialize detail object + const detail: PipelineCancelDetail = { + pipelineId: pipeline.id, + name: `Pipeline-${pipeline.id}`, + status: this.mapPipelineStatus(pipeline.status), + result: 'skipped', + branch: pipeline.ref, + eventType: this.mapGitLabEventType(pipeline), + }; + + try { + if (options.dryRun) { + // Dry run mode - don't actually cancel + detail.result = 'skipped'; + detail.reason = 'Dry run mode'; + result.skipped++; + console.log(`[DryRun] Would cancel pipeline ${pipeline.id}`); + + } else { + // Extract project path from tagged pipeline (added in fetchAllPipelines) + const projectPath = pipeline._projectPath || `${this.getGroup()}/${this.sourceRepoName}`; + + // Actually cancel the pipeline via GitLab API + await this.cancelPipelineViaAPI(pipeline.id, projectPath); + + detail.result = 'cancelled'; + result.cancelled++; + console.log(`✅ [GitLabCI] Cancelled pipeline ${pipeline.id} in ${projectPath} (status: ${pipeline.status})`); + } + + } catch (error: any) { + // Cancellation failed + detail.result = 'failed'; + detail.reason = error.message; + result.failed++; + + // Add to errors array + const cancelError: CancelError = { + pipelineId: pipeline.id, + message: error.message, + error: error, + }; + + // Add status code if available + if (error.response?.status) { + cancelError.statusCode = error.response.status; + } + + // Add provider error code if available + if (error.response?.data?.error) { + cancelError.providerErrorCode = error.response.data.error; + } + + result.errors.push(cancelError); + + console.error(`❌ [GitLabCI] Failed to cancel pipeline ${pipeline.id}: ${error.message}`); + } + + // Add detail to results + result.details.push(detail); + } + + /** + * Actually cancel the pipeline via GitLab API + */ + private async cancelPipelineViaAPI(pipelineId: number, projectPath: string): Promise { + try { + await this.gitlabCIClient.pipelines.cancelPipeline(projectPath, pipelineId); + } catch (error: any) { - console.error(`Error while cancelling pipelines: ${error}`); + // Handle GitLab-specific errors + if (error.response?.status === 404) { + throw new Error('Pipeline not found (may have been deleted)'); + } + if (error.response?.status === 403) { + throw new Error('Insufficient permissions to cancel pipeline'); + } + if (error.response?.data?.message) { + throw new Error(`GitLab API error: ${error.response.data.message}`); + } + + throw error; + } + } + + /** + * Map GitLab pipeline to EventType + */ + private mapGitLabEventType(pipeline: any): EventType | undefined { + if (pipeline.source === 'push') { + return EventType.PUSH; + } + if (pipeline.source === 'merge_request_event') { + return EventType.PULL_REQUEST; + } + return undefined; + } + + /** + * Utility: Split array into chunks + */ + private chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); } + return chunks; } public override async getWebhookUrl(): Promise { diff --git a/src/rhtap/core/integration/ci/providers/jenkinsCI.ts b/src/rhtap/core/integration/ci/providers/jenkinsCI.ts index 41664e56..80b0b161 100644 --- a/src/rhtap/core/integration/ci/providers/jenkinsCI.ts +++ b/src/rhtap/core/integration/ci/providers/jenkinsCI.ts @@ -9,7 +9,16 @@ import { import { KubeClient } from '../../../../../../src/api/ocp/kubeClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; -import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; +import { + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, + PipelineCancelDetail, + CancelError, +} from '../ciInterface'; import retry from 'async-retry'; import { JobActivityStatus } from '../../../../../api/jenkins'; @@ -431,11 +440,7 @@ export class JenkinsCI extends BaseCI { } } - public override async cancelAllInitialPipelines(): Promise { - throw new Error( - 'Jenkins does not support cancelling initial pipeline runs.' - ); - } + /** * Enhanced method to wait for all Jenkins jobs to finish (both running and queued) @@ -573,4 +578,332 @@ export class JenkinsCI extends BaseCI { throw error; } } + + /** + * Cancel all pipelines for this component with optional filtering + */ + public override async cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise { + // 1. Normalize options with defaults + const opts = this.normalizeOptions(options); + + // 2. Initialize result object + const result: CancelResult = { + total: 0, + cancelled: 0, + failed: 0, + skipped: 0, + details: [], + errors: [], + }; + + console.log(`[Jenkins] Starting build cancellation for ${this.componentName}`); + + try { + // 3. Fetch all builds from Jenkins API + const allBuilds = await this.fetchAllBuilds(); + result.total = allBuilds.length; + + if (allBuilds.length === 0) { + console.log(`[Jenkins] No builds found for ${this.componentName}`); + return result; + } + + console.log(`[Jenkins] Found ${allBuilds.length} total builds`); + + // 4. Apply filters + const buildsToCancel = this.filterBuilds(allBuilds, opts); + + console.log(`[Jenkins] ${buildsToCancel.length} builds match filters`); + console.log(`[Jenkins] ${allBuilds.length - buildsToCancel.length} builds filtered out`); + + // 5. Cancel builds in batches + await this.cancelBuildsInBatches(buildsToCancel, opts, result); + + // 6. Log summary + console.log(`[Jenkins] Cancellation complete:`, { + total: result.total, + cancelled: result.cancelled, + failed: result.failed, + skipped: result.skipped, + }); + + } catch (error: any) { + console.error(`[Jenkins] Error in cancelAllPipelines: ${error.message}`); + throw new Error(`Failed to cancel pipelines: ${error.message}`); + } + + return result; + } + + /** + * Normalize options with defaults + */ + private normalizeOptions( + options?: CancelPipelineOptions + ): Required> & Pick { + return { + excludePatterns: options?.excludePatterns || [], + includeCompleted: options?.includeCompleted || false, + eventType: options?.eventType, + branch: options?.branch, + concurrency: options?.concurrency || 10, + dryRun: options?.dryRun || false, + }; + } + + /** + * Fetch all builds from Jenkins API (both source and gitops jobs) + */ + private async fetchAllBuilds(): Promise { + try { + const allBuilds: any[] = []; + const folderName = this.componentName; + + // Fetch builds from source job + const sourceJobName = this.componentName; + try { + const sourceBuilds = await this.jenkinsClient.builds.getRunningBuilds(sourceJobName, folderName); + + // Tag builds with their job name for later cancellation + const taggedSourceBuilds = (sourceBuilds || []).map(build => ({ + ...build, + _jobName: sourceJobName + })); + allBuilds.push(...taggedSourceBuilds); + } catch (sourceError: any) { + console.log(`[Jenkins] Source job ${sourceJobName} not found or no builds: ${sourceError.message}`); + } + + // Fetch builds from gitops job + const gitopsJobName = `${this.componentName}-gitops`; + try { + const gitopsBuilds = await this.jenkinsClient.builds.getRunningBuilds(gitopsJobName, folderName); + + // Tag builds with their job name for later cancellation + const taggedGitopsBuilds = (gitopsBuilds || []).map(build => ({ + ...build, + _jobName: gitopsJobName + })); + allBuilds.push(...taggedGitopsBuilds); + } catch (gitopsError: any) { + // Gitops job might not exist, log but don't fail + console.log(`[Jenkins] Gitops job ${gitopsJobName} not found or no builds: ${gitopsError.message}`); + } + + return allBuilds; + + } catch (error: any) { + console.error(`[Jenkins] Failed to fetch builds: ${error.message}`); + throw error; + } + } + + /** + * Filter builds based on cancellation options + */ + private filterBuilds( + builds: any[], + options: Required> & Pick + ): any[] { + return builds.filter(build => { + // Filter 1: Skip completed builds unless includeCompleted is true + if (!options.includeCompleted && this.isCompletedStatus(build)) { + console.log(`[Filter] Skipping completed build ${build.number} (${build.result})`); + return false; + } + + // Filter 2: Check exclusion patterns + if (this.matchesExclusionPattern(build, options.excludePatterns)) { + console.log(`[Filter] Excluding build ${build.number} by pattern`); + return false; + } + + // Filter 3: Filter by event type if specified + if (options.eventType && !this.matchesEventType(build, options.eventType)) { + console.log(`[Filter] Skipping build ${build.number} (event type mismatch)`); + return false; + } + + // Note: Jenkins builds don't have direct branch information in getRunningBuilds + // Branch filtering would require fetching full build details, skipping for performance + if (options.branch) { + console.log(`[Filter] Branch filtering not supported for Jenkins running builds, ignoring branch filter`); + } + + return true; // Include this build for cancellation + }); + } + + /** + * Check if build status is completed + */ + private isCompletedStatus(build: any): boolean { + // If building is true, it's not completed + if (build.building) { + return false; + } + + // If we have a result, the build is completed + return build.result !== null && build.result !== undefined; + } + + /** + * Check if build matches any exclusion pattern + */ + private matchesExclusionPattern(build: any, patterns: RegExp[]): boolean { + if (patterns.length === 0) { + return false; + } + + const buildName = build.displayName || `Build-${build.number}`; + + return patterns.some(pattern => pattern.test(buildName)); + } + + /** + * Check if build matches the event type + * Jenkins uses trigger type to indicate event type + */ + private matchesEventType(build: any, eventType: EventType): boolean { + // If we have trigger type information from the build + if (build.triggerType) { + switch (eventType) { + case EventType.PUSH: + return build.triggerType === JenkinsBuildTrigger.PUSH; + case EventType.PULL_REQUEST: + return build.triggerType === JenkinsBuildTrigger.PULL_REQUEST; + default: + return false; + } + } + + // If no trigger type info, allow all (can't filter) + return true; + } + + /** + * Cancel builds in batches with concurrency control + */ + private async cancelBuildsInBatches( + builds: any[], + options: Required> & Pick, + result: CancelResult + ): Promise { + // Split into batches + const batches = this.chunkArray(builds, options.concurrency); + + console.log(`[Jenkins] Processing ${batches.length} batches with concurrency ${options.concurrency}`); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log(`[Jenkins] Processing batch ${i + 1}/${batches.length} (${batch.length} builds)`); + + // Create promises for all builds in this batch + const promises = batch.map(build => + this.cancelSingleBuild(build, options, result) + ); + + // Wait for all in batch to complete (don't stop on errors) + await Promise.allSettled(promises); + } + } + + /** + * Cancel a single build and update results + */ + private async cancelSingleBuild( + build: any, + options: Required> & Pick, + result: CancelResult + ): Promise { + // Initialize detail object + const detail: PipelineCancelDetail = { + pipelineId: build.number, + name: build.displayName || `Build-${build.number}`, + status: build.building ? PipelineStatus.RUNNING : PipelineStatus.UNKNOWN, + result: 'skipped', + eventType: this.mapJenkinsEventType(build), + }; + + try { + if (options.dryRun) { + // Dry run mode - don't actually cancel + detail.result = 'skipped'; + detail.reason = 'Dry run mode'; + result.skipped++; + console.log(`[DryRun] Would cancel build ${build.number}`); + + } else { + // Extract job name from tagged build (added in fetchAllBuilds) + const jobName = (build as any)._jobName || this.componentName; + + // Actually cancel the build via Jenkins API + await this.cancelBuildViaAPI(jobName, build.number); + + detail.result = 'cancelled'; + result.cancelled++; + console.log(`✅ [Jenkins] Cancelled build ${jobName} #${build.number} (status: ${build.building ? 'building' : build.result})`); + } + + } catch (error: any) { + // Cancellation failed + detail.result = 'failed'; + detail.reason = error.message; + result.failed++; + + // Add to errors array + const cancelError: CancelError = { + pipelineId: build.number, + message: error.message, + error: error, + }; + + result.errors.push(cancelError); + + console.error(`❌ [Jenkins] Failed to cancel build ${build.number}: ${error.message}`); + } + + // Add detail to results + result.details.push(detail); + } + + /** + * Actually cancel the build via Jenkins API + */ + private async cancelBuildViaAPI(jobName: string, buildNumber: number): Promise { + try { + const folderName = this.componentName; + await this.jenkinsClient.builds.stopBuild(jobName, buildNumber, folderName); + + } catch (error: any) { + // Re-throw - the jenkinsClient.builds.stopBuild already has error handling + throw error; + } + } + + /** + * Map Jenkins build to EventType + */ + private mapJenkinsEventType(build: any): EventType | undefined { + if (build.triggerType === JenkinsBuildTrigger.PUSH) { + return EventType.PUSH; + } + if (build.triggerType === JenkinsBuildTrigger.PULL_REQUEST) { + return EventType.PULL_REQUEST; + } + return undefined; + } + + /** + * Utility: Split array into chunks + */ + private chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } } \ No newline at end of file diff --git a/src/rhtap/core/integration/ci/providers/tektonCI.ts b/src/rhtap/core/integration/ci/providers/tektonCI.ts index 57687013..0c8d7480 100644 --- a/src/rhtap/core/integration/ci/providers/tektonCI.ts +++ b/src/rhtap/core/integration/ci/providers/tektonCI.ts @@ -3,7 +3,16 @@ import { KubeClient } from '../../../../../api/ocp/kubeClient'; import { PullRequest } from '../../git/models'; import { BaseCI } from '../baseCI'; import { TSSC_CI_NAMESPACE } from '../../../../../constants'; -import { CIType, EventType, Pipeline, PipelineStatus } from '../ciInterface'; +import { + CIType, + EventType, + Pipeline, + PipelineStatus, + CancelPipelineOptions, + CancelResult, + PipelineCancelDetail, + CancelError, +} from '../ciInterface'; import { PipelineRunKind } from '@janus-idp/shared-react/index'; import retry from 'async-retry'; @@ -366,11 +375,7 @@ export class TektonCI extends BaseCI { } } - public override async cancelAllInitialPipelines(): Promise { - throw new Error( - 'Tekton does not support cancelling initial pipeline runs.' - ); - } + public override async getWebhookUrl(): Promise { const tektonWebhookUrl = await this.kubeClient.getOpenshiftRoute( @@ -406,4 +411,343 @@ export class TektonCI extends BaseCI { throw new Error(`Failed to get pipeline logs: ${error}`); } } + + /** + * Cancel all pipelines for this component with optional filtering + */ + public override async cancelAllPipelines( + options?: CancelPipelineOptions + ): Promise { + // 1. Normalize options with defaults + const opts = this.normalizeOptions(options); + + // 2. Initialize result object + const result: CancelResult = { + total: 0, + cancelled: 0, + failed: 0, + skipped: 0, + details: [], + errors: [], + }; + + console.log(`[Tekton] Starting pipeline cancellation for ${this.componentName}`); + + try { + // 3. Fetch all PipelineRuns from Tekton API + const allPipelineRuns = await this.fetchAllPipelineRuns(); + result.total = allPipelineRuns.length; + + if (allPipelineRuns.length === 0) { + console.log(`[Tekton] No PipelineRuns found for ${this.componentName}`); + return result; + } + + console.log(`[Tekton] Found ${allPipelineRuns.length} total PipelineRuns`); + + // 4. Apply filters + const pipelineRunsToCancel = this.filterPipelineRuns(allPipelineRuns, opts); + + console.log(`[Tekton] ${pipelineRunsToCancel.length} PipelineRuns match filters`); + console.log(`[Tekton] ${allPipelineRuns.length - pipelineRunsToCancel.length} PipelineRuns filtered out`); + + // 5. Cancel PipelineRuns in batches + await this.cancelPipelineRunsInBatches(pipelineRunsToCancel, opts, result); + + // 6. Log summary + console.log(`[Tekton] Cancellation complete:`, { + total: result.total, + cancelled: result.cancelled, + failed: result.failed, + skipped: result.skipped, + }); + + } catch (error: any) { + console.error(`[Tekton] Error in cancelAllPipelines: ${error.message}`); + throw new Error(`Failed to cancel pipelines: ${error.message}`); + } + + return result; + } + + /** + * Normalize options with defaults + */ + private normalizeOptions( + options?: CancelPipelineOptions + ): Required> & Pick { + return { + excludePatterns: options?.excludePatterns || [], + includeCompleted: options?.includeCompleted || false, + eventType: options?.eventType, + branch: options?.branch, + concurrency: options?.concurrency || 10, + dryRun: options?.dryRun || false, + }; + } + + /** + * Fetch all PipelineRuns from Tekton API (both source and gitops repos) + */ + private async fetchAllPipelineRuns(): Promise { + try { + const allPipelineRuns: any[] = []; + + // Fetch PipelineRuns from source repository + const sourceRepoName = this.componentName; + try { + const sourcePipelineRuns = await this.tektonClient.getPipelineRunsByGitRepository( + TektonCI.CI_NAMESPACE, + sourceRepoName + ); + + // Tag PipelineRuns with their repository name for later cancellation + const taggedSourcePipelineRuns = (sourcePipelineRuns || []).map(pr => ({ + ...pr, + _repositoryName: sourceRepoName + })); + allPipelineRuns.push(...taggedSourcePipelineRuns); + } catch (sourceError: any) { + console.log(`[Tekton] Source repository ${sourceRepoName} not found or no PipelineRuns: ${sourceError.message}`); + } + + // Fetch PipelineRuns from gitops repository + const gitopsRepoName = `${this.componentName}-gitops`; + try { + const gitopsPipelineRuns = await this.tektonClient.getPipelineRunsByGitRepository( + TektonCI.CI_NAMESPACE, + gitopsRepoName + ); + + // Tag PipelineRuns with their repository name for later cancellation + const taggedGitopsPipelineRuns = (gitopsPipelineRuns || []).map(pr => ({ + ...pr, + _repositoryName: gitopsRepoName + })); + allPipelineRuns.push(...taggedGitopsPipelineRuns); + } catch (gitopsError: any) { + // Gitops repository might not exist, log but don't fail + console.log(`[Tekton] Gitops repository ${gitopsRepoName} not found or no PipelineRuns: ${gitopsError.message}`); + } + + return allPipelineRuns; + + } catch (error: any) { + console.error(`[Tekton] Failed to fetch PipelineRuns: ${error.message}`); + throw error; + } + } + + /** + * Filter PipelineRuns based on cancellation options + */ + private filterPipelineRuns( + pipelineRuns: any[], + options: Required> & Pick + ): any[] { + return pipelineRuns.filter(pr => { + const prName = pr.metadata?.name || 'unknown'; + + // Filter 1: Skip completed PipelineRuns unless includeCompleted is true + if (!options.includeCompleted && this.isCompletedStatus(pr)) { + const state = pr.metadata?.labels?.['pipelinesascode.tekton.dev/state']; + console.log(`[Filter] Skipping completed PipelineRun ${prName} (state: ${state})`); + return false; + } + + // Filter 2: Check exclusion patterns + if (this.matchesExclusionPattern(pr, options.excludePatterns)) { + console.log(`[Filter] Excluding PipelineRun ${prName} by pattern`); + return false; + } + + // Filter 3: Filter by event type if specified + if (options.eventType && !this.matchesEventType(pr, options.eventType)) { + const eventType = pr.metadata?.labels?.['pipelinesascode.tekton.dev/event-type']; + console.log(`[Filter] Skipping PipelineRun ${prName} (event type: ${eventType} doesn't match ${options.eventType})`); + return false; + } + + // Filter 4: Filter by branch if specified + if (options.branch) { + const branch = pr.metadata?.labels?.['pipelinesascode.tekton.dev/branch']; + if (branch !== options.branch) { + console.log(`[Filter] Skipping PipelineRun ${prName} (branch: ${branch} doesn't match ${options.branch})`); + return false; + } + } + + return true; // Include this PipelineRun for cancellation + }); + } + + /** + * Check if PipelineRun status is completed + */ + private isCompletedStatus(pipelineRun: any): boolean { + const state = pipelineRun.metadata?.labels?.['pipelinesascode.tekton.dev/state']; + return state === 'completed'; + } + + /** + * Check if PipelineRun matches any exclusion pattern + */ + private matchesExclusionPattern(pipelineRun: any, patterns: RegExp[]): boolean { + if (patterns.length === 0) { + return false; + } + + const prName = pipelineRun.metadata?.name || 'unknown'; + + return patterns.some(pattern => pattern.test(prName)); + } + + /** + * Check if PipelineRun matches the event type + * Tekton uses labels to indicate event type + */ + private matchesEventType(pipelineRun: any, eventType: EventType): boolean { + const tektonEventType = pipelineRun.metadata?.labels?.['pipelinesascode.tekton.dev/event-type']; + + if (!tektonEventType) { + return true; // If no event type label, allow all + } + + switch (eventType) { + case EventType.PUSH: + return tektonEventType === 'push'; + case EventType.PULL_REQUEST: + return tektonEventType === 'pull_request'; + default: + return false; + } + } + + /** + * Cancel PipelineRuns in batches with concurrency control + */ + private async cancelPipelineRunsInBatches( + pipelineRuns: any[], + options: Required> & Pick, + result: CancelResult + ): Promise { + // Split into batches + const batches = this.chunkArray(pipelineRuns, options.concurrency); + + console.log(`[Tekton] Processing ${batches.length} batches with concurrency ${options.concurrency}`); + + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + console.log(`[Tekton] Processing batch ${i + 1}/${batches.length} (${batch.length} PipelineRuns)`); + + // Create promises for all PipelineRuns in this batch + const promises = batch.map(pr => + this.cancelSinglePipelineRun(pr, options, result) + ); + + // Wait for all in batch to complete (don't stop on errors) + await Promise.allSettled(promises); + } + } + + /** + * Cancel a single PipelineRun and update results + */ + private async cancelSinglePipelineRun( + pipelineRun: any, + options: Required> & Pick, + result: CancelResult + ): Promise { + const prName = pipelineRun.metadata?.name || 'unknown'; + + // Initialize detail object + const detail: PipelineCancelDetail = { + pipelineId: prName, + name: prName, + status: this.mapTektonStatusToPipelineStatus(pipelineRun), + result: 'skipped', + eventType: this.mapTektonEventType(pipelineRun), + }; + + try { + if (options.dryRun) { + // Dry run mode - don't actually cancel + detail.result = 'skipped'; + detail.reason = 'Dry run mode'; + result.skipped++; + console.log(`[DryRun] Would cancel PipelineRun ${prName}`); + + } else { + // Extract repository name from tagged PipelineRun (added in fetchAllPipelineRuns) + const repositoryName = (pipelineRun as any)._repositoryName || this.componentName; + + // Actually cancel the PipelineRun via Tekton API + await this.cancelPipelineRunViaAPI(prName); + + detail.result = 'cancelled'; + result.cancelled++; + const state = pipelineRun.metadata?.labels?.['pipelinesascode.tekton.dev/state']; + console.log(`✅ [Tekton] Cancelled PipelineRun ${prName} in ${repositoryName} (state: ${state || 'unknown'})`); + } + + } catch (error: any) { + // Cancellation failed + detail.result = 'failed'; + detail.reason = error.message; + result.failed++; + + // Add to errors array + const cancelError: CancelError = { + pipelineId: prName, + message: error.message, + error: error, + }; + + result.errors.push(cancelError); + + console.error(`❌ [Tekton] Failed to cancel PipelineRun ${prName}: ${error.message}`); + } + + // Add detail to results + result.details.push(detail); + } + + /** + * Actually cancel the PipelineRun via Tekton API + */ + private async cancelPipelineRunViaAPI(pipelineRunName: string): Promise { + try { + await this.tektonClient.cancelPipelineRun(TektonCI.CI_NAMESPACE, pipelineRunName); + + } catch (error: any) { + // Re-throw - the tektonClient.cancelPipelineRun already has error handling + throw error; + } + } + + /** + * Map Tekton PipelineRun to EventType + */ + private mapTektonEventType(pipelineRun: any): EventType | undefined { + const eventType = pipelineRun.metadata?.labels?.['pipelinesascode.tekton.dev/event-type']; + + if (eventType === 'push') { + return EventType.PUSH; + } + if (eventType === 'pull_request') { + return EventType.PULL_REQUEST; + } + return undefined; + } + + /** + * Utility: Split array into chunks + */ + private chunkArray(array: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += size) { + chunks.push(array.slice(i, i + size)); + } + return chunks; + } } diff --git a/src/utils/test/common.ts b/src/utils/test/common.ts index adb87705..b3c8eaa3 100644 --- a/src/utils/test/common.ts +++ b/src/utils/test/common.ts @@ -374,19 +374,6 @@ export async function buildApplicationImageWithPR(git: Git, ci: CI): Promise { - if (ci.getCIType() === CIType.GITLABCI) { - // Cancel initial GitLab CI pipelines triggered by the first commit. - // Gitlabci pipelines takes longer than other CIs, canceling helps reduce total test duration. - console.log('CI Provider is gitlabci - cancelling initial pipelines'); - await ci.cancelAllInitialPipelines(); - } else { - // For other CI providers, wait for the initial pipelines to complete. - console.log(`CI Provider is ${ci.getCIType()} - waiting for initial pipelines to finish`); - await ci.waitForAllPipelineRunsToFinish(); - } -} - export async function handlePromotionToEnvironmentandGetPipeline( git: Git, ci: CI, diff --git a/tests/api/git/github.test.ts b/tests/api/git/github.test.ts deleted file mode 100644 index 97abc5e3..00000000 --- a/tests/api/git/github.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { GithubClient } from '../../../src/api/github/github.client'; -import test from '@playwright/test'; - -// Initialize the GitHub client -const githubClient = new GithubClient({ - token: '', -}); - -// Set timeout for tests (Tekton operations can be slow) - -test.describe('TektonClient Integration Tests', () => { - // Run before all tests - test.beforeAll(async () => { - // // Create a test folder for all tests to use - // try { - // console.log('Setting up test folder...'); - // await jenkins.createFolder({ - // name: TEST_FOLDER_NAME, - // description: 'Folder created for integration testing', - // }); - // } catch (error) { - // console.error('Failed to set up test folder:', error); - // throw error; - // } - }); - - // Run after all tests - test.afterAll(async () => { - // Clean up test resources - // Note: You may want to implement a deleteFolder and deleteJob method in JenkinsClient - // For now, we'll leave this commented out since the methods don't exist yet - /* - try { - console.log('Cleaning up test resources...'); - await jenkins.deleteJob(TEST_JOB_NAME, TEST_FOLDER_NAME); - await jenkins.deleteFolder(TEST_FOLDER_NAME); - } catch (error) { - console.error('Failed to clean up test resources:', error); - } - */ - }); - - test.only('Should extract image from deployment-patch.yaml', async () => { - const repoOwner = 'xjiangorg'; - const gitOpsRepoName = 'nodejs-nojcsoue-gitops'; - const filePath = `components/nodejs-nojcsoue/overlays/development/deployment-patch.yaml`; - - const imagePattern = /(?:^|\s+)-\s+image:(?:\s+(.+)$)?|(^\s+.+$)/gm; - - try { - const matches = await githubClient.repository.extractContentByRegex( - repoOwner, - gitOpsRepoName, - filePath, - imagePattern - ); - - if (!matches || matches.length === 0) { - throw new Error(`No image value found in file: ${filePath}`); - } - - // Process the matches to extract the actual image URL - let imageValue = ''; - - // Check if we have a direct match with '- image: value' - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; - if (match.includes('- image:')) { - // This is a line with "- image:" that might have the value directly - const parts = match.split('- image:'); - if (parts.length > 1 && parts[1].trim()) { - imageValue = parts[1].trim(); - break; - } else if (i + 1 < matches.length && !matches[i + 1].includes('- image:')) { - // If this line just has "- image:" and next line doesn't have "- image:", - // assume next line is the image value - imageValue = matches[i + 1].trim(); - break; - } - } - } - - if (!imageValue) { - throw new Error(`Could not parse image value from matches in file: ${filePath}`); - } - - console.log(`Extracted image from ${filePath}: ${imageValue}`); - // Additional assertion to ensure the extracted value matches expectations - } catch (error) { - console.error('Error during test execution:', error); - throw error; - } - }); - - // test configWebhook - test('Should configure webhook for GitHub repository', async () => { - const repoOwner = 'xjiangorg'; - const repoName = 'nodejs-ufflmxra'; - const webhookUrl = - 'https://jenkins-jenkins.apps.rosa.rhtap-services.xmdt.p3.openshiftapps.com/github-webhookaa/'; // Replace with your actual webhook URL - - try { - await githubClient.webhooks.configWebhook(repoOwner, repoName, { - url: webhookUrl, - secret: 'test-secret', - contentType: 'json', - insecureSSL: false, - events: ['push', 'pull_request'], - active: true - }); - console.log(`Webhook configured successfully`); - } catch (error) { - console.error('Error during webhook configuration:', error); - throw error; - } - }); -}); diff --git a/tests/tssc/full_workflow.test.ts b/tests/tssc/full_workflow.test.ts index 30d3d8b3..5a31cf2c 100644 --- a/tests/tssc/full_workflow.test.ts +++ b/tests/tssc/full_workflow.test.ts @@ -68,9 +68,11 @@ test.describe.serial('TSSC Complete Workflow', () => { await postCreateAction.execute(); console.log('✅ Post-creation actions executed successfully!'); - // Handle initial pipeline runs based on CI provider type - await handleInitialPipelineRuns(ci); - console.log('All initial pipelines have ended!'); + // It is possible to trigger multiple pipelines when a new component is created and make some changes + // to the both source and gitops repos. These pipelines are not needed for the test and should be cancelled. + await ci.cancelAllPipelines(); + console.log('All pipelines have been cancelled!'); + console.log('Component creation is complete!'); }); });