diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index c024f26e3..11c94bd7e 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -87,6 +87,10 @@ export const routes: Routes = [ redirectTo: 'pr', pathMatch: 'full', }, + { + path: 'runs/:runId', + loadComponent: () => import('@app/pages/workflow-run-details/workflow-run-details.component').then(m => m.WorkflowRunDetailsComponent), + }, { path: 'runs', loadComponent: () => import('@app/pages/workflow-run-list/workflow-run-list.component').then(m => m.WorkflowRunListComponent), diff --git a/client/src/app/components/environments/environment-accordion/environment-accordion.component.html b/client/src/app/components/environments/environment-accordion/environment-accordion.component.html index 151e05b92..501a2fc70 100644 --- a/client/src/app/components/environments/environment-accordion/environment-accordion.component.html +++ b/client/src/app/components/environments/environment-accordion/environment-accordion.component.html @@ -127,9 +127,7 @@
- @if (workflowRunId()) { - - } +
} @else { diff --git a/client/src/app/components/environments/environment-accordion/environment-accordion.component.ts b/client/src/app/components/environments/environment-accordion/environment-accordion.component.ts index a9ce00f79..3f3ec18b1 100644 --- a/client/src/app/components/environments/environment-accordion/environment-accordion.component.ts +++ b/client/src/app/components/environments/environment-accordion/environment-accordion.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, input, output } from '@angular/core'; +import { Component, inject, input, output } from '@angular/core'; import { EnvironmentDeployment, EnvironmentDto } from '@app/core/modules/openapi'; import { DeploymentStepperComponent } from '../deployment-stepper/deployment-stepper.component'; import { EnvironmentActionsComponent } from '../environment-actions/environment-actions.component'; @@ -130,15 +130,4 @@ export class EnvironmentAccordionComponent { window.open(url, '_blank'); } } - - workflowRunId = computed(() => { - const deployment = this.environment()?.latestDeployment; - if (!deployment?.workflowRunHtmlUrl) return undefined; - - const matches = deployment.workflowRunHtmlUrl.match(/\/runs\/(\d+)$/); - if (matches && matches[1]) { - return parseInt(matches[1], 10); - } - return undefined; - }); } diff --git a/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.html b/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.html index de818ee4f..e4ac5640d 100644 --- a/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.html +++ b/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.html @@ -1,121 +1,10 @@ -
- @if (jobs().length > 0 && !deploymentSuccessful()) { +@if (jobs().length > 0 && !deploymentSuccessful()) { +

Job Status

-
- @for (job of jobs(); track job.id) { -
-
-
- - - {{ job.name }} - - {{ getStatusText(job.status, job.conclusion) }} - -
-
- @if (job.startedAt) { - Started: {{ formatTime(job.startedAt) }} - - @if (job.completedAt) { - • Completed: {{ formatTime(job.completedAt) }} - - {{ getDuration(job.startedAt, job.completedAt) }} - - } @else { - Running - } - } - @if (!!job.htmlUrl) { - - - - } -
-
- - @if (isJobExpanded(job.id!)) { -
- - @if (job.runnerId) { -
- Runner: - {{ job.runnerName || 'Unknown' }} - - @if (job.labels && job.labels.length > 0) { -
- @for (label of job.labels; track label) { - - {{ label }} - - } -
- } -
- } - - - @if (job.status === 'queued' && !job.runnerId) { -
- - - Waiting for available runner... - -
- } - - - @if (job.status === 'waiting') { -
- - - Waiting for approval... - -
- } - - - @if (job.conclusion === 'skipped') { -
- This job was skipped. -
- } - - @if (job.steps?.length) { -
- @for (step of job.steps; track step.number) { -
- - {{ step.name }} - -
- @if (step.startedAt && step.completedAt) { - - {{ getDuration(step.startedAt, step.completedAt) }} - - } - - {{ getStatusText(step.status, step.conclusion) }} - -
-
- } -
- } -
- } -
- } -
+ @if (deploymentUnsuccessful() && !!latestDeployment()?.workflowRunHtmlUrl) {
@@ -125,5 +14,5 @@

Job Status

} - } -
+
+} diff --git a/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.ts b/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.ts index f7fdc079c..032229429 100644 --- a/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.ts +++ b/client/src/app/components/environments/workflow-job-status/workflow-jobs-status.component.ts @@ -1,66 +1,53 @@ -import { CommonModule, DatePipe } from '@angular/common'; import { Component, computed, effect, inject, input, signal } from '@angular/core'; import { EnvironmentDeployment, WorkflowJobDto } from '@app/core/modules/openapi'; import { getWorkflowJobStatusOptions, getWorkflowJobStatusQueryKey } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; import { PermissionService } from '@app/core/services/permission.service'; +import { WorkflowJobListComponent } from '@app/components/workflow-job-list/workflow-job-list.component'; import { injectQuery } from '@tanstack/angular-query-experimental'; import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons'; -import { - IconBrandGithub, - IconCircleCheck, - IconCircleMinus, - IconCircleX, - IconClock, - IconExternalLink, - IconProgress, - IconChevronDown, - IconChevronRight, -} from 'angular-tabler-icons/icons'; +import { IconBrandGithub } from 'angular-tabler-icons/icons'; import { Button } from 'primeng/button'; @Component({ selector: 'app-workflow-jobs-status', standalone: true, - imports: [CommonModule, TablerIconComponent, Button], + imports: [TablerIconComponent, Button, WorkflowJobListComponent], providers: [ - DatePipe, provideTablerIcons({ - IconClock, - IconProgress, - IconCircleMinus, - IconCircleCheck, - IconCircleX, IconBrandGithub, - IconExternalLink, - IconChevronDown, - IconChevronRight, }), ], templateUrl: './workflow-jobs-status.component.html', }) export class WorkflowJobsStatusComponent { permissions = inject(PermissionService); - latestDeployment = input.required(); - workflowRunId = input.required(); + /** + * The latest deployment to monitor. When undefined, the component renders nothing. + * The workflowRunId is derived from the deployment's workflowRunHtmlUrl. + */ + latestDeployment = input(); - private datePipe = inject(DatePipe); + workflowRunId = computed(() => { + const url = this.latestDeployment()?.workflowRunHtmlUrl; + const match = url?.match(/\/runs\/(\d+)$/); + return match ? parseInt(match[1], 10) : undefined; + }); private extraRefetchStarted = signal(false); private extraRefetchCompleted = signal(false); - expandedJobs = signal>({}); + deploymentInProgress = computed(() => { + return ['IN_PROGRESS', 'WAITING', 'REQUESTED', 'PENDING', 'QUEUED'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; + }); - toggleJobExpansion(jobId: number) { - this.expandedJobs.update(state => ({ - ...state, - [jobId]: !state[jobId], - })); - } + deploymentSuccessful = computed(() => { + return ['SUCCESS'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; + }); - isJobExpanded(jobId: number): boolean { - return !!this.expandedJobs()[jobId]; - } + deploymentUnsuccessful = computed(() => { + return ['ERROR', 'FAILURE'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; + }); // Control when to poll for job status - during active deployment or limited extra fetches shouldPoll = computed(() => { @@ -82,44 +69,19 @@ export class WorkflowJobsStatusComponent { }); workflowJobsQuery = injectQuery(() => ({ - ...getWorkflowJobStatusOptions({ path: { runId: this.workflowRunId() } }), - queryKey: getWorkflowJobStatusQueryKey({ path: { runId: this.workflowRunId() } }), + ...getWorkflowJobStatusOptions({ path: { runId: this.workflowRunId() ?? 0 } }), + queryKey: getWorkflowJobStatusQueryKey({ path: { runId: this.workflowRunId() ?? 0 } }), enabled: this.shouldPoll(), - refetchInterval: this.extraRefetchStarted() ? 10000 : 5000, // Slower interval for extra fetches + refetchInterval: () => (this.extraRefetchStarted() ? 10000 : 5000), staleTime: 0, })); - // Extract jobs data for the template - now properly typed jobs = computed(() => { const response = this.workflowJobsQuery.data(); if (!response || !response.jobs) return []; return response.jobs; }); - // Check if all jobs are completed - allJobsCompleted = computed(() => { - const jobs = this.jobs(); - if (!jobs.length) return false; - return jobs.every(job => job.status === 'completed'); - }); - - deploymentInProgress = computed(() => { - return ['IN_PROGRESS', 'WAITING', 'REQUESTED', 'PENDING', 'QUEUED'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; - }); - - deploymentSuccessful = computed(() => { - return ['SUCCESS'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; - }); - - deploymentUnsuccessful = computed(() => { - return ['ERROR', 'FAILURE'].includes(this.latestDeployment()?.state || '') && this.latestDeployment()?.workflowRunHtmlUrl; - }); - - // Track if any jobs failed - hasFailedJobs = computed(() => { - return this.jobs().some(job => job.conclusion === 'failure'); - }); - constructor() { // Watch for changes to inputs and refresh when needed effect(() => { @@ -141,88 +103,6 @@ export class WorkflowJobsStatusComponent { } }); } - // Get CSS class for job status - getStatusClass(status: string | null | undefined, conclusion: string | null | undefined): string { - if (conclusion === 'success') return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30'; - if (conclusion === 'failure') return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30'; - if (conclusion === 'skipped') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; - if (conclusion === 'cancelled') return 'text-orange-600 bg-orange-50 dark:text-orange-400 dark:bg-orange-900/30'; - - if (status === 'in_progress') return 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'; - if (status === 'queued' || status === 'waiting') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; - - return 'text-gray-600 dark:text-gray-400'; - } - - getStatusIndicatorClass(status: string | null | undefined, conclusion: string | null | undefined): string { - if (conclusion === 'success') return 'bg-green-500 dark:bg-green-400'; - if (conclusion === 'failure') return 'bg-red-500 dark:bg-red-400'; - if (conclusion === 'skipped') return 'bg-gray-400 dark:bg-gray-500'; - if (conclusion === 'cancelled') return 'bg-orange-500 dark:bg-orange-400'; - - if (status === 'in_progress') return 'bg-blue-500 dark:bg-blue-400'; - if (status === 'queued' || status === 'waiting') return 'bg-gray-300 dark:bg-gray-600'; - - return 'bg-gray-300 dark:bg-gray-600'; - } - - // Get icon for job status - getStatusIcon(status: string | null | undefined, conclusion: string | null | undefined): string { - if (conclusion === 'success') return 'circle-check'; - if (conclusion === 'failure') return 'circle-x'; - if (conclusion === 'skipped' || conclusion === 'cancelled') return 'circle-minus'; - - if (status === 'in_progress') return 'progress'; - if (status === 'queued' || status === 'waiting') return 'clock'; - - return 'help'; - } - - getIconColorClass(status: string | null | undefined, conclusion: string | null | undefined): string { - if (conclusion === 'success') return 'text-green-600 dark:text-green-400'; - if (conclusion === 'failure') return 'text-red-600 dark:text-red-400'; - if (conclusion === 'skipped') return 'text-gray-600 dark:text-gray-400'; - if (conclusion === 'cancelled') return 'text-orange-600 dark:text-orange-400'; - - if (status === 'in_progress') return 'text-blue-600 dark:text-blue-400 animate-spin'; - if (status === 'queued' || status === 'waiting') return 'text-gray-600 dark:text-gray-400'; - - return 'text-gray-600 dark:text-gray-400'; - } - - // Get status text for display - getStatusText(status: string | null | undefined, conclusion: string | null | undefined): string { - return conclusion || status || 'Unknown'; - } - - // Format timestamp to readable format - formatTime(timestamp: string | null | undefined): string { - if (!timestamp) return ''; - return this.datePipe.transform(timestamp, 'HH:mm:ss') || ''; - } - - // Calculate duration between start and end time - getDuration(startTime: string | undefined, endTime: string | undefined): string { - if (!startTime) return ''; - - const start = new Date(startTime).getTime(); - const end = endTime ? new Date(endTime).getTime() : Date.now(); - - const durationMs = end - start; - const seconds = Math.floor(durationMs / 1000); - - if (seconds < 60) { - return `${seconds}s`; - } else if (seconds < 3600) { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds}s`; - } else { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return `${hours}h ${minutes}m`; - } - } openLink(url: string | undefined) { if (url) { diff --git a/client/src/app/components/pipeline/pipeline.component.ts b/client/src/app/components/pipeline/pipeline.component.ts index fed223627..2b4ee5ba8 100644 --- a/client/src/app/components/pipeline/pipeline.component.ts +++ b/client/src/app/components/pipeline/pipeline.component.ts @@ -23,6 +23,9 @@ export type PipelineSelector = { repositoryId: number } & ( | { pullRequestId: number; } + | { + workflowRunId: number; + } ); export interface Pipeline { diff --git a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts index 61e4fd017..1adccd974 100644 --- a/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts +++ b/client/src/app/components/pipeline/test-results/pipeline-test-results.component.ts @@ -18,6 +18,7 @@ import { getGitRepoSettingsOptions, getLatestTestResultsByBranchOptions, getLatestTestResultsByPullRequestIdOptions, + getTestResultsByWorkflowRunIdOptions, } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; import { TestCaseDto, TestSuiteDto, TestTypeResults } from '@app/core/modules/openapi'; import { DialogModule } from 'primeng/dialog'; @@ -257,6 +258,12 @@ export class PipelineTestResultsComponent { return 'pullRequestId' in selector ? selector.pullRequestId : null; }); + workflowRunId = computed(() => { + const selector = this.selector(); + if (!selector) return null; + return 'workflowRunId' in selector ? selector.workflowRunId : null; + }); + branchQuery = injectQuery(() => ({ ...getLatestTestResultsByBranchOptions({ query: { @@ -285,8 +292,23 @@ export class PipelineTestResultsComponent { // refetchInterval: 15000, })); + workflowRunQuery = injectQuery(() => ({ + ...getTestResultsByWorkflowRunIdOptions({ + path: { workflowRunId: this.workflowRunId() || 0 }, + query: { + page: this.testSuiteFirst() / this.testSuiteRows(), + size: this.testSuiteRows(), + search: this.searchValue(), + onlyFailed: this.showOnlyFailed(), + }, + }), + enabled: this.workflowRunId() !== null, + })); + resultsQuery = computed(() => { - return this.branchName() ? this.branchQuery : this.pullRequestQuery; + if (this.branchName()) return this.branchQuery; + if (this.pullRequestId()) return this.pullRequestQuery; + return this.workflowRunQuery; }); op = viewChild.required('op'); diff --git a/client/src/app/components/workflow-job-list/workflow-job-list.component.html b/client/src/app/components/workflow-job-list/workflow-job-list.component.html new file mode 100644 index 000000000..b512bc587 --- /dev/null +++ b/client/src/app/components/workflow-job-list/workflow-job-list.component.html @@ -0,0 +1,123 @@ +@if (isPending() && !jobs().length) { +
+ + + +
+} @else if (isError()) { +
+ Failed to load jobs. The workflow run might have been deleted or there was an error fetching the data. +
+} @else if (!isPending() && jobs().length === 0) { +
No jobs found for this workflow run.
+} @else { +
+ @for (job of jobs(); track job.id) { +
+
+
+ + + {{ job.name }} + + {{ getStatusText(job.status, job.conclusion) }} + +
+
+ @if (job.startedAt) { + Started: {{ formatTime(job.startedAt) }} + @if (job.completedAt) { + • Completed: {{ formatTime(job.completedAt) }} + + {{ getDuration(job.startedAt, job.completedAt) }} + + } @else { + Running + } + } + @if (!!job.htmlUrl) { + + + + } +
+
+ + @if (isJobExpanded(job.id!)) { +
+ + @if (job.runnerId) { +
+ Runner: + {{ job.runnerName || 'Unknown' }} + @if (job.labels && job.labels.length > 0) { +
+ @for (label of job.labels; track label) { + + {{ label }} + + } +
+ } +
+ } + + + @if (job.status === 'queued' && !job.runnerId) { +
+ + + Waiting for available runner... + +
+ } + + + @if (job.status === 'waiting') { +
+ + + Waiting for approval... + +
+ } + + + @if (job.conclusion === 'skipped') { +
+ This job was skipped. +
+ } + + @if (job.steps?.length) { +
+ @for (step of job.steps; track step.number) { +
+ + {{ step.name }} +
+ @if (step.startedAt && step.completedAt) { + + {{ getDuration(step.startedAt, step.completedAt) }} + + } + + {{ getStatusText(step.status, step.conclusion) }} + +
+
+ } +
+ } +
+ } +
+ } +
+} diff --git a/client/src/app/components/workflow-job-list/workflow-job-list.component.ts b/client/src/app/components/workflow-job-list/workflow-job-list.component.ts new file mode 100644 index 000000000..360ec102b --- /dev/null +++ b/client/src/app/components/workflow-job-list/workflow-job-list.component.ts @@ -0,0 +1,124 @@ +import { CommonModule, DatePipe } from '@angular/common'; +import { Component, inject, input, signal } from '@angular/core'; +import { WorkflowJobDto } from '@app/core/modules/openapi'; +import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons'; +import { + IconBrandGithub, + IconCircleCheck, + IconCircleMinus, + IconCircleX, + IconClock, + IconExternalLink, + IconProgress, + IconChevronDown, + IconChevronRight, +} from 'angular-tabler-icons/icons'; +import { Button } from 'primeng/button'; +import { SkeletonModule } from 'primeng/skeleton'; + +@Component({ + selector: 'app-workflow-job-list', + standalone: true, + imports: [CommonModule, TablerIconComponent, Button, SkeletonModule], + providers: [ + DatePipe, + provideTablerIcons({ + IconClock, + IconProgress, + IconCircleMinus, + IconCircleCheck, + IconCircleX, + IconBrandGithub, + IconExternalLink, + IconChevronDown, + IconChevronRight, + }), + ], + templateUrl: './workflow-job-list.component.html', +}) +export class WorkflowJobListComponent { + jobs = input.required(); + isPending = input(false); + isError = input(false); + + private datePipe = inject(DatePipe); + + expandedJobs = signal>({}); + + toggleJobExpansion(jobId: number) { + this.expandedJobs.update(state => ({ + ...state, + [jobId]: !state[jobId], + })); + } + + isJobExpanded(jobId: number): boolean { + return !!this.expandedJobs()[jobId]; + } + + getStatusClass(status: string | null | undefined, conclusion: string | null | undefined): string { + if (conclusion === 'success') return 'text-green-600 bg-green-100 dark:text-green-400 dark:bg-green-900/30'; + if (conclusion === 'failure') return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30'; + if (conclusion === 'skipped') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; + if (conclusion === 'cancelled') return 'text-orange-600 bg-orange-50 dark:text-orange-400 dark:bg-orange-900/30'; + if (status === 'in_progress') return 'text-blue-600 bg-blue-50 dark:text-blue-400 dark:bg-blue-900/30'; + if (status === 'queued' || status === 'waiting') return 'text-gray-600 bg-gray-100 dark:text-gray-400 dark:bg-gray-900'; + return 'text-gray-600 dark:text-gray-400'; + } + + getStatusIndicatorClass(status: string | null | undefined, conclusion: string | null | undefined): string { + if (conclusion === 'success') return 'bg-green-500 dark:bg-green-400'; + if (conclusion === 'failure') return 'bg-red-500 dark:bg-red-400'; + if (conclusion === 'skipped') return 'bg-gray-400 dark:bg-gray-500'; + if (conclusion === 'cancelled') return 'bg-orange-500 dark:bg-orange-400'; + if (status === 'in_progress') return 'bg-blue-500 dark:bg-blue-400'; + if (status === 'queued' || status === 'waiting') return 'bg-gray-300 dark:bg-gray-600'; + return 'bg-gray-300 dark:bg-gray-600'; + } + + getStatusIcon(status: string | null | undefined, conclusion: string | null | undefined): string { + if (conclusion === 'success') return 'circle-check'; + if (conclusion === 'failure') return 'circle-x'; + if (conclusion === 'skipped' || conclusion === 'cancelled') return 'circle-minus'; + if (status === 'in_progress') return 'progress'; + if (status === 'queued' || status === 'waiting') return 'clock'; + return 'help'; + } + + getIconColorClass(status: string | null | undefined, conclusion: string | null | undefined): string { + if (conclusion === 'success') return 'text-green-600 dark:text-green-400'; + if (conclusion === 'failure') return 'text-red-600 dark:text-red-400'; + if (conclusion === 'skipped') return 'text-gray-600 dark:text-gray-400'; + if (conclusion === 'cancelled') return 'text-orange-600 dark:text-orange-400'; + if (status === 'in_progress') return 'text-blue-600 dark:text-blue-400 animate-spin'; + if (status === 'queued' || status === 'waiting') return 'text-gray-600 dark:text-gray-400'; + return 'text-gray-600 dark:text-gray-400'; + } + + getStatusText(status: string | null | undefined, conclusion: string | null | undefined): string { + return conclusion || status || 'Unknown'; + } + + formatTime(timestamp: string | null | undefined): string { + if (!timestamp) return ''; + return this.datePipe.transform(timestamp, 'HH:mm:ss') || ''; + } + + getDuration(startTime: string | undefined, endTime: string | undefined): string { + if (!startTime) return ''; + const start = new Date(startTime).getTime(); + const end = endTime ? new Date(endTime).getTime() : Date.now(); + const seconds = Math.floor((end - start) / 1000); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + } + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + } + + openLink(url: string | undefined) { + if (url) { + window.open(url, '_blank'); + } + } +} diff --git a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.html b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.html index a51c551f5..7f42877f6 100644 --- a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.html +++ b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.html @@ -21,9 +21,9 @@ Status - Tests + Test Processing Started - Updated + Duration @@ -38,7 +38,7 @@ } @else { - +
@@ -78,11 +78,11 @@ @if (run.runStartedAt) { - {{ run.runStartedAt | timeAgo }} + {{ run.runStartedAt | timeAgo }} } - {{ run.updatedAt | timeAgo }} + {{ getDuration(run) }} } diff --git a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.spec.ts b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.spec.ts index 269c23674..21e4cb80e 100644 --- a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.spec.ts +++ b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.spec.ts @@ -68,6 +68,7 @@ describe('WorkflowRunsTableComponent', () => { fixture = TestBed.createComponent(WorkflowRunsTableComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('repositoryId', 1); setMockQuery(component, { data: undefined, isPending: false, isError: false }); diff --git a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.ts b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.ts index 278544b22..3dbccec3a 100644 --- a/client/src/app/components/workflow-runs-table/workflow-runs-table.component.ts +++ b/client/src/app/components/workflow-runs-table/workflow-runs-table.component.ts @@ -1,4 +1,5 @@ -import { Component, computed, inject, ViewChild } from '@angular/core'; +import { Component, computed, inject, input, numberAttribute, ViewChild } from '@angular/core'; +import { Router } from '@angular/router'; import { Table, TableModule, TablePageEvent } from 'primeng/table'; import { TagModule } from 'primeng/tag'; import { TooltipModule } from 'primeng/tooltip'; @@ -55,6 +56,9 @@ export class WorkflowRunsTableComponent { messageService = inject(MessageService); paginationService = inject(PaginatedTableService); + private router = inject(Router); + + repositoryId = input.required({ transform: numberAttribute }); queryOptions = computed(() => { const paginationState = this.paginationService.paginationState(); @@ -121,6 +125,12 @@ export class WorkflowRunsTableComponent { event.stopPropagation(); } + navigateToRun(run: WorkflowRunDto) { + this.router.navigate(['/repo', this.repositoryId(), 'ci-cd', 'runs', run.id], { + state: { workflowRun: run }, + }); + } + getWorkflowStatusIcon(run: WorkflowRunDto): string { if (run.conclusion === 'SUCCESS') { return 'circle-check'; @@ -190,4 +200,26 @@ export class WorkflowRunsTableComponent { } return 'text-surface-500'; } + + formatExactDate(dateStr: string | null | undefined): string | undefined { + if (!dateStr) return undefined; + return new Date(dateStr).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + + getDuration(run: WorkflowRunDto): string { + if (!run.runStartedAt) return '—'; + const start = new Date(run.runStartedAt).getTime(); + const end = run.updatedAt ? new Date(run.updatedAt).getTime() : Date.now(); + const seconds = Math.floor((end - start) / 1000); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + } } diff --git a/client/src/app/core/modules/openapi/@tanstack/angular-query-experimental.gen.ts b/client/src/app/core/modules/openapi/@tanstack/angular-query-experimental.gen.ts index 5bfc4a4c4..f44db4e05 100644 --- a/client/src/app/core/modules/openapi/@tanstack/angular-query-experimental.gen.ts +++ b/client/src/app/core/modules/openapi/@tanstack/angular-query-experimental.gen.ts @@ -5,6 +5,7 @@ import { type InfiniteData, infiniteQueryOptions, type MutationOptions, queryOpt import { client } from '../client.gen'; import { cancelDeployment, + cancelWorkflowRun, createReleaseCandidate, createTestType, createWorkflowGroup, @@ -52,10 +53,12 @@ import { getPullRequests, getReleaseInfoByName, getRepositoryById, + getTestResultsByWorkflowRunId, getUserPermissions, getUserSettings, getWorkflowById, getWorkflowJobStatus, + getWorkflowRunById, getWorkflowRuns, getWorkflowsByRepositoryId, getWorkflowsByState, @@ -64,6 +67,8 @@ import { type Options, publishReleaseDraft, reconcilePullRequestState, + reRunFailedJobs, + reRunWorkflow, rotateSecret, setBranchPinnedByRepositoryIdAndNameAndUserId, setPrPinnedByNumber, @@ -85,6 +90,8 @@ import type { CancelDeploymentData, CancelDeploymentError, CancelDeploymentResponse, + CancelWorkflowRunData, + CancelWorkflowRunError, CreateReleaseCandidateData, CreateReleaseCandidateError, CreateReleaseCandidateResponse, @@ -160,10 +167,14 @@ import type { GetReleaseInfoByNameError, GetReleaseInfoByNameResponse, GetRepositoryByIdData, + GetTestResultsByWorkflowRunIdData, + GetTestResultsByWorkflowRunIdError, + GetTestResultsByWorkflowRunIdResponse, GetUserPermissionsData, GetUserSettingsData, GetWorkflowByIdData, GetWorkflowJobStatusData, + GetWorkflowRunByIdData, GetWorkflowRunsData, GetWorkflowRunsError, GetWorkflowRunsResponse, @@ -178,6 +189,10 @@ import type { ReconcilePullRequestStateData, ReconcilePullRequestStateError, ReconcilePullRequestStateResponse, + ReRunFailedJobsData, + ReRunFailedJobsError, + ReRunWorkflowData, + ReRunWorkflowError, RotateSecretData, RotateSecretError, RotateSecretResponse, @@ -457,6 +472,48 @@ export const extendEnvironmentLockMutation = ( return mutationOptions; }; +export const reRunWorkflowMutation = (options?: Partial>): MutationOptions> => { + const mutationOptions: MutationOptions> = { + mutationFn: async fnOptions => { + const { data } = await reRunWorkflow({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const reRunFailedJobsMutation = (options?: Partial>): MutationOptions> => { + const mutationOptions: MutationOptions> = { + mutationFn: async fnOptions => { + const { data } = await reRunFailedJobs({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + +export const cancelWorkflowRunMutation = (options?: Partial>): MutationOptions> => { + const mutationOptions: MutationOptions> = { + mutationFn: async fnOptions => { + const { data } = await cancelWorkflowRun({ + ...options, + ...fnOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const syncWorkflowsByRepositoryIdMutation = ( options?: Partial> ): MutationOptions> => { @@ -991,6 +1048,23 @@ export const getWorkflowRunsInfiniteOptions = (options?: Options) => createQueryKey('getWorkflowRunById', options); + +export const getWorkflowRunByIdOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getWorkflowRunById({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getWorkflowRunByIdQueryKey(options), + }); +}; + export const getWorkflowsByRepositoryIdQueryKey = (options: Options) => createQueryKey('getWorkflowsByRepositoryId', options); export const getWorkflowsByRepositoryIdOptions = (options: Options) => { @@ -1061,6 +1135,60 @@ export const getUserPermissionsOptions = (options?: Options) => createQueryKey('getTestResultsByWorkflowRunId', options); + +export const getTestResultsByWorkflowRunIdOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getTestResultsByWorkflowRunId({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getTestResultsByWorkflowRunIdQueryKey(options), + }); +}; + +export const getTestResultsByWorkflowRunIdInfiniteQueryKey = (options: Options): QueryKey> => + createQueryKey('getTestResultsByWorkflowRunId', options, true); + +export const getTestResultsByWorkflowRunIdInfiniteOptions = (options: Options) => { + return infiniteQueryOptions< + GetTestResultsByWorkflowRunIdResponse, + GetTestResultsByWorkflowRunIdError, + InfiniteData, + QueryKey>, + number | Pick>[0], 'body' | 'headers' | 'path' | 'query'> + >( + // @ts-ignore + { + queryFn: async ({ pageParam, queryKey, signal }) => { + // @ts-ignore + const page: Pick>[0], 'body' | 'headers' | 'path' | 'query'> = + typeof pageParam === 'object' + ? pageParam + : { + query: { + page: pageParam, + }, + }; + const params = createInfiniteParams(queryKey, page); + const { data } = await getTestResultsByWorkflowRunId({ + ...options, + ...params, + signal, + throwOnError: true, + }); + return data; + }, + queryKey: getTestResultsByWorkflowRunIdInfiniteQueryKey(options), + } + ); +}; + export const getLatestTestResultsByPullRequestIdQueryKey = (options: Options) => createQueryKey('getLatestTestResultsByPullRequestId', options); diff --git a/client/src/app/core/modules/openapi/sdk.gen.ts b/client/src/app/core/modules/openapi/sdk.gen.ts index 49c62f8a1..810ebd394 100644 --- a/client/src/app/core/modules/openapi/sdk.gen.ts +++ b/client/src/app/core/modules/openapi/sdk.gen.ts @@ -6,6 +6,9 @@ import type { CancelDeploymentData, CancelDeploymentErrors, CancelDeploymentResponses, + CancelWorkflowRunData, + CancelWorkflowRunErrors, + CancelWorkflowRunResponses, CreateReleaseCandidateData, CreateReleaseCandidateErrors, CreateReleaseCandidateResponses, @@ -147,6 +150,9 @@ import type { GetRepositoryByIdData, GetRepositoryByIdErrors, GetRepositoryByIdResponses, + GetTestResultsByWorkflowRunIdData, + GetTestResultsByWorkflowRunIdErrors, + GetTestResultsByWorkflowRunIdResponses, GetUserPermissionsData, GetUserPermissionsErrors, GetUserPermissionsResponses, @@ -159,6 +165,9 @@ import type { GetWorkflowJobStatusData, GetWorkflowJobStatusErrors, GetWorkflowJobStatusResponses, + GetWorkflowRunByIdData, + GetWorkflowRunByIdErrors, + GetWorkflowRunByIdResponses, GetWorkflowRunsData, GetWorkflowRunsErrors, GetWorkflowRunsResponses, @@ -180,6 +189,12 @@ import type { ReconcilePullRequestStateData, ReconcilePullRequestStateErrors, ReconcilePullRequestStateResponses, + ReRunFailedJobsData, + ReRunFailedJobsErrors, + ReRunFailedJobsResponses, + ReRunWorkflowData, + ReRunWorkflowErrors, + ReRunWorkflowResponses, RotateSecretData, RotateSecretErrors, RotateSecretResponses, @@ -363,6 +378,27 @@ export const extendEnvironmentLock = (opti }); }; +export const reRunWorkflow = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/workflows/runs/{runId}/rerun', + ...options, + }); +}; + +export const reRunFailedJobs = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/workflows/runs/{runId}/rerun-failed-jobs', + ...options, + }); +}; + +export const cancelWorkflowRun = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/workflows/runs/{runId}/cancel', + ...options, + }); +}; + export const syncWorkflowsByRepositoryId = (options: Options) => { return (options.client ?? client).post({ url: '/api/workflows/repository/{repositoryId}/sync', @@ -624,6 +660,13 @@ export const getWorkflowRuns = (options?: }); }; +export const getWorkflowRunById = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/workflows/runs/{runId}', + ...options, + }); +}; + export const getWorkflowsByRepositoryId = (options: Options) => { return (options.client ?? client).get({ url: '/api/workflows/repository/{repositoryId}', @@ -656,6 +699,13 @@ export const getUserPermissions = (options }); }; +export const getTestResultsByWorkflowRunId = (options: Options) => { + return (options.client ?? client).get({ + url: '/api/tests/run/{workflowRunId}', + ...options, + }); +}; + export const getLatestTestResultsByPullRequestId = (options: Options) => { return (options.client ?? client).get({ url: '/api/tests/pr/{pullRequestId}', diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts index 45d426cb2..c9dfde24e 100644 --- a/client/src/app/core/modules/openapi/types.gen.ts +++ b/client/src/app/core/modules/openapi/types.gen.ts @@ -937,6 +937,81 @@ export type ExtendEnvironmentLockResponses = { export type ExtendEnvironmentLockResponse = ExtendEnvironmentLockResponses[keyof ExtendEnvironmentLockResponses]; +export type ReRunWorkflowData = { + body?: never; + path: { + runId: number; + }; + query?: never; + url: '/api/workflows/runs/{runId}/rerun'; +}; + +export type ReRunWorkflowErrors = { + /** + * Conflict + */ + 409: ApiError; +}; + +export type ReRunWorkflowError = ReRunWorkflowErrors[keyof ReRunWorkflowErrors]; + +export type ReRunWorkflowResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type ReRunFailedJobsData = { + body?: never; + path: { + runId: number; + }; + query?: never; + url: '/api/workflows/runs/{runId}/rerun-failed-jobs'; +}; + +export type ReRunFailedJobsErrors = { + /** + * Conflict + */ + 409: ApiError; +}; + +export type ReRunFailedJobsError = ReRunFailedJobsErrors[keyof ReRunFailedJobsErrors]; + +export type ReRunFailedJobsResponses = { + /** + * OK + */ + 200: unknown; +}; + +export type CancelWorkflowRunData = { + body?: never; + path: { + runId: number; + }; + query?: never; + url: '/api/workflows/runs/{runId}/cancel'; +}; + +export type CancelWorkflowRunErrors = { + /** + * Conflict + */ + 409: ApiError; +}; + +export type CancelWorkflowRunError = CancelWorkflowRunErrors[keyof CancelWorkflowRunErrors]; + +export type CancelWorkflowRunResponses = { + /** + * OK + */ + 200: unknown; +}; + export type SyncWorkflowsByRepositoryIdData = { body?: never; path: { @@ -1678,6 +1753,33 @@ export type GetWorkflowRunsResponses = { export type GetWorkflowRunsResponse = GetWorkflowRunsResponses[keyof GetWorkflowRunsResponses]; +export type GetWorkflowRunByIdData = { + body?: never; + path: { + runId: number; + }; + query?: never; + url: '/api/workflows/runs/{runId}'; +}; + +export type GetWorkflowRunByIdErrors = { + /** + * Conflict + */ + 409: ApiError; +}; + +export type GetWorkflowRunByIdError = GetWorkflowRunByIdErrors[keyof GetWorkflowRunByIdErrors]; + +export type GetWorkflowRunByIdResponses = { + /** + * OK + */ + 200: WorkflowRunDto; +}; + +export type GetWorkflowRunByIdResponse = GetWorkflowRunByIdResponses[keyof GetWorkflowRunByIdResponses]; + export type GetWorkflowsByRepositoryIdData = { body?: never; path: { @@ -1786,6 +1888,38 @@ export type GetUserPermissionsResponses = { export type GetUserPermissionsResponse = GetUserPermissionsResponses[keyof GetUserPermissionsResponses]; +export type GetTestResultsByWorkflowRunIdData = { + body?: never; + path: { + workflowRunId: number; + }; + query?: { + page?: number; + size?: number; + search?: string; + onlyFailed?: boolean; + }; + url: '/api/tests/run/{workflowRunId}'; +}; + +export type GetTestResultsByWorkflowRunIdErrors = { + /** + * Conflict + */ + 409: ApiError; +}; + +export type GetTestResultsByWorkflowRunIdError = GetTestResultsByWorkflowRunIdErrors[keyof GetTestResultsByWorkflowRunIdErrors]; + +export type GetTestResultsByWorkflowRunIdResponses = { + /** + * OK + */ + 200: TestResultsDto; +}; + +export type GetTestResultsByWorkflowRunIdResponse = GetTestResultsByWorkflowRunIdResponses[keyof GetTestResultsByWorkflowRunIdResponses]; + export type GetLatestTestResultsByPullRequestIdData = { body?: never; path: { diff --git a/client/src/app/pages/workflow-run-details/workflow-run-details.component.html b/client/src/app/pages/workflow-run-details/workflow-run-details.component.html new file mode 100644 index 000000000..a2c574c98 --- /dev/null +++ b/client/src/app/pages/workflow-run-details/workflow-run-details.component.html @@ -0,0 +1,91 @@ + +
+ + + Back to Workflow Runs + + + @if (runQuery.isPending()) { +
+ + + +
+ } @else if (runQuery.isError()) { +
+

Could not load workflow run #{{ runId() }}.

+ Back to Workflow Runs +
+ } @else if (run(); as runData) { +
+
+
+

{{ runData.displayTitle || runData.name }}

+
+
+ + {{ runData.conclusion || runData.status }} +
+ +
+ @if (runData.headBranch || runData.headSha) { +
+ @if (runData.headBranch) { + Branch: {{ runData.headBranch }} + } + @if (runData.headSha) { + ({{ runData.headSha.slice(0, 7) }}) + } +
+ } + @if (runData.testProcessingStatus) { +
+ Test Processing: + {{ runData.testProcessingStatus }} +
+ } +
+ @if (runData.runStartedAt) { + Started {{ runData.runStartedAt | timeAgo }} + } + Duration: {{ getDuration(runData) }} +
+
+
+ @if (permissions.hasWritePermission()) { + @if (isRunActive()) { + + + + } + @if (isRunCompleted()) { + @if (canReRunFailed()) { + + + + } + + + + } + } + + + +
+
+
+ } +
+ +
+ @if (permissions.hasWritePermission()) { +
+ +

Jobs

+ +
+ } + + +
diff --git a/client/src/app/pages/workflow-run-details/workflow-run-details.component.spec.ts b/client/src/app/pages/workflow-run-details/workflow-run-details.component.spec.ts new file mode 100644 index 000000000..763aacb71 --- /dev/null +++ b/client/src/app/pages/workflow-run-details/workflow-run-details.component.spec.ts @@ -0,0 +1,92 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; + +import { WorkflowRunDetailsComponent } from './workflow-run-details.component'; +import { PermissionService } from '@app/core/services/permission.service'; +import type { WorkflowRunDto, WorkflowJobDto } from '@app/core/modules/openapi'; + +function createRun(overrides: Partial = {}): WorkflowRunDto { + return { + id: 1, + name: 'CI', + displayTitle: 'CI', + status: 'COMPLETED', + workflowId: 10, + htmlUrl: 'https://github.com/org/repo/actions/runs/1', + label: 'TEST', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:01:00Z', + ...overrides, + }; +} + +describe('WorkflowRunDetailsComponent', () => { + let component: WorkflowRunDetailsComponent; + let fixture: ComponentFixture; + + const permissionServiceMock = { + hasWritePermission: () => true, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WorkflowRunDetailsComponent], + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + provideQueryClient(new QueryClient()), + provideNoopAnimations(), + { provide: PermissionService, useValue: permissionServiceMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WorkflowRunDetailsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('repositoryId', 1); + fixture.componentRef.setInput('runId', 1); + + const run = createRun({ id: 1 }); + const jobs: WorkflowJobDto[] = [ + { + id: 11, + name: 'build', + status: 'completed', + conclusion: 'success', + }, + ]; + + Object.defineProperty(component, 'runQuery', { + configurable: true, + value: { + data: () => run, + isPending: () => false, + isError: () => false, + }, + }); + + Object.defineProperty(component, 'workflowJobsQuery', { + configurable: true, + value: { + data: () => ({ jobs }), + isPending: () => false, + isError: () => false, + }, + }); + + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('renders workflow job list section', () => { + const hostElement: HTMLElement = fixture.nativeElement; + const jobList = hostElement.querySelector('app-workflow-job-list'); + expect(jobList).toBeTruthy(); + }); +}); diff --git a/client/src/app/pages/workflow-run-details/workflow-run-details.component.ts b/client/src/app/pages/workflow-run-details/workflow-run-details.component.ts new file mode 100644 index 000000000..1db8b423d --- /dev/null +++ b/client/src/app/pages/workflow-run-details/workflow-run-details.component.ts @@ -0,0 +1,251 @@ +import { Component, computed, inject, input, numberAttribute, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { WorkflowJobDto, WorkflowRunDto } from '@app/core/modules/openapi'; +import { + cancelWorkflowRunMutation, + getWorkflowJobStatusOptions, + getWorkflowJobStatusQueryKey, + getWorkflowRunByIdOptions, + getWorkflowRunByIdQueryKey, + reRunWorkflowMutation, + reRunFailedJobsMutation, +} from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; +import { PermissionService } from '@app/core/services/permission.service'; +import { WorkflowJobListComponent } from '@app/components/workflow-job-list/workflow-job-list.component'; +import { PipelineTestResultsComponent } from '@app/components/pipeline/test-results/pipeline-test-results.component'; +import { PipelineSelector } from '@app/components/pipeline/pipeline.component'; +import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental'; +import { MessageService } from 'primeng/api'; +import { TagModule } from 'primeng/tag'; +import { ButtonModule } from 'primeng/button'; +import { DividerModule } from 'primeng/divider'; +import { ToastModule } from 'primeng/toast'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TimeAgoPipe } from '@app/pipes/time-ago.pipe'; +import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons'; +import { + IconArrowLeft, + IconBrandGithub, + IconCircleCheck, + IconCircleX, + IconClockHour4, + IconProgress, + IconAlertTriangle, + IconPlayerPlay, + IconRefresh, + IconX, +} from 'angular-tabler-icons/icons'; +import { TooltipModule } from 'primeng/tooltip'; + +@Component({ + selector: 'app-workflow-run-details', + standalone: true, + imports: [ + RouterLink, + TagModule, + ButtonModule, + DividerModule, + ToastModule, + SkeletonModule, + TimeAgoPipe, + TablerIconComponent, + TooltipModule, + WorkflowJobListComponent, + PipelineTestResultsComponent, + ], + providers: [ + MessageService, + provideTablerIcons({ + IconArrowLeft, + IconBrandGithub, + IconCircleCheck, + IconCircleX, + IconClockHour4, + IconProgress, + IconAlertTriangle, + IconPlayerPlay, + IconRefresh, + IconX, + }), + ], + templateUrl: './workflow-run-details.component.html', +}) +export class WorkflowRunDetailsComponent { + repositoryId = input.required({ transform: numberAttribute }); + runId = input.required({ transform: numberAttribute }); + + protected permissions = inject(PermissionService); + private messageService = inject(MessageService); + private queryClient = inject(QueryClient); + + // Duration to continue polling after a user action (cancel/rerun) to ensure UI updates promptly. + private readonly FORCED_POLL_DURATION_MS = 2 * 60 * 1000; + + // Timestamp set when a user action (cancel/rerun) is triggered. + private actionTriggeredAt = signal(null); + + runQuery = injectQuery(() => { + const actionAt = this.actionTriggeredAt(); + return { + ...getWorkflowRunByIdOptions({ path: { runId: this.runId() } }), + refetchInterval: (query: { state?: { data?: WorkflowRunDto } }) => { + const data = query.state?.data; + const activeStatuses = ['IN_PROGRESS', 'QUEUED', 'WAITING', 'PENDING', 'REQUESTED']; + if (data && activeStatuses.includes(data.status)) return 5000; + if (actionAt !== null && Date.now() - actionAt < this.FORCED_POLL_DURATION_MS) return 5000; + return 0; + }, + staleTime: 0, + }; + }); + + run = computed(() => this.runQuery.data() ?? null); + + pipelineSelector = computed(() => { + const r = this.run(); + if (!r || r.label !== 'TEST') return null; + return { repositoryId: this.repositoryId(), workflowRunId: this.runId() }; + }); + + workflowJobsQuery = injectQuery(() => { + const actionAt = this.actionTriggeredAt(); + return { + ...getWorkflowJobStatusOptions({ path: { runId: this.runId() } }), + queryKey: getWorkflowJobStatusQueryKey({ path: { runId: this.runId() } }), + enabled: this.permissions.hasWritePermission(), + refetchInterval: (query: { state?: { data?: { jobs?: WorkflowJobDto[] } } }) => { + const data = query.state?.data; + const inProgress = (data?.jobs ?? []).some(j => j.status === 'in_progress' || j.status === 'queued' || j.status === 'waiting'); + if (inProgress) return 5000; + if (actionAt !== null && Date.now() - actionAt < this.FORCED_POLL_DURATION_MS) return 5000; + return 0; + }, + staleTime: 0, + }; + }); + + jobs = computed(() => { + const response = this.workflowJobsQuery.data(); + if (!response || !response.jobs) return []; + return response.jobs; + }); + + reRunMutation = injectMutation(() => ({ + ...reRunWorkflowMutation(), + onSuccess: () => { + this.messageService.add({ severity: 'success', summary: 'Re-run triggered', detail: 'All jobs have been queued for re-run.' }); + this.invalidateRunQueries(); + }, + onError: () => { + this.messageService.add({ severity: 'error', summary: 'Re-run failed', detail: 'Could not trigger re-run. Please try again.' }); + }, + })); + + reRunFailedMutation = injectMutation(() => ({ + ...reRunFailedJobsMutation(), + onSuccess: () => { + this.messageService.add({ severity: 'success', summary: 'Re-run triggered', detail: 'Failed jobs have been queued for re-run.' }); + this.invalidateRunQueries(); + }, + onError: () => { + this.messageService.add({ severity: 'error', summary: 'Re-run failed', detail: 'Could not trigger re-run of failed jobs. Please try again.' }); + }, + })); + + cancelMutation = injectMutation(() => ({ + ...cancelWorkflowRunMutation(), + onSuccess: () => { + this.messageService.add({ severity: 'success', summary: 'Cancellation requested', detail: 'The workflow run has been requested to cancel.' }); + this.invalidateRunQueries(); + }, + onError: () => { + this.messageService.add({ severity: 'error', summary: 'Cancellation failed', detail: 'Could not cancel the workflow run. Please try again.' }); + }, + })); + + private invalidateRunQueries(): void { + const runId = this.runId(); + // Set the timestamp to trigger more frequent polling for a short duration to ensure UI updates promptly after user actions. + this.actionTriggeredAt.set(Date.now()); + this.queryClient.invalidateQueries({ queryKey: getWorkflowRunByIdQueryKey({ path: { runId } }) }); + this.queryClient.invalidateQueries({ queryKey: getWorkflowJobStatusQueryKey({ path: { runId } }) }); + } + + canReRunFailed = computed(() => { + const r = this.run(); + return ['FAILURE', 'STARTUP_FAILURE', 'TIMED_OUT'].includes(r?.conclusion ?? ''); + }); + + isRunCompleted = computed(() => { + const r = this.run(); + return r?.conclusion != null; + }); + + isRunActive = computed(() => { + const r = this.run(); + return ['IN_PROGRESS', 'QUEUED', 'WAITING', 'PENDING', 'REQUESTED'].includes(r?.status ?? ''); + }); + + onReRun(): void { + this.reRunMutation.mutate({ path: { runId: this.runId() } }); + } + + onReRunFailed(): void { + this.reRunFailedMutation.mutate({ path: { runId: this.runId() } }); + } + + onCancel(): void { + this.cancelMutation.mutate({ path: { runId: this.runId() } }); + } + + formatExactDate(dateStr: string | null | undefined): string | undefined { + if (!dateStr) return undefined; + return new Date(dateStr).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + + getDuration(run: WorkflowRunDto | null): string { + if (!run?.runStartedAt) return '—'; + const start = new Date(run.runStartedAt).getTime(); + const end = run.updatedAt ? new Date(run.updatedAt).getTime() : Date.now(); + const seconds = Math.floor((end - start) / 1000); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + } + + openRunExternal(): void { + const r = this.run(); + if (r?.htmlUrl) { + window.open(r.htmlUrl, '_blank'); + } + } + + getWorkflowStatusIcon(run: WorkflowRunDto | null): string { + if (!run) return 'help'; + if (run.conclusion === 'SUCCESS') return 'circle-check'; + if (['FAILURE', 'STARTUP_FAILURE', 'TIMED_OUT'].includes(run.conclusion ?? '')) return 'circle-x'; + if (run.conclusion === 'CANCELLED') return 'circle-x'; + if (run.status === 'IN_PROGRESS') return 'progress'; + if (['QUEUED', 'WAITING', 'PENDING', 'REQUESTED'].includes(run.status)) return 'clock-hour-4'; + if (run.status === 'ACTION_REQUIRED' || run.conclusion === 'ACTION_REQUIRED') return 'alert-triangle'; + return 'circle-x'; + } + + getWorkflowStatusClass(run: WorkflowRunDto | null): string { + if (!run) return 'text-surface-500'; + if (run.conclusion === 'SUCCESS') return 'text-green-500'; + if (['FAILURE', 'STARTUP_FAILURE', 'TIMED_OUT'].includes(run.conclusion ?? '')) return 'text-red-500'; + if (run.conclusion === 'CANCELLED') return 'text-surface-500'; + if (run.status === 'IN_PROGRESS') return 'text-blue-500 animate-spin'; + if (['QUEUED', 'WAITING', 'PENDING', 'REQUESTED'].includes(run.status)) return 'text-amber-500'; + if (run.status === 'ACTION_REQUIRED' || run.conclusion === 'ACTION_REQUIRED') return 'text-orange-500'; + return 'text-surface-500'; + } +} diff --git a/client/src/app/pages/workflow-run-list/workflow-run-list.component.html b/client/src/app/pages/workflow-run-list/workflow-run-list.component.html index 7e82301ed..18f0da5d0 100644 --- a/client/src/app/pages/workflow-run-list/workflow-run-list.component.html +++ b/client/src/app/pages/workflow-run-list/workflow-run-list.component.html @@ -1 +1 @@ - + diff --git a/client/src/app/pages/workflow-run-list/workflow-run-list.component.spec.ts b/client/src/app/pages/workflow-run-list/workflow-run-list.component.spec.ts index 327846204..3b09ce781 100644 --- a/client/src/app/pages/workflow-run-list/workflow-run-list.component.spec.ts +++ b/client/src/app/pages/workflow-run-list/workflow-run-list.component.spec.ts @@ -16,6 +16,7 @@ describe('Integration Test Workflow Run List Page', () => { fixture = TestBed.createComponent(WorkflowRunListComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('repositoryId', 1); await fixture.whenStable(); }); diff --git a/client/src/app/pages/workflow-run-list/workflow-run-list.component.ts b/client/src/app/pages/workflow-run-list/workflow-run-list.component.ts index 547246396..ca87f5fb8 100644 --- a/client/src/app/pages/workflow-run-list/workflow-run-list.component.ts +++ b/client/src/app/pages/workflow-run-list/workflow-run-list.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, input, numberAttribute } from '@angular/core'; import { WorkflowRunsTableComponent } from '@app/components/workflow-runs-table/workflow-runs-table.component'; @Component({ @@ -7,4 +7,6 @@ import { WorkflowRunsTableComponent } from '@app/components/workflow-runs-table/ imports: [WorkflowRunsTableComponent], templateUrl: './workflow-run-list.component.html', }) -export class WorkflowRunListComponent {} +export class WorkflowRunListComponent { + repositoryId = input.required({ transform: numberAttribute }); +} diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index bc9a60dc2..5191dcb40 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -348,6 +348,69 @@ paths: application/json: schema: type: object + /api/workflows/runs/{runId}/rerun: + post: + tags: + - workflow-run-controller + operationId: reRunWorkflow + parameters: + - name: runId + in: path + required: true + schema: + type: integer + format: int64 + responses: + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "200": + description: OK + /api/workflows/runs/{runId}/rerun-failed-jobs: + post: + tags: + - workflow-run-controller + operationId: reRunFailedJobs + parameters: + - name: runId + in: path + required: true + schema: + type: integer + format: int64 + responses: + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "200": + description: OK + /api/workflows/runs/{runId}/cancel: + post: + tags: + - workflow-run-controller + operationId: cancelWorkflowRun + parameters: + - name: runId + in: path + required: true + schema: + type: integer + format: int64 + responses: + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "200": + description: OK /api/workflows/repository/{repositoryId}/sync: post: tags: @@ -1055,6 +1118,31 @@ paths: application/json: schema: $ref: "#/components/schemas/PaginatedWorkflowRunsResponse" + /api/workflows/runs/{runId}: + get: + tags: + - workflow-run-controller + operationId: getWorkflowRunById + parameters: + - name: runId + in: path + required: true + schema: + type: integer + format: int64 + responses: + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/WorkflowRunDto" /api/workflows/repository/{repositoryId}: get: tags: @@ -1153,6 +1241,56 @@ paths: application/json: schema: $ref: "#/components/schemas/GitHubRepositoryRoleDto" + /api/tests/run/{workflowRunId}: + get: + tags: + - test-result-controller + operationId: getTestResultsByWorkflowRunId + parameters: + - name: workflowRunId + in: path + required: true + schema: + type: integer + format: int64 + - name: page + in: query + required: false + schema: + type: integer + format: int32 + default: 0 + - name: size + in: query + required: false + schema: + type: integer + format: int32 + default: 10 + - name: search + in: query + required: false + schema: + type: string + - name: onlyFailed + in: query + required: false + schema: + type: boolean + default: false + responses: + "409": + description: Conflict + content: + application/json: + schema: + $ref: "#/components/schemas/ApiError" + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TestResultsDto" /api/tests/pr/{pullRequestId}: get: tags: diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java index a98408847..d32c3371e 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/github/GitHubService.java @@ -728,21 +728,7 @@ public String getWorkflowJobStatus(String repoNameWithOwner, long runId) throws try (Response response = okHttpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - String errorBody = "No error details"; - ResponseBody responseBody = response.body(); - if (responseBody != null) { - try { - errorBody = responseBody.string(); - } catch (IOException e) { - log.warn("Failed to read error response body", e); - } - } - - log.error( - "GitHub API call failed to fetch workflow jobs with response code: {} and body: {}", - response.code(), - errorBody); - throw new IOException("GitHub API call failed with response code: " + response.code()); + handleErrorResponse(response, "fetch workflow jobs for run " + runId); } ResponseBody responseBody = response.body(); @@ -764,9 +750,60 @@ public String getWorkflowJobStatus(String repoNameWithOwner, long runId) throws * @throws IOException if an I/O error occurs during the API call */ public void cancelWorkflowRun(String repoNameWithOwner, long runId) throws IOException { + executeWorkflowRunAction( + repoNameWithOwner, + runId, + "cancel", + "Successfully sent cancellation request for workflow run ID: {}" + ); + } + + /** + * Re-runs all jobs in a GitHub workflow run. + * + * @param repoNameWithOwner Repository in format "owner/repo" + * @param runId Workflow run ID to re-run + * @throws IOException if an I/O error occurs during the API call + */ + public void reRunWorkflow(String repoNameWithOwner, long runId) throws IOException { + executeWorkflowRunAction( + repoNameWithOwner, + runId, + "rerun", + "Successfully sent re-run request for workflow run ID: {}"); + } + + /** + * Re-runs only the failed jobs in a GitHub workflow run. + * + * @param repoNameWithOwner Repository in format "owner/repo" + * @param runId Workflow run ID whose failed jobs to re-run + * @throws IOException if an I/O error occurs during the API call + */ + public void reRunFailedJobs(String repoNameWithOwner, long runId) throws IOException { + executeWorkflowRunAction( + repoNameWithOwner, + runId, + "rerun-failed-jobs", + "Successfully sent re-run failed jobs request for workflow run ID: {}"); + } + + /** + * Sends an empty POST to a GitHub Actions workflow run sub-resource (e.g. cancel, rerun). + * + * @param repoNameWithOwner Repository in format "owner/repo" + * @param runId Workflow run ID + * @param pathSuffix URL suffix appended after the run ID (e.g. "cancel", "rerun") + * @param successMessage Log message on success; must contain one {} placeholder for the run ID + * @throws IOException if the API call fails + */ + private void executeWorkflowRunAction( + String repoNameWithOwner, long runId, String pathSuffix, String successMessage) + throws IOException { String url = String.format( - "https://api.github.com/repos/%s/actions/runs/%d/cancel", repoNameWithOwner, runId); + "https://api.github.com/repos/%s/actions/runs/%d/%s", + repoNameWithOwner, runId, pathSuffix); Request request = getRequestBuilder() @@ -776,23 +813,9 @@ public void cancelWorkflowRun(String repoNameWithOwner, long runId) throws IOExc try (Response response = okHttpClient.newCall(request).execute()) { if (!response.isSuccessful()) { - String errorBody = "No error details"; - ResponseBody responseBody = response.body(); - if (responseBody != null) { - try { - errorBody = responseBody.string(); - } catch (IOException e) { - log.warn("Failed to read error response body", e); - } - } - - log.error( - "GitHub API call failed to cancel workflow run with response code: {} and body: {}", - response.code(), - errorBody); - throw new IOException("GitHub API call failed with response code: " + response.code()); + handleErrorResponse(response, pathSuffix + " for run " + runId); } - log.info("Successfully sent cancellation request for workflow run ID: {}", runId); + log.info(successMessage, runId); } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultController.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultController.java index 8d39eef9a..459261512 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultController.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultController.java @@ -61,6 +61,25 @@ public ResponseEntity getLatestTestResultsByBranch( branch, new TestResultService.TestSearchCriteria(page, size, search, onlyFailed))); } + /** + * Get the test results for a specific workflow run. + * + * @param workflowRunId the workflow run ID + * @return the grouped test results + */ + @GetMapping("/run/{workflowRunId}") + public ResponseEntity getTestResultsByWorkflowRunId( + @PathVariable Long workflowRunId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String search, + @RequestParam(defaultValue = "false") boolean onlyFailed) { + return ResponseEntity.ok( + testResultService.getTestResultsForWorkflowRun( + workflowRunId, + new TestResultService.TestSearchCriteria(page, size, search, onlyFailed))); + } + /** * Look up historical flakiness scores for a list of test cases. * Authenticated via repository shared secret. diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java index 1ec0a5617..9b6ad772a 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/tests/TestResultService.java @@ -10,6 +10,8 @@ import de.tum.cit.aet.helios.tests.type.TestType; import de.tum.cit.aet.helios.workflow.WorkflowRun; import de.tum.cit.aet.helios.workflow.WorkflowRunRepository; +import jakarta.persistence.EntityNotFoundException; +import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -216,6 +218,69 @@ public TestResultsDto getLatestTestResultsForPr(Long pullRequestId, TestSearchCr return processTestResults(context, criteria); } + /** + * Get the test results for a specific workflow run. + * + * @param workflowRunId the workflow run ID + * @param criteria search and pagination criteria + * @return the grouped test results + */ + public TestResultsDto getTestResultsForWorkflowRun( + Long workflowRunId, TestSearchCriteria criteria) { + final Long repositoryId = RepositoryContext.getRepositoryId(); + var run = + workflowRunRepository + .findByIdAndRepositoryRepositoryId(workflowRunId, repositoryId) + .orElseThrow(() -> new EntityNotFoundException( + "Workflow run with id %d not found".formatted(workflowRunId))); + + var defaultContext = getDefaultBranchContext(run.getRepository()); + + List previousRuns = List.of(); + if (run.getHeadBranch() != null && run.getHeadSha() != null) { + var pullRequests = run.getPullRequests(); + if (pullRequests != null && !pullRequests.isEmpty()) { + // A run can technically be associated with multiple PRs. + // We pick the lowest PR ID (oldest PR) for a deterministic baseline; in + // practice the sync service almost always resolves to exactly one PR per run. + // TODO consider better handling of multiple PRs + var prId = pullRequests.stream() + .min(Comparator.comparingLong(pr -> pr.getId())) + .orElseThrow() + .getId(); + previousRuns = + workflowRunRepository + .findNthLatestCommitShaBehindHeadByPullRequestId(prId, 0, run.getHeadSha()) + .map(prevSha -> workflowRunRepository.findByPullRequestsIdAndHeadSha(prId, prevSha)) + .orElse(List.of()); + } else { + previousRuns = + workflowRunRepository + .findNthLatestCommitShaBehindHeadByBranchAndRepoId( + run.getHeadBranch(), + run.getRepository().getRepositoryId(), + 0, + run.getHeadSha()) + .map( + prevSha -> + workflowRunRepository.findByHeadBranchAndHeadShaAndRepositoryRepositoryId( + run.getHeadBranch(), + prevSha, + run.getRepository().getRepositoryId())) + .orElse(List.of()); + } + } + + var context = + new TestRunContext( + List.of(run), + previousRuns, + defaultContext.defaultBranchName(), + defaultContext.defaultWorkflowRunByTestType()); + + return processTestResults(context, criteria); + } + private TestResultsDto processTestResults(TestRunContext context, TestSearchCriteria criteria) { Map previousWorkflowRunByTestType = new HashMap<>(); diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunController.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunController.java index 2070ad0b6..1ab7f92e7 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunController.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunController.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.helios.workflow; +import de.tum.cit.aet.helios.config.security.annotations.EnforceAtLeastWritePermission; import de.tum.cit.aet.helios.workflow.pagination.PaginatedWorkflowRunsResponse; import de.tum.cit.aet.helios.workflow.pagination.WorkflowRunFilterType; import de.tum.cit.aet.helios.workflow.pagination.WorkflowRunPageRequest; @@ -8,6 +9,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -53,4 +55,30 @@ public ResponseEntity> getLatestWorkflowRunsByBranchAndHead var workflowRuns = workflowRunService.getLatestWorkflowRunsByBranchAndHeadCommitSha(branch); return ResponseEntity.ok(workflowRuns); } + + @GetMapping("/runs/{runId}") + public ResponseEntity getWorkflowRunById(@PathVariable Long runId) { + return ResponseEntity.ok(workflowRunService.getWorkflowRunById(runId)); + } + + @EnforceAtLeastWritePermission + @PostMapping("/runs/{runId}/cancel") + public ResponseEntity cancelWorkflowRun(@PathVariable Long runId) { + workflowRunService.cancelWorkflowRun(runId); + return ResponseEntity.ok().build(); + } + + @EnforceAtLeastWritePermission + @PostMapping("/runs/{runId}/rerun") + public ResponseEntity reRunWorkflow(@PathVariable Long runId) { + workflowRunService.reRunWorkflow(runId); + return ResponseEntity.ok().build(); + } + + @EnforceAtLeastWritePermission + @PostMapping("/runs/{runId}/rerun-failed-jobs") + public ResponseEntity reRunFailedJobs(@PathVariable Long runId) { + workflowRunService.reRunFailedJobs(runId); + return ResponseEntity.ok().build(); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java index bb7e83424..f1f7d51ce 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunRepository.java @@ -16,6 +16,8 @@ public interface WorkflowRunRepository extends JpaRepository, JpaSpecificationExecutor { Optional findById(long id); + Optional findByIdAndRepositoryRepositoryId(long id, Long repositoryId); + @Query( "SELECT DISTINCT wr FROM WorkflowRun wr " + "JOIN wr.pullRequests pr " diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java index 6d98650fb..b67b2af94 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/WorkflowRunService.java @@ -2,13 +2,19 @@ import de.tum.cit.aet.helios.branch.BranchRepository; import de.tum.cit.aet.helios.filters.RepositoryContext; +import de.tum.cit.aet.helios.github.GitHubService; +import de.tum.cit.aet.helios.gitrepo.GitRepoRepository; import de.tum.cit.aet.helios.pullrequest.PullRequestRepository; +import de.tum.cit.aet.helios.tests.TestSuiteRepository; import de.tum.cit.aet.helios.workflow.pagination.PaginatedWorkflowRunsResponse; import de.tum.cit.aet.helios.workflow.pagination.WorkflowRunPageRequest; +import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.criteria.Predicate; +import java.io.IOException; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -20,6 +26,7 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; @Log4j2 @RequiredArgsConstructor @@ -30,6 +37,9 @@ public class WorkflowRunService { private final WorkflowRunRepository workflowRunRepository; private final PullRequestRepository pullRequestRepository; private final BranchRepository branchRepository; + private final GitHubService gitHubService; + private final GitRepoRepository gitRepoRepository; + private final TestSuiteRepository testSuiteRepository; public List getAllWorkflowRuns() { return workflowRunRepository.findAll(); @@ -197,7 +207,83 @@ private Sort resolveSort(WorkflowRunPageRequest request) { return Sort.by(direction, property).and(defaultSort); } - public void deleteWorkflowRun(Long workflowRunId) { - workflowRunRepository.deleteById(workflowRunId); + public WorkflowRunDto getWorkflowRunById(Long runId) { + Long repositoryId = RepositoryContext.getRepositoryId(); + return getWorkflowRunForCurrentRepository(runId, repositoryId, false) + .map(WorkflowRunDto::fromWorkflowRun) + .orElseThrow(() -> new EntityNotFoundException( + "Workflow run with id %d not found".formatted(runId))); + } + + public void cancelWorkflowRun(Long runId) { + executeWorkflowRunAction(runId, gitHubService::cancelWorkflowRun, "cancel", false); + } + + public void reRunWorkflow(Long runId) { + executeWorkflowRunAction(runId, gitHubService::reRunWorkflow, "re-run", true); + } + + public void reRunFailedJobs(Long runId) { + executeWorkflowRunAction(runId, gitHubService::reRunFailedJobs, "re-run failed jobs for", true); + } + + @FunctionalInterface + private interface WorkflowRunAction { + void execute(String repoNameWithOwner, long runId) throws IOException; + } + + private void executeWorkflowRunAction( + Long runId, WorkflowRunAction action, String actionName, boolean resetTestState) { + try { + Long repositoryId = RepositoryContext.getRepositoryId(); + var repository = + gitRepoRepository + .findById(repositoryId) + .orElseThrow(() -> new EntityNotFoundException( + "Repository with id %d not found".formatted(repositoryId))); + + var workflowRun = getWorkflowRunForCurrentRepository(runId, repositoryId, true) + .orElseThrow(() -> new EntityNotFoundException( + "Workflow run with id %d not found".formatted(runId))); + + action.execute(repository.getNameWithOwner(), runId); + + if (resetTestState) { + resetTestStateForRerun(workflowRun); + } + } catch (IOException e) { + log.error("Failed to {} workflow run {}: {}", actionName, runId, e.getMessage()); + throw new RuntimeException( + "Failed to %s workflow run with id %d".formatted(actionName, runId), e); + } + } + + private Optional getWorkflowRunForCurrentRepository( + Long runId, Long repositoryId, boolean actionRequest) { + return workflowRunRepository.findByIdAndRepositoryRepositoryId(runId, repositoryId).or(() -> { + if (actionRequest && workflowRunRepository.findById(runId).isPresent()) { + log.warn( + "Blocked workflow run action for run {} in repository {} due to repository mismatch", + runId, + repositoryId); + } + return Optional.empty(); + }); + } + + private void resetTestStateForRerun(WorkflowRun workflowRun) { + var workflow = workflowRun.getWorkflow(); + if (workflow == null || CollectionUtils.isEmpty(workflow.getTestTypes())) { + return; + } + + var existingTestSuites = testSuiteRepository.findByWorkflowRunId(workflowRun.getId()); + if (!existingTestSuites.isEmpty()) { + testSuiteRepository.deleteAll(existingTestSuites); + } + + workflowRun.setTestSuites(null); + workflowRun.setTestProcessingStatus(null); + workflowRunRepository.save(workflowRun); } } diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java index b81fb250b..efb973fef 100644 --- a/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/github/GitHubServiceTest.java @@ -1148,6 +1148,7 @@ void cancelWorkflowRunApiFailure() throws IOException { () -> { gitHubService.cancelWorkflowRun(repoNameWithOwner, runId); }); - assertTrue(exception.getMessage().contains("GitHub API call failed with response code: 500")); + assertTrue(exception.getMessage() + .contains("GitHub API cancel for run " + runId + " failed with response code: 500")); } } diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/WorkflowRunServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/WorkflowRunServiceTest.java index 66760fcf6..f5718ac1c 100644 --- a/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/WorkflowRunServiceTest.java +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/WorkflowRunServiceTest.java @@ -2,24 +2,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import de.tum.cit.aet.helios.branch.BranchRepository; import de.tum.cit.aet.helios.filters.RepositoryContext; +import de.tum.cit.aet.helios.github.GitHubService; +import de.tum.cit.aet.helios.gitrepo.GitRepoRepository; +import de.tum.cit.aet.helios.gitrepo.GitRepository; import de.tum.cit.aet.helios.pullrequest.PullRequestRepository; +import de.tum.cit.aet.helios.tests.TestSuite; +import de.tum.cit.aet.helios.tests.TestSuiteRepository; +import de.tum.cit.aet.helios.tests.type.TestType; import de.tum.cit.aet.helios.workflow.WorkflowRun.Conclusion; import de.tum.cit.aet.helios.workflow.WorkflowRun.Status; import de.tum.cit.aet.helios.workflow.pagination.PaginatedWorkflowRunsResponse; import de.tum.cit.aet.helios.workflow.pagination.WorkflowRunPageRequest; +import jakarta.persistence.EntityNotFoundException; +import java.io.IOException; import java.time.OffsetDateTime; +import java.util.HashSet; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -37,6 +55,9 @@ public class WorkflowRunServiceTest { @Mock private WorkflowRunRepository workflowRunRepository; @Mock private PullRequestRepository pullRequestRepository; @Mock private BranchRepository branchRepository; + @Mock private GitHubService gitHubService; + @Mock private GitRepoRepository gitRepoRepository; + @Mock private TestSuiteRepository testSuiteRepository; @BeforeEach public void setUp() { @@ -147,6 +168,238 @@ public void getPaginatedWorkflowRuns_usesCustomSortingWhenRequested() { assertNotNull(pageable.getSort().getOrderFor("createdAt")); } + @Test + public void getWorkflowRunById_usesRepositoryScopedLookup() { + WorkflowRun run = createWorkflowRun( + 100L, "Build", "Build workflow", Status.SUCCESS, Conclusion.SUCCESS); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(eq(100L), eq(1L))) + .thenReturn(Optional.of(run)); + + WorkflowRunDto result = workflowRunService.getWorkflowRunById(100L); + + assertEquals(100L, result.id()); + verify(workflowRunRepository).findByIdAndRepositoryRepositoryId(100L, 1L); + } + + @Test + public void getWorkflowRunById_whenRunNotInRepository_throwsNotFound() { + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(eq(100L), eq(1L))) + .thenReturn(Optional.empty()); + + assertThrows(EntityNotFoundException.class, () -> workflowRunService.getWorkflowRunById(100L)); + verify(workflowRunRepository).findByIdAndRepositoryRepositoryId(100L, 1L); + } + + @Test + public void cancelWorkflowRun_success_callsGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(1L, 1L)) + .thenReturn(Optional.of(run)); + + workflowRunService.cancelWorkflowRun(1L); + + verify(gitHubService).cancelWorkflowRun("owner/repo", 1L); + } + + @Test + public void cancelWorkflowRun_whenRunNotInRepository_doesNotCallGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(eq(200L), eq(1L))) + .thenReturn(Optional.empty()); + lenient().when(workflowRunRepository.findById(200L)) + .thenReturn(Optional.of(new WorkflowRun())); + + assertThrows(EntityNotFoundException.class, () -> workflowRunService.cancelWorkflowRun(200L)); + + verify(gitHubService, never()).cancelWorkflowRun(any(), anyLong()); + } + + @Test + public void reRunWorkflowRun_success_callsGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + run.setId(201L); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>(List.of(new TestType()))); + run.setWorkflow(workflow); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(201L, 1L)) + .thenReturn(Optional.of(run)); + when(testSuiteRepository.findByWorkflowRunId(201L)).thenReturn(List.of()); + + workflowRunService.reRunWorkflow(201L); + + verify(gitHubService).reRunWorkflow("owner/repo", 201L); + verify(workflowRunRepository).save(run); + } + + @Test + public void reRunWorkflow_whenTestWorkflowAndExistingSuites_resetsStateAfterGithubCall() + throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + run.setId(205L); + run.setTestProcessingStatus(WorkflowRun.TestProcessingStatus.PROCESSED); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>(List.of(new TestType()))); + run.setWorkflow(workflow); + + TestSuite existingSuite = new TestSuite(); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(205L, 1L)) + .thenReturn(Optional.of(run)); + when(testSuiteRepository.findByWorkflowRunId(205L)).thenReturn(List.of(existingSuite)); + + workflowRunService.reRunWorkflow(205L); + + InOrder inOrder = inOrder(gitHubService, testSuiteRepository); + inOrder.verify(gitHubService).reRunWorkflow("owner/repo", 205L); + inOrder.verify(testSuiteRepository).findByWorkflowRunId(205L); + verify(testSuiteRepository).deleteAll(List.of(existingSuite)); + verify(workflowRunRepository).save(run); + assertNull(run.getTestProcessingStatus()); + } + + @Test + public void reRunWorkflow_whenRunNotInRepository_doesNotCallGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(eq(201L), eq(1L))) + .thenReturn(Optional.empty()); + lenient().when(workflowRunRepository.findById(200L)) + .thenReturn(Optional.of(new WorkflowRun())); + + assertThrows(EntityNotFoundException.class, () -> workflowRunService.reRunWorkflow(201L)); + + verify(gitHubService, never()).reRunWorkflow(any(), anyLong()); + } + + @Test + public void reRunFailedJobs_success_callsGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + run.setId(202L); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>(List.of(new TestType()))); + run.setWorkflow(workflow); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(202L, 1L)) + .thenReturn(Optional.of(run)); + when(testSuiteRepository.findByWorkflowRunId(202L)).thenReturn(List.of()); + + workflowRunService.reRunFailedJobs(202L); + + verify(gitHubService).reRunFailedJobs("owner/repo", 202L); + verify(workflowRunRepository).save(run); + } + + @Test + public void reRunWorkflow_whenWorkflowHasNoTestTypes_doesNotResetTestState() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>()); + run.setWorkflow(workflow); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(203L, 1L)) + .thenReturn(Optional.of(run)); + + workflowRunService.reRunWorkflow(203L); + + verify(gitHubService).reRunWorkflow("owner/repo", 203L); + verify(testSuiteRepository, never()).findByWorkflowRunId(anyLong()); + } + + @Test + public void reRunWorkflow_whenGithubRerunFails_doesNotResetTestState() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>(List.of(new TestType()))); + run.setWorkflow(workflow); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(204L, 1L)) + .thenReturn(Optional.of(run)); + doThrow(new IOException("Dummy error")).when(gitHubService).reRunWorkflow("owner/repo", 204L); + + assertThrows(RuntimeException.class, () -> workflowRunService.reRunWorkflow(204L)); + + verify(testSuiteRepository, never()).findByWorkflowRunId(anyLong()); + verify(workflowRunRepository, never()).save(run); + } + + @Test + public void reRunFailedJobs_whenGithubRerunFails_doesNotResetTestState() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + WorkflowRun run = new WorkflowRun(); + Workflow workflow = new Workflow(); + workflow.setTestTypes(new HashSet<>(List.of(new TestType()))); + run.setWorkflow(workflow); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(206L, 1L)) + .thenReturn(Optional.of(run)); + doThrow(new IOException("Dummy error")).when(gitHubService).reRunFailedJobs("owner/repo", 206L); + + assertThrows(RuntimeException.class, () -> workflowRunService.reRunFailedJobs(206L)); + + verify(testSuiteRepository, never()).findByWorkflowRunId(anyLong()); + verify(workflowRunRepository, never()).save(run); + } + + @Test + public void reRunFailedJobs_whenRunNotInRepository_doesNotCallGithub() throws Exception { + GitRepository repository = new GitRepository(); + repository.setRepositoryId(1L); + repository.setNameWithOwner("owner/repo"); + + when(gitRepoRepository.findById(1L)).thenReturn(Optional.of(repository)); + when(workflowRunRepository.findByIdAndRepositoryRepositoryId(eq(202L), eq(1L))) + .thenReturn(Optional.empty()); + lenient().when(workflowRunRepository.findById(200L)) + .thenReturn(Optional.of(new WorkflowRun())); + + assertThrows(EntityNotFoundException.class, () -> workflowRunService.reRunFailedJobs(202L)); + + verify(gitHubService, never()).reRunFailedJobs(any(), anyLong()); + } + private WorkflowRun createWorkflowRun( Long id, String name, String displayTitle, Status status, Conclusion conclusion) { WorkflowRun run = new WorkflowRun();