Skip to content

Commit f8c1055

Browse files
committed
feat: implement cancelAllPipelines for CI providers
Assisted-by: Claude Code
1 parent 133f39b commit f8c1055

File tree

16 files changed

+2118
-176
lines changed

16 files changed

+2118
-176
lines changed

src/api/azure/services/azure-pipeline.service.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,41 @@ export class AzurePipelineService {
168168
}
169169
}
170170

171+
public async cancelBuild(buildId: number): Promise<void> {
172+
try {
173+
await retry(
174+
async () => {
175+
await this.client.patch(
176+
`${this.project}/_apis/build/builds/${buildId}?${this.getApiVersionParam()}`,
177+
{ status: 'cancelling' }
178+
);
179+
},
180+
{
181+
retries: 3,
182+
minTimeout: 1000,
183+
maxTimeout: 5000,
184+
onRetry: (error: Error, attempt: number) => {
185+
console.log(`[Azure] Retry ${attempt}/3 - Cancelling build ${buildId}: ${error.message}`);
186+
},
187+
}
188+
);
189+
190+
console.log(`[Azure] Successfully cancelled build ${buildId}`);
191+
} catch (error: any) {
192+
// Handle specific error cases
193+
if (error.response?.status === 404) {
194+
throw new Error(`Build ${buildId} not found`);
195+
}
196+
if (error.response?.status === 403) {
197+
throw new Error(`Insufficient permissions to cancel build ${buildId}`);
198+
}
199+
if (error.response?.status === 400) {
200+
throw new Error(`Build ${buildId} cannot be cancelled (already completed or not cancellable)`);
201+
}
202+
throw new Error(`Failed to cancel build ${buildId}: ${error.message}`);
203+
}
204+
}
205+
171206
public async getAllPipelines(): Promise<AzurePipelineDefinition[]> {
172207
try {
173208
const pipelines = await this.client.get<{ value: AzurePipelineDefinition[] }>(

src/api/github/services/github-actions.service.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,4 +590,74 @@ export class GithubActionsService {
590590
throw new GithubApiError(`Error finding workflow run for commit ${sha.substring(0, 7)}`, error.status, error);
591591
}
592592
}
593+
594+
/**
595+
* Cancel a workflow run
596+
*
597+
* @param owner Repository owner
598+
* @param repo Repository name
599+
* @param runId Workflow run ID to cancel
600+
* @returns Promise resolving when the workflow run is cancelled
601+
* @throws GithubNotFoundError if the workflow run is not found
602+
* @throws GithubApiError for other API errors
603+
*/
604+
async cancelWorkflowRun(
605+
owner: string,
606+
repo: string,
607+
runId: number
608+
): Promise<void> {
609+
try {
610+
await retry(
611+
async () => {
612+
await this.octokit.actions.cancelWorkflowRun({
613+
owner,
614+
repo,
615+
run_id: runId,
616+
});
617+
},
618+
{
619+
retries: this.maxRetries,
620+
minTimeout: this.minTimeout,
621+
maxTimeout: this.maxTimeout,
622+
factor: this.factor,
623+
onRetry: (error: Error, attempt: number) => {
624+
console.log(
625+
`[GitHub Actions] Retry ${attempt}/${this.maxRetries} - Cancelling workflow run ${runId}: ${error.message}`
626+
);
627+
},
628+
}
629+
);
630+
631+
console.log(`[GitHub Actions] Successfully cancelled workflow run ${runId}`);
632+
} catch (error: any) {
633+
// Handle specific error cases
634+
if (error.status === 404) {
635+
throw new GithubNotFoundError(
636+
'workflow run',
637+
`${runId} in repository ${owner}/${repo}`
638+
);
639+
}
640+
641+
if (error.status === 403) {
642+
throw new GithubApiError(
643+
`Insufficient permissions to cancel workflow run ${runId}`,
644+
error.status
645+
);
646+
}
647+
648+
if (error.status === 409) {
649+
throw new GithubApiError(
650+
`Workflow run ${runId} cannot be cancelled (already completed or not cancellable)`,
651+
error.status
652+
);
653+
}
654+
655+
// Re-throw with more context
656+
throw new GithubApiError(
657+
`Failed to cancel workflow run ${runId}: ${error.message}`,
658+
error.status || 500,
659+
error
660+
);
661+
}
662+
}
593663
}

src/api/jenkins/services/jenkins-build.service.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,25 @@ export class JenkinsBuildService {
165165
}
166166
}
167167

168+
/**
169+
* Stop/abort a running build
170+
*/
171+
async stopBuild(jobName: string, buildNumber: number, folderName?: string): Promise<void> {
172+
try {
173+
const path = JenkinsPathBuilder.buildBuildPath(
174+
jobName,
175+
buildNumber,
176+
folderName,
177+
'stop'
178+
);
179+
180+
await this.httpClient.post(path, null);
181+
} catch (error) {
182+
// If build is not found or already stopped, throw specific error
183+
throw new JenkinsBuildNotFoundError(jobName, buildNumber, folderName);
184+
}
185+
}
186+
168187
/**
169188
* Wait for a build to complete with timeout
170189
*/

src/api/ocp/kubeClient.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,37 @@ export class KubeClient {
265265
}
266266
}
267267

268+
/**
269+
* Generic method to patch a resource with proper error handling
270+
*
271+
* @template T The resource type to return
272+
* @param {K8sApiOptions} options - API options for the request (must include name)
273+
* @param {any} patchData - The patch data to apply
274+
* @returns {Promise<T>} The patched resource of type T
275+
*/
276+
public async patchResource<T>(options: K8sApiOptions, patchData: any): Promise<T> {
277+
try {
278+
if (!options.name) {
279+
throw new Error('Resource name is required for patchResource');
280+
}
281+
282+
const response = await this.customApi.patchNamespacedCustomObject({
283+
group: options.group,
284+
version: options.version,
285+
namespace: options.namespace,
286+
plural: options.plural,
287+
name: options.name,
288+
body: patchData,
289+
});
290+
return response as T;
291+
} catch (error) {
292+
console.error(
293+
`Error patching resource '${options.name}' in namespace '${options.namespace}': ${error}`
294+
);
295+
throw new Error(`Failed to patch resource '${options.name}': ${error}`);
296+
}
297+
}
298+
268299
/**
269300
* Retrieves logs from a pod or specific containers within a pod
270301
* @param podName The name of the pod

src/api/tekton/services/tekton-pipelinerun.service.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ export class TektonPipelineRunService {
191191
}
192192
}
193193

194+
/**
195+
* Cancel a running PipelineRun by patching its spec
196+
*/
197+
public async cancelPipelineRun(namespace: string, name: string): Promise<void> {
198+
try {
199+
const options = this.kubeClient.createApiOptions(
200+
this.API_GROUP,
201+
this.API_VERSION,
202+
this.PIPELINE_RUNS_PLURAL,
203+
namespace,
204+
{ name }
205+
);
206+
207+
// Patch the PipelineRun to set status to cancelled
208+
const patchData = {
209+
spec: {
210+
status: 'PipelineRunCancelled'
211+
}
212+
};
213+
214+
await this.kubeClient.patchResource(options, patchData);
215+
216+
console.log(`Successfully cancelled PipelineRun: ${name} in namespace: ${namespace}`);
217+
} catch (error: unknown) {
218+
const errorMessage = (error as Error).message;
219+
console.error(`Failed to cancel PipelineRun ${name}: ${errorMessage}`);
220+
throw new Error(`Failed to cancel PipelineRun ${name}: ${errorMessage}`);
221+
}
222+
}
223+
194224
private findPipelineRunByEventType(
195225
pipelineRuns: PipelineRunKind[],
196226
eventType: string,

src/api/tekton/tekton.client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class TektonClient {
4545
return this.pipelineRunService.getPipelineRunLogs(namespace, pipelineRunName);
4646
}
4747

48+
public async cancelPipelineRun(namespace: string, name: string): Promise<void> {
49+
return this.pipelineRunService.cancelPipelineRun(namespace, name);
50+
}
51+
4852
public get pipelineRuns(): TektonPipelineRunService {
4953
return this.pipelineRunService;
5054
}

src/rhtap/core/integration/ci/baseCI.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { PullRequest } from '../git/models';
22
import { KubeClient } from './../../../../../src/api/ocp/kubeClient';
3-
import { CI, CIType, EventType, Pipeline, PipelineStatus } from './ciInterface';
3+
import {
4+
CI,
5+
CIType,
6+
EventType,
7+
Pipeline,
8+
PipelineStatus,
9+
CancelPipelineOptions,
10+
CancelResult,
11+
} from './ciInterface';
412
import retry from 'async-retry';
513

614
/**
@@ -118,7 +126,16 @@ export abstract class BaseCI implements CI {
118126

119127
public abstract waitForAllPipelineRunsToFinish(): Promise<void>;
120128

121-
public abstract cancelAllInitialPipelines(): Promise<void>;
129+
/**
130+
* Abstract method for cancelling all pipelines
131+
* Must be implemented by each provider
132+
*
133+
* @param options Optional configuration for filtering and behavior
134+
* @returns Promise resolving to detailed cancellation results
135+
*/
136+
public abstract cancelAllPipelines(
137+
options?: CancelPipelineOptions
138+
): Promise<CancelResult>;
122139

123140
public abstract getWebhookUrl(): Promise<string>;
124141

0 commit comments

Comments
 (0)