You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by vy...@apache.org on 2022/12/11 21:06:53 UTC
[logging-log4j-tools] branch master updated: LOG4J2-3628 Added `log4j-changelog` module.
This is an automated email from the ASF dual-hosted git repository.
vy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/logging-log4j-tools.git
The following commit(s) were added to refs/heads/master by this push:
new b748116 LOG4J2-3628 Added `log4j-changelog` module.
b748116 is described below
commit b748116be400714135324a3cd2e7e4ebe657121f
Author: Volkan Yazıcı <vo...@yazi.ci>
AuthorDate: Sun Dec 11 22:07:17 2022 +0100
LOG4J2-3628 Added `log4j-changelog` module.
---
CHANGELOG.adoc | 2 +-
README.adoc | 5 +-
log4j-changelog/README.adoc | 238 +++++++++++++
log4j-changelog/pom.xml | 7 +-
.../org/apache/logging/log4j/AsciiDocUtils.java | 40 +++
.../java/org/apache/logging/log4j/FileUtils.java | 63 ++++
.../logging/log4j/PositionalSaxEventHandler.java | 103 ++++++
.../org/apache/logging/log4j/PropertyUtils.java | 58 +++
.../java/org/apache/logging/log4j/StringUtils.java | 31 ++
.../org/apache/logging/log4j/VersionUtils.java | 38 ++
.../java/org/apache/logging/log4j/XmlReader.java | 117 ++++++
.../java/org/apache/logging/log4j/XmlUtils.java | 71 ++++
.../java/org/apache/logging/log4j/XmlWriter.java | 108 ++++++
.../logging/log4j/changelog/ChangelogEntry.java | 207 +++++++++++
.../logging/log4j/changelog/ChangelogFiles.java | 79 ++++
.../logging/log4j/changelog/ChangelogRelease.java | 69 ++++
.../log4j/changelog/exporter/AsciiDocExporter.java | 396 +++++++++++++++++++++
.../changelog/exporter/AsciiDocExporterArgs.java | 40 +++
.../log4j/changelog/importer/MavenChanges.java | 203 +++++++++++
.../changelog/importer/MavenChangesImporter.java | 140 ++++++++
.../importer/MavenChangesImporterArgs.java | 45 +++
.../changelog/releaser/ChangelogReleaser.java | 138 +++++++
.../changelog/releaser/ChangelogReleaserArgs.java | 44 +++
pom.xml | 12 -
24 files changed, 2234 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index c9a6830..18d6767 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -17,4 +17,4 @@ limitations under the License.
== 0.1.0 (????-??-??)
-* The first GA release.
+* Added `log4j-changelog` module (for https://issues.apache.org/jira/browse/LOG4J2-3628[LOG4J2-3628] by `vy`)
diff --git a/README.adoc b/README.adoc
index 228975b..925042f 100644
--- a/README.adoc
+++ b/README.adoc
@@ -19,7 +19,10 @@ https://github.com/apache/logging-log4j-tools/actions[image:https://github.com/a
https://search.maven.org/search?q=g:org.apache.logging.log4j%20a:log4j-tools[image:https://img.shields.io/maven-central/v/org.apache.logging.log4j/log4j-tools.svg[Maven Central]]
https://www.apache.org/licenses/LICENSE-2.0.txt[image:https://img.shields.io/github/license/apache/logging-log4j-tools.svg[License]]
-Tooling internally used by the Apache Log4j project infrastructure.
+Tooling internally used by https://logging.apache.org/log4j/2.x/[the Apache Log4j project] infrastructure.
+
+xref:log4j-changelog/README.adoc[`log4j-changelog`]::
+Tooling to export AsciiDoc-formatted changelog files.
== License
diff --git a/log4j-changelog/README.adoc b/log4j-changelog/README.adoc
new file mode 100644
index 0000000..75f995e
--- /dev/null
+++ b/log4j-changelog/README.adoc
@@ -0,0 +1,238 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+////
+
+This project contains tooling for changelogs of Apache Log4j projects.
+
+[#what-is-a-changelog]
+== What is a changelog?
+
+A changelog is a log of all notable changes made to a project.
+
+[#why-different]
+== Why yet another changelog tool?
+
+Existing changelog practices (e.g., https://keepachangelog.com[Keep a changelog], https://maven.apache.org/plugins/maven-changes-plugin/[maven-changes-plugin]) store changelog entries in the same file.
+This creates merge conflicts between different branches.
+Imagine multiple people working on multiple branches each containing a change to `CHANGELOG.md`.
+Whoever succeeds in merging their branch to `master` first will cause a merge-conflict for the others, even though their work might be totally unrelated from each other.
+
+This project embraces a model where changelog entries are kept in separate files and hence are not prone to merge conflicts.
+Similar to `maven-changes-plugin`, changelog sources and their exports (e.g., AsciiDoc-formatted) are split by design.
+
+[#look]
+== How does it look like?
+
+All changelog _sources_ are stored in folders under _changelog directory_ (e.g., `/src/changelog`):
+
+[source]
+----
+$ ls -a -1 src/changelog
+.
+..
+# ...
+2.18.0
+2.19.0
+.2.x.x
+----
+
+Changelog sources of _released versions_ are stored in `<changelogDirectory>/<releaseVersion>` folders (e.g., `/src/changelog/2.19.0`):
+
+[source]
+----
+$ ls -a -1 src/changelog/2.19.0
+.
+..
+.intro.adoc
+LOG4J2-2975_Add_implementation_of_SLF4J2_fluent_API.xml
+LOG4J2-3545_Add_correct_manifest_entries_for_OSGi_to_log4j_jcl.xml
+LOG4J2-3548_Improve_support_for_passwordless_keystores.xml
+# ...
+LOG4J2-3589_Allow_Plugins_to_be_injected_with_the_LoggerContext_referenc.xml
+LOG4J2-3590_Remove_SLF4J_1_8_x_binding.xml
+LOG4J2-3614_Harden_InstantFormatter_against_delegate_failures.xml
+LOG4J2-708_Add_async_support_to_Log4jServletFilter.xml
+.release.xml
+----
+
+Changelog sources of _upcoming releases_ are stored in `<changelogDirectory>/.<releaseVersionMajor>.x.x` folders (e.g., `/src/changelog/.2.x.x`):
+
+[source]
+----
+$ ls -a -1 src/changelog/.2.x.x
+.
+..
+LOG4J2-2678_Add_LogEvent_timestamp_to_ProducerRecord_in_KafkaAppender.xml
+LOG4J2-3628_new_changelog_infra.xml
+LOG4J2-3631_Fix_Configurator_setLevel_for_internal_classes.xml
+LOG4J2-3634_Fix_level_propagation_in_Log4jBridgeHandler.xml
+----
+
+A typical changelog entry looks as follows:
+
+[source]
+----
+$ cat src/changelog/.2.x.x/LOG4J2-3628_new_changelog_infra.xml
+<?xml version="1.0" encoding="UTF-8"?>
+<entry type="changed">
+ <issue id="LOG4J2-3628" link="https://issues.apache.org/jira/browse/LOG4J2-3628"/>
+ <author id="vy"/>
+ <description format="asciidoc">
+ Replaced `maven-changes-plugin` with a custom changelog implementation
+ </description>
+</entry>
+----
+
+All changelog folders, including the ones for the upcoming releases, can be exported to AsciiDoc:
+
+[source]
+----
+$ ls -1 target/generated-sources/site/asciidoc/changelog
+# ...
+2.18.0.adoc
+2.19.0.adoc
+2.x.x.adoc
+index.adoc
+----
+
+[#released-version-changelogs]
+== Released version changelogs
+
+Changelogs of past released versions are contained in `<changelogDirectory>/<releaseVersion>` directories (e.g., `/src/changelog/2.19.0`).
+A released version changelog directory consists of following files:
+
+`.release.xml`::
+the meta information about the release (e.g., release version and date)
+
+`.intro.adoc`::
+the introductory content used by the AsciiDoc exporter
+
+`[<issueId>_]<shortSummary>.xml`::
+changelog entry associated with a change
+
+The AsciiDoc exporter will compile these source files and output a `<releaseVersion>.adoc` (e.g., `2.19.0.adoc`) file for each release and an `index.adoc`.
+
+[#unreleased-version-changelogs]
+== Unreleased version changelogs
+
+Changelogs of upcoming release versions are stored in `<changelogDirectory>/.<releaseVersionMajor>.x.x` directories (e.g., `/src/changelog/.2.x.x`).
+Compared to released version changelog directories (e.g., `2.19.0`), `.<releaseVersionMajor>.x.x` directories only consist of changelog entry files (i.e., `[<issueId>_]<shortSummary>.xml`).
+
+The AsciiDoc exporter will compile these source files and output `<releaseVersionMajor>.x.x.adoc` (e.g., `2.x.x.adoc`) file for each upcoming release.
+
+[#changelog-entry-file]
+== Changelog entry file
+
+A changelog entry file consists of short meta information regarding a particular change.
+They are named following the `[<issueId>_]<shortSummary>.xml` pattern.
+Consider the following examples:
+
+* `LOG4J2-3556_JsonTemplateLayout_stack_trace_truncation_fix.xml`
+* `LOG4J2-3578_Generate_new_SSL_certs_for_testing.xml`
+* `Update_jackson_2_11_0_2_11_2.xml`
+
+A sample _changelog entry_ file is shared below.
+
+.`src/changelog/LOG4J2-3556_JsonTemplateLayout_stack_trace_truncation_fix.xml` file contents
+[source,xml]
+----
+<entry type="fixed">
+ <issue id="LOG4J2-3556" link="https://issues.apache.org/jira/browse/LOG4J2-3556"/>
+ <author id="vy"/>
+ <author name="Arthur Gavlyukovskiy"/>
+ <description format="asciidoc">Make `JsonTemplateLayout` stack trace truncation operate for each label block.</description>
+</entry>
+----
+
+Some remarks about the structure of changelog entry files:
+
+* The root element must be named `entry`.
+* `entry.type` attribute is required and must be one of the https://keepachangelog.com/en/1.0.0/#how[_Keep a Changelog_ change types]:
+** `added` – for new features
+** `changed` – for changes in existing functionality
+** `deprecated` – for soon-to-be removed features
+** `removed` – for now removed features
+** `fixed` – for any bug fixes
+** `security` – for vulnerabilities
+* `issue` element is optional, and, if present, must contain `id` and `link` attributes.
+* `author` element must have at least one of `id` or `name` attributes.
+* There must be at least one author.
+* There must be a single `description` element with non-blank content and `format="asciidoc"` attribute.
+
+[#qa]
+== Q&A
+
+[#qa-entry]
+=== How can I add an entry for a change I am about to commit?
+
+You have just committed, or better, about to commit a great feature you have been working on.
+Simply create a <<#changelog-entry-file>> and commit it along with your change!
+
+[#qa-generate]
+=== How can I export changelogs to AsciiDoc files?
+
+You need to use `AsciiDocExporter` as follows:
+
+[source,bash]
+----
+java \
+ -cp /path/to/log4j-changelog.jar \
+ -Dlog4j.changelog.directory=/path/to/changelog/directory \
+ -Dlog4j.changelog.exporter.outputDirectory=/path/to/asciiDocOutputDirectory \
+ org.apache.logging.log4j.changelog.exporter.AsciiDocExporter
+----
+
+[#qa-deploy-release]
+=== I am about to deploy a new Log4j release. What shall I do?
+
+Just before a release, two things need to happen in the changelog sources:
+
+. Changelog entries of the upcoming release directory `<changelogDirectory>/.<releaseVersionMajor>.x.x` needs to be moved to the release changelog directory `<changelogDirectory>/<releaseVersion>`
+. `.index.adoc` and `.release.xml` need to be created in the release changelog directory `<changelogDirectory>/<releaseVersion>`
+
+Due to the nature of release candidates, above steps might need to be repeated multiple times.
+
+[TIP]
+====
+Log4j _releases_ and _release candidates_ all get deployed to the same https://repository.apache.org/#stagingRepositories[_staging repository_].
+Their `pom.xml` files all contain the same release version, e.g., `2.19.0`.
+There are no `-rc1`, `-rc2`, etc. suffixes in the version of a release candidate.
+Once a release candidate voting reaches to a consensus for release, associated artifacts simply get promoted from the _staging_ to the _public_ repository.
+Hence, there are no differences between releases and release candidates.
+====
+
+How to carry out aforementioned two changes are explained below in steps:
+
+. Populate the `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) from the upcoming release changelog directory (e.g., `<changelogDirectory>/.2.x.x`):
++
+[source,bash]
+----
+java \
+ -cp /path/to/log4j-changelog.jar \
+ -Dlog4j.changelog.directory=/path/to/changelog/directory \
+ -Dlog4j.changelog.releaseVersion=X.Y.Z \
+ org.apache.logging.log4j.changelog.releaser.ChangelogReleaser
+----
+. Verify that `<changelogDirectory>/.<releaseVersionMajor>.x.x` directory (e.g., `/src/changelog/.2.x.x`) is emptied
+. Verify that `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) is created, and it contains `.intro.adoc`, `.release.xml`, and changelog entry files
++
+[IMPORTANT]
+====
+If `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) already exists with certain content, `ChangelogReleaser` will only move new changelog entry files and override `.release.xml`; `.intro.adoc` will not be touched, if exists.
+This allows one to run `ChangelogReleaser` multiple times, e.g., to incorporate changes added to a release candidate.
+====
+. Edit the created `.intro.adoc`
+. `git add` the changes in the changelog directory (e.g., `/src/changelog`) and commit them
diff --git a/log4j-changelog/pom.xml b/log4j-changelog/pom.xml
index 255d59a..a2763dc 100644
--- a/log4j-changelog/pom.xml
+++ b/log4j-changelog/pom.xml
@@ -29,9 +29,4 @@
<artifactId>log4j-changelog</artifactId>
- <properties>
- <maven.compiler.source>17</maven.compiler.source>
- <maven.compiler.target>17</maven.compiler.target>
- </properties>
-
-</project>
\ No newline at end of file
+</project>
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/AsciiDocUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/AsciiDocUtils.java
new file mode 100644
index 0000000..711c63e
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/AsciiDocUtils.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+public final class AsciiDocUtils {
+
+ public static final String LICENSE_COMMENT_BLOCK = "////\n" +
+ " Licensed to the Apache Software Foundation (ASF) under one or more\n" +
+ " contributor license agreements. See the NOTICE file distributed with\n" +
+ " this work for additional information regarding copyright ownership.\n" +
+ " The ASF licenses this file to You under the Apache License, Version 2.0\n" +
+ " (the \"License\"); you may not use this file except in compliance with\n" +
+ " the License. You may obtain a copy of the License at\n" +
+ "\n" +
+ " https://www.apache.org/licenses/LICENSE-2.0\n" +
+ "\n" +
+ " Unless required by applicable law or agreed to in writing, software\n" +
+ " distributed under the License is distributed on an \"AS IS\" BASIS,\n" +
+ " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" +
+ " See the License for the specific language governing permissions and\n" +
+ " limitations under the License.\n" +
+ "////\n";
+
+ private AsciiDocUtils() {}
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/FileUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/FileUtils.java
new file mode 100644
index 0000000..d62089b
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/FileUtils.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+
+public final class FileUtils {
+
+ private FileUtils() {}
+
+ /**
+ * Finds files non-recursively in the given directory.
+ * <p>
+ * Given directory itself and hidden files are filtered out.
+ * </p>
+ */
+ @SuppressWarnings("RedundantIfStatement")
+ public static Stream<Path> findAdjacentFiles(final Path directory) {
+ try {
+ return Files
+ .walk(directory, 1)
+ .filter(path -> {
+
+ // Skip the directory itself.
+ if (path.equals(directory)) {
+ return false;
+ }
+
+ // Skip hidden files.
+ boolean hiddenFile = path.getFileName().toString().startsWith(".");
+ if (hiddenFile) {
+ return false;
+ }
+
+ // Accept the rest.
+ return true;
+
+ });
+ } catch (final IOException error) {
+ final String message = String.format("failed walking directory: `%s`", directory);
+ throw new UncheckedIOException(message, error);
+ }
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/PositionalSaxEventHandler.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/PositionalSaxEventHandler.java
new file mode 100644
index 0000000..d01ae52
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/PositionalSaxEventHandler.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import java.util.Stack;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.xml.sax.Attributes;
+import org.xml.sax.Locator;
+import org.xml.sax.helpers.DefaultHandler;
+
+/**
+ * A SAX2 event handler adding the associated line number to each emitted nodes' user data.
+ * <p>
+ * The added node user data is keyed with {@code lineNumber}.
+ * </p>
+ */
+final class PositionalSaxEventHandler extends DefaultHandler {
+
+ private final Stack<Element> elementStack = new Stack<>();
+
+ private final StringBuilder textBuffer = new StringBuilder();
+
+ private final Document document;
+
+ private Locator locator;
+
+ PositionalSaxEventHandler(final Document document) {
+ this.document = document;
+ }
+
+ @Override
+ public void setDocumentLocator(final Locator locator) {
+ this.locator = locator;
+ }
+
+ @Override
+ public void startElement(
+ final String uri,
+ final String localName,
+ final String qName,
+ final Attributes attributes) {
+ addTextIfNeeded();
+ final Element element = document.createElement(qName);
+ for (int attributeIndex = 0; attributeIndex < attributes.getLength(); attributeIndex++) {
+ final String attributeQName = attributes.getQName(attributeIndex);
+ final String attributeValue = attributes.getValue(attributeIndex);
+ element.setAttribute(attributeQName, attributeValue);
+ }
+ element.setUserData("lineNumber", String.valueOf(locator.getLineNumber()), null);
+ elementStack.push(element);
+ }
+
+ @Override
+ public void endElement(
+ final String uri,
+ final String localName,
+ final String qName) {
+ addTextIfNeeded();
+ final Element closedElement = elementStack.pop();
+ final boolean rootElement = elementStack.isEmpty();
+ if (rootElement) {
+ document.appendChild(closedElement);
+ } else {
+ final Element parentElement = elementStack.peek();
+ parentElement.appendChild(closedElement);
+ }
+ }
+
+ @Override
+ public void characters(final char[] buffer, final int start, final int length) {
+ textBuffer.append(buffer, start, length);
+ }
+
+ /**
+ * Outputs text accumulated under the current node.
+ */
+ private void addTextIfNeeded() {
+ if (textBuffer.length() > 0) {
+ final Element element = elementStack.peek();
+ final Node textNode = document.createTextNode(textBuffer.toString());
+ element.appendChild(textNode);
+ textBuffer.delete(0, textBuffer.length());
+ }
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/PropertyUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/PropertyUtils.java
new file mode 100644
index 0000000..7aaba35
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/PropertyUtils.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.apache.logging.log4j.StringUtils.isBlank;
+
+public final class PropertyUtils {
+
+ private PropertyUtils() {}
+
+ public static Path requireNonBlankPathProperty(final String key) {
+ final String value = requireNonBlankStringProperty(key);
+ try {
+ return Paths.get(value);
+ } catch (final InvalidPathException error) {
+ final String message = String.format("system property `%s` doesn't contain a valid path: `%s`", key, value);
+ throw new IllegalArgumentException(message, error);
+ }
+ }
+
+ public static int requireNonBlankIntProperty(final String key) {
+ final String value = requireNonBlankStringProperty(key);
+ try {
+ return Integer.parseInt(value);
+ } catch (final NumberFormatException error) {
+ final String message = String.format("system property `%s` doesn't contain a valid integer: `%s`", key, value);
+ throw new IllegalArgumentException(message, error);
+ }
+ }
+
+ public static String requireNonBlankStringProperty(final String key) {
+ final String value = System.getProperty(key);
+ if (isBlank(value)) {
+ final String message = String.format("blank system property: `%s`", key);
+ throw new IllegalArgumentException(message);
+ }
+ return value;
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/StringUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/StringUtils.java
new file mode 100644
index 0000000..7c6e8c5
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/StringUtils.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+public final class StringUtils {
+
+ private StringUtils() {}
+
+ public static String trimNullable(final String input) {
+ return input != null ? input.trim() : null;
+ }
+
+ public static boolean isBlank(final String input) {
+ return input == null || input.matches("\\s*");
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/VersionUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/VersionUtils.java
new file mode 100644
index 0000000..becf004
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/VersionUtils.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+public final class VersionUtils {
+
+ public static final String VERSION_PATTERN = "^\\d+\\.\\d+.\\d+$";
+
+ private VersionUtils() {}
+
+ public static void requireSemanticVersioning(final String version, final String name) {
+ if (version.matches(VERSION_PATTERN)) {
+ final String message = String.format(
+ "provided version in `%s` does not match the expected `major.minor.patch` pattern: `%s`",
+ name, version);
+ throw new IllegalArgumentException(message);
+ }
+ }
+
+ public static int versionMajor(final String version) {
+ return Integer.parseInt(version.split("\\.", 2)[0]);
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlReader.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlReader.java
new file mode 100644
index 0000000..46f3143
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlReader.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+import javax.xml.parsers.*;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * A SAX2-based XML reader.
+ */
+public final class XmlReader {
+
+ private XmlReader() {}
+
+ public static Element readXmlFileRootElement(final Path path, final String rootElementName) {
+ try (final InputStream inputStream = new FileInputStream(path.toFile())) {
+ final Document document = readXml(inputStream);
+ final Element rootElement = document.getDocumentElement();
+ if (!rootElementName.equals(rootElement.getNodeName())) {
+ final String message = String.format(
+ "was expecting root element to be called `%s`, found: `%s`",
+ rootElementName, rootElement.getNodeName());
+ throw new IllegalArgumentException(message);
+ }
+ return rootElement;
+ } catch (final Exception error) {
+ final String message = String.format(
+ "XML read failure for file `%s` and root element `%s`", path, rootElementName);
+ throw new RuntimeException(message, error);
+ }
+ }
+
+ private static Document readXml(final InputStream inputStream) throws Exception {
+ final SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+ final SAXParser parser = parserFactory.newSAXParser();
+ final DocumentBuilderFactory documentBuilderFactory = XmlUtils.createDocumentBuilderFactory();
+ final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+ final Document document = documentBuilder.newDocument();
+ PositionalSaxEventHandler handler = new PositionalSaxEventHandler(document);
+ parser.parse(inputStream, handler);
+ return document;
+ }
+
+ public static Stream<Element> findChildElementsMatchingName(final Element parentElement, final String childElementName) {
+ final NodeList childNodes = parentElement.getChildNodes();
+ return IntStream
+ .range(0, childNodes.getLength())
+ .mapToObj(childNodes::item)
+ .filter(childNode -> childNode.getNodeType() == Node.ELEMENT_NODE && childElementName.equals(childNode.getNodeName()))
+ .map(childNode -> (Element) childNode);
+ }
+
+ public static Element requireChildElementMatchingName(final Element parentElement, final String childElementName) {
+ final List<Element> childElements = findChildElementsMatchingName(parentElement, childElementName)
+ .collect(Collectors.toList());
+ final int childElementCount = childElements.size();
+ if (childElementCount != 1) {
+ throw failureAtXmlNode(
+ parentElement,
+ "was expecting a single `%s` element, found: %d",
+ childElementName,
+ childElementCount);
+ }
+ return childElements.get(0);
+ }
+
+ public static String requireAttribute(final Element element, final String attributeName) {
+ if (!element.hasAttribute(attributeName)) {
+ throw failureAtXmlNode(element, "missing attribute: `%s`", attributeName);
+ }
+ return element.getAttribute(attributeName);
+ }
+
+ public static RuntimeException failureAtXmlNode(
+ final Node node,
+ final String messageFormat,
+ final Object... messageArgs) {
+ return failureAtXmlNode(null, node, messageFormat, messageArgs);
+ }
+
+ public static RuntimeException failureAtXmlNode(
+ final Throwable cause,
+ final Node node,
+ final String messageFormat,
+ final Object... messageArgs) {
+ final Object lineNumber = node.getUserData("lineNumber");
+ final String messagePrefix = String.format("[line %s] ", lineNumber);
+ final String message = String.format(messagePrefix + messageFormat, messageArgs);
+ return new IllegalArgumentException(message, cause);
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlUtils.java
new file mode 100644
index 0000000..0370815
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlUtils.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+final class XmlUtils {
+
+ private XmlUtils() {}
+
+ /**
+ * @return a {@link DocumentBuilderFactory} instance configured with certain XXE protection measures
+ * @see <a href="https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html#jaxp-documentbuilderfactory-saxparserfactory-and-dom4j">XML External Entity Prevention Cheat Sheet</a>
+ */
+ static DocumentBuilderFactory createDocumentBuilderFactory() {
+ final DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ String feature = null;
+ try {
+
+ // This is the PRIMARY defense.
+ // If DTDs (doctypes) are disallowed, almost all XML entity attacks are prevented.
+ // Xerces 2 only - http://xerces.apache.org/xerces2-j/features.html#disallow-doctype-decl
+ feature = "http://apache.org/xml/features/disallow-doctype-decl";
+ dbf.setFeature(feature, true);
+
+ // If you can't completely disable DTDs, then at least do the following:
+ // Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-general-entities
+ // Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-general-entities
+ // JDK7+ - http://xml.org/sax/features/external-general-entities
+ // This feature has to be used together with the following one, otherwise it will not protect you from XXE for sure.
+ feature = "http://xml.org/sax/features/external-general-entities";
+ dbf.setFeature(feature, false);
+
+ // Xerces 1 - http://xerces.apache.org/xerces-j/features.html#external-parameter-entities
+ // Xerces 2 - http://xerces.apache.org/xerces2-j/features.html#external-parameter-entities
+ // JDK7+ - http://xml.org/sax/features/external-parameter-entities
+ // This feature has to be used together with the previous one, otherwise it will not protect you from XXE for sure.
+ feature = "http://xml.org/sax/features/external-parameter-entities";
+ dbf.setFeature(feature, false);
+
+ // Disable external DTDs as well
+ feature = "http://apache.org/xml/features/nonvalidating/load-external-dtd";
+ dbf.setFeature(feature, false);
+
+ // and these as well, per Timothy Morgan's 2014 paper: "XML Schema, DTD, and Entity Attacks"
+ dbf.setXIncludeAware(false);
+ dbf.setExpandEntityReferences(false);
+
+ } catch (final ParserConfigurationException error) {
+ final String message = String.format("`%s` is probably not supported by your XML processor", feature);
+ throw new RuntimeException(message, error);
+ }
+ return dbf;
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlWriter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlWriter.java
new file mode 100644
index 0000000..3776063
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/XmlWriter.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j;
+
+import java.io.StringWriter;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.function.Consumer;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import org.w3c.dom.Comment;
+import org.w3c.dom.Document;
+
+public final class XmlWriter {
+
+ private static final Charset ENCODING = StandardCharsets.UTF_8;
+
+ private XmlWriter() {}
+
+ public static void toFile(final Path filepath, final Consumer<Document> documentConsumer) {
+ try {
+ final String xml = toString(documentConsumer);
+ final byte[] xmlBytes = xml.getBytes(ENCODING);
+ Files.createDirectories(filepath.getParent());
+ Files.write(filepath, xmlBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+ } catch (final Exception error) {
+ final String message = String.format("failed writing XML to file `%s`", filepath);
+ throw new RuntimeException(message, error);
+ }
+ }
+
+ public static String toString(final Consumer<Document> documentConsumer) {
+ try {
+
+ // Create the document.
+ final DocumentBuilderFactory documentBuilderFactory = XmlUtils.createDocumentBuilderFactory();
+ final DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
+
+ // Append the license comment.
+ final Document document = documentBuilder.newDocument();
+ document.setXmlStandalone(true);
+ final Comment licenseComment = document.createComment("\n" +
+ " Licensed to the Apache Software Foundation (ASF) under one or more\n" +
+ " contributor license agreements. See the NOTICE file distributed with\n" +
+ " this work for additional information regarding copyright ownership.\n" +
+ " The ASF licenses this file to You under the Apache License, Version 2.0\n" +
+ " (the \"License\"); you may not use this file except in compliance with\n" +
+ " the License. You may obtain a copy of the License at\n" +
+ "\n" +
+ " http://www.apache.org/licenses/LICENSE-2.0\n" +
+ "\n" +
+ " Unless required by applicable law or agreed to in writing, software\n" +
+ " distributed under the License is distributed on an \"AS IS\" BASIS,\n" +
+ " WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n" +
+ " See the License for the specific language governing permissions and\n" +
+ " limitations under the License." +
+ "\n");
+ document.appendChild(licenseComment);
+
+ // Execute request changes.
+ documentConsumer.accept(document);
+
+ // Serialize the document.
+ return serializeXmlDocument(document);
+
+ } catch (final Exception error) {
+ throw new RuntimeException("failed writing XML", error);
+ }
+ }
+
+ private static String serializeXmlDocument(final Document document) throws Exception {
+ final Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ final StreamResult result = new StreamResult(new StringWriter());
+ final DOMSource source = new DOMSource(document);
+ transformer.setOutputProperty(OutputKeys.ENCODING, ENCODING.name());
+ transformer.setOutputProperty(OutputKeys.INDENT, "yes");
+ transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
+ transformer.transform(source, result);
+ return result.getWriter().toString()
+ // Life is too short to solve DOM transformer issues decently.
+ .replace("?><!--", "?>\n<!--")
+ .replace("--><", "-->\n<");
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogEntry.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogEntry.java
new file mode 100644
index 0000000..59f5513
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogEntry.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.XmlReader;
+import org.apache.logging.log4j.XmlWriter;
+import org.w3c.dom.Element;
+
+import static org.apache.logging.log4j.StringUtils.trimNullable;
+
+public final class ChangelogEntry {
+
+ public final Type type;
+
+ public final List<Issue> issues;
+
+ public final List<Author> authors;
+
+ public final Description description;
+
+ public enum Type {
+
+ ADDED,
+
+ CHANGED,
+
+ DEPRECATED,
+
+ REMOVED,
+
+ FIXED,
+
+ SECURITY;
+
+ private String toXmlAttribute() {
+ return toString().toLowerCase(Locale.US);
+ }
+
+ private static Type fromXmlAttribute(final String attribute) {
+ final String upperCaseAttribute = attribute != null ? attribute.toUpperCase(Locale.US) : null;
+ return Type.valueOf(upperCaseAttribute);
+ }
+
+ }
+
+ public static final class Issue {
+
+ public final String id;
+
+ public final String link;
+
+ public Issue(final String id, final String link) {
+ this.id = id;
+ this.link = link;
+ }
+
+ }
+
+ public static final class Author {
+
+ public final String id;
+
+ public final String name;
+
+ public Author(final String id, final String name) {
+ this.id = id;
+ this.name = name;
+ }
+
+ }
+
+ public static final class Description {
+
+ public final String format;
+
+ public final String text;
+
+ public Description(final String format, final String text) {
+ this.format = format;
+ this.text = text;
+ }
+
+ }
+
+ public ChangelogEntry(
+ final Type type,
+ final List<Issue> issues,
+ final List<Author> authors,
+ final Description description) {
+ this.type = type;
+ this.issues = issues;
+ this.authors = authors;
+ this.description = description;
+ }
+
+ public void writeToXmlFile(final Path path) {
+ XmlWriter.toFile(path, document -> {
+
+ // Create the `entry` root element.
+ final Element entryElement = document.createElement("entry");
+ entryElement.setAttribute("type", type.toXmlAttribute());
+ document.appendChild(entryElement);
+
+ // Create the `issue` elements.
+ issues.forEach(issue -> {
+ final Element issueElement = document.createElement("issue");
+ issueElement.setAttribute("id", issue.id);
+ issueElement.setAttribute("link", issue.link);
+ entryElement.appendChild(issueElement);
+ });
+
+ // Create the `author` elements.
+ authors.forEach(author -> {
+ final Element authorElement = document.createElement("author");
+ if (author.id != null) {
+ authorElement.setAttribute("id", author.id);
+ } else {
+ authorElement.setAttribute("name", author.name);
+ }
+ entryElement.appendChild(authorElement);
+ });
+
+ // Create the `description` element.
+ final Element descriptionElement = document.createElement("description");
+ if (description.format != null) {
+ descriptionElement.setAttribute("format", description.format);
+ }
+ descriptionElement.setTextContent(description.text);
+ entryElement.appendChild(descriptionElement);
+
+ });
+ }
+
+ public static ChangelogEntry readFromXmlFile(final Path path) {
+
+ // Read the `entry` root element.
+ final Element entryElement = XmlReader.readXmlFileRootElement(path, "entry");
+ final String typeAttribute = XmlReader.requireAttribute(entryElement, "type");
+ final Type type;
+ try {
+ type = Type.fromXmlAttribute(typeAttribute);
+ } catch (final Exception error) {
+ throw XmlReader.failureAtXmlNode(error, entryElement, "`type` attribute read failure");
+ }
+
+ // Read the `issue` elements.
+ final List<Issue> issues = XmlReader
+ .findChildElementsMatchingName(entryElement, "issue")
+ .map(issueElement -> {
+ final String issueId = XmlReader.requireAttribute(issueElement, "id");
+ final String issueLink = XmlReader.requireAttribute(issueElement, "link");
+ return new Issue(issueId, issueLink);
+ })
+ .collect(Collectors.toList());
+
+ // Read the `author` elements.
+ final List<Author> authors = XmlReader
+ .findChildElementsMatchingName(entryElement, "author")
+ .map(authorElement -> {
+ final String authorId = authorElement.hasAttribute("id")
+ ? authorElement.getAttribute("id")
+ : null;
+ final String authorName = authorElement.hasAttribute("name")
+ ? authorElement.getAttribute("name")
+ : null;
+ if (authorId == null && authorName == null) {
+ throw XmlReader.failureAtXmlNode(
+ authorElement, "`author` must have at least one of `id` or `name` attributes");
+ }
+ return new Author(authorId, authorName);
+ })
+ .collect(Collectors.toList());
+ if (authors.isEmpty()) {
+ throw XmlReader.failureAtXmlNode(entryElement, "no `author` elements found");
+ }
+
+ // Read the `description` element.
+ final Element descriptionElement = XmlReader.requireChildElementMatchingName(entryElement, "description");
+ final String descriptionFormat = XmlReader.requireAttribute(descriptionElement, "format");
+ final String descriptionText = trimNullable(descriptionElement.getTextContent());
+ final Description description = new Description(descriptionFormat, descriptionText);
+
+ // Create the instance.
+ return new ChangelogEntry(type, issues, authors, description);
+
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogFiles.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogFiles.java
new file mode 100644
index 0000000..aaade2c
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogFiles.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public final class ChangelogFiles {
+
+ private ChangelogFiles() {}
+
+ public static Path unreleasedDirectory(final Path changelogDirectory, final int versionMajor) {
+ final String filename = String.format(".%d.x.x", versionMajor);
+ return changelogDirectory.resolve(filename);
+ }
+
+ public static Set<Integer> unreleasedDirectoryVersionMajors(final Path changelogDirectory) {
+ try {
+ return Files
+ .walk(changelogDirectory, 1)
+ .flatMap(path -> {
+
+ // Skip the directory itself.
+ if (path.equals(changelogDirectory)) {
+ return Stream.empty();
+ }
+
+ // Only select directories matching with the `^\.(\d+)\.x\.x$` pattern.
+ final Pattern versionPattern = Pattern.compile("^\\.(\\d+)\\.x\\.x$");
+ final Matcher versionMatcher = versionPattern.matcher(path.getFileName().toString());
+ if (!versionMatcher.matches()) {
+ return Stream.empty();
+ }
+ final String versionMajorString = versionMatcher.group(1);
+ final int versionMajor = Integer.parseInt(versionMajorString);
+ return Stream.of(versionMajor);
+
+ })
+ .collect(Collectors.toSet());
+ } catch (final IOException error) {
+ final String message = String.format("failed walking directory: `%s`", changelogDirectory);
+ throw new UncheckedIOException(message, error);
+ }
+ }
+
+ public static Path releaseDirectory(final Path changelogDirectory, final String releaseVersion) {
+ return changelogDirectory.resolve(releaseVersion);
+ }
+
+ public static Path releaseXmlFile(final Path releaseDirectory) {
+ return releaseDirectory.resolve(".release.xml");
+ }
+
+ public static Path introAsciiDocFile(final Path releaseDirectory) {
+ return releaseDirectory.resolve(".intro.adoc");
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogRelease.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogRelease.java
new file mode 100644
index 0000000..b7ab562
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/ChangelogRelease.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog;
+
+import java.nio.file.Path;
+
+import org.apache.logging.log4j.XmlReader;
+import org.apache.logging.log4j.XmlWriter;
+import org.w3c.dom.Element;
+
+import static org.apache.logging.log4j.StringUtils.trimNullable;
+
+public final class ChangelogRelease {
+
+ public final String version;
+
+ public final String date;
+
+ public ChangelogRelease(final String version, final String date) {
+ this.version = version;
+ this.date = date;
+ }
+
+ public void writeToXmlFile(final Path path) {
+ XmlWriter.toFile(path, document -> {
+ final Element releaseElement = document.createElement("release");
+ releaseElement.setAttribute("version", version);
+ releaseElement.setAttribute("date", date);
+ document.appendChild(releaseElement);
+ });
+ }
+
+ public static ChangelogRelease readFromXmlFile(final Path path) {
+
+ // Read the XML file.
+ final Element releaseElement = XmlReader.readXmlFileRootElement(path, "release");
+
+ // Read the `version` attribute.
+ final String version = trimNullable(releaseElement.getAttribute("version"));
+ if (version == null) {
+ throw new IllegalArgumentException("blank or missing attribute: `version`");
+ }
+
+ // Read the `date` attribute.
+ final String date = trimNullable(releaseElement.getAttribute("date"));
+ if (date == null) {
+ throw new IllegalArgumentException("blank or missing attribute: `date`");
+ }
+
+ // Create the instance.
+ return new ChangelogRelease(version, date);
+
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporter.java
new file mode 100644
index 0000000..f8c383c
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporter.java
@@ -0,0 +1,396 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.exporter;
+
+import org.apache.logging.log4j.AsciiDocUtils;
+import org.apache.logging.log4j.FileUtils;
+import org.apache.logging.log4j.changelog.ChangelogEntry;
+import org.apache.logging.log4j.changelog.ChangelogFiles;
+import org.apache.logging.log4j.changelog.ChangelogRelease;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public final class AsciiDocExporter {
+
+ private static final String AUTO_GENERATION_WARNING_ASCIIDOC = "////\n" +
+ "*DO NOT EDIT THIS FILE!*\n" +
+ "This file is automatically generated from the release changelog directory!\n" +
+ "////\n";
+
+ private AsciiDocExporter() {}
+
+ public static void main(final String[] mainArgs) {
+
+ // Read arguments.
+ final AsciiDocExporterArgs args = AsciiDocExporterArgs.fromSystemProperties();
+
+ // Find release directories.
+ final List<Path> releaseDirectories = FileUtils
+ .findAdjacentFiles(args.changelogDirectory)
+ .filter(file -> file.toFile().isDirectory())
+ .sorted(Comparator.comparing(releaseDirectory -> {
+ final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+ final ChangelogRelease changelogRelease = ChangelogRelease.readFromXmlFile(releaseXmlFile);
+ return changelogRelease.date;
+ }))
+ .collect(Collectors.toList());
+ final int releaseDirectoryCount = releaseDirectories.size();
+
+ // Read the release information files.
+ final List<ChangelogRelease> changelogReleases = releaseDirectories
+ .stream()
+ .map(releaseDirectory -> {
+ final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+ return ChangelogRelease.readFromXmlFile(releaseXmlFile);
+ })
+ .collect(Collectors.toList());
+
+ // Export releases.
+ if (releaseDirectoryCount > 0) {
+
+ // Export each release directory.
+ for (int releaseIndex = 0; releaseIndex < releaseDirectories.size(); releaseIndex++) {
+ final Path releaseDirectory = releaseDirectories.get(releaseIndex);
+ final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+ try {
+ exportRelease(args.outputDirectory, releaseDirectory, changelogRelease);
+ } catch (final Exception error) {
+ final String message =
+ String.format("failed exporting release from directory `%s`", releaseDirectory);
+ throw new RuntimeException(message, error);
+ }
+ }
+
+ // Report the operation.
+ if (releaseDirectoryCount == 1) {
+ System.out.format("exported a single release directory: `%s`%n", releaseDirectories.get(0));
+ } else {
+ System.out.format(
+ "exported %d release directories: ..., `%s`%n",
+ releaseDirectories.size(),
+ releaseDirectories.get(releaseDirectoryCount - 1));
+ }
+
+ }
+
+ // Export unreleased.
+ ChangelogFiles
+ .unreleasedDirectoryVersionMajors(args.changelogDirectory)
+ .stream()
+ .sorted(Comparator.reverseOrder())
+ .forEach(upcomingReleaseVersionMajor -> {
+ final Path upcomingReleaseDirectory =
+ ChangelogFiles.unreleasedDirectory(args.changelogDirectory, upcomingReleaseVersionMajor);
+ final ChangelogRelease upcomingRelease = upcomingRelease(upcomingReleaseVersionMajor);
+ System.out.format("exporting upcoming release directory: `%s`%n", upcomingReleaseDirectory);
+ exportUnreleased(args.outputDirectory, upcomingReleaseDirectory, upcomingRelease);
+ changelogReleases.add(upcomingRelease);
+ });
+
+ // Export the release index.
+ exportReleaseIndex(args.outputDirectory, changelogReleases);
+
+ }
+
+ private static void exportRelease(
+ final Path outputDirectory,
+ final Path releaseDirectory,
+ final ChangelogRelease changelogRelease) {
+ final String introAsciiDoc = readIntroAsciiDoc(releaseDirectory);
+ final List<ChangelogEntry> changelogEntries = readChangelogEntries(releaseDirectory);
+ try {
+ exportRelease(outputDirectory, changelogRelease, introAsciiDoc, changelogEntries);
+ } catch (final IOException error) {
+ final String message = String.format("failed exporting release from directory `%s`", releaseDirectory);
+ throw new UncheckedIOException(message, error);
+ }
+ }
+
+ private static String readIntroAsciiDoc(final Path releaseDirectory) {
+
+ // Determine the file to be read.
+ final Path introAsciiDocFile = ChangelogFiles.introAsciiDocFile(releaseDirectory);
+ if (!Files.exists(introAsciiDocFile)) {
+ return "";
+ }
+
+ // Read the file.
+ final List<String> introAsciiDocLines;
+ try {
+ introAsciiDocLines = Files.readAllLines(introAsciiDocFile, StandardCharsets.UTF_8);
+ } catch (final IOException error) {
+ final String message = String.format("failed reading intro AsciiDoc file: `%s`", introAsciiDocFile);
+ throw new UncheckedIOException(message, error);
+ }
+
+ // Erase comment blocks.
+ final boolean[] inCommentBlock = {false};
+ return introAsciiDocLines
+ .stream()
+ .filter(line -> {
+ final boolean commentBlock = "////".equals(line);
+ if (commentBlock) {
+ inCommentBlock[0] = !inCommentBlock[0];
+ return false;
+ }
+ return !inCommentBlock[0];
+ })
+ .collect(Collectors.joining("\n"))
+ + "\n";
+
+ }
+
+ private static List<ChangelogEntry> readChangelogEntries(final Path releaseDirectory) {
+ return FileUtils
+ .findAdjacentFiles(releaseDirectory)
+ // Sorting is needed to generate the same output between different runs.
+ .sorted()
+ .map(ChangelogEntry::readFromXmlFile)
+ .collect(Collectors.toList());
+ }
+
+ private static void exportRelease(
+ final Path outputDirectory,
+ final ChangelogRelease release,
+ final String introAsciiDoc,
+ final List<ChangelogEntry> entries)
+ throws IOException {
+ final String asciiDocFilename = changelogReleaseAsciiDocFilename(release);
+ final Path asciiDocFile = outputDirectory.resolve(asciiDocFilename);
+ Files.createDirectories(asciiDocFile.getParent());
+ final String asciiDoc = exportReleaseToAsciiDoc(release, introAsciiDoc, entries);
+ final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
+ Files.write(asciiDocFile, asciiDocBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+ }
+
+ private static String exportReleaseToAsciiDoc(
+ final ChangelogRelease release,
+ final String introAsciiDoc,
+ final List<ChangelogEntry> entries) {
+
+ // Write the header.
+ final StringBuilder stringBuilder = new StringBuilder();
+ stringBuilder
+ .append(AsciiDocUtils.LICENSE_COMMENT_BLOCK)
+ .append('\n')
+ .append(AUTO_GENERATION_WARNING_ASCIIDOC)
+ .append('\n')
+ .append("= ")
+ .append(release.version);
+ if (release.date != null) {
+ stringBuilder
+ .append(" (")
+ .append(release.date)
+ .append(")\n")
+ .append(introAsciiDoc)
+ .append("\n");
+ } else {
+ stringBuilder.append("\n\nChanges staged for the next version that is yet to be released.\n\n");
+ }
+
+ if (!entries.isEmpty()) {
+
+ stringBuilder.append("== Changes\n");
+
+ // Group entries by type.
+ final Map<ChangelogEntry.Type, List<ChangelogEntry>> entriesByType = entries
+ .stream()
+ .collect(Collectors.groupingBy(changelogEntry -> changelogEntry.type));
+
+ // Write entries for each type.
+ entriesByType
+ .keySet()
+ .stream()
+ // Sorting is necessary for a consistent layout across different runs.
+ .sorted()
+ .forEach(type -> {
+ stringBuilder.append('\n');
+ appendEntryTypeHeader(stringBuilder, type);
+ entriesByType.get(type).forEach(entry -> appendEntry(stringBuilder, entry));
+ });
+
+ }
+
+ // Return the accumulated document so far.
+ return stringBuilder.toString();
+
+ }
+
+ private static void appendEntryTypeHeader(final StringBuilder stringBuilder, final ChangelogEntry.Type type) {
+ final String typeName = type.toString().toLowerCase(Locale.US);
+ final String header = typeName.substring(0, 1).toUpperCase(Locale.US) + typeName.substring(1);
+ stringBuilder
+ .append("=== ")
+ .append(header)
+ .append("\n\n");
+ }
+
+ private static void appendEntry(final StringBuilder stringBuilder, final ChangelogEntry entry) {
+ stringBuilder.append("* ");
+ appendEntryDescription(stringBuilder, entry.description);
+ final boolean containingIssues = !entry.issues.isEmpty();
+ final boolean containingAuthors = !entry.authors.isEmpty();
+ if (containingIssues || containingAuthors) {
+ stringBuilder.append(" (");
+ if (containingIssues) {
+ appendEntryIssues(stringBuilder, entry.issues);
+ }
+ if (containingIssues && containingAuthors) {
+ stringBuilder.append(' ');
+ }
+ if (containingAuthors) {
+ appendEntryAuthors(stringBuilder, entry.authors);
+ }
+ stringBuilder.append(")");
+ }
+ stringBuilder.append('\n');
+ }
+
+ private static void appendEntryDescription(
+ final StringBuilder stringBuilder,
+ final ChangelogEntry.Description description) {
+ if (!"asciidoc".equals(description.format)) {
+ final String message = String.format("unsupported description format: `%s`", description.format);
+ throw new RuntimeException(message);
+ }
+ stringBuilder.append(description.text);
+ }
+
+ private static void appendEntryIssues(
+ final StringBuilder stringBuilder,
+ final List<ChangelogEntry.Issue> issues) {
+ stringBuilder.append("for ");
+ final int issueCount = issues.size();
+ for (int issueIndex = 0; issueIndex < issueCount; issueIndex++) {
+ final ChangelogEntry.Issue issue = issues.get(issueIndex);
+ appendEntryIssue(stringBuilder, issue);
+ if ((issueIndex + 1) != issueCount) {
+ stringBuilder.append(", ");
+ }
+ }
+ }
+
+ private static void appendEntryIssue(final StringBuilder stringBuilder, final ChangelogEntry.Issue issue) {
+ stringBuilder
+ .append(issue.link)
+ .append('[')
+ .append(issue.id)
+ .append(']');
+ }
+
+ private static void appendEntryAuthors(
+ final StringBuilder stringBuilder,
+ final List<ChangelogEntry.Author> authors) {
+ stringBuilder.append("by ");
+ final int authorCount = authors.size();
+ for (int authorIndex = 0; authorIndex < authors.size(); authorIndex++) {
+ final ChangelogEntry.Author author = authors.get(authorIndex);
+ appendEntryAuthor(stringBuilder, author);
+ if ((authorIndex + 1) != authorCount) {
+ stringBuilder.append(", ");
+ }
+ }
+ }
+
+ private static void appendEntryAuthor(final StringBuilder stringBuilder, final ChangelogEntry.Author author) {
+ if (author.id != null) {
+ stringBuilder
+ .append('`')
+ .append(author.id)
+ .append('`');
+ } else {
+ // Normalize author names written in `Doe, John` form.
+ if (author.name.contains(",")) {
+ String[] nameFields = author.name.split(",", 2);
+ stringBuilder.append(nameFields[1].trim());
+ stringBuilder.append(nameFields[0].trim());
+ } else {
+ stringBuilder.append(author.name);
+ }
+ }
+ }
+
+ private static void exportUnreleased(
+ final Path outputDirectory,
+ final Path upcomingReleaseDirectory,
+ final ChangelogRelease upcomingRelease) {
+ final List<ChangelogEntry> changelogEntries = readChangelogEntries(upcomingReleaseDirectory);
+ try {
+ exportRelease(outputDirectory, upcomingRelease, null, changelogEntries);
+ } catch (final IOException error) {
+ throw new UncheckedIOException("failed exporting unreleased changes", error);
+ }
+ }
+
+ private static ChangelogRelease upcomingRelease(final int versionMajor) {
+ final String releaseVersion = versionMajor + ".x.x";
+ return new ChangelogRelease(releaseVersion, null);
+ }
+
+ private static void exportReleaseIndex(
+ final Path outputDirectory,
+ final List<ChangelogRelease> changelogReleases) {
+ final String asciiDoc = exportReleaseIndexToAsciiDoc(changelogReleases);
+ final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
+ final Path asciiDocFile = outputDirectory.resolve("index.adoc");
+ System.out.format("exporting release index to `%s`%n", asciiDocFile);
+ try {
+ Files.write(asciiDocFile, asciiDocBytes);
+ } catch (final IOException error) {
+ throw new UncheckedIOException(error);
+ }
+ }
+
+ private static String exportReleaseIndexToAsciiDoc(final List<ChangelogRelease> changelogReleases) {
+ final StringBuilder stringBuilder = new StringBuilder();
+ stringBuilder
+ .append(AsciiDocUtils.LICENSE_COMMENT_BLOCK)
+ .append('\n')
+ .append(AUTO_GENERATION_WARNING_ASCIIDOC)
+ .append("\n= Release changelogs\n\n");
+ for (int releaseIndex = changelogReleases.size() - 1; releaseIndex >= 0; releaseIndex--) {
+ final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+ final String asciiDocFilename = changelogReleaseAsciiDocFilename(changelogRelease);
+ final String asciiDocBulletDateSuffix = changelogRelease.date != null
+ ? (" (" + changelogRelease.date + ')')
+ : "";
+ final String asciiDocBullet = String.format(
+ "* xref:%s[%s]%s\n",
+ asciiDocFilename,
+ changelogRelease.version,
+ asciiDocBulletDateSuffix);
+ stringBuilder.append(asciiDocBullet);
+ }
+ return stringBuilder.toString();
+ }
+
+ private static String changelogReleaseAsciiDocFilename(final ChangelogRelease changelogRelease) {
+ // Using only the version (that is, avoiding the date) in the filename so that one can determine the link to the changelog of a particular release with only version information.
+ return String.format("%s.adoc", changelogRelease.version);
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporterArgs.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporterArgs.java
new file mode 100644
index 0000000..5fb7597
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/exporter/AsciiDocExporterArgs.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.exporter;
+
+import java.nio.file.Path;
+
+import static org.apache.logging.log4j.PropertyUtils.requireNonBlankPathProperty;
+
+final class AsciiDocExporterArgs {
+
+ final Path changelogDirectory;
+
+ final Path outputDirectory;
+
+ private AsciiDocExporterArgs(final Path changelogDirectory, final Path outputDirectory) {
+ this.changelogDirectory = changelogDirectory;
+ this.outputDirectory = outputDirectory;
+ }
+
+ static AsciiDocExporterArgs fromSystemProperties() {
+ final Path changelogDirectory = requireNonBlankPathProperty("log4j.changelog.directory");
+ final Path outputDirectory = requireNonBlankPathProperty("log4j.changelog.exporter.outputDirectory");
+ return new AsciiDocExporterArgs(changelogDirectory, outputDirectory);
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChanges.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChanges.java
new file mode 100644
index 0000000..743b6e6
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChanges.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.importer;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.apache.logging.log4j.XmlReader;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import static org.apache.logging.log4j.StringUtils.isBlank;
+import static org.apache.logging.log4j.StringUtils.trimNullable;
+import static org.apache.logging.log4j.XmlReader.failureAtXmlNode;
+import static org.apache.logging.log4j.XmlReader.readXmlFileRootElement;
+
+final class MavenChanges {
+
+ final List<Release> releases;
+
+ private MavenChanges(final List<Release> releases) {
+ this.releases = releases;
+ }
+
+ static MavenChanges readFromFile(final Path file) {
+
+ // Read the root element.
+ final Element documentElement = readXmlFileRootElement(file, "document");
+
+ // Read the `body` element.
+ final Element bodyElement = XmlReader.requireChildElementMatchingName(documentElement, "body");
+
+ // Read releases.
+ final List<Release> releases = new ArrayList<>();
+ final NodeList releaseNodes = bodyElement.getChildNodes();
+ final int releaseNodeCount = releaseNodes.getLength();
+ for (int releaseNodeIndex = 0; releaseNodeIndex < releaseNodeCount; releaseNodeIndex++) {
+ final Node releaseNode = releaseNodes.item(releaseNodeIndex);
+ if ("release".equals(releaseNode.getNodeName()) && Node.ELEMENT_NODE == releaseNode.getNodeType()) {
+ final Element releaseElement = (Element) releaseNode;
+ final Release release = Release.fromElement(releaseElement);
+ releases.add(release);
+ }
+ }
+
+ // Create the instance.
+ return new MavenChanges(releases);
+
+ }
+
+ static final class Release {
+
+ final String version;
+
+ final String date;
+
+ final List<Action> actions;
+
+ private Release(final String version, final String date, final List<Action> actions) {
+ this.version = version;
+ this.date = date;
+ this.actions = actions;
+ }
+
+ private static Release fromElement(final Element element) {
+
+ // Read `version`.
+ final String version = trimNullable(element.getAttribute("version"));
+ if (isBlank(version)) {
+ throw XmlReader.failureAtXmlNode(element, "blank attribute: `version`");
+ }
+
+ // Read `date`.
+ final String date = trimNullable(element.getAttribute("date"));
+ final String datePattern = "^(TBD|[0-9]{4}-[0-9]{2}-[0-9]{2})$";
+ if (!date.matches(datePattern)) {
+ throw XmlReader.failureAtXmlNode(element, "`date` doesn't match with the `%s` pattern: `%s`", datePattern, date);
+ }
+
+ // Read actions.
+ final List<Action> actions = new ArrayList<>();
+ final NodeList actionNodes = element.getChildNodes();
+ final int actionNodeCount = actionNodes.getLength();
+ for (int actionNodeIndex = 0; actionNodeIndex < actionNodeCount; actionNodeIndex++) {
+ final Node actionNode = actionNodes.item(actionNodeIndex);
+ if ("action".equals(actionNode.getNodeName()) && Node.ELEMENT_NODE == actionNode.getNodeType()) {
+ Element actionElement = (Element) actionNode;
+ Action action = Action.fromElement(actionElement);
+ actions.add(action);
+ }
+ }
+
+ // Create the instance.
+ return new Release(version, date, actions);
+
+ }
+
+ @Override
+ public String toString() {
+ return version + " @ " + date;
+ }
+
+ }
+
+ static final class Action {
+
+ final String issue;
+
+ final Type type;
+
+ final String dev;
+
+ final String dueTo;
+
+ final String description;
+
+ enum Type {ADD, FIX, UPDATE, REMOVE}
+
+ private Action(
+ final String issue,
+ final Type type,
+ final String dev,
+ final String dueTo,
+ final String description) {
+ this.issue = issue;
+ this.type = type;
+ this.dev = dev;
+ this.dueTo = dueTo;
+ this.description = description;
+ }
+
+ private static Action fromElement(final Element element) {
+
+ // Read `issue`.
+ String issue = trimNullable(element.getAttribute("issue"));
+ final String issuePattern = "^LOG4J2-[0-9]+$";
+ if (isBlank(issue)) {
+ issue = null;
+ } else if (!issue.matches(issuePattern)) {
+ throw XmlReader.failureAtXmlNode(element, "`issue` doesn't match with the `%s` pattern: `%s`", issuePattern, issue);
+ }
+
+ // Read `type`.
+ final String typeString = trimNullable(element.getAttribute("type"));
+ final Type type;
+ if (isBlank(typeString)) {
+ type = Type.UPDATE;
+ } else {
+ try {
+ type = Type.valueOf(typeString.toUpperCase(Locale.US));
+ } catch (IllegalArgumentException error) {
+ throw failureAtXmlNode(error, element, "invalid type: `%s`", typeString);
+ }
+ }
+
+ // Read `dev`.
+ final String dev = trimNullable(element.getAttribute("dev"));
+ if (isBlank(dev)) {
+ throw XmlReader.failureAtXmlNode(element, "blank attribute: `dev`");
+ }
+
+ // Read `dueTo`.
+ String dueTo = trimNullable(element.getAttribute("due-to"));
+ if (isBlank(dueTo)) {
+ dueTo = null;
+ }
+
+ // Read `description`.
+ final String description = trimNullable(element.getTextContent());
+ if (isBlank(description)) {
+ throw XmlReader.failureAtXmlNode(element, "blank `description`");
+ }
+
+ // Create the instance.
+ return new Action(issue, type, dev, dueTo, description);
+
+ }
+
+ @Override
+ public String toString() {
+ return issue != null ? issue : "unknown";
+ }
+
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporter.java
new file mode 100644
index 0000000..e57dcb0
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporter.java
@@ -0,0 +1,140 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.importer;
+
+import org.apache.logging.log4j.changelog.ChangelogEntry;
+import org.apache.logging.log4j.changelog.ChangelogFiles;
+import org.apache.logging.log4j.changelog.ChangelogRelease;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.logging.log4j.StringUtils.isBlank;
+
+public final class MavenChangesImporter {
+
+ private MavenChangesImporter() {}
+
+ public static void main(final String[] mainArgs) {
+ final MavenChangesImporterArgs args = MavenChangesImporterArgs.fromSystemProperties();
+ final MavenChanges mavenChanges = MavenChanges.readFromFile(args.changesXmlFile);
+ mavenChanges.releases.forEach(release -> {
+ if ("TBD".equals(release.date)) {
+ writeUnreleased(args.changelogDirectory, args.releaseVersionMajor, release);
+ } else {
+ writeReleased(args.changelogDirectory, release);
+ }
+ });
+ }
+
+ private static void writeUnreleased(
+ final Path changelogDirectory,
+ final int releaseVersionMajor,
+ final MavenChanges.Release release) {
+ final Path releaseDirectory = ChangelogFiles.unreleasedDirectory(changelogDirectory, releaseVersionMajor);
+ release.actions.forEach(action -> writeAction(releaseDirectory, action));
+ }
+
+ private static void writeReleased(final Path changelogDirectory, final MavenChanges.Release release) {
+
+ // Determine the directory for this particular release.
+ final Path releaseDirectory = ChangelogFiles.releaseDirectory(changelogDirectory, release.version);
+
+ // Write release information.
+ final Path releaseFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+ final ChangelogRelease changelogRelease = new ChangelogRelease(release.version, release.date);
+ changelogRelease.writeToXmlFile(releaseFile);
+
+ // Write release actions.
+ release.actions.forEach(action -> writeAction(releaseDirectory, action));
+
+ }
+
+ private static void writeAction(final Path releaseDirectory, final MavenChanges.Action action) {
+ final ChangelogEntry changelogEntry = changelogEntry(action);
+ final String changelogEntryFilename = changelogEntryFilename(action);
+ final Path changelogEntryFile = releaseDirectory.resolve(changelogEntryFilename);
+ changelogEntry.writeToXmlFile(changelogEntryFile);
+ }
+
+ private static String changelogEntryFilename(final MavenChanges.Action action) {
+ final StringBuilder actionRelativeFileBuilder = new StringBuilder();
+ if (action.issue != null) {
+ actionRelativeFileBuilder
+ .append(action.issue)
+ .append('_');
+ }
+ final String sanitizedDescription = action
+ .description
+ .substring(0, Math.min(action.description.length(), 60))
+ .replaceAll("[^A-Za-z0-9]", "_")
+ .replaceAll("_+", "_")
+ .replaceAll("[^A-Za-z0-9]$", "");
+ actionRelativeFileBuilder.append(sanitizedDescription);
+ actionRelativeFileBuilder.append(".xml");
+ return actionRelativeFileBuilder.toString();
+ }
+
+ private static ChangelogEntry changelogEntry(final MavenChanges.Action action) {
+
+ // Create the `type`.
+ final ChangelogEntry.Type type = changelogType(action.type);
+
+ // Create the `issue`s.
+ final List<ChangelogEntry.Issue> issues = new ArrayList<>(1);
+ if (action.issue != null) {
+ final String issueLink = String.format("https://issues.apache.org/jira/browse/%s", action.issue);
+ final ChangelogEntry.Issue issue = new ChangelogEntry.Issue(action.issue, issueLink);
+ issues.add(issue);
+ }
+
+ // Create the `author`s.
+ final List<ChangelogEntry.Author> authors = new ArrayList<>(2);
+ for (final String authorId : action.dev.split("\\s*,\\s*")) {
+ if (!isBlank(authorId)) {
+ authors.add(new ChangelogEntry.Author(authorId, null));
+ }
+ }
+ if (action.dueTo != null) {
+ authors.add(new ChangelogEntry.Author(null, action.dueTo));
+ }
+
+ // Create the `description`.
+ final ChangelogEntry.Description description = new ChangelogEntry.Description("asciidoc", action.description);
+
+ // Create the instance.
+ return new ChangelogEntry(type, issues, authors, description);
+
+ }
+
+ /**
+ * Maps `maven-changes-plugin` action types to their `Keep a Changelog` equivalents.
+ */
+ private static ChangelogEntry.Type changelogType(final MavenChanges.Action.Type type) {
+ if (MavenChanges.Action.Type.ADD.equals(type)) {
+ return ChangelogEntry.Type.ADDED;
+ } else if (MavenChanges.Action.Type.FIX.equals(type)) {
+ return ChangelogEntry.Type.FIXED;
+ } else if (MavenChanges.Action.Type.REMOVE.equals(type)) {
+ return ChangelogEntry.Type.REMOVED;
+ } else {
+ return ChangelogEntry.Type.CHANGED;
+ }
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporterArgs.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporterArgs.java
new file mode 100644
index 0000000..78e541d
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/importer/MavenChangesImporterArgs.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.importer;
+
+import java.nio.file.Path;
+
+import static org.apache.logging.log4j.PropertyUtils.requireNonBlankIntProperty;
+import static org.apache.logging.log4j.PropertyUtils.requireNonBlankPathProperty;
+
+final class MavenChangesImporterArgs {
+
+ final Path changelogDirectory;
+
+ final Path changesXmlFile;
+
+ final int releaseVersionMajor;
+
+ private MavenChangesImporterArgs(final Path changelogDirectory, final Path changesXmlFile, final int releaseVersionMajor) {
+ this.changelogDirectory = changelogDirectory;
+ this.changesXmlFile = changesXmlFile;
+ this.releaseVersionMajor = releaseVersionMajor;
+ }
+
+ static MavenChangesImporterArgs fromSystemProperties() {
+ final Path changelogDirectory = requireNonBlankPathProperty("log4j.changelog.directory");
+ final Path changesXmlFile = requireNonBlankPathProperty("log4j.changelog.changesXmlFile");
+ final int releaseVersion = requireNonBlankIntProperty("log4j.changelog.releaseVersionMajor");
+ return new MavenChangesImporterArgs(changelogDirectory, changesXmlFile, releaseVersion);
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaser.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaser.java
new file mode 100644
index 0000000..17745cb
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaser.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.releaser;
+
+import org.apache.logging.log4j.AsciiDocUtils;
+import org.apache.logging.log4j.FileUtils;
+import org.apache.logging.log4j.VersionUtils;
+import org.apache.logging.log4j.changelog.ChangelogFiles;
+import org.apache.logging.log4j.changelog.ChangelogRelease;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDate;
+
+import static java.time.format.DateTimeFormatter.ISO_DATE;
+import static org.apache.logging.log4j.changelog.ChangelogFiles.releaseDirectory;
+
+public final class ChangelogReleaser {
+
+ private ChangelogReleaser() {}
+
+ public static void main(final String[] mainArgs) throws Exception {
+
+ // Read arguments.
+ final ChangelogReleaserArgs args = ChangelogReleaserArgs.fromSystemProperties();
+
+ // Read the release date and version.
+ final String releaseDate = ISO_DATE.format(LocalDate.now());
+ final int releaseVersionMajor = VersionUtils.versionMajor(args.releaseVersion);
+ System.out.format("using `%s` for the release date%n", releaseDate);
+
+ // Populate the changelog entry files in the release directory.
+ final Path releaseDirectory = releaseDirectory(args.changelogDirectory, args.releaseVersion);
+ populateChangelogEntryFiles(args.changelogDirectory, releaseVersionMajor, releaseDirectory);
+
+ // Write the release information.
+ populateReleaseXmlFiles(releaseDate, args.releaseVersion, releaseDirectory);
+
+ // Write the release introduction.
+ populateReleaseIntroAsciiDocFile(releaseDirectory);
+
+ }
+
+ private static void populateChangelogEntryFiles(
+ final Path changelogDirectory,
+ int releaseVersionMajor,
+ final Path releaseDirectory)
+ throws IOException {
+ final Path unreleasedDirectory = ChangelogFiles.unreleasedDirectory(changelogDirectory, releaseVersionMajor);
+ if (Files.exists(releaseDirectory)) {
+ System.out.format(
+ "release directory `%s` already exists, only moving the changelog entry files from `%s`%n",
+ releaseDirectory, unreleasedDirectory);
+ moveUnreleasedChangelogEntryFiles(unreleasedDirectory, releaseDirectory);
+ } else {
+ System.out.format(
+ "release directory `%s` doesn't exist, simply renaming the unreleased directory `%s`%n",
+ releaseDirectory, unreleasedDirectory);
+ moveUnreleasedDirectory(unreleasedDirectory, releaseDirectory);
+ }
+ }
+
+ private static void moveUnreleasedChangelogEntryFiles(final Path unreleasedDirectory, final Path releaseDirectory) {
+ FileUtils
+ .findAdjacentFiles(unreleasedDirectory)
+ .forEach(unreleasedChangelogEntryFile -> {
+ final String fileName = unreleasedChangelogEntryFile.getFileName().toString();
+ final Path releasedChangelogEntryFile = releaseDirectory.resolve(fileName);
+ System.out.format(
+ "moving changelog entry file `%s` to `%s`%n",
+ unreleasedChangelogEntryFile, releasedChangelogEntryFile);
+ try {
+ Files.move(unreleasedChangelogEntryFile, releasedChangelogEntryFile);
+ } catch (final IOException error) {
+ final String message = String.format(
+ "failed to move `%s` to `%s`",
+ unreleasedChangelogEntryFile,
+ releasedChangelogEntryFile);
+ throw new UncheckedIOException(message, error);
+ }
+ });
+ }
+
+ private static void moveUnreleasedDirectory(
+ final Path unreleasedDirectory,
+ final Path releaseDirectory)
+ throws IOException {
+ if (!Files.exists(unreleasedDirectory)) {
+ final String message = String.format(
+ "`%s` does not exist! A release without any changelogs don't make sense!",
+ unreleasedDirectory);
+ throw new IllegalStateException(message);
+ }
+ System.out.format("moving changelog directory `%s` to `%s`%n", unreleasedDirectory, releaseDirectory);
+ Files.move(unreleasedDirectory, releaseDirectory);
+ Files.createDirectories(unreleasedDirectory);
+ }
+
+ private static void populateReleaseXmlFiles(
+ final String releaseDate,
+ final String releaseVersion,
+ final Path releaseDirectory)
+ throws IOException {
+ final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+ System.out.format("writing release information to `%s`%n", releaseXmlFile);
+ final ChangelogRelease changelogRelease = new ChangelogRelease(releaseVersion, releaseDate);
+ Files.deleteIfExists(releaseXmlFile);
+ changelogRelease.writeToXmlFile(releaseXmlFile);
+ }
+
+ private static void populateReleaseIntroAsciiDocFile(final Path releaseDirectory) throws IOException {
+ final Path introAsciiDocFile = ChangelogFiles.introAsciiDocFile(releaseDirectory);
+ if (Files.exists(introAsciiDocFile)) {
+ System.out.format("keeping the existing intro file: `%s`%n", introAsciiDocFile);
+ } else {
+ Files.write(introAsciiDocFile, AsciiDocUtils.LICENSE_COMMENT_BLOCK.getBytes(StandardCharsets.UTF_8));
+ System.out.format("created the intro file: `%s`%n", introAsciiDocFile);
+ }
+ }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaserArgs.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaserArgs.java
new file mode 100644
index 0000000..f1ea212
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/changelog/releaser/ChangelogReleaserArgs.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache license, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the license for the specific language governing permissions and
+ * limitations under the license.
+ */
+package org.apache.logging.log4j.changelog.releaser;
+
+import java.nio.file.Path;
+
+import static org.apache.logging.log4j.PropertyUtils.requireNonBlankPathProperty;
+import static org.apache.logging.log4j.PropertyUtils.requireNonBlankStringProperty;
+import static org.apache.logging.log4j.VersionUtils.requireSemanticVersioning;
+
+final class ChangelogReleaserArgs {
+
+ final Path changelogDirectory;
+
+ final String releaseVersion;
+
+ private ChangelogReleaserArgs(final Path changelogDirectory, final String releaseVersion) {
+ this.changelogDirectory = changelogDirectory;
+ this.releaseVersion = releaseVersion;
+ }
+
+ static ChangelogReleaserArgs fromSystemProperties() {
+ final Path changelogDirectory = requireNonBlankPathProperty("log4j.changelog.directory");
+ final String releaseVersionProperty = "log4j.changelog.releaseVersion";
+ final String releaseVersion = requireNonBlankStringProperty(releaseVersionProperty);
+ requireSemanticVersioning(releaseVersion, releaseVersionProperty);
+ return new ChangelogReleaserArgs(changelogDirectory, releaseVersion);
+ }
+
+}
diff --git a/pom.xml b/pom.xml
index d66a1ed..7f6b61c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,9 +57,6 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
- <!-- library versions -->
- <errorprone.version>2.16</errorprone.version>
-
<!-- plugin versions -->
<flatten-maven-plugin.version>1.3.0</flatten-maven-plugin.version>
@@ -79,16 +76,7 @@
<fork>true</fork>
<compilerArgs>
<arg>-Xlint:all</arg>
- <arg>-XDcompilePolicy=simple</arg>
- <arg>-Xplugin:ErrorProne</arg>
</compilerArgs>
- <annotationProcessorPaths>
- <path>
- <groupId>com.google.errorprone</groupId>
- <artifactId>error_prone_core</artifactId>
- <version>${errorprone.version}</version>
- </path>
- </annotationProcessorPaths>
</configuration>
</plugin>