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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
* Build and Push Docker Image"}.
*/
@ConfigurationProperties(prefix = "helios.pipeline")
public record PipelineProperties(List<Category> categories) {
public record PipelineProperties(List<String> repositories, List<Category> categories) {

Comment on lines 19 to 21
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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
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;
import lombok.RequiredArgsConstructor;
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.
*
* <p>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
Expand All @@ -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));
}
Comment on lines 63 to 65

public PipelineDto getPipelineForPullRequest(Long pullRequestId) {
return build(
return buildFor(
workflowRunService.getLatestWorkflowRunsByPullRequestIdAndHeadCommit(pullRequestId));
}
Comment on lines 67 to 70

private PipelineDto build(List<WorkflowRunDto> headRuns) {
private PipelineDto buildFor(List<WorkflowRunDto> 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<WorkflowRunDto> headRuns) {
final List<Long> runIds = headRuns.stream().map(WorkflowRunDto::id).toList();
// Runs are already scoped to the current repository, so their jobs are too.
final List<WorkflowJob> jobs =
Expand Down Expand Up @@ -139,4 +177,62 @@ private static WorkflowRun.Conclusion aggregateConclusion(List<WorkflowJob> jobs
}
return WorkflowRun.Conclusion.NEUTRAL;
}

// --- Group fallback (previous behaviour for non-canonical repositories) ----------------------

private PipelineDto buildGrouped(Long repositoryId, List<WorkflowRunDto> 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<Long, WorkflowRunDto> runByWorkflowId = new HashMap<>();
for (WorkflowRunDto run : headRuns) {
runByWorkflowId.putIfAbsent(run.workflowId(), run);
}

final Set<Long> groupedWorkflowIds = new HashSet<>();
final List<PipelineDto.Category> categories = new ArrayList<>();

final List<WorkflowGroupDto> groups =
workflowGroupService.getAllWorkflowGroupsByRepositoryId(repositoryId).stream()
.sorted(Comparator.comparing(WorkflowGroupDto::orderIndex))
.toList();
for (WorkflowGroupDto group : groups) {
final List<WorkflowMembershipDto> memberships =
group.memberships() == null ? List.of() : group.memberships();
final List<PipelineDto.Node> 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<PipelineDto.Node> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ helios:
# whose Build/Test/Quality/E2E stages surface as jobs named "<Stage> / <Job>". 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
* <ul>
* <li>a canonical repository (in {@code helios.pipeline.repositories}) aggregates its jobs into
* the always-visible Build/Tests/Quality nodes (unmatched → PENDING);
* <li>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;
* <li>no {@code X-REPOSITORY-ID} yields an empty pipeline (never a cross-repository leak).
* </ul>
*/
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);
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading