Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
deleteTestType,
deleteWorkflowGroup,
deployToEnvironment,
detectDeploymentJob,
evaluate,
extendEnvironmentLock,
generateReleaseNotes,
Expand Down Expand Up @@ -116,6 +117,9 @@ import type {
DeployToEnvironmentData,
DeployToEnvironmentError,
DeployToEnvironmentResponse,
DetectDeploymentJobData,
DetectDeploymentJobError,
DetectDeploymentJobResponse,
EvaluateData,
EvaluateError,
ExtendEnvironmentLockData,
Expand Down Expand Up @@ -690,6 +694,22 @@ export const createTestTypeMutation = (
return mutationOptions;
};

export const detectDeploymentJobMutation = (
options?: Partial<Options<DetectDeploymentJobData>>
): MutationOptions<DetectDeploymentJobResponse, DetectDeploymentJobError, Options<DetectDeploymentJobData>> => {
const mutationOptions: MutationOptions<DetectDeploymentJobResponse, DetectDeploymentJobError, Options<DetectDeploymentJobData>> = {
mutationFn: async fnOptions => {
const { data } = await detectDeploymentJob({
...options,
...fnOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};

export const rotateSecretMutation = (options?: Partial<Options<RotateSecretData>>): MutationOptions<RotateSecretResponse, RotateSecretError, Options<RotateSecretData>> => {
const mutationOptions: MutationOptions<RotateSecretResponse, RotateSecretError, Options<RotateSecretData>> = {
mutationFn: async fnOptions => {
Expand Down
27 changes: 27 additions & 0 deletions client/src/app/core/modules/openapi/schemas.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,33 @@ export const TestFlakinessScoreDtoSchema = {
required: ['className', 'testName', 'testSuiteName'],
} as const;

export const WorkflowDeploymentJobDetectionDtoSchema = {
type: 'object',
properties: {
workflowId: {
type: 'integer',
format: 'int64',
},
workflowPath: {
type: 'string',
},
ref: {
type: 'string',
},
deploymentJobName: {
type: 'string',
},
status: {
type: 'string',
enum: ['FOUND', 'NOT_FOUND', 'UNCLEAR', 'ERROR'],
},
message: {
type: 'string',
},
},
required: ['message', 'ref', 'status', 'workflowId', 'workflowPath'],
} as const;

export const ReleaseCandidateCreateDtoSchema = {
type: 'object',
properties: {
Expand Down
10 changes: 10 additions & 0 deletions client/src/app/core/modules/openapi/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import type {
DeployToEnvironmentData,
DeployToEnvironmentErrors,
DeployToEnvironmentResponses,
DetectDeploymentJobData,
DetectDeploymentJobErrors,
DetectDeploymentJobResponses,
EvaluateData,
EvaluateErrors,
EvaluateResponses,
Expand Down Expand Up @@ -501,6 +504,13 @@ export const createTestType = <ThrowOnError extends boolean = false>(options: Op
});
};

export const detectDeploymentJob = <ThrowOnError extends boolean = false>(options: Options<DetectDeploymentJobData, ThrowOnError>) => {
return (options.client ?? client).post<DetectDeploymentJobResponses, DetectDeploymentJobErrors, ThrowOnError>({
url: '/api/settings/{repositoryId}/workflows/{workflowId}/detect-deployment-job',
...options,
});
};

export const rotateSecret = <ThrowOnError extends boolean = false>(options: Options<RotateSecretData, ThrowOnError>) => {
return (options.client ?? client).post<RotateSecretResponses, RotateSecretErrors, ThrowOnError>({
url: '/api/settings/{repositoryId}/secret',
Expand Down
37 changes: 37 additions & 0 deletions client/src/app/core/modules/openapi/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ export type TestFlakinessScoreDto = {
combinedFailureRate?: number;
};

export type WorkflowDeploymentJobDetectionDto = {
workflowId: number;
workflowPath: string;
ref: string;
deploymentJobName?: string;
status: 'FOUND' | 'NOT_FOUND' | 'UNCLEAR' | 'ERROR';
message: string;
};

export type ReleaseCandidateCreateDto = {
name: string;
commitSha: string;
Expand Down Expand Up @@ -1435,6 +1444,34 @@ export type CreateTestTypeResponses = {

export type CreateTestTypeResponse = CreateTestTypeResponses[keyof CreateTestTypeResponses];

export type DetectDeploymentJobData = {
body?: never;
path: {
repositoryId: number;
workflowId: number;
};
query?: never;
url: '/api/settings/{repositoryId}/workflows/{workflowId}/detect-deployment-job';
};

export type DetectDeploymentJobErrors = {
/**
* Conflict
*/
409: ApiError;
};

export type DetectDeploymentJobError = DetectDeploymentJobErrors[keyof DetectDeploymentJobErrors];

export type DetectDeploymentJobResponses = {
/**
* OK
*/
200: WorkflowDeploymentJobDetectionDto;
};

export type DetectDeploymentJobResponse = DetectDeploymentJobResponses[keyof DetectDeploymentJobResponses];

export type RotateSecretData = {
body?: never;
path: {
Expand Down
42 changes: 42 additions & 0 deletions client/src/app/core/services/workflow-ai.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from 'environments/environment';
import { KeycloakService } from './keycloak/keycloak.service';
import { RepositoryService } from './repository.service';

export type WorkflowDeploymentJobDetectionStatus = 'FOUND' | 'NOT_FOUND' | 'UNCLEAR' | 'ERROR';

export interface WorkflowDeploymentJobDetectionResponse {
workflowId: number;
workflowPath: string;
ref: string;
deploymentJobName: string | null;
status: WorkflowDeploymentJobDetectionStatus;
message: string;
}

@Injectable({
providedIn: 'root',
})
export class WorkflowAiService {
private readonly http = inject(HttpClient);
private readonly keycloakService = inject(KeycloakService);
private readonly repositoryService = inject(RepositoryService);
private readonly baseUrl = environment.serverUrl.replace(/\/$/, '');

detectDeploymentJob(repositoryId: number, workflowId: number): Observable<WorkflowDeploymentJobDetectionResponse> {
const token = this.keycloakService.keycloak.token;
const currentRepositoryId = this.repositoryService.currentRepositoryId();
return this.http.post<WorkflowDeploymentJobDetectionResponse>(
`${this.baseUrl}/api/settings/${repositoryId}/workflows/${workflowId}/detect-deployment-job`,
{},
{
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(currentRepositoryId ? { 'X-Repository-Id': String(currentRepositoryId) } : {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use request repositoryId for X-Repository-Id header

This request builds the URL from the repositoryId argument but sets X-Repository-Id from repositoryService.currentRepositoryId(), so the path and authorization context can diverge. When they differ (for example during route transitions or if this service is reused with an explicit repository id), the backend computes roles from the header and can authorize against the wrong repository or return 403 for a valid maintainer request. Build the header from the same repositoryId argument used in the URL to keep permission checks consistent.

Useful? React with 👍 / 👎.

},
}
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,25 @@ <h3 class="text-xl">Workflows</h3>
</p-select>
</td>
<td>
<p-button label="Configure" size="small" severity="secondary" (click)="openDeploymentConfig(workflow)" />
<div class="flex flex-col items-start gap-2">
<p-button label="Configure" size="small" severity="secondary" (click)="openDeploymentConfig(workflow)" />
<p-button
label="Detect Deployment Job"
size="small"
severity="secondary"
[loading]="detectingWorkflowId() === workflow.id && detectDeploymentJobMutation.isPending()"
(click)="detectDeploymentJob(workflow.id)"
/>
@if (getDetectionResult(workflow.id); as detectionResult) {
<small [class]="detectionResultClass(detectionResult)">
@if (detectionResult.status === 'FOUND' && detectionResult.deploymentJobName) {
Deployment job: {{ detectionResult.deploymentJobName }}
} @else {
{{ detectionResult.message }}
}
</small>
}
</div>
</td>
</tr>
</ng-template>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, computed, effect, inject, input, numberAttribute, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental';
Expand Down Expand Up @@ -45,6 +46,8 @@ import { provideTablerIcons, TablerIconComponent } from 'angular-tabler-icons';
import { IconCircleCheck, IconPencil, IconPlus, IconTrash } from 'angular-tabler-icons/icons';
import { SecretGenerateConfirmationComponent } from '@app/components/dialogs/secret-generate-confirmation/secret-generate-confirmation.component';
import { getStatusColors } from '@app/core/utils/status-colors';
import { firstValueFrom } from 'rxjs';
import { WorkflowAiService, WorkflowDeploymentJobDetectionResponse } from '@app/core/services/workflow-ai.service';

@Component({
selector: 'app-project-settings',
Expand Down Expand Up @@ -81,6 +84,7 @@ import { getStatusColors } from '@app/core/utils/status-colors';
export class ProjectSettingsComponent {
private messageService = inject(MessageService);
private confirmationService = inject(ConfirmationService);
private workflowAiService = inject(WorkflowAiService);
queryClient = inject(QueryClient);

// Signals for repository ID, workflows, and workflow groups
Expand Down Expand Up @@ -112,6 +116,8 @@ export class ProjectSettingsComponent {
// This is recalculated from dropdown selection/assignment in onChangeGroup()
// This is recalculated from drag&drop logic in updateGroups()
workflowGroupsMap = signal<Record<number, string>>({});
workflowDetectionResults = signal<Record<number, WorkflowDeploymentJobDetectionResponse>>({});
detectingWorkflowId = signal<number | null>(null);
readonly successIconClasses = getStatusColors('success').icon;

constructor() {
Expand Down Expand Up @@ -287,6 +293,44 @@ export class ProjectSettingsComponent {
},
}));

detectDeploymentJobMutation = injectMutation(() => ({
mutationFn: async ({ workflowId }: { workflowId: number }) => {
return await firstValueFrom(this.workflowAiService.detectDeploymentJob(this.repositoryId(), workflowId));
},
onMutate: ({ workflowId }) => {
this.detectingWorkflowId.set(workflowId);
},
onSuccess: result => {
this.workflowDetectionResults.update(previous => ({ ...previous, [result.workflowId]: result }));
},
onError: error => {
const workflowId = this.detectingWorkflowId();
if (workflowId == null) {
return;
Comment on lines +307 to +309
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope detection state to each mutation invocation

The mutation callbacks read and clear a single shared detectingWorkflowId, which races when users trigger detection on multiple workflows before earlier calls settle. In that case, one request can overwrite the id of another, causing errors to be recorded under the wrong workflow (or skipped entirely when onSettled from another call sets it to null first). Use per-invocation variables/context (onError(_, vars) and onSettled(_, _, vars)) or a per-workflow pending map instead of shared global state.

Useful? React with 👍 / 👎.

}

const detail =
error instanceof HttpErrorResponse
? error.error?.message || error.message || 'Helios could not analyze the workflow right now.'
: 'Helios could not analyze the workflow right now.';

this.workflowDetectionResults.update(previous => ({
...previous,
[workflowId]: {
workflowId,
workflowPath: '',
ref: '',
deploymentJobName: null,
status: 'ERROR',
message: detail,
},
}));
},
onSettled: () => {
this.detectingWorkflowId.set(null);
},
}));

workflowLabelOptions = Object.values(WorkflowDtoSchema.properties.label.enum);

// Deployment workflow config: per-workflow job name configuration
Expand Down Expand Up @@ -445,6 +489,22 @@ export class ProjectSettingsComponent {
this.syncWorkflowsMutation.mutate({ path: { repositoryId } });
}

detectDeploymentJob(workflowId: number) {
this.detectDeploymentJobMutation.mutate({ workflowId });
}

getDetectionResult(workflowId: number) {
return this.workflowDetectionResults()[workflowId];
}

detectionResultClass(result: WorkflowDeploymentJobDetectionResponse | undefined) {
if (!result) {
return 'text-muted-color';
}

return result.status === 'FOUND' ? 'text-green-600 dark:text-green-400' : 'text-muted-color';
}

// Update the groups on the server
updateGroups() {
if (!this.repositoryId()) return;
Expand Down
2 changes: 2 additions & 0 deletions client/src/app/test.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NgModule, provideZonelessChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ConfirmationService, MessageService } from 'primeng/api';
import { KeycloakService } from './core/services/keycloak/keycloak.service';
Expand All @@ -10,6 +11,7 @@ import { DatePipe } from '@angular/common';
@NgModule({
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
provideNoopAnimations(),
MessageService,
ConfirmationService,
Expand Down
8 changes: 8 additions & 0 deletions server/application-server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ HELIOS_TOKEN_EXCHANGE_CLIENT=helios-token-exchange
HELIOS_TOKEN_EXCHANGE_SECRET=wHBfstYLRLDG8xmJtHNsrGjo40UULNmy
HELIOS_LOGS_BASE_PATH=/tmp/helios/workflow-logs
HELIOS_DEVELOPERS_GITHUB_USERNAMES=
HELIOS_AI_ENABLED=true
HELIOS_AI_DEFAULT_PROVIDER=openai
HELIOS_AI_PROVIDER_OPENAI_ENABLED=true
HELIOS_AI_TEST_FAILURE_MAX_SECTION_CHARS=6000
OPENAI_API_KEY=Refer to documentation
OPENAI_BASE_URL=https://logos.aet.cit.tum.de:8080
OPENAI_MODEL=openai/gpt-oss-120b
SPRING_AI_MODEL_CHAT=openai
2 changes: 2 additions & 0 deletions server/application-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ configurations {
}

dependencies {
implementation platform('org.springframework.ai:spring-ai-bom:1.1.4')
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.security:spring-security-oauth2-jose'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'
implementation 'org.openapitools:jackson-databind-nullable:0.2.9'
Expand Down
Loading
Loading