From ca809c572dce3fce3e655e8b11aaa40c2b03966b Mon Sep 17 00:00:00 2001 From: Paul Thiel Date: Sun, 23 Feb 2025 15:45:03 +0100 Subject: [PATCH 1/3] feat: add pinning for prs and branches --- .../branches-table.component.html | 46 ++++++-- .../branches-table.component.ts | 32 +++++- .../pull-request-table.component.html | 55 +++++++--- .../pull-request-table.component.ts | 22 +++- .../angular-query-experimental.gen.ts | 68 ++++++++++++ .../app/core/modules/openapi/schemas.gen.ts | 7 +- .../src/app/core/modules/openapi/sdk.gen.ts | 18 +++ .../src/app/core/modules/openapi/types.gen.ts | 40 ++++++- client/src/icons.module.ts | 4 + docs/development/local/testing.rst | 4 +- server/application-server/openapi.yaml | 51 ++++++++- .../de/tum/cit/aet/helios/branch/Branch.java | 8 ++ .../aet/helios/branch/BranchController.java | 10 ++ .../cit/aet/helios/branch/BranchInfoDto.java | 12 +- .../cit/aet/helios/branch/BranchService.java | 75 +++++++++---- .../helios/deployment/DeploymentService.java | 4 +- .../aet/helios/pullrequest/PullRequest.java | 5 + .../pullrequest/PullRequestBaseInfoDto.java | 20 ++-- .../pullrequest/PullRequestController.java | 9 ++ .../pullrequest/PullRequestService.java | 55 +++++++++- .../ReleaseCandidateService.java | 103 +++++++++--------- .../helios/userpreference/UserPreference.java | 38 +++++++ .../UserPreferenceRepository.java | 11 ++ .../db/migration/V15__add_user_preference.sql | 52 +++++++++ .../helios/branch/BranchControllerTest.java | 7 +- 25 files changed, 619 insertions(+), 137 deletions(-) create mode 100644 server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreference.java create mode 100644 server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreferenceRepository.java create mode 100644 server/application-server/src/main/resources/db/migration/V15__add_user_preference.sql diff --git a/client/src/app/components/branches-table/branches-table.component.html b/client/src/app/components/branches-table/branches-table.component.html index 62c8868a9..f8f8c0232 100644 --- a/client/src/app/components/branches-table/branches-table.component.html +++ b/client/src/app/components/branches-table/branches-table.component.html @@ -39,23 +39,45 @@ - + @if (!rowNode.node.subheader) { - -
- - - -
- @for (part of branch.name | highlight: searchTableService.searchValue(); track part.text) { - @if (part.highlight) { - {{ part.text }} + +
+
+
+ @if (isHovered.get(branch.name)) { + @if (branch.isPinned) { + + + } @else { - {{ part.text }} + + + } }
- +
+
+ + + +
+ @for (part of branch.name | highlight: searchTableService.searchValue(); track part.text) { + @if (part.highlight) { + {{ part.text }} + } @else { + {{ part.text }} + } + } +
+
+
@if (branch.isProtected) { } diff --git a/client/src/app/components/branches-table/branches-table.component.ts b/client/src/app/components/branches-table/branches-table.component.ts index 90d033bff..e45fa1cff 100644 --- a/client/src/app/components/branches-table/branches-table.component.ts +++ b/client/src/app/components/branches-table/branches-table.component.ts @@ -2,7 +2,7 @@ import { Component, computed, inject } from '@angular/core'; import { TableModule } from 'primeng/table'; import { AvatarModule } from 'primeng/avatar'; import { TagModule } from 'primeng/tag'; -import { injectQuery } from '@tanstack/angular-query-experimental'; +import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental'; import { IconsModule } from 'icons.module'; import { SkeletonModule } from 'primeng/skeleton'; import { InputIconModule } from 'primeng/inputicon'; @@ -12,7 +12,11 @@ import { TreeTableModule } from 'primeng/treetable'; import { ButtonModule } from 'primeng/button'; import { BranchViewPreferenceService } from '@app/core/services/branches-table/branch-view-preference'; import { Router } from '@angular/router'; -import { getAllBranchesOptions } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; +import { + getAllBranchesOptions, + getAllBranchesQueryKey, + setBranchPinnedByRepositoryIdAndNameAndUserIdMutation, +} from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; import { BranchInfoDto } from '@app/core/modules/openapi'; import { ProgressBarModule } from 'primeng/progressbar'; import { DividerModule } from 'primeng/divider'; @@ -23,6 +27,7 @@ import { FILTER_OPTIONS_TOKEN, SearchTableService } from '@app/core/services/sea import { TableFilterComponent } from '../table-filter/table-filter.component'; import { WorkflowRunStatusComponent } from '@app/components/workflow-run-status-component/workflow-run-status.component'; import { HighlightPipe } from '@app/pipes/highlight.pipe'; +import { MessageService } from 'primeng/api'; type BranchInfoWithLink = BranchInfoDto & { link: string; lastCommitLink: string }; @@ -83,14 +88,26 @@ const FILTER_OPTIONS = [ export class BranchTableComponent { router = inject(Router); viewPreference = inject(BranchViewPreferenceService); + messageService = inject(MessageService); + queryClient = inject(QueryClient); searchTableService = inject(SearchTableService); - featureBranchesTree = computed(() => this.convertBranchesToTreeNodes(this.searchTableService.activeFilter().filter(this.branches()))); - query = injectQuery(() => getAllBranchesOptions()); + setPinnedMutation = injectMutation(() => ({ + ...setBranchPinnedByRepositoryIdAndNameAndUserIdMutation(), + onSuccess: () => { + this.messageService.add({ severity: 'success', summary: 'Pin Pull Request', detail: 'The pull request was pinned successfully' }); + this.queryClient.invalidateQueries({ queryKey: getAllBranchesQueryKey() }); + }, + })); + + // Use only branch name for map, because it is unique in this view + isHovered = new Map(); globalFilterFields = ['name', 'commitSha']; + featureBranchesTree = computed(() => this.convertBranchesToTreeNodes(this.searchTableService.activeFilter().filter(this.branches()))); + branches = computed( () => this.query.data()?.map(branch => ({ @@ -117,6 +134,13 @@ export class BranchTableComponent { } } + setPinned(event: Event, branch: BranchInfoDto, isPinned: boolean): void { + event.stopPropagation(); + if (!branch.repository) return; + this.setPinnedMutation.mutate({ path: { repoId: branch.repository.id }, query: { name: branch.name, isPinned } }); + this.isHovered.set(branch.name, false); + } + convertBranchesToTreeNodes(branches: BranchInfoWithLink[]): TreeNode[] { const rootNodes: TreeNode[] = [ { diff --git a/client/src/app/components/pull-request-table/pull-request-table.component.html b/client/src/app/components/pull-request-table/pull-request-table.component.html index 639885d9d..179a28c8e 100644 --- a/client/src/app/components/pull-request-table/pull-request-table.component.html +++ b/client/src/app/components/pull-request-table/pull-request-table.component.html @@ -52,22 +52,47 @@ - - -
- - - + + +
+
+
+ @if (isHovered.get(pr.id)) { + @if (pr.isPinned) { + + + + } @else { + + + + } + } +
+
- @for (label of pr.labels; track label.id) { - - } -
-
- - {{ pr.author?.name }} opened #{{ pr.number }} {{ pr.createdAt | timeAgo }} - - updated {{ pr.updatedAt | timeAgo }} +
+
+ + + + + @for (label of pr.labels; track label.id) { + + } +
+
+ + {{ pr.author?.name }} opened #{{ pr.number }} {{ pr.createdAt | timeAgo }} + + updated {{ pr.updatedAt | timeAgo }} +
+
diff --git a/client/src/app/components/pull-request-table/pull-request-table.component.ts b/client/src/app/components/pull-request-table/pull-request-table.component.ts index 01974921f..9328d6e1e 100644 --- a/client/src/app/components/pull-request-table/pull-request-table.component.ts +++ b/client/src/app/components/pull-request-table/pull-request-table.component.ts @@ -5,12 +5,12 @@ import { Component, computed, inject } from '@angular/core'; import { TableModule } from 'primeng/table'; import { AvatarModule } from 'primeng/avatar'; import { TagModule } from 'primeng/tag'; -import { injectQuery } from '@tanstack/angular-query-experimental'; +import { injectMutation, injectQuery, QueryClient } from '@tanstack/angular-query-experimental'; import { IconsModule } from 'icons.module'; import { SkeletonModule } from 'primeng/skeleton'; import { ActivatedRoute, Router } from '@angular/router'; import { DateService } from '@app/core/services/date.service'; -import { getAllPullRequestsOptions } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; +import { getAllPullRequestsOptions, getAllPullRequestsQueryKey, setPrPinnedByNumberMutation } from '@app/core/modules/openapi/@tanstack/angular-query-experimental.gen'; import { PullRequestBaseInfoDto, PullRequestInfoDto } from '@app/core/modules/openapi'; import { ButtonModule } from 'primeng/button'; import { DividerModule } from 'primeng/divider'; @@ -22,6 +22,7 @@ import { FILTER_OPTIONS_TOKEN, SearchTableService } from '@app/core/services/sea import { TableFilterComponent } from '../table-filter/table-filter.component'; import { WorkflowRunStatusComponent } from '@app/components/workflow-run-status-component/workflow-run-status.component'; import { PullRequestStatusIconComponent } from '@app/components/pull-request-status-icon/pull-request-status-icon.component'; +import { MessageService } from 'primeng/api'; const FILTER_OPTIONS = [ { name: 'All pull requests', filter: (prs: PullRequestBaseInfoDto[]) => prs }, @@ -76,11 +77,22 @@ const FILTER_OPTIONS = [ export class PullRequestTableComponent { dateService = inject(DateService); searchTableService = inject(SearchTableService); + messageService = inject(MessageService); + queryClient = inject(QueryClient); router = inject(Router); route = inject(ActivatedRoute); keycloak = inject(KeycloakService); query = injectQuery(() => getAllPullRequestsOptions()); + setPinnedMutation = injectMutation(() => ({ + ...setPrPinnedByNumberMutation(), + onSuccess: () => { + this.messageService.add({ severity: 'success', summary: 'Pin Pull Request', detail: 'The pull request was pinned successfully' }); + this.queryClient.invalidateQueries({ queryKey: getAllPullRequestsQueryKey() }); + }, + })); + + isHovered = new Map(); filteredPrs = computed(() => this.searchTableService.activeFilter().filter(this.query.data() || [], this.keycloak.decodedToken()?.preferred_username)); @@ -111,4 +123,10 @@ export class PullRequestTableComponent { relativeTo: this.route.parent, }); } + + setPinned(event: Event, pr: PullRequestInfoDto, isPinned: boolean): void { + this.setPinnedMutation.mutate({ path: { pr: pr.id }, query: { isPinned } }); + this.isHovered.set(pr.id, false); + event.stopPropagation(); + } } 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 fd0f69a02..db3149f66 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 @@ -16,8 +16,10 @@ import type { CreateReleaseCandidateData, CreateReleaseCandidateResponse, EvaluateData, + SetPrPinnedByNumberData, DeployToEnvironmentData, DeployToEnvironmentResponse, + SetBranchPinnedByRepositoryIdAndNameAndUserIdData, HealthCheckData, GetAllWorkflowsData, GetWorkflowByIdData, @@ -64,7 +66,9 @@ import { getAllReleaseCandidates, createReleaseCandidate, evaluate, + setPrPinnedByNumber, deployToEnvironment, + setBranchPinnedByRepositoryIdAndNameAndUserId, healthCheck, getAllWorkflows, getWorkflowById, @@ -341,6 +345,37 @@ export const evaluateMutation = (options?: Partial>) => { return mutationOptions; }; +export const setPrPinnedByNumberQueryKey = (options: Options) => [createQueryKey('setPrPinnedByNumber', options)]; + +export const setPrPinnedByNumberOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await setPrPinnedByNumber({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: setPrPinnedByNumberQueryKey(options), + }); +}; + +export const setPrPinnedByNumberMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async localOptions => { + const { data } = await setPrPinnedByNumber({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const deployToEnvironmentQueryKey = (options: Options) => [createQueryKey('deployToEnvironment', options)]; export const deployToEnvironmentOptions = (options: Options) => { @@ -372,6 +407,39 @@ export const deployToEnvironmentMutation = (options?: Partial) => [ + createQueryKey('setBranchPinnedByRepositoryIdAndNameAndUserId', options), +]; + +export const setBranchPinnedByRepositoryIdAndNameAndUserIdOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await setBranchPinnedByRepositoryIdAndNameAndUserId({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: setBranchPinnedByRepositoryIdAndNameAndUserIdQueryKey(options), + }); +}; + +export const setBranchPinnedByRepositoryIdAndNameAndUserIdMutation = (options?: Partial>) => { + const mutationOptions: MutationOptions> = { + mutationFn: async localOptions => { + const { data } = await setBranchPinnedByRepositoryIdAndNameAndUserId({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; + export const healthCheckQueryKey = (options?: Options) => [createQueryKey('healthCheck', options)]; export const healthCheckOptions = (options?: Options) => { diff --git a/client/src/app/core/modules/openapi/schemas.gen.ts b/client/src/app/core/modules/openapi/schemas.gen.ts index c4133d2fd..7b6358930 100644 --- a/client/src/app/core/modules/openapi/schemas.gen.ts +++ b/client/src/app/core/modules/openapi/schemas.gen.ts @@ -548,6 +548,9 @@ export const BranchInfoDtoSchema = { isProtected: { type: 'boolean', }, + isPinned: { + type: 'boolean', + }, updatedAt: { type: 'string', format: 'date-time', @@ -719,8 +722,8 @@ export const PullRequestBaseInfoDtoSchema = { isMerged: { type: 'boolean', }, - repository: { - $ref: '#/components/schemas/RepositoryInfoDto', + isPinned: { + type: 'boolean', }, htmlUrl: { type: 'string', diff --git a/client/src/app/core/modules/openapi/sdk.gen.ts b/client/src/app/core/modules/openapi/sdk.gen.ts index 0abb19095..517297360 100644 --- a/client/src/app/core/modules/openapi/sdk.gen.ts +++ b/client/src/app/core/modules/openapi/sdk.gen.ts @@ -18,8 +18,10 @@ import type { CreateReleaseCandidateData, CreateReleaseCandidateResponse, EvaluateData, + SetPrPinnedByNumberData, DeployToEnvironmentData, DeployToEnvironmentResponse, + SetBranchPinnedByRepositoryIdAndNameAndUserIdData, HealthCheckData, HealthCheckResponse, GetAllWorkflowsData, @@ -188,6 +190,13 @@ export const evaluate = (options: Options< }); }; +export const setPrPinnedByNumber = (options: Options) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/pullrequests/{pr}/pin', + }); +}; + export const deployToEnvironment = (options: Options) => { return (options?.client ?? client).post({ ...options, @@ -199,6 +208,15 @@ export const deployToEnvironment = (option }); }; +export const setBranchPinnedByRepositoryIdAndNameAndUserId = ( + options: Options +) => { + return (options?.client ?? client).post({ + ...options, + url: '/api/branches/repository/{repoId}/pin', + }); +}; + export const healthCheck = (options?: Options) => { return (options?.client ?? client).get({ ...options, diff --git a/client/src/app/core/modules/openapi/types.gen.ts b/client/src/app/core/modules/openapi/types.gen.ts index 11f09a957..206c7549b 100644 --- a/client/src/app/core/modules/openapi/types.gen.ts +++ b/client/src/app/core/modules/openapi/types.gen.ts @@ -182,6 +182,7 @@ export type BranchInfoDto = { behindBy?: number; isDefault?: boolean; isProtected?: boolean; + isPinned?: boolean; updatedAt?: string; updatedBy?: UserInfoDto; repository?: RepositoryInfoDto; @@ -247,7 +248,7 @@ export type PullRequestBaseInfoDto = { state: 'OPEN' | 'CLOSED'; isDraft: boolean; isMerged: boolean; - repository?: RepositoryInfoDto; + isPinned?: boolean; htmlUrl: string; createdAt?: string; updatedAt?: string; @@ -515,6 +516,24 @@ export type EvaluateResponses = { 200: unknown; }; +export type SetPrPinnedByNumberData = { + body?: never; + path: { + pr: number; + }; + query: { + isPinned: boolean; + }; + url: '/api/pullrequests/{pr}/pin'; +}; + +export type SetPrPinnedByNumberResponses = { + /** + * OK + */ + 200: unknown; +}; + export type DeployToEnvironmentData = { body: DeployRequest; path?: never; @@ -531,6 +550,25 @@ export type DeployToEnvironmentResponses = { export type DeployToEnvironmentResponse = DeployToEnvironmentResponses[keyof DeployToEnvironmentResponses]; +export type SetBranchPinnedByRepositoryIdAndNameAndUserIdData = { + body?: never; + path: { + repoId: number; + }; + query: { + name: string; + isPinned: boolean; + }; + url: '/api/branches/repository/{repoId}/pin'; +}; + +export type SetBranchPinnedByRepositoryIdAndNameAndUserIdResponses = { + /** + * OK + */ + 200: unknown; +}; + export type HealthCheckData = { body?: never; path?: never; diff --git a/client/src/icons.module.ts b/client/src/icons.module.ts index b63d35d10..33ab6cd9f 100644 --- a/client/src/icons.module.ts +++ b/client/src/icons.module.ts @@ -60,6 +60,8 @@ import { IconChevronsRight, IconCircleChevronsRight, IconFileText, + IconPinned, + IconPinnedOff, } from 'angular-tabler-icons/icons'; // Select some icons (use an object, not an array) @@ -122,6 +124,8 @@ const icons = { IconTrash, IconX, IconFileText, + IconPinned, + IconPinnedOff, }; @NgModule({ diff --git a/docs/development/local/testing.rst b/docs/development/local/testing.rst index 5dcbe2e50..02d1fa9f0 100644 --- a/docs/development/local/testing.rst +++ b/docs/development/local/testing.rst @@ -50,7 +50,7 @@ Server-Side Unit Testing Long id = this.branches.get(0).repository().id(); String branchName = this.branches.get(0).name(); - when(branchService.getBranchInfo(id, branchName)).thenReturn(Optional.of(branches.get(0))); + when(branchService.getBranchByRepositoryIdAndName(id, branchName)).thenReturn(Optional.of(branches.get(0))); ResultActions request = this.mockMvc .perform( @@ -71,7 +71,7 @@ Server-Side Unit Testing Long id = -1L; String branchName = this.branches.get(0).name(); - when(branchService.getBranchInfo(id, branchName)).thenReturn(Optional.empty()); + when(branchService.getBranchByRepositoryIdAndName(id, branchName)).thenReturn(Optional.empty()); this.mockMvc .perform( get("/api/branches/repository/{repoId}/branch", id) diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index 7de2be720..24a80a58f 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -240,6 +240,26 @@ paths: responses: "200": description: OK + /api/pullrequests/{pr}/pin: + post: + tags: + - pull-request-controller + operationId: setPrPinnedByNumber + parameters: + - name: pr + in: path + required: true + schema: + type: integer + format: int64 + - name: isPinned + in: query + required: true + schema: + type: boolean + responses: + "200": + description: OK /api/deployments/deploy: post: tags: @@ -258,6 +278,31 @@ paths: application/json: schema: type: string + /api/branches/repository/{repoId}/pin: + post: + tags: + - branch-controller + operationId: setBranchPinnedByRepositoryIdAndNameAndUserId + parameters: + - name: repoId + in: path + required: true + schema: + type: integer + format: int64 + - name: name + in: query + required: true + schema: + type: string + - name: isPinned + in: query + required: true + schema: + type: boolean + responses: + "200": + description: OK /status/health: get: tags: @@ -1351,6 +1396,8 @@ components: type: boolean isProtected: type: boolean + isPinned: + type: boolean updatedAt: type: string format: date-time @@ -1491,8 +1538,8 @@ components: type: boolean isMerged: type: boolean - repository: - $ref: "#/components/schemas/RepositoryInfoDto" + isPinned: + type: boolean htmlUrl: type: string createdAt: diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/Branch.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/Branch.java index 25eaa6a99..ec60987a8 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/Branch.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/Branch.java @@ -51,4 +51,12 @@ public class Branch { @JoinColumn(name = "updated_by_id") @ToString.Exclude private User updatedBy; + + @Override + public boolean equals(Object obj) { + return this.getRepository() + .getRepositoryId() + .equals(((Branch) obj).getRepository().getRepositoryId()) + && this.getName().equals(((Branch) obj).getName()); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchController.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchController.java index 22b9cd957..f5535aaf7 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchController.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchController.java @@ -4,6 +4,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; @@ -32,4 +33,13 @@ public ResponseEntity getBranchByRepositoryIdAndName( .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } + + @PostMapping("/repository/{repoId}/pin") + public ResponseEntity setBranchPinnedByRepositoryIdAndNameAndUserId( + @PathVariable(name = "repoId") Long repoId, + @RequestParam(name = "name") String name, + @RequestParam(name = "isPinned") Boolean isPinned) { + branchService.setBranchPinnedByRepositoryIdAndName(repoId, name, isPinned); + return ResponseEntity.ok().build(); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchInfoDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchInfoDto.java index 94806e539..ec599fc2e 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchInfoDto.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchInfoDto.java @@ -3,7 +3,9 @@ import com.fasterxml.jackson.annotation.JsonInclude; import de.tum.cit.aet.helios.gitrepo.RepositoryInfoDto; import de.tum.cit.aet.helios.user.UserInfoDto; +import de.tum.cit.aet.helios.userpreference.UserPreference; import java.time.OffsetDateTime; +import java.util.Optional; import org.springframework.lang.NonNull; @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -14,11 +16,13 @@ public record BranchInfoDto( int behindBy, boolean isDefault, boolean isProtected, + boolean isPinned, OffsetDateTime updatedAt, UserInfoDto updatedBy, RepositoryInfoDto repository) { - public static BranchInfoDto fromBranch(Branch branch) { + public static BranchInfoDto fromBranchAndUserPreference( + Branch branch, Optional userPreference) { return new BranchInfoDto( branch.getName(), branch.getCommitSha(), @@ -26,8 +30,14 @@ public static BranchInfoDto fromBranch(Branch branch) { branch.getBehindBy(), branch.isDefault(), branch.isProtection(), + userPreference.map(up -> up.getFavouriteBranches().contains(branch)).orElseGet(() -> false), branch.getUpdatedAt(), UserInfoDto.fromUser(branch.getUpdatedBy()), RepositoryInfoDto.fromRepository(branch.getRepository())); } + + public static BranchInfoDto fromBranch( + Branch branch) { + return fromBranchAndUserPreference(branch, Optional.empty()); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchService.java index 757253a69..7a4c11c86 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/branch/BranchService.java @@ -1,53 +1,52 @@ package de.tum.cit.aet.helios.branch; +import de.tum.cit.aet.helios.auth.AuthService; import de.tum.cit.aet.helios.releasecandidate.ReleaseCandidate; import de.tum.cit.aet.helios.releasecandidate.ReleaseCandidateRepository; +import de.tum.cit.aet.helios.userpreference.UserPreference; +import de.tum.cit.aet.helios.userpreference.UserPreferenceRepository; +import jakarta.persistence.EntityNotFoundException; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional +@RequiredArgsConstructor public class BranchService { private final BranchRepository branchRepository; private final ReleaseCandidateRepository releaseCandidateRepository; - - public BranchService( - BranchRepository branchRepository, ReleaseCandidateRepository releaseCandidateRepository) { - this.branchRepository = branchRepository; - this.releaseCandidateRepository = releaseCandidateRepository; - } + private final UserPreferenceRepository userPreferenceRepository; + private final AuthService authService; public List getAllBranches() { + final Optional userPreference = + userPreferenceRepository.findByUser(authService.getUserFromGithubId()); return branchRepository.findAll().stream() - .map(BranchInfoDto::fromBranch) + .map((branch) -> BranchInfoDto.fromBranchAndUserPreference(branch, userPreference)) + .sorted( + (pr1, pr2) -> { + if (pr1.isPinned() && !pr2.isPinned()) { + return -1; + } else if (!pr1.isPinned() && pr2.isPinned()) { + return 1; + } else { + return pr2.updatedAt().compareTo(pr1.updatedAt()); + } + }) .collect(Collectors.toList()); } - public Optional getBranchByName(String name) { - return branchRepository.findByName(name).map(BranchInfoDto::fromBranch); - } - @Transactional public void deleteBranchByNameAndRepositoryId(String name, Long repositoryId) { branchRepository.deleteByNameAndRepositoryRepositoryId(name, repositoryId); } - public List getBranchesByRepositoryId(Long repositoryId) { - return branchRepository.findByRepositoryRepositoryId(repositoryId).stream() - .map(BranchInfoDto::fromBranch) - .collect(Collectors.toList()); - } - - public Optional getBranchInfo(Long repositoryId, String name) { - return branchRepository - .findByRepositoryRepositoryIdAndName(repositoryId, name) - .map(BranchInfoDto::fromBranch); - } - public Optional getBranchByRepositoryIdAndName(Long repositoryId, String name) { return branchRepository .findByRepositoryRepositoryIdAndName(repositoryId, name) @@ -61,4 +60,34 @@ public Optional getBranchByRepositoryIdAndName(Long repository .map(ReleaseCandidate::getName) .orElseGet(() -> null))); } + + public void setBranchPinnedByRepositoryIdAndName(Long repoId, String name, Boolean isPinned) { + final UserPreference userPreference = + userPreferenceRepository + .findByUser(authService.getUserFromGithubId()) + .orElseGet( + () -> { + final UserPreference pref = new UserPreference(); + pref.setUser(authService.getUserFromGithubId()); + pref.setFavouriteBranches(new HashSet<>()); + pref.setFavouritePullRequests(new HashSet<>()); + return userPreferenceRepository.saveAndFlush(pref); + }); + + if (!isPinned) { + userPreference + .getFavouriteBranches() + .removeIf( + branch -> + branch.getRepository().getRepositoryId().equals(repoId) + && branch.getName().equals(name)); + } else { + final Branch branch = + branchRepository + .findByNameAndRepositoryRepositoryId(name, repoId) + .orElseThrow(() -> new EntityNotFoundException("Branch " + name + " not found")); + userPreference.getFavouriteBranches().add(branch); + } + userPreferenceRepository.save(userPreference); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java index dc30e3f46..3d4f5d564 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/deployment/DeploymentService.java @@ -127,7 +127,9 @@ private String determineCommitSha(DeployRequest deployRequest, Environment.Type return commitSha != null ? commitSha : this.branchService - .getBranchByName(deployRequest.branchName()) + .getBranchByRepositoryIdAndName( + RepositoryContext.getRepositoryId(), + deployRequest.branchName()) .orElseThrow(() -> new DeploymentException("Branch not found")) .commitSha(); } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequest.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequest.java index 424d38c10..a2239b03d 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequest.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequest.java @@ -76,6 +76,11 @@ public boolean isPullRequest() { @ManyToMany private Set workflowRuns; + @Override + public boolean equals(Object o) { + return this.id.equals(((PullRequest) o).id); + } + // Missing properties: // - PullRequestReview // - PullRequestReviewComment diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestBaseInfoDto.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestBaseInfoDto.java index f8c92250b..5bf98bc6b 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestBaseInfoDto.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestBaseInfoDto.java @@ -1,15 +1,16 @@ package de.tum.cit.aet.helios.pullrequest; import com.fasterxml.jackson.annotation.JsonInclude; -import de.tum.cit.aet.helios.gitrepo.RepositoryInfoDto; import de.tum.cit.aet.helios.issue.Issue; import de.tum.cit.aet.helios.issue.Issue.State; import de.tum.cit.aet.helios.label.LabelInfoDto; import de.tum.cit.aet.helios.user.UserInfoDto; +import de.tum.cit.aet.helios.userpreference.UserPreference; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import org.springframework.lang.NonNull; @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -20,17 +21,17 @@ public record PullRequestBaseInfoDto( @NonNull State state, @NonNull Boolean isDraft, @NonNull Boolean isMerged, - RepositoryInfoDto repository, + Boolean isPinned, @NonNull String htmlUrl, OffsetDateTime createdAt, OffsetDateTime updatedAt, UserInfoDto author, List labels, List assignees, - List reviewers -) { + List reviewers) { - public static PullRequestBaseInfoDto fromPullRequest(PullRequest pullRequest) { + public static PullRequestBaseInfoDto fromPullRequestAndUserPreference( + PullRequest pullRequest, Optional userPreference) { return new PullRequestBaseInfoDto( pullRequest.getId(), pullRequest.getNumber(), @@ -38,7 +39,9 @@ public static PullRequestBaseInfoDto fromPullRequest(PullRequest pullRequest) { pullRequest.getState(), pullRequest.isDraft(), pullRequest.isMerged(), - RepositoryInfoDto.fromRepository(pullRequest.getRepository()), + userPreference + .map(up -> up.getFavouritePullRequests().contains(pullRequest)) + .orElseGet(() -> false), pullRequest.getHtmlUrl(), pullRequest.getCreatedAt(), pullRequest.getUpdatedAt(), @@ -65,7 +68,7 @@ public static PullRequestBaseInfoDto fromIssue(Issue issue) { issue.getState(), false, false, - RepositoryInfoDto.fromRepository(issue.getRepository()), + false, issue.getHtmlUrl(), issue.getCreatedAt(), issue.getUpdatedAt(), @@ -78,7 +81,6 @@ public static PullRequestBaseInfoDto fromIssue(Issue issue) { .map(UserInfoDto::fromUser) .sorted(Comparator.comparing(UserInfoDto::login)) .toList(), - new ArrayList<>() - ); + new ArrayList<>()); } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestController.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestController.java index f2456919f..7e2a08924 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestController.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestController.java @@ -4,7 +4,9 @@ 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; @RestController @@ -47,4 +49,11 @@ public ResponseEntity getPullRequestByRepositoryIdAndNumber( .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } + + @PostMapping("/{pr}/pin") + public ResponseEntity setPrPinnedByNumber( + @PathVariable Long pr, @RequestParam(name = "isPinned") Boolean isPinned) { + pullRequestService.setPrPinnedByNumberAndUserId(pr, isPinned); + return ResponseEntity.ok().build(); + } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java index a076810d5..af07b99fe 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java @@ -1,24 +1,41 @@ package de.tum.cit.aet.helios.pullrequest; +import de.tum.cit.aet.helios.auth.AuthService; +import de.tum.cit.aet.helios.userpreference.UserPreference; +import de.tum.cit.aet.helios.userpreference.UserPreferenceRepository; +import jakarta.persistence.EntityNotFoundException; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional +@RequiredArgsConstructor public class PullRequestService { private final PullRequestRepository pullRequestRepository; - - public PullRequestService(PullRequestRepository pullRequestRepository) { - this.pullRequestRepository = pullRequestRepository; - } + private final UserPreferenceRepository userPreferenceRepository; + private final AuthService authService; public List getAllPullRequests() { + final Optional userPreference = + userPreferenceRepository.findByUser(authService.getUserFromGithubId()); return pullRequestRepository.findAllByOrderByUpdatedAtDesc().stream() - .map(PullRequestBaseInfoDto::fromPullRequest) + .map((pr) -> PullRequestBaseInfoDto.fromPullRequestAndUserPreference(pr, userPreference)) + .sorted( + (pr1, pr2) -> { + if (pr1.isPinned() && !pr2.isPinned()) { + return -1; + } else if (!pr1.isPinned() && pr2.isPinned()) { + return 1; + } else { + return pr2.updatedAt().compareTo(pr1.updatedAt()); + } + }) .collect(Collectors.toList()); } @@ -27,12 +44,38 @@ public Optional getPullRequestById(Long id) { } public List getPullRequestByRepositoryId(Long repositoryId) { - return pullRequestRepository.findByRepositoryRepositoryIdOrderByUpdatedAtDesc(repositoryId) + return pullRequestRepository + .findByRepositoryRepositoryIdOrderByUpdatedAtDesc(repositoryId) .stream() .map(PullRequestInfoDto::fromPullRequest) .collect(Collectors.toList()); } + public void setPrPinnedByNumberAndUserId(Long prId, Boolean isPinned) { + final UserPreference userPreference = + userPreferenceRepository + .findByUser(authService.getUserFromGithubId()) + .orElseGet( + () -> { + final UserPreference pref = new UserPreference(); + pref.setUser(authService.getUserFromGithubId()); + pref.setFavouriteBranches(new HashSet<>()); + pref.setFavouritePullRequests(new HashSet<>()); + return userPreferenceRepository.saveAndFlush(pref); + }); + + if (!isPinned) { + userPreference.getFavouritePullRequests().removeIf(pr -> pr.getId().equals(prId)); + } else { + final PullRequest pullRequest = + pullRequestRepository + .findById(prId) + .orElseThrow(() -> new EntityNotFoundException("PR " + prId + " not found")); + userPreference.getFavouritePullRequests().add(pullRequest); + } + userPreferenceRepository.save(userPreference); + } + public Optional getPullRequestByRepositoryIdAndNumber( Long repoId, Integer number) { return pullRequestRepository diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/releasecandidate/ReleaseCandidateService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/releasecandidate/ReleaseCandidateService.java index 19132b2d8..565e533bf 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/releasecandidate/ReleaseCandidateService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/releasecandidate/ReleaseCandidateService.java @@ -57,24 +57,25 @@ public List getAllReleaseCandidates() { } /** - * Returns all deployments for a given release candidate, meaning a specific commit and - * repository. It considers HeliosDeployment and Deployment entities and returns the latest one + * Returns all deployments for a given release candidate, meaning a specific + * commit and + * repository. It considers HeliosDeployment and Deployment entities and returns + * the latest one * for each environment. * * @param candidate The release candidate to get deployments for - * @return A list of LatestDeploymentUnion objects, each representing the latest deployment for an - * environment + * @return A list of LatestDeploymentUnion objects, each representing the latest + * deployment for an + * environment */ private List getCandidateDeployments(final ReleaseCandidate candidate) { Map deploymentsByEnvironment = new HashMap<>(); - List heliosDeployments = - heliosDeploymentRepository.findByRepositoryIdAndSha( - candidate.getRepository().getRepositoryId(), candidate.getCommit().getSha()); + List heliosDeployments = heliosDeploymentRepository.findByRepositoryIdAndSha( + candidate.getRepository().getRepositoryId(), candidate.getCommit().getSha()); - List deployments = - deploymentRepository.findByRepositoryRepositoryIdAndSha( - candidate.getRepository().getRepositoryId(), candidate.getCommit().getSha()); + List deployments = deploymentRepository.findByRepositoryRepositoryIdAndSha( + candidate.getRepository().getRepositoryId(), candidate.getCommit().getSha()); for (HeliosDeployment heliosDeployment : heliosDeployments) { deploymentsByEnvironment.put( @@ -89,8 +90,8 @@ private List getCandidateDeployments(final ReleaseCandida continue; } - LatestDeploymentUnion latestDeploymentUnion = - deploymentsByEnvironment.get(deployment.getEnvironment().getId()); + LatestDeploymentUnion latestDeploymentUnion = deploymentsByEnvironment + .get(deployment.getEnvironment().getId()); if (latestDeploymentUnion.getCreatedAt().isAfter(deployment.getCreatedAt())) { continue; @@ -110,10 +111,9 @@ public ReleaseCandidateDetailsDto getReleaseCandidateByName(String name) { .findByRepositoryRepositoryIdAndName(repositoryId, name) .map( releaseCandidate -> { - var deployments = - this.getCandidateDeployments(releaseCandidate).stream() - .map(ReleaseCandidateDetailsDto.ReleaseCandidateDeploymentDto::fromDeployment) - .toList(); + var deployments = this.getCandidateDeployments(releaseCandidate).stream() + .map(ReleaseCandidateDetailsDto.ReleaseCandidateDeploymentDto::fromDeployment) + .toList(); return new ReleaseCandidateDetailsDto( releaseCandidate.getName(), @@ -133,39 +133,37 @@ public CommitsSinceReleaseCandidateDto getCommitsFromBranchSinceLastReleaseCandi String branchName) { final Long repositoryId = RepositoryContext.getRepositoryId(); try { - final GitRepository repository = - gitRepoRepository - .findById(repositoryId) - .orElseThrow(() -> new ReleaseCandidateException("Repository not found")); + final GitRepository repository = gitRepoRepository + .findById(repositoryId) + .orElseThrow(() -> new ReleaseCandidateException("Repository not found")); - final GHRepository githubRepository = - gitHubService.getRepository(repository.getNameWithOwner()); + final GHRepository githubRepository = gitHubService + .getRepository(repository.getNameWithOwner()); - final Branch branch = - branchRepository - .findByRepositoryRepositoryIdAndName(repositoryId, branchName) - .orElseThrow(() -> new ReleaseCandidateException("Branch not found")); + final Branch branch = branchRepository + .findByRepositoryRepositoryIdAndName(repositoryId, branchName) + .orElseThrow(() -> new ReleaseCandidateException("Branch not found")); - final ReleaseCandidate lastReleaseCandidate = - releaseCandidateRepository.findByRepository(repository).stream() - .sorted(ReleaseCandidate::compareToByDate) - .findFirst() - .orElseGet(() -> null); + final ReleaseCandidate lastReleaseCandidate = releaseCandidateRepository + .findByRepository(repository) + .stream() + .sorted(ReleaseCandidate::compareToByDate) + .findFirst() + .orElseGet(() -> null); if (lastReleaseCandidate == null) { return new CommitsSinceReleaseCandidateDto(-1, new ArrayList<>()); } - final GHCompare compare = - githubRepository.getCompare( - lastReleaseCandidate.getCommit().getSha(), branch.getCommitSha()); + final GHCompare compare = githubRepository.getCompare( + lastReleaseCandidate.getCommit().getSha(), branch.getCommitSha()); return new CommitsSinceReleaseCandidateDto(compare.getTotalCommits(), new ArrayList<>()); // Add this snippet later when showing commit info // Arrays.stream(compare.getCommits()) - // .map(commitConverter::convert) - // .map(CommitInfoDto::fromCommit) - // .toList())); + // .map(commitConverter::convert) + // .map(CommitInfoDto::fromCommit) + // .toList())); } catch (IOException e) { log.error("Failed to compare commits for branch {}: {}", branchName, e.getMessage()); throw new ReleaseCandidateException("Failed to fetch compare commit data from GitHub"); @@ -178,8 +176,7 @@ public ReleaseCandidateInfoDto createReleaseCandidate( final String login = authService.getPreferredUsername(); if (releaseCandidateRepository.existsByRepositoryRepositoryIdAndName( - repositoryId, releaseCandidate.name()) - == true) { + repositoryId, releaseCandidate.name()) == true) { throw new ReleaseCandidateException("ReleaseCandidate with this name already exists"); } @@ -209,10 +206,9 @@ public ReleaseCandidateInfoDto createReleaseCandidate( public void evaluateReleaseCandidate(String name, boolean isWorking) { final Long repositoryId = RepositoryContext.getRepositoryId(); - final ReleaseCandidate releaseCandidate = - releaseCandidateRepository - .findByRepositoryRepositoryIdAndName(repositoryId, name) - .orElseThrow(() -> new ReleaseCandidateException("ReleaseCandidate not found")); + final ReleaseCandidate releaseCandidate = releaseCandidateRepository + .findByRepositoryRepositoryIdAndName(repositoryId, name) + .orElseThrow(() -> new ReleaseCandidateException("ReleaseCandidate not found")); final User user = authService.getUserFromGithubId(); @@ -220,16 +216,15 @@ public void evaluateReleaseCandidate(String name, boolean isWorking) { throw new ReleaseCandidateException("User not found"); } - final ReleaseCandidateEvaluation evaluation = - releaseCandidateEvaluationRepository - .findByReleaseCandidateAndEvaluatedById(releaseCandidate, user.getId()) - .orElseGet( - () -> { - ReleaseCandidateEvaluation newEvaluation = new ReleaseCandidateEvaluation(); - newEvaluation.setReleaseCandidate(releaseCandidate); - newEvaluation.setEvaluatedBy(user); - return newEvaluation; - }); + final ReleaseCandidateEvaluation evaluation = releaseCandidateEvaluationRepository + .findByReleaseCandidateAndEvaluatedById(releaseCandidate, user.getId()) + .orElseGet( + () -> { + ReleaseCandidateEvaluation newEvaluation = new ReleaseCandidateEvaluation(); + newEvaluation.setReleaseCandidate(releaseCandidate); + newEvaluation.setEvaluatedBy(user); + return newEvaluation; + }); evaluation.setWorking(isWorking); releaseCandidateEvaluationRepository.save(evaluation); @@ -245,7 +240,7 @@ public ReleaseCandidateInfoDto deleteReleaseCandidateByName(String name) { releaseCandidateRepository .deleteByRepositoryRepositoryIdAndName(repositoryId, name); - + return rc; } } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreference.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreference.java new file mode 100644 index 000000000..7830a89cc --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreference.java @@ -0,0 +1,38 @@ +package de.tum.cit.aet.helios.userpreference; + +import de.tum.cit.aet.helios.branch.Branch; +import de.tum.cit.aet.helios.pullrequest.PullRequest; +import de.tum.cit.aet.helios.user.User; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.Set; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Table(name = "user_preference", schema = "public") +@Getter +@Setter +@ToString(callSuper = true) +public class UserPreference { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @ManyToMany + private Set favouriteBranches; + + @ManyToMany + private Set favouritePullRequests; +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreferenceRepository.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreferenceRepository.java new file mode 100644 index 000000000..4c9d7d4e5 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/userpreference/UserPreferenceRepository.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.helios.userpreference; + +import de.tum.cit.aet.helios.user.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserPreferenceRepository extends JpaRepository { + Optional findByUser(User user); +} diff --git a/server/application-server/src/main/resources/db/migration/V15__add_user_preference.sql b/server/application-server/src/main/resources/db/migration/V15__add_user_preference.sql new file mode 100644 index 000000000..6530492b2 --- /dev/null +++ b/server/application-server/src/main/resources/db/migration/V15__add_user_preference.sql @@ -0,0 +1,52 @@ +create table + public.user_preference ( + id bigint not null, + user_id bigint not null, + primary key (id) + ); + +ALTER TABLE public.user_preference ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.user_preference_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +alter table if exists public.user_preference +drop constraint if exists UKs5oeayykfc7bpkpdwyrffwcqx; + +alter table if exists public.user_preference add constraint UKs5oeayykfc7bpkpdwyrffwcqx unique (user_id); + +create table + user_preference_favourite_branches ( + user_preference_id bigint not null, + favourite_branches_name varchar(255) not null, + favourite_branches_repository_id bigint not null, + primary key ( + user_preference_id, + favourite_branches_name, + favourite_branches_repository_id + ) + ); + +create table + user_preference_favourite_pull_requests ( + user_preference_id bigint not null, + favourite_pull_requests_id bigint not null, + primary key (user_preference_id, favourite_pull_requests_id) + ); + +alter table if exists public.user_preference add constraint FKq5oj1co3wu38ltb5g1xg9wel4 foreign key (user_id) references public.user; + +alter table if exists user_preference_favourite_branches add constraint FKgyxf2ri6f3j4pourba7shdsma foreign key ( + favourite_branches_repository_id, + favourite_branches_name +) references branch; + +alter table if exists user_preference_favourite_branches add constraint FKj734saube6d10apw9l8q038e3 foreign key (user_preference_id) references public.user_preference; + +alter table if exists user_preference_favourite_pull_requests add constraint FK773fsk43ewsg4oy5ut1hvslec foreign key (favourite_pull_requests_id) references issue; + +alter table if exists user_preference_favourite_pull_requests add constraint FKtnbluehl24n2x8nwh2gxvqr8 foreign key (user_preference_id) references public.user_preference; \ No newline at end of file diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchControllerTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchControllerTest.java index 7641ff52e..7da802a4b 100644 --- a/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchControllerTest.java +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchControllerTest.java @@ -40,10 +40,11 @@ public class BranchControllerTest { 0, false, false, + false, null, null, new RepositoryInfoDto(1L, "repo", "repo", null, "url")), - new BranchInfoDto("branch2", "sha2", 0, 0, false, false, null, null, null)); + new BranchInfoDto("branch2", "sha2", 0, 0, false, false, false, null, null, null)); private final BranchDetailsDto branch = new BranchDetailsDto( @@ -106,7 +107,7 @@ void testGetBranchByNonExistingRepoId() throws Exception { Long id = -1L; String branchName = this.branches.get(0).name(); - when(branchService.getBranchInfo(id, branchName)).thenReturn(Optional.empty()); + when(branchService.getBranchByRepositoryIdAndName(id, branchName)).thenReturn(Optional.empty()); this.mockMvc .perform( get("/api/branches/repository/{repoId}/branch", id) @@ -121,7 +122,7 @@ void testGetBranchByNonExistingBranchName() throws Exception { Long id = this.branches.get(0).repository().id(); String branchName = "invalid"; - when(branchService.getBranchInfo(id, branchName)).thenReturn(Optional.empty()); + when(branchService.getBranchByRepositoryIdAndName(id, branchName)).thenReturn(Optional.empty()); this.mockMvc .perform( get("/api/branches/repository/{repoId}/branch", id) From 1c2c28aee8aa544eaccfa1b01fdcf32061129a67 Mon Sep 17 00:00:00 2001 From: Paul Thiel Date: Sun, 23 Feb 2025 15:45:29 +0100 Subject: [PATCH 2/3] chore: add makemigration script to build.gradle --- server/application-server/build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/application-server/build.gradle b/server/application-server/build.gradle index 7a88a3f59..5081126a3 100644 --- a/server/application-server/build.gradle +++ b/server/application-server/build.gradle @@ -157,6 +157,14 @@ tasks.register("bootRunOpenApi", BootRun) { mainClass.set("de.tum.cit.aet.helios.HeliosApplication") } +tasks.register("makemigration", BootRun) { + group = "application" + description = "Proposes a new Flyway migration script" + systemProperty "spring.profiles.active", "migration" + classpath = sourceSets.main.runtimeClasspath + mainClass.set("de.tum.cit.aet.helios.HeliosApplication") +} + openApi { apiDocsUrl = 'http://localhost:8080/v3/api-docs.yaml' outputDir = file('.') From 8c77c825631e6b08836cf12249bead88a282e94b Mon Sep 17 00:00:00 2001 From: Paul Thiel Date: Sun, 23 Feb 2025 16:34:39 +0100 Subject: [PATCH 3/3] test(BranchService): add test that pinned branches and prs are shown first --- .../pullrequest/PullRequestService.java | 2 +- .../aet/helios/branch/BranchServiceTest.java | 67 ++++++++++++++++++ .../pullrequest/PullRequestServiceTest.java | 69 +++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchServiceTest.java create mode 100644 server/application-server/src/test/java/de/tum/cit/aet/helios/pullrequest/PullRequestServiceTest.java diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java index af07b99fe..8ebf4137a 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/pullrequest/PullRequestService.java @@ -33,7 +33,7 @@ public List getAllPullRequests() { } else if (!pr1.isPinned() && pr2.isPinned()) { return 1; } else { - return pr2.updatedAt().compareTo(pr1.updatedAt()); + return 0; } }) .collect(Collectors.toList()); diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchServiceTest.java new file mode 100644 index 000000000..db132dec1 --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/branch/BranchServiceTest.java @@ -0,0 +1,67 @@ +package de.tum.cit.aet.helios.branch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import de.tum.cit.aet.helios.auth.AuthService; +import de.tum.cit.aet.helios.gitrepo.GitRepository; +import de.tum.cit.aet.helios.releasecandidate.ReleaseCandidateRepository; +import de.tum.cit.aet.helios.userpreference.UserPreference; +import de.tum.cit.aet.helios.userpreference.UserPreferenceRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class BranchServiceTest { + + @InjectMocks private BranchService branchService; + @Mock private BranchRepository branchRepository; + @Mock private ReleaseCandidateRepository releaseCandidateRepository; + @Mock private UserPreferenceRepository userPreferenceRepository; + @Mock private AuthService authService; + + @BeforeEach + public void init() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testPinnedBranchesAreShownFirst() { + final GitRepository repo = new GitRepository(); + repo.setRepositoryId(1L); + + final Branch b1 = new Branch(); + b1.setName("branch1"); + b1.setRepository(repo); + + final Branch b2 = new Branch(); + b2.setName("branch2"); + b2.setRepository(repo); + + final List branches = List.of(b1, b2); + + final UserPreference userPreference = new UserPreference(); + userPreference.setFavouriteBranches(Set.of(b2)); + + when(branchRepository.findAll()).thenReturn(branches); + when(authService.getUserFromGithubId()).thenReturn(null); + when(userPreferenceRepository.findByUser(null)).thenReturn(Optional.of(userPreference)); + + BranchInfoDto b1Dto = + BranchInfoDto.fromBranchAndUserPreference(b1, Optional.of(userPreference)); + BranchInfoDto b2Dto = + BranchInfoDto.fromBranchAndUserPreference(b2, Optional.of(userPreference)); + + assertEquals(2, branchService.getAllBranches().size()); + Assertions.assertIterableEquals(List.of(b2Dto, b1Dto), branchService.getAllBranches()); + } +} diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/pullrequest/PullRequestServiceTest.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/pullrequest/PullRequestServiceTest.java new file mode 100644 index 000000000..02b9d95d9 --- /dev/null +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/pullrequest/PullRequestServiceTest.java @@ -0,0 +1,69 @@ +package de.tum.cit.aet.helios.pullrequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import de.tum.cit.aet.helios.auth.AuthService; +import de.tum.cit.aet.helios.gitrepo.GitRepository; +import de.tum.cit.aet.helios.userpreference.UserPreference; +import de.tum.cit.aet.helios.userpreference.UserPreferenceRepository; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class PullRequestServiceTest { + + @InjectMocks private PullRequestService pullRequestService; + @Mock private PullRequestRepository pullRequestsRepository; + @Mock private UserPreferenceRepository userPreferenceRepository; + @Mock private AuthService authService; + + @BeforeEach + public void init() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testPinnedBranchesAreShownFirst() { + final GitRepository repo = new GitRepository(); + repo.setRepositoryId(1L); + + final PullRequest pr1 = new PullRequest(); + pr1.setId(1L); + pr1.setNumber(1); + pr1.setRepository(repo); + + final PullRequest pr2 = new PullRequest(); + pr2.setId(2L); + pr2.setNumber(2); + pr2.setRepository(repo); + + final List prs = List.of(pr1, pr2); + + final UserPreference userPreference = new UserPreference(); + userPreference.setFavouritePullRequests(Set.of(pr2)); + + when(pullRequestsRepository.findAllByOrderByUpdatedAtDesc()).thenReturn(prs); + when(authService.getUserFromGithubId()).thenReturn(null); + when(userPreferenceRepository.findByUser(null)).thenReturn(Optional.of(userPreference)); + + PullRequestBaseInfoDto pr1Dto = + PullRequestBaseInfoDto.fromPullRequestAndUserPreference(pr1, Optional.of(userPreference)); + + PullRequestBaseInfoDto pr2Dto = + PullRequestBaseInfoDto.fromPullRequestAndUserPreference(pr2, Optional.of(userPreference)); + + assertEquals(2, pullRequestService.getAllPullRequests().size()); + Assertions.assertIterableEquals( + List.of(pr2Dto, pr1Dto), pullRequestService.getAllPullRequests()); + } +}