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
16 changes: 15 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ ghrnc:

The `ghrnc.github-token` is optional but increases the rate limit of GitHub. If required, the endpoint of the GitHub API could be set with `ghrnc.base-url`.

If `sections` isn't set, the following default is used:
If `ghrnc.sections` isn't set, the following default is used:

[source,yaml]
----
Expand All @@ -51,6 +51,20 @@ ghrnc:
labels: ["documentation"]
----

Optionally, a list of all contributors can be created. The corresponding configuration may look like this:

[source,yaml]
----
ghrnc:
contributors:
enabled: true
title: ":heart: Contributors"
message: "Thank you to all the contributors who worked on this release."
excludes:
- "core-developer-1"
- "core-developer-2"
----

== Running Standalone

Download the https://github.com/th-schwarz/GithubReleaseNotesCreator/releases[last release jar].
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<shade-plugin.version>3.5.3</shade-plugin.version>

<github-api.version>2.0-rc.3</github-api.version>
<github-api.version>2.0-rc.4</github-api.version>
<jackson.version>2.18.3</jackson.version>
<logback.version>1.5.18</logback.version>
<jetbrains-annotations.version>24.0.0</jetbrains-annotations.version>
Expand Down
48 changes: 29 additions & 19 deletions src/main/java/codes/thischwa/ghrnc/GithubService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;

import org.jetbrains.annotations.Nullable;
import org.kohsuke.github.GHIssue;
Expand All @@ -15,42 +16,51 @@
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.GitHubBuilder;
import org.kohsuke.github.PagedIterable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

private static final Logger LOG = org.slf4j.LoggerFactory.getLogger(GithubService.class);
private final GitHub github;
private final GHRepository repo;
private final GHRepository repository;

public GithubService(@Nullable String githubToken, String repo) throws IOException {
this(null, githubToken, repo);
public GithubService(@Nullable String githubToken, String repositoryName) throws IOException {
this(null, githubToken, repositoryName);
}

public GithubService(@Nullable String baseUrl, @Nullable String githubToken, String repo) throws IOException {
assert repo != null;
GitHubBuilder builder = (githubToken == null || githubToken.isBlank()) ? new GitHubBuilder() :
public GithubService(@Nullable String baseUrl, @Nullable String githubToken,
String repositoryName) throws IOException {
Objects.requireNonNull(repositoryName, "Repository name must not be null");
this.github = initGithubRepository(baseUrl, githubToken);
this.repository = github.getRepository(repositoryName);
LOG.debug("GitHub-Service initialized for {}", repositoryName);
}

private GitHub initGithubRepository(@Nullable String baseUrl, @Nullable String githubToken)
throws IOException {
GitHubBuilder builder = isBlank(githubToken) ? new GitHubBuilder() :
new GitHubBuilder().withOAuthToken(githubToken);
github = (baseUrl == null || baseUrl.isBlank()) ? builder.build() : builder.withEndpoint(baseUrl).build();
this.repo = github.getRepository(repo);
LOG.debug("GitHub-Service initialized for {}", repo);
return isBlank(baseUrl) ? builder.build() : builder.withEndpoint(baseUrl).build();
}

private boolean isBlank(@Nullable String str) {
return (str == null || str.isBlank());
}

public GHMilestone findMilestone(String title) throws NoSuchElementException {
PagedIterable<GHMilestone> milestonePage = repo.listMilestones(GHIssueState.ALL);
for (GHMilestone mileStone : milestonePage) {
if (mileStone.getTitle().equals(title)) {
return mileStone;
public GHMilestone findMilestone(String title) {
for (GHMilestone milestone : repository.listMilestones(GHIssueState.ALL)) {
if (milestone.getTitle().equals(title)) {
return milestone;
}
}
throw new NoSuchElementException("No such milestone: " + title);
}

public List<GHIssue> getClosedIssuesForMilestone(GHMilestone milestone) throws IOException {
List<GHIssue> ghIssues = repo.getIssues(GHIssueState.CLOSED, milestone);
LOG.info("Found {} closed issues for milestone {}", ghIssues.size(), milestone.getTitle());
return ghIssues;
List<GHIssue> closedIssues = repository.getIssues(GHIssueState.CLOSED, milestone);
LOG.info("Found {} closed issues for milestone {}", closedIssues.size(), milestone.getTitle());
return closedIssues;
}

public Map<GHLabel, List<GHIssue>> groupByLabel(List<GHIssue> issues) {
Expand Down
77 changes: 50 additions & 27 deletions src/main/java/codes/thischwa/ghrnc/ReleaseNotesService.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package codes.thischwa.ghrnc;

import codes.thischwa.ghrnc.model.Conf;
import codes.thischwa.ghrnc.model.Ghrnc;
import codes.thischwa.ghrnc.model.Section;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.*;

import org.kohsuke.github.GHIssue;
import org.kohsuke.github.GHLabel;
import org.kohsuke.github.GHMilestone;
import org.kohsuke.github.GHUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ReleaseNotesService {

private static final Logger LOG = LoggerFactory.getLogger(ReleaseNotesService.class);
private static final String NL = "\n";
private static final String DELIMITER_CONTRIBUTORS = ", ";

private final GithubService githubService;
private final Conf config;
Expand All @@ -27,50 +28,72 @@ public ReleaseNotesService(GithubService githubService, Conf config) {

public String generateChangelog(String milestoneTitle)
throws IOException, NoSuchElementException {
// Find the milestone
GHMilestone milestone = githubService.findMilestone(milestoneTitle);

// Get closed issues for the milestone
List<GHIssue> closedIssues = githubService.getClosedIssuesForMilestone(milestone);

// Group issues by their labels
Map<String, List<GHIssue>> groupedIssues = groupBySection(closedIssues);

// Generate markdown content
return generateMarkdown(groupedIssues);
}

Map<String, List<GHIssue>> groupBySection(List<GHIssue> issues) {
final Map<String, List<GHIssue>> groupedIssues = new HashMap<>();

for (GHIssue issue : issues) {
for (String label : issue.getLabels().stream().map(GHLabel::getName).toList()) {
for (Section section : config.ghrnc().sections()) {
if (section.getLabels().contains(label)) {
String sectionTitle = section.getTitle();
if (!groupedIssues.containsKey(sectionTitle)) {
groupedIssues.put(sectionTitle, new ArrayList<>());
}
groupedIssues.get(sectionTitle).add(issue);
groupedIssues.computeIfAbsent(section.getTitle(), k -> new ArrayList<>()).add(issue);
}
}
}
}
return groupedIssues;
}

private Set<GHUser> collectContributors(List<GHIssue> closedIssues, Ghrnc ghrnc) {
Set<GHUser> contributors = new HashSet<>();
if (!ghrnc.isContributorsEnabled()) {
return contributors;
}
for (GHIssue issue : closedIssues) {
GHUser contributor = issue.getUser();
if (contributor != null && !contributor.getLogin().endsWith("[bot]") &&
!ghrnc.contributors().excludes().contains(contributor.getLogin())) {
contributors.add(contributor);
LOG.debug("Contributor found: {}", contributor.getLogin());
}
}
LOG.info("Found {} contributors in {} closed issues", contributors.size(), closedIssues.size());
return contributors;
}

String generateMarkdown(Map<String, List<GHIssue>> groupedIssues) {
StringBuilder markdown = new StringBuilder();
List<GHIssue> usedIssues = new ArrayList<>();
config.ghrnc().sections().forEach(section -> {
List<GHIssue> issues = groupedIssues.get(section.getTitle());
if (issues != null && !issues.isEmpty()) {
markdown.append("## ").append(section.getTitle()).append(NL).append(NL);
for (GHIssue issue : issues) {
markdown.append("- ").append(issue.getTitle()).append(" [#").append(issue.getNumber())
.append("](").append(issue.getHtmlUrl()).append(")").append(NL);
}
markdown.append(NL);
List<GHIssue> issues = groupedIssues.get(section.getTitle());
if (issues != null && !issues.isEmpty()) {
usedIssues.addAll(issues);
markdown.append("## ").append(section.getTitle()).append(NL).append(NL);
for (GHIssue issue : issues) {
markdown.append("- ").append(issue.getTitle()).append(" [#").append(issue.getNumber())
.append("](").append(issue.getHtmlUrl()).append(")").append(NL);
}
markdown.append(NL);
}
});

Set<GHUser> contributors = collectContributors(usedIssues, config.ghrnc());
if (config.ghrnc().isContributorsEnabled() && !contributors.isEmpty()) {
List<GHUser> contributorsSorted = new ArrayList<>(contributors);
contributorsSorted.sort(Comparator.comparing(u -> u.getLogin().toLowerCase(Locale.ROOT)));
markdown.append("## ").append(config.ghrnc().contributors().title()).append(NL).append(NL);
markdown.append(config.ghrnc().contributors().message()).append(NL).append(NL);
contributorsSorted.forEach(
contributor -> markdown.append("[@").append(contributor.getLogin()).append("](")
.append(contributor.getHtmlUrl()).append(")").append(DELIMITER_CONTRIBUTORS));
if (markdown.toString().endsWith(DELIMITER_CONTRIBUTORS)) {
markdown.delete(markdown.length() - DELIMITER_CONTRIBUTORS.length(), markdown.length());
}
}
return markdown.toString().trim();
}
}
6 changes: 6 additions & 0 deletions src/main/java/codes/thischwa/ghrnc/model/Contributors.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package codes.thischwa.ghrnc.model;

import java.util.List;

public record Contributors(boolean enabled, String title, String message, List<String> excludes) {
}
16 changes: 12 additions & 4 deletions src/main/java/codes/thischwa/ghrnc/model/Ghrnc.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
package codes.thischwa.ghrnc.model;

import java.util.List;

import org.jetbrains.annotations.Nullable;

public record Ghrnc(@Nullable String baseUrl, String repo, String githubToken,
List<Section> sections) {
public record Ghrnc(
@Nullable String baseUrl,
String repo,
String githubToken,
List<Section> sections,
@Nullable Contributors contributors
) {

public Ghrnc(Ghrnc ghrnc, List<Section> sections) {
this(ghrnc.baseUrl(), ghrnc.repo(), ghrnc.githubToken(), sections);
this(ghrnc.baseUrl(), ghrnc.repo(), ghrnc.githubToken(), sections, ghrnc.contributors());
}

public boolean isContributorsEnabled() {
return contributors != null && contributors.enabled();
}
}
23 changes: 18 additions & 5 deletions src/test/java/codes/thischwa/ghrnc/ReleaseNotesServiceTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package codes.thischwa.ghrnc;

import codes.thischwa.ghrnc.model.Ghrnc;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.BufferedReader;
import java.io.IOException;
Expand All @@ -10,13 +11,14 @@
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.kohsuke.github.GHIssue;

public class ReleaseNotesServiceTest extends AbstractTest {

@Test
void test() throws Exception {
void testOnline() throws Exception {
GithubService service = new GithubService(GITHUB_TOKEN, REPO);
ReleaseNotesService changelogService = new ReleaseNotesService(service,
new YamlUtil().readInputStream(this.getClass().getResourceAsStream("/ghrnc.yml")));
Expand All @@ -26,16 +28,27 @@ void test() throws Exception {
}

@Test
void testSpring() throws Exception {
@Disabled
void testSpringOnline() throws Exception {
GithubService service = new GithubService(null, "spring-projects/spring-framework");
ReleaseNotesService changelogService = new ReleaseNotesService(service,
new YamlUtil().readInputStream(this.getClass().getResourceAsStream("/spring-framework-contr.yml")));
String changelog = changelogService.generateChangelog("6.2.5");
String expected = readInputStreamToString(this.getClass().getResourceAsStream("/changelog-spring-contr.md"));
assertEquals(expected, changelog);
}

@Test
void testSpringOffline() throws Exception {
List<GHIssue> issues = GithubApiYamlTestConfig.configureObjectMapper()
.readValue(this.getClass().getResourceAsStream("/issues_spring-6.2.5.yml"),
new TypeReference<>() {
});
ReleaseNotesService changelogService = new ReleaseNotesService(null,
ReleaseNotesService releaseNotesService = new ReleaseNotesService(null,
new YamlUtil().readInputStream(
this.getClass().getResourceAsStream("/spring-framework.yml")));
Map<String, List<GHIssue>> groupedIssues = changelogService.groupBySection(issues);
String actual = changelogService.generateMarkdown(groupedIssues);
Map<String, List<GHIssue>> groupedIssues = releaseNotesService.groupBySection(issues);
String actual = releaseNotesService.generateMarkdown(groupedIssues);
String expected =
readInputStreamToString(this.getClass().getResourceAsStream("/changelog-spring.md"));
assertEquals(expected, actual);
Expand Down
18 changes: 16 additions & 2 deletions src/test/java/codes/thischwa/ghrnc/YamlUtilTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import codes.thischwa.ghrnc.model.Conf;
import codes.thischwa.ghrnc.model.Ghrnc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand Down Expand Up @@ -35,8 +36,6 @@ void testMissingSections() {
this.getClass().getResourceAsStream("/ghrnc_without-sections.yml"));
assertNotNull(result);
Ghrnc config = result.ghrnc();
assertEquals("owner/project", config.repo());

assertEquals("owner/project", config.repo());
assertEquals("ghp_abcdefghijklmnopqrstxyz0123456789bla", config.githubToken());

Expand All @@ -45,4 +44,19 @@ void testMissingSections() {
assertEquals(":lady_beetle: Bug Fixes", config.sections().get(1).getTitle());
assertTrue(config.sections().get(1).getLabels().contains("bug"));
}

@Test
void testContributorsConfig() {
YamlUtil yamlUtil = new YamlUtil();
Conf result = yamlUtil.readInputStream(this.getClass().getResourceAsStream("/ghrnc.yml"));
assertNotNull(result);

Ghrnc config = result.ghrnc();
assertNotNull(config.contributors());
assertFalse(config.contributors().enabled());
assertEquals("Contributors", config.contributors().title());
assertEquals("Thank you to all the contributors who worked on this release.", config.contributors().message());
assertTrue(config.contributors().excludes().contains("core-developer"));
}

}
Loading