Skip to content

Improved handling of dependency updates #95

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions .changeset/legal-parents-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"changesets": minor
---

Add support for handling dependency updates separately from other changesets, so that they can be presented in a more organised way.

This is utilized by setting the update type to `dependency` in place of major/minor/patch.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ users recognize the ways of working and feel at home.

This is it, at the moment. Stay tuned for more docs later on, thanks!



## Dependency updates
Due to the way automated dependency update bots like Dependabot and Renovate work, there is often a large influx of automated changesets that are not easy to merge into the normal changelog. They can also be the source of an unwanted amount of noise in the changelog.

To help with this, we have added a feature to mark changesets as dependency update using the update type "dependency". This will make the changeset appear in a separate section in the changelog, and will be added as a single list of updates in the end of the released version.

Dependencies that have been updated to new versions multiple times between releases will have each of the updates listed in the changelog.

```
---
"changesets-java": dependency
---

- ch.qos.logback:logback-core: 1.5.12
- com.google.errorprone:error_prone_annotations: 2.34.0
```

# Release Maven Plugin Integration

To delegate versioning to the Release Maven Plugin, you can use the `ChangesetsVersionPolicy` together with the `useReleasePluginIntegration` flag:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import static java.util.stream.Collectors.toList;
import static org.slf4j.LoggerFactory.getLogger;
import static se.fortnox.changesets.ChangesetWriter.CHANGESET_DIR;
import static se.fortnox.changesets.Level.DEPENDENCY;
import static se.fortnox.changesets.Level.MAJOR;
import static se.fortnox.changesets.Level.MINOR;
import static se.fortnox.changesets.Level.PATCH;
Expand All @@ -27,9 +28,15 @@ public class ChangelogAggregator {
private static final Logger LOG = getLogger(ChangelogAggregator.class);
public static final String CHANGELOG_FILE = "CHANGELOG.md";
private final Path baseDir;
private final DependencyUpdatesParser dependencyUpdatesParser;

public ChangelogAggregator(Path baseDir) {
this(baseDir, new DependencyUpdatesParser());
}

public ChangelogAggregator(Path baseDir, DependencyUpdatesParser dependencyUpdatesParser) {
this.baseDir = baseDir;
this.dependencyUpdatesParser = dependencyUpdatesParser;
}

/**
Expand All @@ -38,23 +45,26 @@ public ChangelogAggregator(Path baseDir) {
*
* @param packageName The package name to get changesets for
* @param version The version number of the merged changes
* @return
*/
public void mergeChangesetsToChangelog(String packageName, String version) {
public Path mergeChangesetsToChangelog(String packageName, String version) {
Path changesetsDir = this.baseDir.resolve(CHANGESET_DIR);

ChangesetLocator changesetLocator = new ChangesetLocator(this.baseDir);
List<Changeset> changesets = changesetLocator.getChangesets(packageName);
if (changesets.isEmpty()) {
LOG.info("No changesets found in {}", this.baseDir);
return;
return changesetsDir;
}

String changelog = generateChangelog(packageName, version, changesets);

Path changelogFile;
try {
writeChangelog(changelog);
changelogFile = writeChangelog(changelog);
} catch (ChangelogException exception) {
LOG.error("Failed to update changelog at {}", changesetsDir, exception);
return changesetsDir;
}

changesets.forEach(changeset -> {
Expand All @@ -65,26 +75,25 @@ public void mergeChangesetsToChangelog(String packageName, String version) {
LOG.error("Failed to delete {}", file, e);
}
});
return changelogFile;
}

private static String generateChangelog(String packageName, String version, List<Changeset> changesets) {
private String generateChangelog(String packageName, String version, List<Changeset> changesets) {
String changes = changesets
.stream()
.collect(groupingBy(Changeset::level, mapping(Changeset::message, toList())))
.entrySet()
.stream()
.sorted(sortChangesets())
.map(entry -> {
String level = entry.getKey().getPresentationString();
String levelChanges = entry.getValue().stream()
.map(ChangelogAggregator::formatChangeAsBulletPoint)
.sorted()
.collect(Collectors.joining("\n"));
Level level = entry.getKey();
String levelString = level.getPresentationString();
String levelChanges = formatChangeset(level, entry.getValue());

return """
### %s Changes
### %s

%s""".formatted(level, levelChanges);
%s""".formatted(levelString, levelChanges);
})
.collect(Collectors.joining("\n\n"));

Expand All @@ -99,6 +108,23 @@ private static String generateChangelog(String packageName, String version, List
return MarkdownFormatter.format(markdown);
}

private String formatChangeset(Level level, List<String> changes) {
if (level == DEPENDENCY) {
// Extract all dependencies from each dependency change and put them into a single list
return changes.stream()
.flatMap(change -> dependencyUpdatesParser.parseDependencyChangeset(change).stream())
.map(ChangelogAggregator::formatChangeAsBulletPoint)
.distinct()
.sorted()
.collect(Collectors.joining("\n"));
}

return changes.stream()
.map(ChangelogAggregator::formatChangeAsBulletPoint)
.sorted()
.collect(Collectors.joining("\n"));
}

private static String formatChangeAsBulletPoint(String change) {
// Add the change as a bullet point, with leading dash and each subsequent line indented with two spaces
String firstLinePrefix = "- ";
Expand All @@ -118,7 +144,7 @@ private static String formatChangeAsBulletPoint(String change) {
}

private static Comparator<Map.Entry<Level, List<String>>> sortChangesets() {
List<Level> levelOrder = List.of(MAJOR, MINOR, PATCH);
List<Level> levelOrder = List.of(MAJOR, MINOR, PATCH, DEPENDENCY);

return (o1, o2) -> {
// Sort levels in the order specified in levelOrder
Expand All @@ -135,9 +161,10 @@ private static Comparator<Map.Entry<Level, List<String>>> sortChangesets() {
* If the file already exists, trim the first header before prepending it with the new changelog entries.
*
* @param changelog The new changelog content
* @return
* @throws ChangelogException Thrown if file operations on the existing or new file are unsuccessful
*/
private void writeChangelog(String changelog) throws ChangelogException {
private Path writeChangelog(String changelog) throws ChangelogException {
Path changelogFile = this.baseDir.resolve(CHANGELOG_FILE);

if (Files.exists(changelogFile)) {
Expand All @@ -146,6 +173,7 @@ private void writeChangelog(String changelog) throws ChangelogException {

try {
Files.writeString(changelogFile, changelog, TRUNCATE_EXISTING, CREATE);
return changelogFile;

} catch (IOException e) {
throw new ChangelogException("Failed to write " + changelogFile, e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public static List<Changeset> parseFile(File file) {

// Translate to enum
Level level = switch (levelString) {
case "dependency" -> Level.DEPENDENCY;
case "patch" -> Level.PATCH;
case "minor" -> Level.MINOR;
case "major" -> Level.MAJOR;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package se.fortnox.changesets;

import com.vladsch.flexmark.ast.Paragraph;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Document;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.collection.iteration.ReversiblePeekingIterable;
import org.slf4j.Logger;

import java.util.List;
import java.util.stream.StreamSupport;

import static org.slf4j.LoggerFactory.getLogger;

public class DependencyUpdatesParser {
private static final Logger LOG = getLogger(DependencyUpdatesParser.class);

public List<String> parseDependencyChangeset(String change) {
Parser parser = Parser.builder().build();
Document parsed = parser.parse(change);

if (!parsed.hasChildren()) {
return List.of();
}

String nodeName = parsed.getFirstChild().getNodeName();
if (!nodeName.equals("BulletList")) {
LOG.warn("Unexpected node type {}", nodeName);
return List.of();
}

ReversiblePeekingIterable<Node> dependencyNodes = parsed.getFirstChild().getChildren();

return StreamSupport.stream(dependencyNodes.spliterator(), false)
.map(node -> {
Paragraph paragraph = (Paragraph)node.getChildOfType(Paragraph.class);
return paragraph.getChars().toString().trim();
})
.distinct()
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package se.fortnox.changesets;

public enum Level {
MAJOR("major", "Major"),
MINOR("minor", "Minor"),
PATCH("patch", "Patch");
MAJOR("major", "Major Changes"),
MINOR("minor", "Minor Changes"),
PATCH("patch", "Patch Changes"),
DEPENDENCY("dependency", "Dependency Updates");

private final String textValue;
private final String presentationString;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ public class MarkdownFormatter {
public static String format(String markdown) {
MutableDataSet formatOptions = new MutableDataSet();
// Clean up whitespaces in different ways
formatOptions.set(FORMAT_FLAGS, LineAppendable.F_FORMAT_ALL);
// Do not use F_TRIM_TRAILING_WHITESPACE as it is required for line breaks in bullet lists
int format = LineAppendable.F_CONVERT_TABS |
// LineAppendable.F_COLLAPSE_WHITESPACE;
// LineAppendable.F_TRIM_TRAILING_WHITESPACE |
LineAppendable.F_TRIM_LEADING_WHITESPACE |
LineAppendable.F_TRIM_LEADING_EOL
;
formatOptions.set(FORMAT_FLAGS, format);

// Limit line lengths
formatOptions.set(RIGHT_MARGIN, 120);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,48 @@ void shouldFormatMarkdown(@TempDir Path tempDir) throws FileAlreadyExistsExcepti

""");
}

@Test
void shouldAggregateDependencyUpdates(@TempDir Path tempDir) throws FileAlreadyExistsException {
ChangelogAggregator changelog = new ChangelogAggregator(tempDir);

ChangesetWriter changesetWriter = new ChangesetWriter(tempDir);
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, "- Some dependency\n- Another dependency");
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, "- Third dependency");
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, " - Differently indented");
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, "- Fourth dependency\n - Fifth dependency");
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, "- Multiline dependency \n that should be kept as a single item");
changesetWriter.writeChangeset(PACKAGE_NAME, Level.DEPENDENCY, "- Multi \nline");

assertThat(tempDir.resolve(ChangesetWriter.CHANGESET_DIR))
.exists()
.isDirectory()
.isDirectoryContaining(path -> path.toFile().getName().endsWith(".md"));

changelog.mergeChangesetsToChangelog(PACKAGE_NAME, "1.0.0");

assertThat(tempDir.resolve(CHANGELOG_FILE))
.exists()
.isRegularFile()
.content()
.isEqualTo("""
# my-package

## 1.0.0

### Dependency Updates

- Another dependency
- Differently indented
- Fifth dependency
- Fourth dependency
- Multi\s\s
line
- Multiline dependency\s\s
that should be kept as a single item
- Some dependency
- Third dependency

""");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package se.fortnox.changesets;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import static org.assertj.core.api.Assertions.assertThat;

class CompareTest {
@Test
void shouldGenerateChangelogForReactiveWizard(@TempDir Path tempDir) throws IOException {
Path changesetsPath = Path.of("src/test/resources/changesets/reactive-wizard/.changeset");

Path changesetsTarget = tempDir.resolve(".changeset");
changesetsTarget.toFile().mkdir();

String[] files = changesetsPath.toFile().list();
for (String file : files) {
Files.copy(changesetsPath.resolve(file), changesetsTarget.resolve(file));
}

assertThat(changesetsTarget.toFile().list())
.isNotEmpty()
.containsExactly(changesetsPath.toFile().list());

ChangelogAggregator changelog = new ChangelogAggregator(tempDir);
Path changelogFile = changelog.mergeChangesetsToChangelog("reactivewizard-parent", "26.0.0");

String actualChangelogText = Files.readString(changelogFile);
String expectedChangelogText = Files.readString(changesetsPath.resolve("../CHANGELOG-expected.md"));

assertThat(actualChangelogText)
.isEqualTo(expectedChangelogText);

}
@Test
void shouldGenerateChangelogForReactiveWizardWithDependencyTypes(@TempDir Path tempDir) throws IOException {
Path changesetsPath = Path.of("src/test/resources/changesets/reactive-wizard-with-dependencies/.changeset");

Path changesetsTarget = tempDir.resolve(".changeset");
changesetsTarget.toFile().mkdir();

String[] files = changesetsPath.toFile().list();
for (String file : files) {
Files.copy(changesetsPath.resolve(file), changesetsTarget.resolve(file));
}

assertThat(changesetsTarget.toFile().list())
.isNotEmpty()
.containsExactly(changesetsPath.toFile().list());

ChangelogAggregator changelog = new ChangelogAggregator(tempDir);
Path changelogFile = changelog.mergeChangesetsToChangelog("reactivewizard-parent", "26.0.0");

String actualChangelogText = Files.readString(changelogFile);
String expectedChangelogText = Files.readString(changesetsPath.resolve("../CHANGELOG-expected.md"));

assertThat(actualChangelogText)
.isEqualTo(expectedChangelogText);

}
}
Loading
Loading