diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineProperties.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineProperties.java index 7bc95b216..ff5891ca7 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineProperties.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineProperties.java @@ -17,9 +17,12 @@ * Build and Push Docker Image"}. */ @ConfigurationProperties(prefix = "helios.pipeline") -public record PipelineProperties(List categories) { +public record PipelineProperties(List repositories, List categories) { public PipelineProperties { + // nameWithOwner allow-list: repositories that render the canonical catalog. Others fall back + // to the group-based pipeline (PipelineService), so a non-matching repo isn't all-pending. + repositories = repositories == null ? List.of() : repositories; categories = categories == null ? List.of() : categories; } diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineService.java index 13d1375b8..f0296b354 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineService.java @@ -1,12 +1,23 @@ package de.tum.cit.aet.helios.workflow.pipeline; +import de.tum.cit.aet.helios.filters.RepositoryContext; +import de.tum.cit.aet.helios.gitrepo.GitRepoRepository; +import de.tum.cit.aet.helios.gitrepo.GitRepository; +import de.tum.cit.aet.helios.gitreposettings.WorkflowGroupDto; +import de.tum.cit.aet.helios.gitreposettings.WorkflowGroupService; +import de.tum.cit.aet.helios.gitreposettings.WorkflowMembershipDto; import de.tum.cit.aet.helios.workflow.WorkflowJob; import de.tum.cit.aet.helios.workflow.WorkflowJobRepository; import de.tum.cit.aet.helios.workflow.WorkflowRun; import de.tum.cit.aet.helios.workflow.WorkflowRunDto; import de.tum.cit.aet.helios.workflow.WorkflowRunService; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -14,10 +25,14 @@ import org.springframework.stereotype.Service; /** - * Builds the canonical pipeline (see {@link PipelineProperties}) for a branch or pull request. - * Reuses {@link WorkflowRunService} to resolve the head-commit runs (already tenant-scoped), loads - * their {@link WorkflowJob}s, and aggregates the jobs that match each configured node into a single - * status. Nodes with no matching job are reported as {@code PENDING}. + * Builds the pipeline for a branch or pull request. + * + *

For repositories listed in {@code helios.pipeline.repositories} the canonical, always-visible + * node catalog (see {@link PipelineProperties}) is used: the head-commit runs' {@link WorkflowJob}s + * are matched against each configured node and aggregated. Every other repository falls back to + * the previous behaviour — its {@code WorkflowGroup}s rendered as categories of workflow-run nodes + * — so a repository whose CI job names don't match the (Artemis-shaped) catalog keeps a meaningful + * pipeline instead of an all-pending skeleton. */ @Service @RequiredArgsConstructor @@ -42,17 +57,40 @@ public class PipelineService { private final PipelineProperties properties; private final WorkflowRunService workflowRunService; private final WorkflowJobRepository workflowJobRepository; + private final WorkflowGroupService workflowGroupService; + private final GitRepoRepository gitRepoRepository; public PipelineDto getPipelineForBranch(String branchName) { - return build(workflowRunService.getLatestWorkflowRunsByBranchAndHeadCommitSha(branchName)); + return buildFor(workflowRunService.getLatestWorkflowRunsByBranchAndHeadCommitSha(branchName)); } public PipelineDto getPipelineForPullRequest(Long pullRequestId) { - return build( + return buildFor( workflowRunService.getLatestWorkflowRunsByPullRequestIdAndHeadCommit(pullRequestId)); } - private PipelineDto build(List headRuns) { + private PipelineDto buildFor(List headRuns) { + final Long repositoryId = RepositoryContext.getRepositoryId(); + return isCanonicalRepository(repositoryId) + ? buildCanonical(headRuns) + : buildGrouped(repositoryId, headRuns); + } + + /** Whether the current repository uses the canonical node catalog (vs. the group fallback). */ + private boolean isCanonicalRepository(Long repositoryId) { + if (repositoryId == null || properties.repositories().isEmpty()) { + return false; + } + return gitRepoRepository + .findByRepositoryId(repositoryId) + .map(GitRepository::getNameWithOwner) + .map(properties.repositories()::contains) + .orElse(false); + } + + // --- Canonical catalog (config-driven Build/Tests/Quality nodes) ----------------------------- + + private PipelineDto buildCanonical(List headRuns) { final List runIds = headRuns.stream().map(WorkflowRunDto::id).toList(); // Runs are already scoped to the current repository, so their jobs are too. final List jobs = @@ -139,4 +177,62 @@ private static WorkflowRun.Conclusion aggregateConclusion(List jobs } return WorkflowRun.Conclusion.NEUTRAL; } + + // --- Group fallback (previous behaviour for non-canonical repositories) ---------------------- + + private PipelineDto buildGrouped(Long repositoryId, List headRuns) { + if (repositoryId == null) { + return new PipelineDto(List.of()); + } + // getLatest... returns the latest run per workflowId, so a workflowId maps to one run. + final Map runByWorkflowId = new HashMap<>(); + for (WorkflowRunDto run : headRuns) { + runByWorkflowId.putIfAbsent(run.workflowId(), run); + } + + final Set groupedWorkflowIds = new HashSet<>(); + final List categories = new ArrayList<>(); + + final List groups = + workflowGroupService.getAllWorkflowGroupsByRepositoryId(repositoryId).stream() + .sorted(Comparator.comparing(WorkflowGroupDto::orderIndex)) + .toList(); + for (WorkflowGroupDto group : groups) { + final List memberships = + group.memberships() == null ? List.of() : group.memberships(); + final List nodes = new ArrayList<>(); + for (WorkflowMembershipDto membership : + memberships.stream().sorted(Comparator.comparing(WorkflowMembershipDto::orderIndex)) + .toList()) { + groupedWorkflowIds.add(membership.workflowId()); + final WorkflowRunDto run = runByWorkflowId.get(membership.workflowId()); + if (run != null) { + nodes.add(runNode(run)); + } + } + if (!nodes.isEmpty()) { + categories.add(new PipelineDto.Category(group.name(), nodes)); + } + } + + // Runs whose workflow is in no group surface under an "Ungrouped" category (as before). + final List ungrouped = + headRuns.stream() + .filter(run -> !groupedWorkflowIds.contains(run.workflowId())) + .map(PipelineService::runNode) + .toList(); + if (!ungrouped.isEmpty()) { + categories.add(new PipelineDto.Category("Ungrouped", ungrouped)); + } + return new PipelineDto(categories); + } + + private static PipelineDto.Node runNode(WorkflowRunDto run) { + return new PipelineDto.Node( + "run-" + run.id(), + run.name(), + run.status() == null ? null : run.status().name(), + run.conclusion() == null ? null : run.conclusion().name(), + run.htmlUrl()); + } } diff --git a/server/application-server/src/main/resources/application.yml b/server/application-server/src/main/resources/application.yml index 679e94ee5..ec0d485c8 100644 --- a/server/application-server/src/main/resources/application.yml +++ b/server/application-server/src/main/resources/application.yml @@ -133,6 +133,10 @@ helios: # whose Build/Test/Quality/E2E stages surface as jobs named " / ". See the pipeline # configuration docs to adapt these matchers to another CI layout. pipeline: + # Repositories (nameWithOwner) that render the canonical catalog below. Any other repo + # falls back to its WorkflowGroup-based pipeline, so a repo whose CI job names don't match + # these Artemis-shaped matchers keeps a meaningful view instead of all-pending nodes. + repositories: ["ls1intum/Artemis"] categories: - name: "Build" nodes: diff --git a/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineIT.java b/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineIT.java index 8e652fc00..239bfb987 100644 --- a/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineIT.java +++ b/server/application-server/src/test/java/de/tum/cit/aet/helios/workflow/pipeline/PipelineIT.java @@ -10,25 +10,37 @@ import org.springframework.jdbc.core.JdbcTemplate; /** - * End-to-end guard for the canonical pipeline endpoint {@code GET /api/pipeline/branch}. Seeds one - * head-commit run with jobs matching several configured nodes and asserts: every canonical node is - * always present; matched nodes aggregate to the right status/conclusion; unmatched nodes are - * PENDING; and with no {@code X-REPOSITORY-ID} the runs are unscoped away, so all nodes are PENDING - * (never a cross-repository leak). + * End-to-end guard for {@code GET /api/pipeline/branch}. Covers both modes: + * + *

    + *
  • a canonical repository (in {@code helios.pipeline.repositories}) aggregates its jobs into + * the always-visible Build/Tests/Quality nodes (unmatched → PENDING); + *
  • a non-canonical repository falls back to its workflow-run view (never the Artemis-shaped + * catalog), so it isn't reduced to an all-pending skeleton; + *
  • no {@code X-REPOSITORY-ID} yields an empty pipeline (never a cross-repository leak). + *
*/ class PipelineIT extends HeliosIntegrationTest { + // Canonical repo: nameWithOwner must be in helios.pipeline.repositories (default: Artemis). private static final long REPO = 1L; private static final long WF = 51L; private static final long RUN = 61L; private static final String BRANCH = "main"; private static final String SHA = "deadbeef"; + // Non-canonical repo -> group/run fallback. + private static final long REPO_B = 2L; + private static final long WF_B = 71L; + private static final long RUN_B = 81L; + private static final String BRANCH_B = "dev"; + private static final String SHA_B = "cafebabe"; + @BeforeEach void seed() { JdbcTemplate jdbc = new JdbcTemplate(dataSource); jdbc.execute("TRUNCATE TABLE repository CASCADE"); - insertRepo(jdbc, REPO, "ls1intum/repo-a"); + insertRepo(jdbc, REPO, "ls1intum/Artemis"); insertBranch(jdbc, REPO, BRANCH, SHA); insertWorkflow(jdbc, WF, REPO); insertRun(jdbc, RUN, REPO, WF, BRANCH, SHA); @@ -41,10 +53,15 @@ void seed() { insertJob(jdbc, 103, REPO, RUN, "Test / Client Tests", "COMPLETED", "FAILURE"); insertJob(jdbc, 104, REPO, RUN, "Test / Server Tests (PostgreSQL)", "COMPLETED", "SUCCESS"); insertJob(jdbc, 105, REPO, RUN, "Quality / Client Code Style", "COMPLETED", "SUCCESS"); + + insertRepo(jdbc, REPO_B, "ls1intum/other"); + insertBranch(jdbc, REPO_B, BRANCH_B, SHA_B); + insertWorkflow(jdbc, WF_B, REPO_B); + insertRun(jdbc, RUN_B, REPO_B, WF_B, BRANCH_B, SHA_B); } @Test - void pipelineAggregatesJobsIntoCanonicalNodes() throws Exception { + void canonicalRepositoryAggregatesJobsIntoCanonicalNodes() throws Exception { mockMvc .perform( get("/api/pipeline/branch") @@ -79,16 +96,29 @@ void pipelineAggregatesJobsIntoCanonicalNodes() throws Exception { } @Test - void withoutRepositoryContextAllNodesArePending() throws Exception { - // No X-REPOSITORY-ID -> the branch/run lookup is unscoped away -> no jobs -> every node - // pending, structure still fully present (never leaks another repo's runs). + void nonCanonicalRepositoryFallsBackToItsRunsNotCanonicalNodes() throws Exception { + // ls1intum/other is not in the canonical allow-list, so the pipeline shows its workflow runs + // (here ungrouped) rather than the Artemis Build/Tests/Quality catalog. + mockMvc + .perform( + get("/api/pipeline/branch") + .param("branch", BRANCH_B) + .header(X_REPOSITORY_ID, String.valueOf(REPO_B))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.categories.length()").value(1)) + .andExpect(jsonPath("$.categories[0].name").value("Ungrouped")) + .andExpect(jsonPath("$.categories[0].nodes[0].key").value("run-" + RUN_B)) + .andExpect(jsonPath("$.categories[0].nodes[0].label").value("CI")) + .andExpect(jsonPath("$.categories[0].nodes[0].status").value("COMPLETED")); + } + + @Test + void withoutRepositoryContextPipelineIsEmpty() throws Exception { + // No X-REPOSITORY-ID → no repository → nothing to show (never leaks another repo's runs). mockMvc .perform(get("/api/pipeline/branch").param("branch", BRANCH)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.categories.length()").value(3)) - .andExpect(jsonPath("$.categories[0].nodes[0].status").value("PENDING")) - .andExpect(jsonPath("$.categories[1].nodes[1].status").value("PENDING")) - .andExpect(jsonPath("$.categories[2].nodes[0].status").value("PENDING")); + .andExpect(jsonPath("$.categories.length()").value(0)); } private static void insertRepo(JdbcTemplate jdbc, long id, String nameWithOwner) { @@ -121,9 +151,9 @@ private static void insertWorkflow(JdbcTemplate jdbc, long id, long repositoryId private static void insertRun( JdbcTemplate jdbc, long id, long repositoryId, long workflowId, String branch, String sha) { jdbc.update( - "INSERT INTO workflow_run (id, repository_id, workflow_id, run_attempt, run_number, " + "INSERT INTO workflow_run (id, repository_id, workflow_id, run_attempt, run_number, name, " + "status, head_branch, head_sha, created_at, updated_at) " - + "VALUES (?, ?, ?, 1, ?, 'COMPLETED', ?, ?, now(), now())", + + "VALUES (?, ?, ?, 1, ?, 'CI', 'COMPLETED', ?, ?, now(), now())", id, repositoryId, workflowId,