Skip to content

Commit 3239051

Browse files
committed
merged master into branch
2 parents 8d5d00b + f11bfc1 commit 3239051

7 files changed

Lines changed: 183 additions & 73 deletions

File tree

src/main/frontend/src/app/pages/release-roadmap/milestone-row/milestone-row.component.spec.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DatePipe } from '@angular/common';
33
import { SimpleChanges } from '@angular/core';
44
import { MilestoneRowComponent } from './milestone-row.component';
55
import { Milestone } from '../../../services/milestone.service';
6-
import { Issue, IssuePriority } from '../../../services/issue.service';
6+
import { Issue } from '../../../services/issue.service';
77
import { GitHubStates } from '../../../app.service';
88
import { IssueBarComponent } from '../issue-bar/issue-bar.component';
99
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -177,20 +177,5 @@ describe('MilestoneRowComponent', () => {
177177

178178
expect(component.trackCount).toBeGreaterThan(1);
179179
});
180-
181-
it('should sort issues by priority (critical first) before layout', () => {
182-
const lowPrio: Issue = { ...MOCK_OPEN_ISSUE, id: 'low', issuePriority: { name: 'Low Prio' } as IssuePriority };
183-
const critPrio: Issue = {
184-
...MOCK_OPEN_ISSUE,
185-
id: 'crit',
186-
issuePriority: { name: 'Critical Prio' } as IssuePriority,
187-
};
188-
initializeComponent(MOCK_MILESTONE, [lowPrio, critPrio]);
189-
190-
const critIndex = component.positionedIssues.findIndex((p) => p.issue.id === 'crit');
191-
const lowIndex = component.positionedIssues.findIndex((p) => p.issue.id === 'low');
192-
193-
expect(critIndex).toBeLessThan(lowIndex);
194-
});
195180
});
196181
});

src/main/frontend/src/app/pages/release-roadmap/milestone-row/milestone-row.component.ts

Lines changed: 33 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
22
import { CommonModule, DatePipe } from '@angular/common';
33
import { IssueBarComponent } from '../issue-bar/issue-bar.component';
44
import { Milestone } from '../../../services/milestone.service';
5-
import { Issue, IssuePriority } from '../../../services/issue.service';
5+
import { Issue } from '../../../services/issue.service';
66
import { GitHubStates } from '../../../app.service';
77

88
interface PositionedIssue {
@@ -113,11 +113,6 @@ export class MilestoneRowComponent implements OnChanges {
113113
}
114114
}
115115

116-
for (const issues of quarterMap.values()) {
117-
issues.open = this.getSortedIssues(issues.open);
118-
issues.closed = this.getSortedIssues(issues.closed);
119-
}
120-
121116
return quarterMap;
122117
}
123118

@@ -177,8 +172,7 @@ export class MilestoneRowComponent implements OnChanges {
177172
return { positionedIssues: [], trackCount: 0 };
178173
}
179174

180-
const trackCount = this.estimateTrackCount(issues, window);
181-
const issuesByTrack = this.distributeIssuesRoundRobin(issues, trackCount);
175+
const issuesByTrack = this.distributeIssuesWithBinPacking(issues, window);
182176
const positionedIssues: PositionedIssue[] = [];
183177

184178
for (const [trackIndex, trackIssues] of issuesByTrack.entries()) {
@@ -188,6 +182,7 @@ export class MilestoneRowComponent implements OnChanges {
188182
(sum, issue) => sum + this.getIssueDurationMsWithMinWidth(issue),
189183
0,
190184
);
185+
191186
const totalWhitespace = window.end - window.start - totalIssueDuration;
192187
const gapSize = totalWhitespace > 0 ? totalWhitespace / (trackIssues.length + 1) : 0;
193188
let cursor = window.start + gapSize;
@@ -214,44 +209,42 @@ export class MilestoneRowComponent implements OnChanges {
214209
};
215210
}
216211

217-
private estimateTrackCount(issues: Issue[], window: PlanningWindow): number {
218-
const windowDurationMs = window.end - window.start;
219-
if (windowDurationMs <= 0) return issues.length || 1;
220-
221-
const totalDurationWithGaps = issues.reduce(
222-
(sum, issue) => sum + this.getIssueDurationMsWithMinWidth(issue) + this.GAP_MS,
223-
-this.GAP_MS,
224-
);
225-
226-
return Math.max(1, Math.ceil(totalDurationWithGaps / windowDurationMs));
227-
}
228-
229-
private distributeIssuesRoundRobin(issues: Issue[], trackCount: number): Map<number, Issue[]> {
212+
private distributeIssuesWithBinPacking(issues: Issue[], window: PlanningWindow): Map<number, Issue[]> {
230213
const issuesByTrack = new Map<number, Issue[]>();
231-
if (trackCount === 0) return issuesByTrack;
214+
const windowDurationMs = window.end - window.start;
215+
const trackCapacities = new Map<number, number>();
216+
let currentTrack = 0;
217+
218+
for (const issue of issues) {
219+
const issueDuration = this.getIssueDurationMsWithMinWidth(issue);
220+
const spaceNeeded = issueDuration + this.GAP_MS;
221+
222+
let placed = false;
223+
for (let trackIndex = 0; trackIndex <= currentTrack; trackIndex++) {
224+
const trackUsed = trackCapacities.get(trackIndex) ?? 0;
225+
const trackRemaining = windowDurationMs - trackUsed;
226+
227+
if (trackRemaining >= spaceNeeded) {
228+
if (!issuesByTrack.has(trackIndex)) {
229+
issuesByTrack.set(trackIndex, []);
230+
}
231+
issuesByTrack.get(trackIndex)!.push(issue);
232+
trackCapacities.set(trackIndex, trackUsed + spaceNeeded);
233+
placed = true;
234+
break;
235+
}
236+
}
232237

233-
for (let index = 0; index < trackCount; index++) issuesByTrack.set(index, []);
234-
for (const [index, issue] of issues.entries()) issuesByTrack.get(index % trackCount)!.push(issue);
238+
if (!placed) {
239+
currentTrack++;
240+
issuesByTrack.set(currentTrack, [issue]);
241+
trackCapacities.set(currentTrack, spaceNeeded);
242+
}
243+
}
235244

236245
return issuesByTrack;
237246
}
238247

239-
private getSortedIssues(issues: Issue[]): Issue[] {
240-
const priorityOrder: Record<string, number> = { critical: 1, high: 2, medium: 3, low: 4, no: 5 };
241-
242-
return [...issues].sort((a, b) => {
243-
const priorityA = priorityOrder[this.getPriorityKey(a.issuePriority)] ?? 5;
244-
const priorityB = priorityOrder[this.getPriorityKey(b.issuePriority)] ?? 5;
245-
if (priorityA !== priorityB) return priorityA - priorityB;
246-
247-
const pointsA = a.points ?? this.DEFAULT_POINTS;
248-
const pointsB = b.points ?? this.DEFAULT_POINTS;
249-
if (pointsA !== pointsB) return pointsB - pointsA;
250-
251-
return b.number - a.number;
252-
});
253-
}
254-
255248
private calculateBarPosition(startDate: Date, durationDays: number): Record<string, string> {
256249
const startDays = (startDate.getTime() - this.timelineStartDate.getTime()) / (1000 * 3600 * 24);
257250
const leftPercentage = (startDays / this.totalTimelineDays) * 100;
@@ -290,14 +283,4 @@ export class MilestoneRowComponent implements OnChanges {
290283
const total = this.milestone.openIssueCount + this.milestone.closedIssueCount;
291284
this.progressPercentage = total === 0 ? 0 : Math.round((this.milestone.closedIssueCount / total) * 100);
292285
}
293-
294-
private getPriorityKey(priority: IssuePriority | undefined | null): string {
295-
if (!priority?.name) return 'no';
296-
const lowerCaseName = priority.name.toLowerCase();
297-
const keys = ['critical', 'high', 'medium', 'low'];
298-
for (const key of keys) {
299-
if (lowerCaseName.includes(key)) return key;
300-
}
301-
return 'no';
302-
}
303286
}

src/main/java/org/frankframework/insights/issue/IssueService.java

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
@Slf4j
3737
public class IssueService {
3838
private static final String ISSUE_TYPE_EPIC_NAME = "Epic";
39+
private static final double DEFAULT_POINTS = 3.0;
3940

4041
private final GitHubClient gitHubClient;
4142
private final Mapper mapper;
@@ -241,8 +242,9 @@ private Set<Issue> saveIssues(Set<Issue> issues) {
241242
*/
242243
public Set<IssueResponse> getIssuesByReleaseId(String releaseId) throws ReleaseNotFoundException {
243244
Release release = releaseService.checkIfReleaseExists(releaseId);
244-
Set<Issue> rootIssues = issueRepository.findIssuesByReleaseId(release.getId());
245-
return buildIssueResponseTree(rootIssues);
245+
Set<Issue> allIssues = issueRepository.findIssuesByReleaseId(release.getId());
246+
Set<Issue> rootIssues = filterRootIssues(allIssues);
247+
return buildIssueResponseTree(rootIssues);
246248
}
247249

248250
/**
@@ -253,7 +255,8 @@ public Set<IssueResponse> getIssuesByReleaseId(String releaseId) throws ReleaseN
253255
*/
254256
public Set<IssueResponse> getIssuesByMilestoneId(String milestoneId) throws MilestoneNotFoundException {
255257
Milestone milestone = milestoneService.checkIfMilestoneExists(milestoneId);
256-
Set<Issue> rootIssues = issueRepository.findDistinctByMilestoneId(milestone.getId());
258+
Set<Issue> allIssues = issueRepository.findDistinctByMilestoneId(milestone.getId());
259+
Set<Issue> rootIssues = filterRootIssues(allIssues);
257260
return buildIssueResponseTree(rootIssues);
258261
}
259262

@@ -263,7 +266,24 @@ public Set<IssueResponse> getIssuesByMilestoneId(String milestoneId) throws Mile
263266
*/
264267
public Set<IssueResponse> getFutureEpicIssues() {
265268
Set<Issue> futureEpicIssues = issueRepository.findIssuesByIssueTypeNameAndMilestoneIsNull(ISSUE_TYPE_EPIC_NAME);
266-
return buildIssueResponseTree(futureEpicIssues);
269+
return buildIssueResponseTreeWithoutFiltering(futureEpicIssues);
270+
}
271+
272+
/**
273+
* Filters out issues that are sub-issues of other issues in the set.
274+
* @param issues the set of all issues
275+
* @return a set of root issues (issues that are not sub-issues of other issues in the set)
276+
*/
277+
private Set<Issue> filterRootIssues(Set<Issue> issues) {
278+
Set<String> allSubIssueIds = issues.stream()
279+
.filter(issue -> issue.getSubIssues() != null)
280+
.flatMap(issue -> issue.getSubIssues().stream())
281+
.map(Issue::getId)
282+
.collect(Collectors.toSet());
283+
284+
return issues.stream()
285+
.filter(issue -> !allSubIssueIds.contains(issue.getId()))
286+
.collect(Collectors.toSet());
267287
}
268288

269289
/**
@@ -272,6 +292,20 @@ public Set<IssueResponse> getFutureEpicIssues() {
272292
* @return a set of IssueResponse objects representing the root issues and their sub-issues, with labels included
273293
*/
274294
private Set<IssueResponse> buildIssueResponseTree(Set<Issue> rootIssues) {
295+
Set<String> allIds = collectAllIssueIdsRecursively(rootIssues);
296+
Map<String, Set<LabelResponse>> labelsMap = fetchLabelsForIssueIds(allIds);
297+
return rootIssues.stream()
298+
.map(issue -> mapIssueTreeWithLabels(issue, labelsMap))
299+
.filter(this::hasRelevantLabelsRecursively)
300+
.collect(Collectors.toSet());
301+
}
302+
303+
/**
304+
* Builds a tree of IssueResponse objects without filtering by labels.
305+
* @param rootIssues the set of root issues to build the tree from
306+
* @return a set of IssueResponse objects representing the root issues and their sub-issues, with labels included
307+
*/
308+
private Set<IssueResponse> buildIssueResponseTreeWithoutFiltering(Set<Issue> rootIssues) {
275309
Set<String> allIds = collectAllIssueIdsRecursively(rootIssues);
276310
Map<String, Set<LabelResponse>> labelsMap = fetchLabelsForIssueIds(allIds);
277311
return rootIssues.stream()
@@ -309,6 +343,7 @@ private Stream<String> flattenIssueIds(Issue issue) {
309343
private Map<String, Set<LabelResponse>> fetchLabelsForIssueIds(Set<String> issueIds) {
310344
Set<IssueLabel> labels = issueLabelRepository.findAllByIssue_IdIn(new ArrayList<>(issueIds));
311345
return labels.stream()
346+
.filter(l -> labelService.isLabelIncluded(l.getLabel()))
312347
.collect(Collectors.groupingBy(
313348
l -> l.getIssue().getId(),
314349
Collectors.mapping(l -> mapper.toDTO(l.getLabel(), LabelResponse.class), Collectors.toSet())));
@@ -328,6 +363,10 @@ private IssueResponse mapIssueTreeWithLabels(Issue issue, Map<String, Set<LabelR
328363

329364
response.setLabels(labelsMap.getOrDefault(issue.getId(), Set.of()));
330365
response.setSubIssues(mapSubIssuesToResponses(issue, labelsMap));
366+
367+
double totalPoints = calculateTotalPoints(issue, response);
368+
response.setPoints(totalPoints);
369+
331370
return response;
332371
}
333372

@@ -374,12 +413,49 @@ private Set<IssueResponse> mapSubIssuesToResponses(Issue issue, Map<String, Set<
374413
if (issue.getSubIssues() != null && !issue.getSubIssues().isEmpty()) {
375414
return issue.getSubIssues().stream()
376415
.map(sub -> mapIssueTreeWithLabels(sub, labelsMap))
416+
.filter(this::hasRelevantLabelsRecursively)
377417
.collect(Collectors.toSet());
378418
} else {
379419
return Set.of();
380420
}
381421
}
382422

423+
/**
424+
* Checks if an issue or any of its sub-issues have relevant labels.
425+
* @param issueResponse the issue response to check
426+
* @return true if the issue or any of its sub-issues have at least one relevant label
427+
*/
428+
private boolean hasRelevantLabelsRecursively(IssueResponse issueResponse) {
429+
if (issueResponse.getLabels() != null && !issueResponse.getLabels().isEmpty()) {
430+
return true;
431+
}
432+
if (issueResponse.getSubIssues() != null
433+
&& !issueResponse.getSubIssues().isEmpty()) {
434+
return issueResponse.getSubIssues().stream().anyMatch(this::hasRelevantLabelsRecursively);
435+
}
436+
return false;
437+
}
438+
439+
/**
440+
* Calculates the total points for an issue, including its own points and all sub-issue points.
441+
* Uses DEFAULT_POINTS (3.0) for issues without assigned points.
442+
* @param issue the issue entity
443+
* @param response the issue response with mapped sub-issues
444+
* @return the total points for the issue and all its sub-issues
445+
*/
446+
private double calculateTotalPoints(Issue issue, IssueResponse response) {
447+
double issuePoints = issue.getPoints() != null ? issue.getPoints() : DEFAULT_POINTS;
448+
449+
if (response.getSubIssues() != null && !response.getSubIssues().isEmpty()) {
450+
double subIssuesPoints = response.getSubIssues().stream()
451+
.mapToDouble(IssueResponse::getPoints)
452+
.sum();
453+
return issuePoints + subIssuesPoints;
454+
}
455+
456+
return issuePoints;
457+
}
458+
383459
/**
384460
* Get all issues from the database
385461
* @return a map of issue id to issue

src/main/java/org/frankframework/insights/label/LabelService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,13 @@ private void saveLabels(Set<Label> labels) {
164164
List<Label> savedLabels = labelRepository.saveAll(labels);
165165
log.info("Successfully saved {} labels", savedLabels.size());
166166
}
167+
168+
/**
169+
* Checks if a label is included based on its color.
170+
* @param label the label to check
171+
* @return true if the label's color is in the included labels list, false otherwise
172+
*/
173+
public boolean isLabelIncluded(Label label) {
174+
return label != null && includedLabels.contains(label.getColor().toUpperCase());
175+
}
167176
}

src/main/resources/db/e2e/R__Seed_Data.sql

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,36 @@ MERGE INTO issue_label (issue_id, label_id) KEY(issue_id, label_id) VALUES
140140
('issue-feat-103', 'label-ui'),
141141
('issue-feat-103', 'label-perf'),
142142
('issue-bug-104', 'label-ui'),
143-
('issue-feat-105', 'label-sec');
143+
('issue-feat-105', 'label-sec'),
144+
('issue-past-closed', 'label-perf'),
145+
('issue-past-open', 'label-sec'),
146+
('issue-current-closed-1', 'label-ui'),
147+
('issue-current-closed-2', 'label-ui'),
148+
('issue-current-open-1', 'label-perf'),
149+
('issue-current-open-2', 'label-ui'),
150+
('issue-zero-points', 'label-ci'),
151+
('issue-future-open-1', 'label-ui'),
152+
('issue-future-open-2', 'label-perf'),
153+
('issue-overflow-1', 'label-ui'),
154+
('issue-overflow-2', 'label-perf'),
155+
('issue-overflow-3', 'label-ui'),
156+
('issue-overflow-4', 'label-sec'),
157+
('issue-overflow-5', 'label-ui'),
158+
('issue-overflow-6', 'label-perf'),
159+
('issue-overflow-7', 'label-ui'),
160+
('issue-overflow-8', 'label-sec'),
161+
('issue-overflow-9', 'label-ui'),
162+
('issue-overflow-10', 'label-perf'),
163+
('issue-overflow-11', 'label-ui'),
164+
('issue-overflow-12', 'label-sec'),
165+
('issue-overflow-13', 'label-ui'),
166+
('issue-overflow-14', 'label-perf'),
167+
('issue-overflow-15', 'label-ui'),
168+
('issue-overflow-16', 'label-sec'),
169+
('issue-overflow-17', 'label-ui'),
170+
('issue-overflow-18', 'label-perf'),
171+
('issue-overflow-19', 'label-ui'),
172+
('issue-overflow-20', 'label-sec');
144173

145174
MERGE INTO pull_request (id, number, title, url, merged_at) KEY(id) VALUES
146175
('pr-501', 501, 'feat(ui): Add new graphing widget and icon set', 'http://example.com/pulls/501', DATEADD('DAY', -2, DATEADD('MONTH', -3, CURRENT_TIMESTAMP()))),

src/test/java/org/frankframework/insights/issue/IssueServiceTest.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,12 @@ public void getIssuesByReleaseId_returnsResponsesWithLabels() throws ReleaseNotF
261261

262262
Label label = new Label();
263263
label.setId("l1");
264-
LabelResponse lr = new LabelResponse("l1", "bug", "desc", "red");
264+
label.setColor("RED");
265+
LabelResponse lr = new LabelResponse("l1", "bug", "desc", "RED");
265266

266267
IssueLabel issueLabel = new IssueLabel(issue1, label);
267268
when(issueLabelRepository.findAllByIssue_IdIn(any())).thenReturn(Set.of(issueLabel));
269+
when(labelService.isLabelIncluded(any(Label.class))).thenReturn(true);
268270

269271
when(mapper.toDTO(any(Issue.class), eq(IssueResponse.class))).thenAnswer(inv -> {
270272
Issue issue = inv.getArgument(0);
@@ -297,10 +299,12 @@ public void getIssuesByMilestoneId_returnsResponsesWithLabels() throws Milestone
297299

298300
Label label = new Label();
299301
label.setId("l1");
300-
LabelResponse lr = new LabelResponse("l1", "bug", "desc", "red");
302+
label.setColor("RED");
303+
LabelResponse lr = new LabelResponse("l1", "bug", "desc", "RED");
301304

302305
IssueLabel issueLabel = new IssueLabel(issue1, label);
303306
when(issueLabelRepository.findAllByIssue_IdIn(any())).thenReturn(Set.of(issueLabel));
307+
when(labelService.isLabelIncluded(any(Label.class))).thenReturn(true);
304308

305309
when(mapper.toDTO(any(Issue.class), eq(IssueResponse.class))).thenAnswer(inv -> {
306310
Issue issue = inv.getArgument(0);

0 commit comments

Comments
 (0)