Repository: ls1intum/Helios
Pull request #638: feat: generate release notes
Author: login: gbanu
Labels: name: client
name: size:XL
name: ready for review
name: application-server
name: feature
name: migration-script
Created: 2025-04-11T13:49:40Z | Merged: 2025-04-12T00:27:01Z


https://github.com/user-attachments/assets/61982c12-ce69-4027-813b-7595e59714ed




Diff:
diff --git a/client/src/app/core/modules/markdown/markdown.pipe.ts b/client/src/app/core/modules/markdown/markdown.pipe.ts
index 8e14c640e..eb0d7f7cf 100644
--- a/client/src/app/core/modules/markdown/markdown.pipe.ts
+++ b/client/src/app/core/modules/markdown/markdown.pipe.ts
@@ -18,6 +18,16 @@ export class MarkdownPipe implements PipeTransform {
     renderer.codespan = code => {
       return `<span style="background-color: rgb(243 244 246); padding: 2px 6px; border-radius: 4px; font-family: monospace;">${code.text}</span>`;
     };
+    renderer.heading = ({ text, depth }) => {
+      const id = text.toLowerCase().replace(/\s+/g, '-');
+      return `<h${depth} id="${id}" style="font-size: 1.5em; font-weight: bold;">${text}</h${depth}>`;
+    };
+    renderer.listitem = ({ text }) => {
+      return `   • ${text}<br>`;
+    };
+    renderer.blockquote = text => {
+      return `<blockquote style="border-left: 4px solid #ccc; padding-left: 16px; color: #555;">${text}</blockquote>`;
+    };
     marked.setOptions({ renderer });
   }
 
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 f2d4861e1..784f471ba 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
@@ -8,6 +8,7 @@ import {
   getGitRepoSettings,
   updateGitRepoSettings,
   updateWorkflowGroups,
+  updateReleaseNotes,
   getEnvironmentById,
   updateEnvironment,
   unlockEnvironment,
@@ -19,6 +20,7 @@ import {
   createWorkflowGroup,
   getAllReleaseInfos,
   createReleaseCandidate,
+  generateReleaseNotes,
   publishReleaseDraft,
   evaluate,
   setPrPinnedByNumber,
@@ -76,6 +78,8 @@ import type {
   UpdateGitRepoSettingsResponse,
   UpdateWorkflowGroupsData,
   UpdateWorkflowGroupsError,
+  UpdateReleaseNotesData,
+  UpdateReleaseNotesError,
   GetEnvironmentByIdData,
   UpdateEnvironmentData,
   UpdateEnvironmentError,
@@ -102,6 +106,9 @@ import type {
   CreateReleaseCandidateData,
   CreateReleaseCandidateError,
   CreateReleaseCandidateResponse,
+  GenerateReleaseNotesData,
+  GenerateReleaseNotesError,
+  GenerateReleaseNotesResponse,
   PublishReleaseDraftData,
   PublishReleaseDraftError,
   EvaluateData,
@@ -272,6 +279,20 @@ export const updateWorkflowGroupsMutation = (options?: Partial<Options<UpdateWor
   return mutationOptions;
 };
 
+export const updateReleaseNotesMutation = (options?: Partial<Options<UpdateReleaseNotesData>>) => {
+  const mutationOptions: MutationOptions<unknown, UpdateReleaseNotesError, Options<UpdateReleaseNotesData>> = {
+    mutationFn: async localOptions => {
+      const { data } = await updateReleaseNotes({
+        ...options,
+        ...localOptions,
+        throwOnError: true,
+      });
+      return data;
+    },
+  };
+  return mutationOptions;
+};
+
 export const getEnvironmentByIdQueryKey = (options: Options<GetEnvironmentByIdData>) => createQueryKey('getEnvironmentById', options);
 
 export const getEnvironmentByIdOptions = (options: Options<GetEnvironmentByIdData>) => {
@@ -503,6 +524,37 @@ export const createReleaseCandidateMutation = (options?: Partial<Options<CreateR
   return mutationOptions;
 };
 
+export const generateReleaseNotesQueryKey = (options: Options<GenerateReleaseNotesData>) => createQueryKey('generateReleaseNotes', options);
+
+export const generateReleaseNotesOptions = (options: Options<GenerateReleaseNotesData>) => {
+  return queryOptions({
+    queryFn: async ({ queryKey, signal }) => {
+      const { data } = await generateReleaseNotes({
+        ...options,
+        ...queryKey[0],
+        signal,
+        throwOnError: true,
+      });
+      return data;
+    },
+    queryKey: generateReleaseNotesQueryKey(options),
+  });
+};
+
+export const generateReleaseNotesMutation = (options?: Partial<Options<GenerateReleaseNotesData>>) => {
+  const mutationOptions: MutationOptions<GenerateReleaseNotesResponse, GenerateReleaseNotesError, Options<GenerateReleaseNotesData>> = {
+    mutationFn: async localOptions => {
+      const { data } = await generateReleaseNotes({
+        ...options,
+        ...localOptions,
+        throwOnError: true,
+      });
+      return data;
+    },
+  };
+  return mutationOptions;
+};
+
 export const publishReleaseDraftQueryKey = (options: Options<PublishReleaseDraftData>) => createQueryKey('publishReleaseDraft', options);
 
 export const publishReleaseDraftOptions = (options: Options<PublishReleaseDraftData>) => {
diff --git a/client/src/app/core/modules/openapi/schemas.gen.ts b/client/src/app/core/modules/openapi/schemas.gen.ts
index 471116fa8..e767206e3 100644
--- a/client/src/app/core/modules/openapi/schemas.gen.ts
+++ b/client/src/app/core/modules/openapi/schemas.gen.ts
@@ -105,6 +105,15 @@ export const WorkflowMembershipDtoSchema = {
   required: ['orderIndex', 'workflowId'],
 } as const;
 
+export const ReleaseNotesDtoSchema = {
+  type: 'object',
+  properties: {
+    body: {
+      type: 'string',
+    },
+  },
+} as const;
+
 export const EnvironmentDeploymentSchema = {
   type: 'object',
   properties: {
@@ -865,6 +874,9 @@ export const ReleaseInfoDetailsDtoSchema = {
       type: 'string',
       format: 'date-time',
     },
+    body: {
+      type: 'string',
+    },
   },
   required: ['commit', 'createdAt', 'deployments', 'evaluations', 'name'],
 } as const;
diff --git a/client/src/app/core/modules/openapi/sdk.gen.ts b/client/src/app/core/modules/openapi/sdk.gen.ts
index bcfc63c1c..d6f1a0f3d 100644
--- a/client/src/app/core/modules/openapi/sdk.gen.ts
+++ b/client/src/app/core/modules/openapi/sdk.gen.ts
@@ -18,6 +18,8 @@ import type {
   UpdateGitRepoSettingsError,
   UpdateWorkflowGroupsData,
   UpdateWorkflowGroupsError,
+  UpdateReleaseNotesData,
+  UpdateReleaseNotesError,
   GetEnvironmentByIdData,
   GetEnvironmentByIdResponse,
   GetEnvironmentByIdError,
@@ -50,6 +52,9 @@ import type {
   CreateReleaseCandidateData,
   CreateReleaseCandidateResponse,
   CreateReleaseCandidateError,
+  GenerateReleaseNotesData,
+  GenerateReleaseNotesResponse,
+  GenerateReleaseNotesError,
   PublishReleaseDraftData,
   PublishReleaseDraftError,
   EvaluateData,
@@ -240,6 +245,17 @@ export const updateWorkflowGroups = <ThrowOnError extends boolean = false>(optio
   });
 };
 
+export const updateReleaseNotes = <ThrowOnError extends boolean = false>(options: Options<UpdateReleaseNotesData, ThrowOnError>) => {
+  return (options.client ?? _heyApiClient).put<unknown, UpdateReleaseNotesError, ThrowOnError>({
+    url: '/api/release-info/{name}/release-notes',
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...options?.headers,
+    },
+  });
+};
+
 export const getEnvironmentById = <ThrowOnError extends boolean = false>(options: Options<GetEnvironmentByIdData, ThrowOnError>) => {
   return (options.client ?? _heyApiClient).get<GetEnvironmentByIdResponse, GetEnvironmentByIdError, ThrowOnError>({
     url: '/api/environments/{id}',
@@ -333,6 +349,13 @@ export const createReleaseCandidate = <ThrowOnError extends boolean = false>(opt
   });
 };
 
+export const generateReleaseNotes = <ThrowOnError extends boolean = false>(options: Options<GenerateReleaseNotesData, ThrowOnError>) => {
+  return (options.client ?? _heyApiClient).post<GenerateReleaseNotesResponse, GenerateReleaseNotesError, ThrowOnError>({
+    url: '/api/release-info/{tagName}/generate-release-notes',
+    ...options,
+  });
+};
+
 export const publishReleaseDraft = <ThrowOnError extends boolean = false>(options: Options<PublishReleaseDraftData, ThrowOnError>) => {
   return (options.client ?? _heyApiClient).post<unknown, PublishReleaseDraftError, ThrowOnError>({
     url: '/api/release-info/{name}/publish',
diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts
index 8b79ac466..948f9520e 100644
--- a/client/src/app/core/modules/openapi/types.gen.ts
+++ b/client/src/app/core/modules/openapi/types.gen.ts
@@ -33,6 +33,10 @@ export type WorkflowMembershipDto = {
   orderIndex: number;
 };
 
+export type ReleaseNotesDto = {
+  body?: string;
+};
+
 export type EnvironmentDeployment = {
   id: number;
   url?: string;
@@ -282,6 +286,7 @@ export type ReleaseInfoDetailsDto = {
   release?: ReleaseDto;
   createdBy?: UserInfoDto;
   createdAt: string;
+  body?: string;
 };
 
 export type CommitsSinceReleaseCandidateDto = {
@@ -582,6 +587,31 @@ export type UpdateWorkflowGroupsResponses = {
   200: unknown;
 };
 
+export type UpdateReleaseNotesData = {
+  body: ReleaseNotesDto;
+  path: {
+    name: string;
+  };
+  query?: never;
+  url: '/api/release-info/{name}/release-notes';
+};
+
+export type UpdateReleaseNotesErrors = {
+  /**
+   * Conflict
+   */
+  409: ApiError;
+};
+
+export type UpdateReleaseNotesError = UpdateReleaseNotesErrors[keyof UpdateReleaseNotesErrors];
+
+export type UpdateReleaseNotesResponses = {
+  /**
+   * OK
+   */
+  200: unknown;
+};
+
 export type GetEnvironmentByIdData = {
   body?: never;
   path: {
@@ -877,6 +907,33 @@ export type CreateReleaseCandidateResponses = {
 
 export type CreateReleaseCandidateResponse = CreateReleaseCandidateResponses[keyof CreateReleaseCandidateResponses];
 
+export type GenerateReleaseNotesData = {
+  body?: never;
+  path: {
+    tagName: string;
+  };
+  query?: never;
+  url: '/api/release-info/{tagName}/generate-release-notes';
+};
+
+export type GenerateReleaseNotesErrors = {
+  /**
+   * Conflict
+   */
+  409: ApiError;
+};
+
+export type GenerateReleaseNotesError = GenerateReleaseNotesErrors[keyof GenerateReleaseNotesErrors];
+
+export type GenerateReleaseNotesResponses = {
+  /**
+   * OK
+   */
+  200: string;
+};
+
+export type GenerateReleaseNotesResponse = GenerateReleaseNotesResponses[keyof GenerateReleaseNotesResponses];
+
 export type PublishReleaseDraftData = {
   body?: never;
   path: {
diff --git a/client/src/app/pages/release-candidate-details/release-candidate-details.component.html b/client/src/app/pages/release-candidate-details/release-candidate-details.component.html
index f13317bc1..8f01f73a1 100644
--- a/client/src/app/pages/release-candidate-details/release-candidate-details.component.html
+++ b/client/src/app/pages/release-candidate-details/release-candidate-details.component.html
@@ -39,12 +39,6 @@ <h2 class="text-2xl flex items-center gap-2">
           {{ releaseCandidate.createdBy?.login || 'Unknown' }}
           <span [pTooltip]="releaseCandidate.createdAt">{{ releaseCandidate.createdAt | timeAgo }}</span>
         </div>
-        <div class="flex-col gap-1 mt-4">
-          @if (releaseCandidate.release && releaseCandidate.release.body) {
-            <span class="text-xl">Description</span>
-            <p class="text-sm" [innerHtml]="releaseCandidate.release.body | markdown">{{ releaseCandidate.release.body }}</p>
-          }
-        </div>
       </div>
     </div>
 
@@ -91,6 +85,49 @@ <h2 class="text-2xl flex items-center gap-2">
     </div>
   </div>
 
+  <!-- Unified Release Notes Section -->
+  <div class="my-6">
+    <div class="flex justify-between items-center mb-3">
+      <h3 class="text-xl">Release Notes</h3>
+      <div class="flex gap-2">
+        @if (permissionService.isAtLeastMaintainer() && !releaseCandidate.release) {
+          <p-button label="Generate Release Notes" (onClick)="generateReleaseNotes()" [loading]="generateReleaseNotesMutation.isPending()">
+            <i-tabler name="plus" />
+          </p-button>
+
+          @if (!isEditingReleaseNotes()) {
+            <p-button label="Edit" (onClick)="editReleaseNotes()">
+              <i-tabler name="pencil" />
+            </p-button>
+          }
+        }
+      </div>
+    </div>
+
+    @if (permissionService.isAtLeastMaintainer() && isEditingReleaseNotes() && !releaseCandidate.release) {
+      <!-- Edit mode - only available if not published to GitHub -->
+      <form [formGroup]="releaseNotesForm" class="w-full">
+        <textarea rows="10" cols="100" pTextarea placeholder="Write release notes in markdown format" [autoResize]="true" formControlName="releaseNotes"></textarea>
+        <div class="flex justify-start gap-2">
+          <p-button label="Save" (onClick)="saveReleaseNotes()" [loading]="updateReleaseNotesMutation.isPending()"></p-button>
+          <p-button label="Cancel" text severity="danger" (onClick)="cancelEditing()"></p-button>
+        </div>
+      </form>
+    } @else {
+      <!-- Display mode based on release status -->
+      @if (releaseNotes()) {
+        <div class="p-3 border rounded-md bg-gray-50" [innerHtml]="releaseNotes() | markdown"></div>
+      } @else {
+        <div class="p-3 border rounded-md bg-gray-50 text-gray-500">
+          No release notes available.
+          @if (permissionService.isAtLeastMaintainer() && !releaseCandidate.release) {
+            Edit to add release notes or use the "Generate Release Notes" button.
+          }
+        </div>
+      }
+    }
+  </div>
+
   <h3 class="text-xl mb-2 mt-5">Release Candidate Status on Environments</h3>
   <app-release-candidate-deployment-table [releaseCandidate]="releaseCandidate" />
 } @else {
diff --git a/client/src/app/pages/release-candidate-details/release-candidate-details.component.ts b/client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
index d2fb0695b..a1782ffe8 100644
--- a/client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
+++ b/client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
@@ -1,10 +1,12 @@
-import { Component, inject, input } from '@angular/core';
+import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
 import {
   deleteReleaseCandidateByNameMutation,
   evaluateMutation,
   getReleaseInfoByNameOptions,
   getReleaseInfoByNameQueryKey,
   publishReleaseDraftMutation,
+  updateReleaseNotesMutation,
+  generateReleaseNotesMutation,
 } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen';
 import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental';
 import { ButtonModule } from 'primeng/button';
@@ -15,7 +17,6 @@ import { AvatarModule } from 'primeng/avatar';
 import { IconsModule } from 'icons.module';
 import { TimeAgoPipe } from '@app/pipes/time-ago.pipe';
 import { TooltipModule } from 'primeng/tooltip';
-import { SlicePipe } from '@angular/common';
 import { TagModule } from 'primeng/tag';
 import { ConfirmationService, MessageService } from 'primeng/api';
 import { KeycloakService } from '@app/core/services/keycloak/keycloak.service';
@@ -23,6 +24,9 @@ import { PermissionService } from '@app/core/services/permission.service';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ReleaseInfoDetailsDto } from '@app/core/modules/openapi';
 import { MarkdownPipe } from '@app/core/modules/markdown/markdown.pipe';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { TextareaModule } from 'primeng/textarea';
+import { SlicePipe } from '@angular/common';
 
 @Component({
   selector: 'app-release-candidate-details',
@@ -38,10 +42,12 @@ import { MarkdownPipe } from '@app/core/modules/markdown/markdown.pipe';
     TooltipModule,
     SlicePipe,
     TagModule,
+    ReactiveFormsModule,
+    TextareaModule,
   ],
   templateUrl: './release-candidate-details.component.html',
 })
-export class ReleaseCandidateDetailsComponent {
+export class ReleaseCandidateDetailsComponent implements OnInit {
   private messageService = inject(MessageService);
   private keycloakService = inject(KeycloakService);
   permissionService = inject(PermissionService);
@@ -51,7 +57,43 @@ export class ReleaseCandidateDetailsComponent {
   private route = inject(ActivatedRoute);
 
   name = input.required<string>();
-  releaseCandidateQuery = injectQuery(() => ({ ...getReleaseInfoByNameOptions({ path: { name: this.name() } }), refetchInterval: 3000 }));
+  releaseCandidateQuery = injectQuery(() => ({
+    ...getReleaseInfoByNameOptions({ path: { name: this.name() } }),
+    onSuccess: () => {
+      this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+    },
+  }));
+
+  releaseNotesForm = new FormGroup({
+    releaseNotes: new FormControl(''),
+  });
+
+  // Computed property that handles the priorities as required
+  releaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    if (!releaseCandidate) return '';
+
+    // Priority 1: If it's a full release, use release.body
+    if (releaseCandidate?.release?.body) {
+      return releaseCandidate.release.body;
+    }
+    // Priority 2: If it's a draft published to GitHub, use releaseCandidate.body
+    else if (releaseCandidate?.body) {
+      return releaseCandidate.body;
+    }
+    // Default empty if nothing is available
+    else {
+      return '';
+    }
+  });
+
+  isEditingReleaseNotes = signal(false);
+
+  // Check if editing is allowed (only if not published to GitHub)
+  canEditReleaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    return !releaseCandidate?.release && this.permissionService.isAtLeastMaintainer();
+  });
 
   evaluateReleaseCandidateMutation = injectMutation(() => ({
     ...evaluateMutation(),
@@ -64,8 +106,10 @@ export class ReleaseCandidateDetailsComponent {
   publishReleaseDraftMutation = injectMutation(() => ({
     ...publishReleaseDraftMutation(),
     onSuccess: () => {
-      this.messageService.add({ severity: 'success', summary: 'Release Candidate Deletion', detail: 'Release candidate has been deleted successfully' });
-      this.router.navigate(['..'], { relativeTo: this.route });
+      this.messageService.add({ severity: 'success', summary: 'Release Draft Published', detail: 'Release draft has been published to GitHub successfully' });
+      this.queryClient.invalidateQueries({ queryKey: getReleaseInfoByNameQueryKey({ path: { name: this.name() } }) });
+      // Once published, editing should be disabled
+      this.isEditingReleaseNotes.set(false);
     },
   }));
 
@@ -77,6 +121,36 @@ export class ReleaseCandidateDetailsComponent {
     },
   }));
 
+  generateReleaseNotesMutation = injectMutation(() => ({
+    ...generateReleaseNotesMutation(),
+    onSuccess: data => {
+      // Set the markdown content directly
+      this.releaseNotesForm.get('releaseNotes')?.setValue(data.toString());
+      this.isEditingReleaseNotes.set(true);
+      this.messageService.add({ severity: 'success', summary: 'Release Notes', detail: 'Release notes generated successfully' });
+    },
+    onError: error => {
+      this.messageService.add({ severity: 'error', summary: 'Release Notes Generation Failed', detail: error.message });
+    },
+  }));
+
+  updateReleaseNotesMutation = injectMutation(() => ({
+    ...updateReleaseNotesMutation(),
+    onSuccess: () => {
+      this.messageService.add({ severity: 'success', summary: 'Release Notes', detail: 'Release notes saved successfully' });
+      this.isEditingReleaseNotes.set(false);
+      this.queryClient.invalidateQueries({ queryKey: getReleaseInfoByNameQueryKey({ path: { name: this.name() } }) });
+    },
+    onError: error => {
+      this.messageService.add({ severity: 'error', summary: 'Release Notes Update Failed', detail: error.message });
+    },
+  }));
+
+  ngOnInit() {
+    // Initialize with existing release notes using the computed property
+    this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+  }
+
   evaluateReleaseCandidate = (isWorking: boolean) => {
     this.evaluateReleaseCandidateMutation.mutate({ path: { name: this.name(), isWorking } });
   };
@@ -115,4 +189,39 @@ export class ReleaseCandidateDetailsComponent {
     if (!rc || !rc.release) return;
     window.open(rc.release?.githubUrl, '_blank');
   }
+
+  generateReleaseNotes() {
+    const rc = this.releaseCandidateQuery.data();
+    if (rc?.release) return; // Don't allow generation if already published
+
+    this.generateReleaseNotesMutation.mutate({
+      path: { tagName: this.name() },
+    });
+  }
+
+  editReleaseNotes() {
+    const rc = this.releaseCandidateQuery.data();
+    if (rc?.release) return; // Don't allow editing if already published
+
+    this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+    this.isEditingReleaseNotes.set(true);
+  }
+
+  saveReleaseNotes() {
+    const rc = this.releaseCandidateQuery.data();
+    if (rc?.release) return; // Don't allow saving if already published
+
+    const markdownContent = this.releaseNotesForm.get('releaseNotes')?.value || '';
+
+    this.updateReleaseNotesMutation.mutate({
+      path: { name: this.name() },
+      body: { body: markdownContent },
+    });
+  }
+
+  cancelEditing() {
+    // Reset to original value from the computed property
+    this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+    this.isEditingReleaseNotes.set(false);
+  }
 }
diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml
index 86f9ec421..b56c6a4e0 100644
--- a/server/application-server/openapi.yaml
+++ b/server/application-server/openapi.yaml
@@ -178,6 +178,32 @@ paths:
                 $ref: "#/components/schemas/ApiError"
         "200":
           description: OK
+  /api/release-info/{name}/release-notes:
+    put:
+      tags:
+      - release-info-controller
+      operationId: updateReleaseNotes
+      parameters:
+      - name: name
+        in: path
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/ReleaseNotesDto"
+        required: true
+      responses:
+        "409":
+          description: Conflict
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ApiError"
+        "200":
+          description: OK
   /api/environments/{id}:
     get:
       tags:
@@ -446,6 +472,30 @@ paths:
             application/json:
               schema:
                 $ref: "#/components/schemas/ReleaseInfoListDto"
+  /api/release-info/{tagName}/generate-release-notes:
+    post:
+      tags:
+      - release-info-controller
+      operationId: generateReleaseNotes
+      parameters:
+      - name: tagName
+        in: path
+        required: true
+        schema:
+          type: string
+      responses:
+        "409":
+          description: Conflict
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ApiError"
+        "200":
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: string
   /api/release-info/{name}/publish:
     post:
       tags:
@@ -1558,6 +1608,11 @@ components:
       required:
       - orderIndex
       - workflowId
+    ReleaseNotesDto:
+      type: object
+      properties:
+        body:
+          type: string
     EnvironmentDeployment:
       type: object
       properties:
@@ -2210,6 +2265,8 @@ components:
         createdAt:
           type: string
           format: date-time
+        body:
+          type: string
       required:
       - commit
       - createdAt
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 11aeb154d..2d039f6ef 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
@@ -15,6 +15,7 @@
 import jakarta.transaction.Transactional;
 import java.io.IOException;
 import java.time.OffsetDateTime;
+import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -316,21 +317,24 @@ public GitHubRepositoryRoleDto getRepositoryRole(String repositoryId, String use
    * Creates a successful commit status for a GitHub pull request with a link to the Helios page.
    *
    * <p>This method sets up a commit status with the following characteristics:
+   *
    * <ul>
-   *   <li>State: SUCCESS</li>
-   *   <li>Context: "Helios"</li>
-   *   <li>Target URL: A formatted URL to the Helios page for this specific pull request</li>
-   *   <li>Description: A message indicating what the link leads to</li>
+   *   <li>State: SUCCESS
+   *   <li>Context: "Helios"
+   *   <li>Target URL: A formatted URL to the Helios page for this specific pull request
+   *   <li>Description: A message indicating what the link leads to
    * </ul>
    *
-   * <p>The commit status is created for the HEAD commit of the pull request.
-   * Any IO exceptions during the status creation are logged as errors.
+   * <p>The commit status is created for the HEAD commit of the pull request. Any IO exceptions
+   * during the status creation are logged as errors.
    *
    * @param pullRequest The GitHub pull request object for which to create the commit status
    */
   public void createCommitStatusForPullRequest(GHPullRequest pullRequest) {
-    final String targetUrl = String.format("%s/repo/%d/ci-cd/pr/%d",
-        heliosClientBaseUrl, pullRequest.getRepository().getId(), pullRequest.getNumber());
+    final String targetUrl =
+        String.format(
+            "%s/repo/%d/ci-cd/pr/%d",
+            heliosClientBaseUrl, pullRequest.getRepository().getId(), pullRequest.getNumber());
     final String description = "Click to view the Helios page of this pull request.";
     final String context = "Helios";
     try {
@@ -419,4 +423,71 @@ public void approveDeploymentOnBehalfOfUser(
       throw e;
     }
   }
+
+  /**
+   * Generates release notes for a GitHub repository by comparing changes between versions.
+   *
+   * @param repositoryNameWithOwner The repository name including the owner (e.g., "owner/repo")
+   * @param tagName The tag name for the new release
+   * @param targetCommitish The commitish value that determines where the Git tag is created from
+   * @return The generated release notes as a string
+   * @throws IOException If there's an error communicating with the GitHub API
+   */
+  public String generateReleaseNotes(
+      String repositoryNameWithOwner, String tagName, String targetCommitish) throws IOException {
+
+    // Create the request payload with only non-null fields
+    Map<String, String> requestPayload = new HashMap<>();
+    requestPayload.put("tag_name", tagName);
+
+    if (targetCommitish != null && !targetCommitish.isEmpty()) {
+      requestPayload.put("target_commitish", targetCommitish);
+    }
+
+    String jsonPayload = objectMapper.writeValueAsString(requestPayload);
+    log.info("Request payload: {}", jsonPayload);
+
+    // Build the request
+    Request request =
+        getRequestBuilder()
+            .url(
+                "https://api.github.com/repos/"
+                    + repositoryNameWithOwner
+                    + "/releases/generate-notes")
+            .post(
+                RequestBody.create(MediaType.parse("application/json; charset=utf-8"), jsonPayload))
+            .build();
+
+    // Execute the request
+    try (Response response = okHttpClient.newCall(request).execute()) {
+      ResponseBody responseBody = response.body();
+      if (!response.isSuccessful()) {
+        String errorBody = "No error details";
+        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 with response code: {} and body: {}",
+            response.code(),
+            errorBody);
+        throw new IOException("GitHub API call failed with response code: " + response.code());
+      }
+
+      if (responseBody == null) {
+        throw new IOException("Response body is null");
+      }
+
+      // Parse the response
+      Map<String, Object> responseMap = objectMapper.readValue(responseBody.string(), Map.class);
+
+      // Return the generated notes
+      log.debug("Successfully generated release notes using the GitHub API");
+      return (String) responseMap.get("body");
+    }
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoController.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoController.java
index 6f17d2f4b..b900dc0cd 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoController.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoController.java
@@ -10,6 +10,7 @@
 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.PutMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -64,4 +65,31 @@ public ResponseEntity<Void> publishReleaseDraft(@PathVariable String name) {
     releaseInfoService.publishReleaseDraft(name);
     return ResponseEntity.ok().build();
   }
+
+  /**
+   * Endpoint to generate release notes for a given tag.
+   *
+   * @param tagName Name of the tag
+   * @return Generated release notes
+   */
+  @EnforceAtLeastMaintainer
+  @PostMapping("/{tagName}/generate-release-notes")
+  public ResponseEntity<String> generateReleaseNotes(@PathVariable String tagName) {
+    return ResponseEntity.ok(releaseInfoService.generateReleaseNotes(tagName));
+  }
+
+  /**
+   * Endpoint to update the release notes of a release candidate.
+   *
+   * @param name Name of the release candidate
+   * @param releaseNotes DTO containing the updated release notes
+   * @return Updated release info details
+   */
+  @EnforceAtLeastMaintainer
+  @PutMapping("/{name}/release-notes")
+  public ResponseEntity<Void> updateReleaseNotes(
+      @PathVariable String name, @RequestBody ReleaseNotesDto releaseNotes) {
+    releaseInfoService.updateReleaseNotes(name, releaseNotes.body());
+    return ResponseEntity.ok().build();
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoDetailsDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoDetailsDto.java
index 5e44959e2..475255a53 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoDetailsDto.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoDetailsDto.java
@@ -19,7 +19,8 @@ public record ReleaseInfoDetailsDto(
     @NonNull List<ReleaseCandidateEvaluationDto> evaluations,
     ReleaseDto release,
     UserInfoDto createdBy,
-    @NonNull OffsetDateTime createdAt) {
+    @NonNull OffsetDateTime createdAt,
+    String body) {
 
   public record ReleaseCandidateEvaluationDto(@NonNull UserInfoDto user, boolean isWorking) {
     public static ReleaseCandidateEvaluationDto fromEvaluation(
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoService.java
index 8cacd13af..787908b84 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoService.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoService.java
@@ -18,6 +18,7 @@
 import de.tum.cit.aet.helios.heliosdeployment.HeliosDeploymentRepository;
 import de.tum.cit.aet.helios.releaseinfo.ReleaseInfoDetailsDto.ReleaseCandidateEvaluationDto;
 import de.tum.cit.aet.helios.releaseinfo.release.ReleaseDto;
+import de.tum.cit.aet.helios.releaseinfo.release.ReleaseRepository;
 import de.tum.cit.aet.helios.releaseinfo.release.github.GitHubReleaseSyncService;
 import de.tum.cit.aet.helios.releaseinfo.releasecandidate.CommitsSinceReleaseCandidateDto;
 import de.tum.cit.aet.helios.releaseinfo.releasecandidate.ReleaseCandidate;
@@ -41,6 +42,7 @@
 import org.kohsuke.github.GHCompare;
 import org.kohsuke.github.GHCompare.Commit;
 import org.kohsuke.github.GHRelease;
+import org.kohsuke.github.GHReleaseBuilder;
 import org.kohsuke.github.GHRepository;
 import org.springframework.stereotype.Service;
 
@@ -52,6 +54,7 @@ public class ReleaseInfoService {
   private final GitHubService gitHubService;
   private final GitRepoRepository gitRepoRepository;
   private final ReleaseCandidateRepository releaseCandidateRepository;
+  private final ReleaseRepository releaseRepository;
   private final CommitRepository commitRepository;
   private final DeploymentRepository deploymentRepository;
   private final BranchRepository branchRepository;
@@ -141,7 +144,8 @@ public ReleaseInfoDetailsDto getReleaseInfoByName(String name) {
                       ? ReleaseDto.fromRelease(releaseCandidate.getRelease())
                       : null,
                   UserInfoDto.fromUser(releaseCandidate.getCreatedBy()),
-                  releaseCandidate.getCreatedAt());
+                  releaseCandidate.getCreatedAt(),
+                  releaseCandidate.getBody());
             })
         .orElseThrow(() -> new ReleaseCandidateException("ReleaseCandidate not found"));
   }
@@ -283,15 +287,27 @@ public ReleaseInfoListDto deleteReleaseCandidateByName(String name) {
   public void publishReleaseDraft(String tagName) {
     Long repositoryId = RepositoryContext.getRepositoryId();
     GitRepository repository = gitRepoRepository.findById(repositoryId).orElseThrow();
+    ReleaseCandidate releaseCandidate =
+        releaseCandidateRepository
+            .findByRepositoryRepositoryIdAndName(repositoryId, tagName)
+            .orElseThrow(() -> new ReleaseCandidateException("Release candidate not found"));
     try {
-      GHRelease ghRelease =
+      GHReleaseBuilder ghReleaseBuilder =
           gitHubService
               .getRepository(repository.getNameWithOwner())
               .createRelease(tagName)
-              .generateReleaseNotes(true)
               .draft(true)
               .name(tagName)
-              .create();
+              .commitish(releaseCandidate.getCommit().getSha());
+
+      // If the release candidate has a description, use it. Otherwise, generate release notes.
+      if (releaseCandidate.getBody() != null) {
+        ghReleaseBuilder.body(releaseCandidate.getBody());
+      } else {
+        ghReleaseBuilder.body(generateReleaseNotes(tagName));
+      }
+
+      GHRelease ghRelease = ghReleaseBuilder.create();
 
       gitHubReleaseSyncService.processRelease(
           ghRelease, gitHubService.getRepository(repository.getNameWithOwner()));
@@ -300,4 +316,48 @@ public void publishReleaseDraft(String tagName) {
       throw new ReleaseCandidateException("Release candidate could not be pushed to GitHub.");
     }
   }
+
+  public String generateReleaseNotes(String tagName) {
+    final Long repositoryId = RepositoryContext.getRepositoryId();
+    final GitRepository repository =
+        gitRepoRepository
+            .findById(repositoryId)
+            .orElseThrow(() -> new ReleaseCandidateException("Repository not found"));
+
+    String targetCommitish = null;
+
+    // If release is not yet published, get commit SHA from release candidate
+    if (!releaseRepository
+        .findByTagNameAndRepositoryRepositoryId(tagName, repositoryId)
+        .isPresent()) {
+      targetCommitish =
+          releaseCandidateRepository
+              .findByRepositoryRepositoryIdAndName(repositoryId, tagName)
+              .map(rc -> rc.getCommit().getSha())
+              .orElseThrow(() -> new ReleaseCandidateException("Release candidate not found"));
+    }
+
+    try {
+      return gitHubService.generateReleaseNotes(
+          repository.getNameWithOwner(), tagName, targetCommitish);
+    } catch (IOException e) {
+      log.error("Failed to generate release notes: {}", e.getMessage());
+      throw new ReleaseCandidateException("Failed to generate release notes");
+    }
+  }
+
+  public void updateReleaseNotes(String tagName, String releaseNotes) {
+    final Long repositoryId = RepositoryContext.getRepositoryId();
+
+    releaseCandidateRepository
+        .findByRepositoryRepositoryIdAndName(repositoryId, tagName)
+        .ifPresentOrElse(
+            releaseCandidate -> {
+              releaseCandidate.setBody(releaseNotes);
+              releaseCandidateRepository.save(releaseCandidate);
+            },
+            () -> {
+              throw new ReleaseCandidateException("Release candidate not found");
+            });
+  }
 }
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseNotesDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseNotesDto.java
new file mode 100644
index 000000000..c9dcc9941
--- /dev/null
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseNotesDto.java
@@ -0,0 +1,3 @@
+package de.tum.cit.aet.helios.releaseinfo;
+
+public record ReleaseNotesDto(String body) {}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java
index 973d56e96..1e43ca62f 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java
@@ -1,5 +1,8 @@
 package de.tum.cit.aet.helios.releaseinfo.release;
 
+import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
 
-public interface ReleaseRepository extends JpaRepository<Release, Long> {}
+public interface ReleaseRepository extends JpaRepository<Release, Long> {
+  Optional<Release> findByTagNameAndRepositoryRepositoryId(String tagName, Long repositoryId);
+}
diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/releasecandidate/ReleaseCandidate.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/releasecandidate/ReleaseCandidate.java
index ca5371411..32487a913 100644
--- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/releasecandidate/ReleaseCandidate.java
+++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/releasecandidate/ReleaseCandidate.java
@@ -6,6 +6,7 @@
 import de.tum.cit.aet.helios.releaseinfo.release.Release;
 import de.tum.cit.aet.helios.user.User;
 import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
 import jakarta.persistence.Entity;
 import jakarta.persistence.FetchType;
 import jakarta.persistence.Id;
@@ -54,6 +55,9 @@ public class ReleaseCandidate {
 
   @OneToOne private Release release;
 
+  @Column(columnDefinition = "TEXT")
+  private String body;
+
   public int compareToByDate(ReleaseCandidate other) {
     return this.createdAt.compareTo(other.createdAt);
   }
diff --git a/server/application-server/src/main/resources/db/migration/V28__add_body_to_release_candidate.sql b/server/application-server/src/main/resources/db/migration/V28__add_body_to_release_candidate.sql
new file mode 100644
index 000000000..a44ce8322
--- /dev/null
+++ b/server/application-server/src/main/resources/db/migration/V28__add_body_to_release_candidate.sql
@@ -0,0 +1,3 @@
+-- Add body column to release_candidate table to store generated release notes
+ALTER TABLE release_candidate
+ADD COLUMN body TEXT; 
\ No newline at end of file


Reviews:
user: login: TurkerKoc
body: Solid and great work! 🚀  I've tried it locally and it works like a charm! Thanks for adding generate release functionality. 
I've added some comments for minor stuff that's not affecting the functionality. You can merge the PR after looking at them.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-11T17:10:05Z
user: login: gbanu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T00:24:17Z

Review comments:
diff_hunk: @@ -91,6 +85,49 @@ <h2 class="text-2xl flex items-center gap-2">
     </div>
   </div>
 
+  <!-- Unified Release Notes Section -->
+  <div class="my-6">
+    <div class="flex justify-between items-center mb-3">
+      <h3 class="text-xl">Release Notes</h3>
+      <div class="flex gap-2">
+        @if (permissionService.isAtLeastMaintainer() && !releaseCandidate.release) {
+          <p-button label="Generate Release Notes" (onClick)="generateReleaseNotes()" [loading]="generateReleaseNotesMutation.isPending()">
+            <i-tabler name="plus" />
+          </p-button>
+
+          @if (!isEditingReleaseNotes()) {
+            <p-button label="Edit" (onClick)="editReleaseNotes()">
+              <i-tabler name="pencil" />
+            </p-button>
+          }
+        }
+      </div>
+    </div>
+
+    @if (permissionService.isAtLeastMaintainer() && isEditingReleaseNotes() && !releaseCandidate.release) {
+      <!-- Edit mode - only available if not published to GitHub -->
+      <form [formGroup]="releaseNotesForm" class="w-full">
+        <textarea rows="10" cols="100" pTextarea placeholder="Write release notes in markdown format" [autoResize]="true" formControlName="releaseNotes"></textarea>
+        <div class="flex justify-start gap-2">
+          <p-button label="Save" (onClick)="saveReleaseNotes()" [loading]="updateReleaseNotesMutation.isPending()"></p-button>
+          <p-button label="Cancel" text severity="danger" (onClick)="cancelEditing()"></p-button>
path: client/src/app/pages/release-candidate-details/release-candidate-details.component.html
user: login: TurkerKoc
body: Minor thing: This cancel button has white background. Would be better if we change this :)
created_at: 2025-04-11T16:47:58Z
author_association: MEMBER
diff_hunk: @@ -51,7 +57,44 @@ export class ReleaseCandidateDetailsComponent {
   private route = inject(ActivatedRoute);
 
   name = input.required<string>();
-  releaseCandidateQuery = injectQuery(() => ({ ...getReleaseInfoByNameOptions({ path: { name: this.name() } }), refetchInterval: 3000 }));
+  releaseCandidateQuery = injectQuery(() => ({
+    ...getReleaseInfoByNameOptions({ path: { name: this.name() } }),
+    refetchInterval: 3000,
+    onSuccess: () => {
+      this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+    },
+  }));
+
+  releaseNotesForm = new FormGroup({
+    releaseNotes: new FormControl(''),
+  });
+
+  // Computed property that handles the priorities as required
+  releaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    if (!releaseCandidate) return '';
+
+    // Priority 1: If it's a full release, use release.body
+    if (releaseCandidate?.release?.body) {
+      return releaseCandidate.release.body;
+    }
+    // Priority 2: If it's a draft published to GitHub, use releaseCandidate.body
+    else if (releaseCandidate?.body) {
+      return releaseCandidate.body;
+    }
+    // Default empty if nothing is available
+    else {
+      return '';
+    }
+  });
+
+  isEditingReleaseNotes = signal(false);
+
+  // Check if editing is allowed (only if not published to GitHub)
+  canEditReleaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    return !releaseCandidate?.release && this.permissionService.isAtLeastMaintainer();
path: client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
user: login: TurkerKoc
body: Is it possible to edit releaseCandidate which is published to github but it's still draft?
created_at: 2025-04-11T16:50:52Z
author_association: MEMBER
diff_hunk: @@ -51,7 +57,44 @@ export class ReleaseCandidateDetailsComponent {
   private route = inject(ActivatedRoute);
 
   name = input.required<string>();
-  releaseCandidateQuery = injectQuery(() => ({ ...getReleaseInfoByNameOptions({ path: { name: this.name() } }), refetchInterval: 3000 }));
+  releaseCandidateQuery = injectQuery(() => ({
+    ...getReleaseInfoByNameOptions({ path: { name: this.name() } }),
+    refetchInterval: 3000,
path: client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
user: login: TurkerKoc
body: I know that it's something from previous implementation but do we need to refetch everything every 3 seconds? Since it's not a thing that could be uptaded async, right?
created_at: 2025-04-11T16:53:04Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,10 @@
 package de.tum.cit.aet.helios.releaseinfo.release;
 
+import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
 
-public interface ReleaseRepository extends JpaRepository<Release, Long> {}
+public interface ReleaseRepository extends JpaRepository<Release, Long> {
+  Optional<Release> findFirstByOrderByPublishedAtDesc();
path: server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java
user: login: TurkerKoc
body: I guess this is not used anywhere right? If we need to keep this, should we add repositoryId filtering as well?
created_at: 2025-04-11T17:00:29Z
author_association: MEMBER
path: server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/ReleaseInfoService.java
user: login: TurkerKoc
body: Nice one!
created_at: 2025-04-11T17:05:30Z
author_association: MEMBER
diff_hunk: @@ -1,5 +1,10 @@
 package de.tum.cit.aet.helios.releaseinfo.release;
 
+import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
 
-public interface ReleaseRepository extends JpaRepository<Release, Long> {}
+public interface ReleaseRepository extends JpaRepository<Release, Long> {
+  Optional<Release> findFirstByOrderByPublishedAtDesc();
path: server/application-server/src/main/java/de/tum/cit/aet/helios/releaseinfo/release/ReleaseRepository.java
user: login: gbanu
body: you are right, we are not using it, thanks!
created_at: 2025-04-12T00:14:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +57,44 @@ export class ReleaseCandidateDetailsComponent {
   private route = inject(ActivatedRoute);
 
   name = input.required<string>();
-  releaseCandidateQuery = injectQuery(() => ({ ...getReleaseInfoByNameOptions({ path: { name: this.name() } }), refetchInterval: 3000 }));
+  releaseCandidateQuery = injectQuery(() => ({
+    ...getReleaseInfoByNameOptions({ path: { name: this.name() } }),
+    refetchInterval: 3000,
path: client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
user: login: gbanu
body: true, nice catch!
created_at: 2025-04-12T00:18:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,7 +57,44 @@ export class ReleaseCandidateDetailsComponent {
   private route = inject(ActivatedRoute);
 
   name = input.required<string>();
-  releaseCandidateQuery = injectQuery(() => ({ ...getReleaseInfoByNameOptions({ path: { name: this.name() } }), refetchInterval: 3000 }));
+  releaseCandidateQuery = injectQuery(() => ({
+    ...getReleaseInfoByNameOptions({ path: { name: this.name() } }),
+    refetchInterval: 3000,
+    onSuccess: () => {
+      this.releaseNotesForm.get('releaseNotes')?.setValue(this.releaseNotes());
+    },
+  }));
+
+  releaseNotesForm = new FormGroup({
+    releaseNotes: new FormControl(''),
+  });
+
+  // Computed property that handles the priorities as required
+  releaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    if (!releaseCandidate) return '';
+
+    // Priority 1: If it's a full release, use release.body
+    if (releaseCandidate?.release?.body) {
+      return releaseCandidate.release.body;
+    }
+    // Priority 2: If it's a draft published to GitHub, use releaseCandidate.body
+    else if (releaseCandidate?.body) {
+      return releaseCandidate.body;
+    }
+    // Default empty if nothing is available
+    else {
+      return '';
+    }
+  });
+
+  isEditingReleaseNotes = signal(false);
+
+  // Check if editing is allowed (only if not published to GitHub)
+  canEditReleaseNotes = computed(() => {
+    const releaseCandidate = this.releaseCandidateQuery.data();
+    return !releaseCandidate?.release && this.permissionService.isAtLeastMaintainer();
path: client/src/app/pages/release-candidate-details/release-candidate-details.component.ts
user: login: gbanu
body: yes, it's a bit confusing here but when the release candidate gets published as a draft we process this draft GH release and link it to the release candidate, so releaseCandidate?.release is basically checking if it's published to GH
created_at: 2025-04-12T00:20:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: codacy-production[bot]
created_at: 2025-04-11T13:51:06Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/ls1intum/Helios/pull-requests/638/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **-0.05%** (target: -1.00%) | :white_check_mark: **11.38%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (efd73f50cabb464ac158807ef0479178f12a10dc) | 6536 | 877 | 13.42% | |
| Head commit (f6a18f9e479ee628db107b7a2775d473d9893366) | 6649 (+113) | 889 (+12) | 13.37% (**-0.05%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#638) | 123 | 14 | **11.38%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/ls1intum/Helios/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/ls1intum/Helios/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE