• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jreleaser / jreleaser / #556

22 Nov 2025 04:17PM UTC coverage: 46.213% (-2.0%) from 48.203%
#556

push

github

aalmiray
feat(jdks): Allow filtering by platform

Closes #2000

Co-authored-by: Ixchel Ruiz <ixchelruiz@yahoo.com>

0 of 42 new or added lines in 5 files covered. (0.0%)

1116 existing lines in 107 files now uncovered.

24939 of 53965 relevant lines covered (46.21%)

0.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.18
/sdks/jreleaser-git-java-sdk/src/main/java/org/jreleaser/sdk/git/ChangelogGenerator.java
1
/*
2
 * SPDX-License-Identifier: Apache-2.0
3
 *
4
 * Copyright 2020-2025 The JReleaser authors.
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 *     https://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18
package org.jreleaser.sdk.git;
19

20
import org.eclipse.jgit.api.Git;
21
import org.eclipse.jgit.api.errors.GitAPIException;
22
import org.eclipse.jgit.lib.Constants;
23
import org.eclipse.jgit.lib.ObjectId;
24
import org.eclipse.jgit.lib.Ref;
25
import org.eclipse.jgit.revwalk.RevCommit;
26
import org.jreleaser.bundle.RB;
27
import org.jreleaser.model.internal.JReleaserContext;
28
import org.jreleaser.model.internal.project.Project;
29
import org.jreleaser.model.internal.release.BaseReleaser;
30
import org.jreleaser.model.internal.release.Changelog;
31
import org.jreleaser.model.internal.util.VersionUtils;
32
import org.jreleaser.model.spi.release.User;
33
import org.jreleaser.mustache.TemplateContext;
34
import org.jreleaser.util.CollectionUtils;
35
import org.jreleaser.util.StringUtils;
36
import org.jreleaser.version.Version;
37

38
import java.io.IOException;
39
import java.util.ArrayList;
40
import java.util.Arrays;
41
import java.util.Comparator;
42
import java.util.LinkedHashMap;
43
import java.util.LinkedHashSet;
44
import java.util.List;
45
import java.util.Locale;
46
import java.util.Map;
47
import java.util.Objects;
48
import java.util.Optional;
49
import java.util.OptionalInt;
50
import java.util.Set;
51
import java.util.TreeSet;
52
import java.util.regex.Matcher;
53
import java.util.regex.Pattern;
54
import java.util.stream.IntStream;
55
import java.util.stream.StreamSupport;
56

57
import static java.lang.System.lineSeparator;
58
import static java.util.Collections.singletonMap;
59
import static java.util.Collections.unmodifiableList;
60
import static java.util.Collections.unmodifiableSet;
61
import static java.util.stream.Collectors.groupingBy;
62
import static java.util.stream.Collectors.joining;
63
import static java.util.stream.Collectors.toList;
64
import static org.jreleaser.model.Constants.KEY_CATEGORIZE_SCOPES;
65
import static org.jreleaser.model.Constants.KEY_CHANGELOG_CHANGES;
66
import static org.jreleaser.model.Constants.KEY_CHANGELOG_CONTRIBUTORS;
67
import static org.jreleaser.mustache.MustacheUtils.applyTemplate;
68
import static org.jreleaser.mustache.MustacheUtils.passThrough;
69
import static org.jreleaser.mustache.Templates.resolveTemplate;
70
import static org.jreleaser.sdk.git.ChangelogProvider.extractIssues;
71
import static org.jreleaser.sdk.git.ChangelogProvider.storeIssues;
72
import static org.jreleaser.sdk.git.GitSdk.extractTagName;
73
import static org.jreleaser.util.ComparatorUtils.lessThan;
74
import static org.jreleaser.util.StringUtils.isBlank;
75
import static org.jreleaser.util.StringUtils.isNotBlank;
76
import static org.jreleaser.util.StringUtils.isTrue;
77
import static org.jreleaser.util.StringUtils.normalizeRegexPattern;
78
import static org.jreleaser.util.StringUtils.stripMargin;
79
import static org.jreleaser.util.StringUtils.toSafeRegexPattern;
80

81
/**
82
 * @author Andres Almiray
83
 * @since 0.1.0
84
 */
85
public class ChangelogGenerator {
1✔
86
    private static final String UNCATEGORIZED = "<<UNCATEGORIZED>>";
87
    private static final String REGEX_PREFIX = "regex:";
88

89
    protected String createChangelog(JReleaserContext context) throws IOException {
90
        BaseReleaser<?, ?> releaser = context.getModel().getRelease().getReleaser();
1✔
91
        Changelog changelog = releaser.getChangelog();
1✔
92

93
        String separator = lineSeparator();
1✔
94
        if (org.jreleaser.model.api.release.GitlabReleaser.TYPE.equals(releaser.getServiceName())) {
1✔
95
            separator += lineSeparator();
×
96
        }
97
        String commitSeparator = separator;
1✔
98

99
        try {
100
            Git git = GitSdk.of(context).open();
1✔
101
            context.getLogger().debug(RB.$("changelog.generator.resolve.commits"));
1✔
102
            Iterable<RevCommit> commits = resolveCommits(git, context);
1✔
103

104
            Comparator<RevCommit> revCommitComparator = Comparator.comparing(RevCommit::getCommitTime).reversed();
1✔
105
            if (changelog.getSort() == org.jreleaser.model.Changelog.Sort.ASC) {
1✔
106
                revCommitComparator = Comparator.comparing(RevCommit::getCommitTime);
×
107
            }
108
            context.getLogger().debug(RB.$("changelog.generator.sort.commits", changelog.getSort()));
1✔
109

110
            // collect
111
            List<RevCommit> commitList = StreamSupport.stream(commits.spliterator(), false)
1✔
112
                .filter(c -> !changelog.isSkipMergeCommits() || c.getParentCount() <= 1)
1✔
113
                .collect(toList());
1✔
114

115
            if (context.getModel().getRelease().getReleaser().getIssues().isEnabled()) {
1✔
116
                // extract issues
117
                String rawContent = commitList.stream()
1✔
118
                    .map(RevCommit::getFullMessage)
1✔
119
                    .collect(joining(lineSeparator()));
1✔
120

121
                context.getLogger().info(RB.$("issues.generator.extract"));
1✔
122
                Set<Integer> issues = extractIssues(context, rawContent);
1✔
123
                storeIssues(context, issues);
1✔
124
            }
125

126
            if (changelog.resolveFormatted(context.getModel().getProject())) {
1✔
127
                return formatChangelog(context, changelog, commitList, revCommitComparator, commitSeparator);
1✔
128
            }
129

UNCOV
130
            String commitsUrl = releaser.getResolvedCommitUrl(context.getModel());
×
131

UNCOV
132
            return "## Changelog" +
×
UNCOV
133
                lineSeparator() +
×
UNCOV
134
                lineSeparator() +
×
UNCOV
135
                commitList.stream()
×
UNCOV
136
                    .sorted(revCommitComparator)
×
UNCOV
137
                    .map(commit -> formatCommit(commit, commitsUrl, changelog, commitSeparator))
×
UNCOV
138
                    .collect(joining(commitSeparator));
×
139
        } catch (GitAPIException e) {
×
140
            throw new IOException(e);
×
141
        }
142
    }
143

144
    protected String formatCommit(RevCommit commit, String commitsUrl, Changelog changelog, String commitSeparator) {
UNCOV
145
        String commitHash = commit.getId().name();
×
UNCOV
146
        String abbreviation = commit.getId().abbreviate(7).name();
×
UNCOV
147
        String[] input = commit.getFullMessage().trim().split(lineSeparator());
×
148

UNCOV
149
        List<String> lines = new ArrayList<>();
×
150

UNCOV
151
        if (changelog.isLinks()) {
×
152
            lines.add("[" + abbreviation + "](" + commitsUrl + "/" + commitHash + ") " + input[0].trim());
×
153
        } else {
UNCOV
154
            lines.add(abbreviation + " " + input[0].trim());
×
155
        }
156

UNCOV
157
        return String.join(commitSeparator, lines);
×
158
    }
159

160
    private Version version(JReleaserContext context, Ref tag, Pattern versionPattern) {
161
        return version(context, tag, versionPattern, false);
×
162
    }
163

164
    private Version version(JReleaserContext context, Ref tag, Pattern versionPattern, boolean strict) {
165
        return VersionUtils.version(context, extractTagName(tag), versionPattern, strict);
×
166
    }
167

168
    private Version defaultVersion(JReleaserContext context) {
169
        return VersionUtils.defaultVersion(context);
1✔
170
    }
171

172
    public Tags resolveTags(Git git, JReleaserContext context) throws GitAPIException {
173
        GitSdk gitSdk = GitSdk.of(context);
1✔
174
        if (gitSdk.isShallow()) {
1✔
175
            context.getLogger().warn(RB.$("changelog.shallow.warning"));
×
176
        }
177

178
        List<Ref> tags = git.tagList().call();
1✔
179

180
        BaseReleaser<?, ?> releaser = context.getModel().getRelease().getReleaser();
1✔
181
        String effectiveTagName = releaser.getEffectiveTagName(context.getModel());
1✔
182
        String tagName = releaser.getTagName();
1✔
183
        String tagPattern = tagName.replaceAll("\\{\\{.*}}", "\\.\\*");
1✔
184

185
        Pattern versionPattern = VersionUtils.resolveVersionPattern(context);
1✔
186
        VersionUtils.clearUnparseableTags();
1✔
187

188
        tags.sort((tag1, tag2) -> {
1✔
189
            Version v1 = version(context, tag1, versionPattern);
×
190
            Version v2 = version(context, tag2, versionPattern);
×
191
            return v2.compareTo(v1);
×
192
        });
193

194
        context.getLogger().debug(RB.$("changelog.generator.lookup.tag"), effectiveTagName);
1✔
195
        Optional<Ref> tag = tags.stream()
1✔
196
            .filter(ref -> extractTagName(ref).equals(effectiveTagName))
1✔
197
            .findFirst();
1✔
198

199
        Optional<Ref> previousTag = Optional.empty();
1✔
200
        String previousTagName = releaser.getResolvedPreviousTagName(context.getModel());
1✔
201
        if (isNotBlank(previousTagName)) {
1✔
202
            context.getLogger().debug(RB.$("changelog.generator.lookup.previous.tag"), previousTagName);
×
203
            previousTag = tags.stream()
×
204
                .filter(ref -> extractTagName(ref).equals(previousTagName))
×
205
                .findFirst();
×
206
        }
207

208
        Version currentVersion = context.getModel().getProject().version();
1✔
209
        Version defaultVersion = defaultVersion(context);
1✔
210

211
        // tag: early-access
212
        if (context.getModel().getProject().isSnapshot()) {
1✔
213
            Project.Snapshot snapshot = context.getModel().getProject().getSnapshot();
×
214
            String effectiveLabel = snapshot.getEffectiveLabel();
×
215
            if (effectiveLabel.equals(effectiveTagName)) {
×
216
                if (snapshot.isFullChangelog()) {
×
217
                    tag = Optional.empty();
×
218
                }
219

220
                if (!tag.isPresent()) {
×
221
                    if (previousTag.isPresent()) {
×
222
                        tag = previousTag;
×
223
                    }
224

225
                    if (!tag.isPresent()) {
×
226
                        context.getLogger().debug(RB.$("changelog.generator.lookup.matching.tag"), tagPattern, effectiveTagName);
×
227

228
                        tag = tags.stream()
×
229
                            .filter(ref -> !extractTagName(ref).equals(effectiveTagName))
×
230
                            .filter(ref -> versionPattern.matcher(extractTagName(ref)).matches())
×
231
                            .filter(ref -> currentVersion.equalsSpec(version(context, ref, versionPattern, true)))
×
232
                            .filter(ref -> !defaultVersion.equals(version(context, ref, versionPattern, true)))
×
233
                            .findFirst();
×
234
                    }
235
                } else {
236
                    previousTag = tags.stream()
×
237
                        .filter(ref -> extractTagName(ref).matches(tagPattern))
×
238
                        .filter(ref -> !defaultVersion.equals(version(context, ref, versionPattern, true)))
×
239
                        .filter(ref -> lessThan(version(context, ref, versionPattern, true), currentVersion))
×
240
                        .findFirst();
×
241

242

243
                    if (previousTag.isPresent()) {
×
244
                        RevCommit earlyAccessCommit = gitSdk.resolveSingleCommit(git, tag.get());
×
245
                        RevCommit previousTagCommit = gitSdk.resolveSingleCommit(git, previousTag.get());
×
246

247
                        if (previousTagCommit.getCommitTime() > earlyAccessCommit.getCommitTime()) {
×
248
                            tag = previousTag;
×
249
                        }
250
                    }
251
                }
252

253
                if (tag.isPresent()) {
×
254
                    context.getLogger().debug(RB.$("changelog.generator.tag.found"), extractTagName(tag.get()));
×
255
                    context.getModel().getRelease().getReleaser().setPreviousTagName(extractTagName(tag.get()));
×
256
                    return Tags.previous(tag.get());
×
257
                }
258

259
                return Tags.empty();
×
260
            }
261
        }
262

263
        // tag: latest
264
        if (!tag.isPresent()) {
1✔
265
            if (previousTag.isPresent()) {
1✔
266
                tag = previousTag;
×
267
            }
268

269
            if (!tag.isPresent()) {
1✔
270
                context.getLogger().debug(RB.$("changelog.generator.lookup.matching.tag"), tagPattern, effectiveTagName);
1✔
271
                tag = tags.stream()
1✔
272
                    .filter(ref -> !extractTagName(ref).equals(effectiveTagName))
1✔
273
                    .filter(ref -> versionPattern.matcher(extractTagName(ref)).matches())
1✔
274
                    .filter(ref -> currentVersion.equalsSpec(version(context, ref, versionPattern, true)))
1✔
275
                    .filter(ref -> !defaultVersion.equals(version(context, ref, versionPattern, true)))
1✔
276
                    .findFirst();
1✔
277
            }
278

279
            if (tag.isPresent()) {
1✔
280
                context.getLogger().debug(RB.$("changelog.generator.tag.found"), extractTagName(tag.get()));
×
281
                context.getModel().getRelease().getReleaser().setPreviousTagName(extractTagName(tag.get()));
×
282
                return Tags.previous(tag.get());
×
283
            }
284

285
            return Tags.empty();
1✔
286
        }
287

288
        // tag: somewhere in the middle
289
        if (!previousTag.isPresent()) {
×
290
            context.getLogger().debug(RB.$("changelog.generator.lookup.before.tag"), effectiveTagName, tagPattern);
×
291
            previousTag = tags.stream()
×
292
                .filter(ref -> extractTagName(ref).matches(tagPattern))
×
293
                .filter(ref -> !defaultVersion.equals(version(context, ref, versionPattern, true)))
×
294
                .filter(ref -> lessThan(version(context, ref, versionPattern, true), currentVersion))
×
295
                .findFirst();
×
296
        }
297

298
        if (previousTag.isPresent()) {
×
299
            context.getLogger().debug(RB.$("changelog.generator.tag.found"), extractTagName(previousTag.get()));
×
300
            context.getModel().getRelease().getReleaser().setPreviousTagName(extractTagName(previousTag.get()));
×
301
            return Tags.of(tag.get(), previousTag.get());
×
302
        }
303

304
        return Tags.current(tag.get());
×
305
    }
306

307
    protected Iterable<RevCommit> resolveCommits(Git git, JReleaserContext context) throws GitAPIException, IOException {
308
        Tags tags = resolveTags(git, context);
1✔
309
        BaseReleaser<?, ?> releaser = context.getModel().getRelease().getReleaser();
1✔
310

311
        ObjectId head = git.getRepository().resolve(Constants.HEAD);
1✔
312

313
        // tag: early-access
314
        if (context.getModel().getProject().isSnapshot()) {
1✔
315
            Project.Snapshot snapshot = context.getModel().getProject().getSnapshot();
×
316
            String effectiveLabel = snapshot.getEffectiveLabel();
×
317
            if (effectiveLabel.equals(releaser.getEffectiveTagName(context.getModel()))) {
×
318
                if (tags.getPrevious().isPresent()) {
×
319
                    Ref fromRef = tags.getPrevious().get();
×
320
                    return git.log().addRange(getObjectId(git, fromRef), head).call();
×
321
                } else {
322
                    return git.log().add(head).call();
×
323
                }
324
            }
325
        }
326

327
        // tag: latest
328
        if (!tags.getCurrent().isPresent()) {
1✔
329
            if (tags.getPrevious().isPresent()) {
1✔
330
                Ref fromRef = tags.getPrevious().get();
×
331
                return git.log().addRange(getObjectId(git, fromRef), head).call();
×
332
            } else {
333
                return git.log().add(head).call();
1✔
334
            }
335
        }
336

337
        // tag: somewhere in the middle
338
        if (tags.getPrevious().isPresent()) {
×
339
            ObjectId fromRef = getObjectId(git, tags.getPrevious().get());
×
340
            ObjectId toRef = getObjectId(git, tags.getCurrent().get());
×
341
            return git.log().addRange(fromRef, toRef).call();
×
342
        }
343

344
        ObjectId toRef = getObjectId(git, tags.getCurrent().get());
×
345
        return git.log().add(toRef).call();
×
346
    }
347

348
    private ObjectId getObjectId(Git git, Ref ref) throws IOException {
349
        Ref peeled = git.getRepository().getRefDatabase().peel(ref);
×
350
        return null != peeled.getPeeledObjectId() ? peeled.getPeeledObjectId() : peeled.getObjectId();
×
351
    }
352

353
    protected String formatChangelog(JReleaserContext context,
354
                                     Changelog changelog,
355
                                     List<RevCommit> commits,
356
                                     Comparator<RevCommit> revCommitComparator,
357
                                     String lineSeparator) {
358
        Set<Contributor> contributors = new TreeSet<>();
1✔
359
        Map<String, List<Commit>> categories = new LinkedHashMap<>();
1✔
360

361
        commits.stream()
1✔
362
            .sorted(revCommitComparator)
1✔
363
            .map(rc -> "conventional-commits".equals(changelog.getPreset()) ? ConventionalCommit.of(rc) : Commit.of(rc))
1✔
364
            .map(c -> c.extractIssues(context))
1✔
365
            .peek(c -> {
1✔
366
                applyLabels(c, changelog.getLabelers());
1✔
367

368
                if (!changelog.getContributors().isEnabled()) return;
1✔
369

370
                if (!changelog.getHide().containsContributor(c.author.name) &&
1✔
371
                    !changelog.getHide().containsContributor(c.author.email)) {
1✔
372
                    contributors.add(new Contributor(c.author));
1✔
373
                }
374
                c.committers.stream()
1✔
375
                    .filter(author -> !changelog.getHide().containsContributor(author.name))
1✔
376
                    .filter(author -> !changelog.getHide().containsContributor(author.email))
1✔
377
                    .forEach(author -> contributors.add(new Contributor(author)));
1✔
378
            })
1✔
379
            .filter(c -> checkLabels(c, changelog))
1✔
380
            .forEach(commit -> categories
1✔
381
                .computeIfAbsent(categorize(commit, changelog), k -> new ArrayList<>())
1✔
382
                .add(commit));
1✔
383

384
        BaseReleaser<?, ?> releaser = context.getModel().getRelease().getReleaser();
1✔
385
        String commitsUrl = releaser.getResolvedCommitUrl(context.getModel());
1✔
386
        String issueTracker = releaser.getResolvedIssueTrackerUrl(context.getModel(), true);
1✔
387

388
        TemplateContext props = context.fullProps();
1✔
389
        props.setAll(changelog.resolvedExtraProperties());
1✔
390
        StringBuilder changes = new StringBuilder();
1✔
391
        for (Changelog.Category category : changelog.getCategories()) {
1✔
392
            String categoryKey = category.getKey();
1✔
393
            if (!categories.containsKey(categoryKey) || changelog.getHide().containsCategory(categoryKey)) continue;
1✔
394

395
            props.set("categoryTitle", category.getTitle());
1✔
396
            changes.append(applyTemplate(changelog.getCategoryTitleFormat(), props))
1✔
397
                .append(lineSeparator);
1✔
398

399
            final String categoryFormat = resolveCommitFormat(changelog, category);
1✔
400

401
            if (isConventionalCommits(changelog) && isCategorizeScopes(changelog)) {
1✔
402
                Map<String, List<Commit>> scopes = categories.get(categoryKey).stream()
×
403
                    .collect(groupingBy(commit -> {
×
404
                        if (commit instanceof ConventionalCommit) {
×
405
                            ConventionalCommit cc = (ConventionalCommit) commit;
×
406
                            return isNotBlank(cc.ccScope) ? cc.ccScope : UNCATEGORIZED;
×
407
                        }
408
                        return UNCATEGORIZED;
×
409
                    }));
410

411
                scopes.keySet().stream().sorted()
×
412
                    .filter(scope -> !UNCATEGORIZED.equals(scope))
×
413
                    .forEach(scope -> changes.append("**")
×
414
                        .append(scope)
×
415
                        .append("**")
×
416
                        .append(lineSeparator)
×
417
                        .append(scopes.get(scope).stream()
×
418
                            .map(c -> {
×
419
                                ((ConventionalCommit) c).ccScope = ""; // clear scope
×
420
                                return resolveTemplate(categoryFormat, c.asContext(changelog.isLinks(), commitsUrl, issueTracker));
×
421
                            })
422
                            .collect(joining(lineSeparator)))
×
423
                        .append(lineSeparator)
×
424
                        .append(lineSeparator()));
×
425

426
                if (scopes.containsKey(UNCATEGORIZED)) {
×
427
                    // add unscoped header only if there are more than one uncategorized commits
428
                    if (scopes.size() > 1) changes.append("**unscoped**");
×
429
                    changes.append(lineSeparator).append(scopes.get(UNCATEGORIZED).stream()
×
430
                            .map(c -> resolveTemplate(categoryFormat, c.asContext(changelog.isLinks(), commitsUrl, issueTracker)))
×
431
                            .collect(joining(lineSeparator)))
×
432
                        .append(lineSeparator)
×
433
                        .append(lineSeparator());
×
434
                }
435
            } else {
×
436
                changes.append(categories.get(categoryKey).stream()
1✔
437
                        .map(c -> resolveTemplate(categoryFormat, c.asContext(changelog.isLinks(), commitsUrl, issueTracker)))
1✔
438
                        .collect(joining(lineSeparator)))
1✔
439
                    .append(lineSeparator)
1✔
440
                    .append(lineSeparator());
1✔
441
            }
442
        }
1✔
443

444
        if (!changelog.getHide().isUncategorized() && categories.containsKey(UNCATEGORIZED)) {
1✔
445
            if (changes.length() > 0) {
1✔
446
                changes.append("---")
1✔
447
                    .append(lineSeparator);
1✔
448
            }
449

450
            changes.append(categories.get(UNCATEGORIZED).stream()
1✔
451
                    .map(c -> resolveTemplate(changelog.getFormat(), c.asContext(changelog.isLinks(), commitsUrl, issueTracker)))
1✔
452
                    .collect(joining(lineSeparator)))
1✔
453
                .append(lineSeparator)
1✔
454
                .append(lineSeparator());
1✔
455
        }
456

457
        StringBuilder formattedContributors = new StringBuilder();
1✔
458
        if (changelog.getContributors().isEnabled() && !contributors.isEmpty()) {
1✔
459
            formattedContributors.append(applyTemplate(changelog.getContributorsTitleFormat(), props))
1✔
460
                .append(lineSeparator)
1✔
461
                .append("We'd like to thank the following people for their contributions:")
1✔
462
                .append(lineSeparator)
1✔
463
                .append(formatContributors(context, changelog, contributors, lineSeparator))
1✔
464
                .append(lineSeparator);
1✔
465
        }
466

467
        props.set(KEY_CHANGELOG_CHANGES, passThrough(changes.toString()));
1✔
468
        props.set(KEY_CHANGELOG_CONTRIBUTORS, passThrough(formattedContributors.toString()));
1✔
469
        context.getChangelog().setFormattedChanges(changes.toString());
1✔
470
        context.getChangelog().setFormattedContributors(formattedContributors.toString());
1✔
471

472
        return applyReplacers(context, changelog, stripMargin(applyTemplate(changelog.getResolvedContentTemplate(context), props)));
1✔
473
    }
474

475
    private boolean isConventionalCommits(Changelog changelog) {
476
        return isNotBlank(changelog.getPreset()) &&
1✔
477
            "conventional-commits".equals(changelog.getPreset().toLowerCase(Locale.ENGLISH).trim());
1✔
478
    }
479

480
    private boolean isCategorizeScopes(Changelog changelog) {
481
        return isTrue(changelog.getExtraProperties().get(KEY_CATEGORIZE_SCOPES));
1✔
482
    }
483

484
    private String resolveCommitFormat(Changelog changelog, Changelog.Category category) {
485
        if (StringUtils.isNotBlank(category.getFormat())) {
1✔
486
            return category.getFormat();
×
487
        }
488
        return changelog.getFormat();
1✔
489
    }
490

491
    private String formatContributors(JReleaserContext context,
492
                                      Changelog changelog,
493
                                      Set<Contributor> contributors,
494
                                      String lineSeparator) {
495
        List<String> list = new ArrayList<>();
1✔
496
        String format = changelog.getContributors().getFormat();
1✔
497

498
        Map<String, List<Contributor>> grouped = contributors.stream()
1✔
499
            .peek(contributor -> {
1✔
500
                if (!context.isDryrun() && isNotBlank(format) && (format.contains("AsLink") || format.contains("Username"))) {
1✔
UNCOV
501
                    context.getReleaser().findUser(contributor.email, contributor.name)
×
UNCOV
502
                        .ifPresent(contributor::setUser);
×
503
                }
504
            })
1✔
505
            .collect(groupingBy(Contributor::getName));
1✔
506

507
        String contributorFormat = isNotBlank(format) ? format : "{{contributorName}}";
1✔
508

509
        grouped.keySet().stream().sorted().forEach(name -> {
1✔
510
            List<Contributor> cs = grouped.get(name);
1✔
511
            Optional<Contributor> contributor = cs.stream()
1✔
512
                .filter(c -> null != c.getUser())
1✔
513
                .findFirst();
1✔
514
            if (contributor.isPresent()) {
1✔
UNCOV
515
                list.add(resolveTemplate(contributorFormat, contributor.get().asContext()));
×
516
            } else {
517
                list.add(resolveTemplate(contributorFormat, cs.get(0).asContext()));
1✔
518
            }
519
        });
1✔
520

521
        String separator = contributorFormat.startsWith("-") || contributorFormat.startsWith("*") ? lineSeparator : ", ";
1✔
522
        return String.join(separator, list);
1✔
523
    }
524

525
    private String applyReplacers(JReleaserContext context, Changelog changelog, String text) {
526
        TemplateContext props = context.getModel().props();
1✔
527
        context.getModel().getRelease().getReleaser().fillProps(props, context.getModel());
1✔
528
        for (Changelog.Replacer replacer : changelog.getReplacers()) {
1✔
529
            String search = resolveTemplate(replacer.getSearch(), props);
×
530
            String replace = resolveTemplate(replacer.getReplace(), props);
×
531
            text = text.replaceAll(search, replace);
×
532
        }
×
533

534
        return text;
1✔
535
    }
536

537
    protected String categorize(Commit commit, Changelog changelog) {
538
        if (!commit.labels.isEmpty()) {
1✔
539
            for (Changelog.Category category : changelog.getCategories()) {
1✔
540
                if (CollectionUtils.intersects(category.getLabels(), commit.labels)) {
1✔
541
                    return category.getKey();
1✔
542
                }
543
            }
1✔
544
        }
545

546
        return UNCATEGORIZED;
1✔
547
    }
548

549
    private void applyLabels(Commit commit, Set<Changelog.Labeler> labelers) {
550
        for (Changelog.Labeler labeler : labelers) {
1✔
551
            String label = labeler.getLabel();
1✔
552

553
            String title = labeler.getTitle();
1✔
554
            if (isNotBlank(title)) {
1✔
555
                if (title.startsWith(REGEX_PREFIX)) {
1✔
556
                    String regex = title.substring(REGEX_PREFIX.length());
1✔
557
                    if (commit.title.matches(normalizeRegexPattern(regex))) {
1✔
558
                        commit.labels.add(label);
1✔
559
                    }
560
                } else {
1✔
561
                    if (matches(commit.title, title)) {
1✔
562
                        commit.labels.add(label);
×
563
                    }
564
                }
565
            }
566

567
            String body = labeler.getBody();
1✔
568
            if (isNotBlank(body)) {
1✔
569
                if (body.startsWith(REGEX_PREFIX)) {
×
570
                    String regex = body.substring(REGEX_PREFIX.length());
×
571
                    if (commit.body.matches(normalizeRegexPattern(regex))) {
×
572
                        commit.labels.add(label);
×
573
                    }
574
                } else {
×
575
                    if (matches(commit.body, body)) {
×
576
                        commit.labels.add(label);
×
577
                    }
578
                }
579
            }
580

581
            String contributor = labeler.getContributor();
1✔
582
            if (isNotBlank(contributor)) {
1✔
583
                if (contributor.startsWith(REGEX_PREFIX)) {
×
584
                    String regex = contributor.substring(REGEX_PREFIX.length());
×
585
                    if (commit.author.name.matches(normalizeRegexPattern(regex)) ||
×
586
                        commit.author.email.matches(normalizeRegexPattern(regex))) {
×
587
                        commit.labels.add(label);
×
588
                    }
589
                    for (Author committer : commit.committers) {
×
590
                        if (committer.name.matches(normalizeRegexPattern(regex)) ||
×
591
                            committer.email.matches(normalizeRegexPattern(regex))) {
×
592
                            commit.labels.add(label);
×
593
                        }
594
                    }
×
595
                } else {
×
596
                    if (matches(commit.author.name, contributor) ||
×
597
                        matches(commit.author.email, contributor)) {
×
598
                        commit.labels.add(label);
×
599
                    }
600
                    for (Author committer : commit.committers) {
×
601
                        if (matches(committer.name, contributor) ||
×
602
                            matches(committer.email, contributor)) {
×
603
                            commit.labels.add(label);
×
604
                        }
605
                    }
×
606
                }
607
            }
608
        }
1✔
609
    }
1✔
610

611
    private boolean matches(String haystack, String needle) {
612
        return haystack.contains(needle) || haystack.matches(toSafeRegexPattern(needle));
1✔
613
    }
614

615
    protected boolean checkLabels(Commit commit, Changelog changelog) {
616
        if (!changelog.getIncludeLabels().isEmpty()) {
1✔
617
            return CollectionUtils.intersects(changelog.getIncludeLabels(), commit.labels);
×
618
        }
619

620
        if (!changelog.getExcludeLabels().isEmpty()) {
1✔
621
            return !CollectionUtils.intersects(changelog.getExcludeLabels(), commit.labels);
×
622
        }
623

624
        return true;
1✔
625
    }
626

627
    public static String generate(JReleaserContext context) throws IOException {
628
        if (!context.getModel().getRelease().getReleaser().getChangelog().isEnabled()) {
1✔
629
            return "";
×
630
        }
631

632
        return new ChangelogGenerator().createChangelog(context);
1✔
633
    }
634

635
    public static class Tags {
636
        private final Ref current;
637
        private final Ref previous;
638

639
        private Tags(Ref current, Ref previous) {
1✔
640
            this.current = current;
1✔
641
            this.previous = previous;
1✔
642
        }
1✔
643

644
        public Optional<Ref> getCurrent() {
645
            return Optional.ofNullable(current);
1✔
646
        }
647

648
        public Optional<Ref> getPrevious() {
649
            return Optional.ofNullable(previous);
1✔
650
        }
651

652
        private static Tags empty() {
653
            return new Tags(null, null);
1✔
654
        }
655

656
        private static Tags current(Ref tag) {
657
            return new Tags(tag, null);
×
658
        }
659

660
        private static Tags previous(Ref tag) {
661
            return new Tags(null, tag);
×
662
        }
663

664
        private static Tags of(Ref tag1, Ref tag2) {
665
            return new Tags(tag1, tag2);
×
666
        }
667
    }
668

669
    protected static class Commit {
670
        private static final Pattern CO_AUTHORED_BY_PATTERN = Pattern.compile("^[Cc]o-authored-by:\\s+(.*)\\s+<(.*)>.*$");
1✔
671
        private final Set<String> labels = new LinkedHashSet<>();
1✔
672
        private final Set<Author> committers = new LinkedHashSet<>();
1✔
673
        private final Set<Integer> issues = new TreeSet<>();
1✔
674
        private final String fullHash;
675
        private final String shortHash;
676
        private final String title;
677
        private final Author author;
678
        protected String body;
679

680
        protected Commit(RevCommit rc) {
1✔
681
            fullHash = rc.getId().name();
1✔
682
            shortHash = rc.getId().abbreviate(7).name();
1✔
683
            body = rc.getFullMessage().trim();
1✔
684
            String[] lines = split(body);
1✔
685
            if (lines.length > 0) {
1✔
686
                title = lines[0].trim();
1✔
687
            } else {
688
                title = "";
×
689
            }
690
            author = new Author(rc.getAuthorIdent().getName(), rc.getAuthorIdent().getEmailAddress());
1✔
691
            addContributor(rc.getCommitterIdent().getName(), rc.getCommitterIdent().getEmailAddress());
1✔
692
            for (String line : lines) {
1✔
693
                Matcher m = CO_AUTHORED_BY_PATTERN.matcher(line);
1✔
694
                if (m.matches()) {
1✔
695
                    addContributor(m.group(1), m.group(2));
×
696
                }
697
            }
698
        }
1✔
699

700
        TemplateContext asContext(boolean links, String commitsUrl, String issueTrackerUrl) {
701
            TemplateContext context = new TemplateContext();
1✔
702
            if (links) {
1✔
703
                context.set("commitShortHash", passThrough("[" + shortHash + "](" + commitsUrl + "/" + shortHash + ")"));
×
704
            } else {
705
                context.set("commitShortHash", shortHash);
1✔
706
            }
707
            context.set("commitsUrl", commitsUrl);
1✔
708
            context.set("commitFullHash", fullHash);
1✔
709
            context.set("commitTitle", passThrough(title));
1✔
710
            context.set("commitAuthor", passThrough(author.name));
1✔
711
            context.set("commitBody", passThrough(body));
1✔
712
            context.set("commitHasIssues", !issues.isEmpty());
1✔
713
            context.set("commitIssues", issues.stream().map(i -> {
1✔
714
                String issue = links ? passThrough("[#" + i + "](" + issueTrackerUrl + i + ")") : "#" + i;
×
715
                return singletonMap("issue", issue);
×
716
            }).collect(toList()));
1✔
717
            return context;
1✔
718
        }
719

720
        public Set<Integer> getIssues() {
721
            return unmodifiableSet(issues);
1✔
722
        }
723

724
        private void addContributor(String name, String email) {
725
            if (isNotBlank(name) && isNotBlank(email)) {
1✔
726
                committers.add(new Author(name, email));
1✔
727
            }
728
        }
1✔
729

730
        public Commit extractIssues(JReleaserContext context) {
731
            issues.addAll(ChangelogProvider.extractIssues(context, body));
1✔
732
            return this;
1✔
733
        }
734

735
        static Commit of(RevCommit rc) {
736
            return new Commit(rc);
1✔
737
        }
738

739
        protected static String[] split(String str) {
740
            // Any Unicode linebreak sequence
741
            return str.split("\\R");
1✔
742
        }
743
    }
744

745
    static class ConventionalCommit extends Commit {
746
        private static final Pattern FIRST_LINE_PATTERN =
1✔
747
            Pattern.compile("^(?<type>\\w+)(?:\\((?<scope>[^)\\n]+)\\))?(?<bang>!)?: (?<description>.*$)");
1✔
748
        private static final Pattern BREAKING_CHANGE_PATTERN = Pattern.compile("^BREAKING[ \\-]CHANGE:\\s+(?<content>[\\w\\W]+)", Pattern.MULTILINE);
1✔
749
        private static final Pattern TRAILER_PATTERN = Pattern.compile("(?<token>^\\w+(?:-\\w+)*)(?:: | #)(?<value>.*$)");
1✔
750

751
        private final List<Trailer> trailers = new ArrayList<>();
1✔
752
        private boolean isConventional = true;
1✔
753
        private boolean ccIsBreakingChange;
754
        private String ccType = "";
1✔
755
        private String ccScope = "";
1✔
756
        private String ccDescription = "";
1✔
757
        private String ccBody = "";
1✔
758
        private String ccBreakingChangeContent = "";
1✔
759

760
        private ConventionalCommit(RevCommit rc) {
761
            super(rc);
1✔
762
            List<String> lines = new ArrayList<>(Arrays.asList(split(body)));
1✔
763
            Matcher matcherFirstLine = FIRST_LINE_PATTERN.matcher(lines.get(0).trim());
1✔
764
            if (matcherFirstLine.matches()) {
1✔
765
                lines.remove(0); // consumed first line
1✔
766
                if (null != matcherFirstLine.group("bang") && !matcherFirstLine.group("bang").isEmpty()) {
1✔
767
                    ccIsBreakingChange = true;
1✔
768
                }
769
                ccType = matcherFirstLine.group("type");
1✔
770
                ccScope = null == matcherFirstLine.group("scope") ? "" : matcherFirstLine.group("scope");
1✔
771
                ccDescription = matcherFirstLine.group("description");
1✔
772
            } else {
773
                isConventional = false;
1✔
774
                return;
1✔
775
            }
776

777
            // drop any empty lines at the beginning
778
            while (!lines.isEmpty() && isBlank(lines.get(0))) {
1✔
779
                lines.remove(0);
1✔
780
            }
781

782
            // try to match trailers from the end
783
            while (!lines.isEmpty()) {
1✔
784
                Matcher matcherTrailer = TRAILER_PATTERN.matcher(lines.get(lines.size() - 1).trim());
1✔
785
                if (matcherTrailer.matches()) {
1✔
786
                    String token = matcherTrailer.group("token");
1✔
787
                    if ("BREAKING-CHANGE".equals(token)) break;
1✔
788
                    trailers.add(new Trailer(token, matcherTrailer.group("value")));
1✔
789
                    lines.remove(lines.size() - 1); // consume last line
1✔
790
                } else {
791
                    break;
792
                }
793
            }
1✔
794

795
            // drop any empty lines at the end
796
            while (!lines.isEmpty() && isBlank(lines.get(lines.size() - 1))) {
1✔
797
                lines.remove(lines.size() - 1);
1✔
798
            }
799

800
            Matcher matcherBC = BREAKING_CHANGE_PATTERN.matcher(String.join("\n", lines));
1✔
801
            if (matcherBC.find()) {
1✔
802
                ccIsBreakingChange = true;
1✔
803
                ccBreakingChangeContent = matcherBC.group("content");
1✔
804
                // consume the breaking change
805
                OptionalInt match = IntStream.range(0, lines.size())
1✔
806
                    .filter(i -> BREAKING_CHANGE_PATTERN.matcher(lines.get(i).trim()).find())
1✔
807
                    .findFirst();
1✔
808
                if (match.isPresent() && lines.size() > match.getAsInt()) {
1✔
809
                    lines.subList(match.getAsInt(), lines.size()).clear();
1✔
810
                }
811
            }
812

813
            // the rest is the body
814
            ccBody = String.join("\n", lines);
1✔
815
        }
1✔
816

817
        @Override
818
        TemplateContext asContext(boolean links, String commitsUrl, String issueTrackerUrl) {
819
            TemplateContext context = super.asContext(links, commitsUrl, issueTrackerUrl);
1✔
820
            context.set("commitIsConventional", isConventional);
1✔
821
            context.set("conventionalCommitBreakingChangeContent", passThrough(ccBreakingChangeContent));
1✔
822
            context.set("conventionalCommitIsBreakingChange", ccIsBreakingChange);
1✔
823
            context.set("conventionalCommitType", ccType);
1✔
824
            context.set("conventionalCommitScope", ccScope);
1✔
825
            context.set("conventionalCommitDescription", passThrough(ccDescription));
1✔
826
            context.set("conventionalCommitBody", passThrough(ccBody));
1✔
827
            context.set("conventionalCommitTrailers", unmodifiableList(trailers));
1✔
828
            return context;
1✔
829
        }
830

831
        public List<Trailer> getTrailers() {
832
            return trailers;
1✔
833
        }
834

835
        public static Commit of(RevCommit rc) {
836
            ConventionalCommit c = new ConventionalCommit(rc);
1✔
837
            if (c.isConventional) {
1✔
838
                return c;
1✔
839
            } else {
840
                // not ideal to reparse the commit, but that way we return a Commit instead of a ConventionalCommit
841
                return Commit.of(rc);
1✔
842
            }
843
        }
844

845
        static class Trailer {
846
            private final String token;
847
            private final String value;
848

849
            public Trailer(String token, String value) {
1✔
850
                this.token = token;
1✔
851
                this.value = value;
1✔
852
            }
1✔
853

854
            public String getToken() {
855
                return token;
×
856
            }
857

858
            public String getValue() {
859
                return value;
×
860
            }
861

862
            @Override
863
            public String toString() {
864
                return passThrough(token + ": " + value);
×
865
            }
866

867
            @Override
868
            public boolean equals(Object o) {
869
                if (this == o) return true;
1✔
870
                if (!(o instanceof Trailer)) return false;
1✔
871
                Trailer trailer = (Trailer) o;
1✔
872
                return token.equals(trailer.token) && value.equals(trailer.value);
1✔
873
            }
874

875
            @Override
876
            public int hashCode() {
877
                return Objects.hash(token, value);
×
878
            }
879
        }
880
    }
881

882
    private static class Author implements Comparable<Author> {
883
        protected final String name;
884
        protected final String email;
885

886
        private Author(String name, String email) {
1✔
887
            this.name = name;
1✔
888
            this.email = email;
1✔
889
        }
1✔
890

891
        public String getName() {
892
            return name;
×
893
        }
894

895
        public String getEmail() {
896
            return email;
×
897
        }
898

899
        @Override
900
        public int compareTo(Author that) {
901
            return name.compareTo(that.name);
×
902
        }
903

904
        @Override
905
        public boolean equals(Object o) {
906
            if (this == o) return true;
×
907
            if (null == o || getClass() != o.getClass()) return false;
×
908
            Author that = (Author) o;
×
909
            return name.equals(that.name);
×
910
        }
911

912
        @Override
913
        public int hashCode() {
914
            return Objects.hash(name);
1✔
915
        }
916

917
        @Override
918
        public String toString() {
919
            return name + " <" + email + ">";
×
920
        }
921
    }
922

923
    private static class Contributor implements Comparable<Contributor> {
924
        private final String name;
925
        private final String email;
926
        private User user;
927

928
        private Contributor(Author author) {
1✔
929
            this.name = author.name;
1✔
930
            this.email = author.email;
1✔
931
        }
1✔
932

933
        public String getName() {
934
            return name;
1✔
935
        }
936

937
        public String getEmail() {
938
            return email;
×
939
        }
940

941
        public User getUser() {
942
            return user;
1✔
943
        }
944

945
        public void setUser(User user) {
UNCOV
946
            this.user = user;
×
UNCOV
947
        }
×
948

949
        TemplateContext asContext() {
950
            TemplateContext context = new TemplateContext();
1✔
951
            context.set("contributorName", passThrough(name));
1✔
952
            context.set("contributorNameAsLink", passThrough(name));
1✔
953
            context.set("contributorUsername", "");
1✔
954
            context.set("contributorUsernameAsLink", "");
1✔
955
            if (null != user) {
1✔
UNCOV
956
                context.set("contributorNameAsLink", passThrough(user.asLink(name)));
×
UNCOV
957
                context.set("contributorUsername", passThrough(user.getUsername()));
×
UNCOV
958
                context.set("contributorUsernameAsLink", passThrough(user.asLink("@" + user.getUsername())));
×
959
            }
960
            return context;
1✔
961
        }
962

963
        @Override
964
        public int compareTo(Contributor that) {
965
            return name.compareTo(that.name);
1✔
966
        }
967

968
        @Override
969
        public boolean equals(Object o) {
970
            if (this == o) return true;
×
971
            if (null == o || getClass() != o.getClass()) return false;
×
972
            Contributor that = (Contributor) o;
×
973
            return name.equals(that.name);
×
974
        }
975

976
        @Override
977
        public int hashCode() {
978
            return Objects.hash(name);
×
979
        }
980

981
        @Override
982
        public String toString() {
983
            return name + " <" + email + ">";
×
984
        }
985
    }
986
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc