diff --git a/pom.xml b/pom.xml index ac8e8342..1bae3b25 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ 8 - 1.2.1 + 1.3.0 -SNAPSHOT diff --git a/src/main/java/io/jenkins/plugins/checks/api/ChecksOutput.java b/src/main/java/io/jenkins/plugins/checks/api/ChecksOutput.java index addd96bf..4d11209a 100644 --- a/src/main/java/io/jenkins/plugins/checks/api/ChecksOutput.java +++ b/src/main/java/io/jenkins/plugins/checks/api/ChecksOutput.java @@ -5,7 +5,7 @@ import java.util.List; import java.util.Optional; -import static java.util.Objects.*; +import static java.util.Objects.requireNonNull; /** * An output of a check. The output usually contains the most useful information like summary, description, @@ -13,12 +13,12 @@ */ public class ChecksOutput { private final String title; - private final String summary; - private final String text; + private final TruncatedString summary; + private final TruncatedString text; private final List annotations; private final List images; - private ChecksOutput(final String title, final String summary, final String text, + private ChecksOutput(final String title, final TruncatedString summary, final TruncatedString text, final List annotations, final List images) { this.title = title; this.summary = summary; @@ -34,7 +34,9 @@ private ChecksOutput(final String title, final String summary, final String text * the source to copy from */ public ChecksOutput(final ChecksOutput that) { - this(that.getTitle().orElse(null), that.getSummary().orElse(null), that.getText().orElse(null), + this(that.getTitle().orElse(null), + that.getSummary().map(TruncatedString::fromString).orElse(null), + that.getText().map(TruncatedString::fromString).orElse(null), that.getChecksAnnotations(), that.getChecksImages()); } @@ -43,11 +45,31 @@ public Optional getTitle() { } public Optional getSummary() { - return Optional.ofNullable(summary); + return Optional.ofNullable(summary).map(TruncatedString::toString); + } + + /** + * Get the output summary, truncated by {@link TruncatedString} to maxSize. + * + * @param maxSize maximum size to truncate summary to. + * @return Summary, truncated to maxSize with truncation message if appropriate. + */ + public Optional getSummary(final int maxSize) { + return Optional.ofNullable(summary).map(s -> s.build(maxSize)); } public Optional getText() { - return Optional.ofNullable(text); + return Optional.ofNullable(text).map(TruncatedString::toString); + } + + /** + * Get the output text, truncated by {@link TruncatedString} to maxSize. + * + * @param maxSize maximum size to truncate text to. + * @return Text, truncated to maxSize with truncation message if appropriate. + */ + public Optional getText(final int maxSize) { + return Optional.ofNullable(text).map(s -> s.build(maxSize)); } public List getChecksAnnotations() { @@ -74,8 +96,8 @@ public String toString() { */ public static class ChecksOutputBuilder { private String title; - private String summary; - private String text; + private TruncatedString summary; + private TruncatedString text; private List annotations; private List images; @@ -114,6 +136,22 @@ public ChecksOutputBuilder withTitle(final String title) { */ @SuppressWarnings("HiddenField") // builder pattern public ChecksOutputBuilder withSummary(final String summary) { + return withSummary(TruncatedString.fromString(summary)); + } + + /** + * Sets the summary of the check run, using a {@link TruncatedString}. + * + *

+ * Note that for the GitHub check runs, the {@code summary} supports Markdown. + *

+ * + * @param summary + * the summary of the check run as a {@link TruncatedString} + * @return this builder + */ + @SuppressWarnings("HiddenField") + public ChecksOutputBuilder withSummary(final TruncatedString summary) { this.summary = requireNonNull(summary); return this; } @@ -131,6 +169,22 @@ public ChecksOutputBuilder withSummary(final String summary) { */ @SuppressWarnings("HiddenField") // builder pattern public ChecksOutputBuilder withText(final String text) { + return withText(TruncatedString.fromString(text)); + } + + /** + * Adds the details description for a check run, using a {@link TruncatedString}. This parameter supports Markdown. + * + *

+ * Note that for a GitHub check run, the {@code text} supports Markdown. + *

+ * + * @param text + * the details description in Markdown as a {@link TruncatedString} + * @return this builder + */ + @SuppressWarnings("HiddenField") + public ChecksOutputBuilder withText(final TruncatedString text) { this.text = requireNonNull(text); return this; } diff --git a/src/main/java/io/jenkins/plugins/checks/api/TruncatedString.java b/src/main/java/io/jenkins/plugins/checks/api/TruncatedString.java new file mode 100644 index 00000000..d78be4eb --- /dev/null +++ b/src/main/java/io/jenkins/plugins/checks/api/TruncatedString.java @@ -0,0 +1,219 @@ +package io.jenkins.plugins.checks.api; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; + +/** + * Utility wrapper that silently truncates output with a message at a certain size. + *

+ * The GitHub Checks API has a size limit on text fields. Because it also accepts markdown, it is not trivial to + * truncate to the required length as this could lead to unterminated syntax. The use of this class allows for adding + * chunks of complete markdown until an overflow is detected, at which point a message will be added and all future + * additions will be silently discarded. + */ +public class TruncatedString { + + @NonNull + private final List chunks; + @NonNull + private final String truncationText; + private final boolean truncateStart; + private final boolean chunkOnNewlines; + + + private TruncatedString(@NonNull final List chunks, @NonNull final String truncationText, final boolean truncateStart, final boolean chunkOnNewlines) { + this.chunks = Collections.unmodifiableList(Objects.requireNonNull(chunks)); + this.truncationText = Objects.requireNonNull(truncationText); + this.truncateStart = truncateStart; + this.chunkOnNewlines = chunkOnNewlines; + } + + /** + * Wrap the provided string as a {@link TruncatedString}. + * + * @param string String to wrap as a {@link TruncatedString} + * @return a {@link TruncatedString} wrapping the provided input + */ + static TruncatedString fromString(final String string) { + return new Builder().setChunkOnNewlines().addText(string).build(); + } + + /** + * Builds the string without truncation. + * + * @return A string comprising the joined chunks. + */ + @Override + public String toString() { + return String.join("", chunks); + } + + private List getChunks() { + if (chunkOnNewlines) { + return Arrays.asList(String.join("", chunks).split("(?<=\r?\n)")); + } + return new ArrayList<>(chunks); + } + + /** + * Builds the string such that it does not exceed maxSize, including the truncation string. + * + * @param maxSize the maximum size of the resultant string. + * @return A string comprising as many of the joined chunks that will fit in the given size, plus the truncation + * string if truncation was necessary. + */ + public String build(final int maxSize) { + List parts = getChunks(); + if (truncateStart) { + Collections.reverse(parts); + } + List truncatedParts = parts.stream().collect(new Joiner(truncationText, maxSize)); + if (truncateStart) { + Collections.reverse(truncatedParts); + } + return String.join("", truncatedParts); + } + + + /** + * Builder for {@link TruncatedString}. + */ + public static class Builder { + private String truncationText = "Output truncated."; + private boolean truncateStart = false; + private boolean chunkOnNewlines = false; + private final List chunks = new ArrayList<>(); + + /** + * Builds the {@link TruncatedString}. + * + * @return the build {@link TruncatedString}. + */ + public TruncatedString build() { + return new TruncatedString(chunks, truncationText, truncateStart, chunkOnNewlines); + } + + /** + * Adds a chunk of text to the builder. + * + * @param text the chunk of text to append to this builder + * @return this builder + */ + public Builder addText(@NonNull final String text) { + this.chunks.add(Objects.requireNonNull(text)); + return this; + } + + /** + * Sets the truncation text. + * + * @param truncationText the text to append on overflow + * @return this builder + */ + @SuppressWarnings("HiddenField") + public Builder withTruncationText(@NonNull final String truncationText) { + this.truncationText = Objects.requireNonNull(truncationText); + return this; + } + + /** + * Sets truncator to remove excess text from the start, rather than the end. + * + * @return this builder + */ + public Builder setTruncateStart() { + this.truncateStart = true; + return this; + } + + /** + * Sets truncator to chunk on newlines rather than the chunks. + * + * @return this builder + */ + public Builder setChunkOnNewlines() { + this.chunkOnNewlines = true; + return this; + } + + } + + private static class Joiner implements Collector> { + + private final int maxLength; + private final String truncationText; + + Joiner(final String truncationText, final int maxLength) { + if (maxLength < truncationText.length()) { + throw new IllegalArgumentException("Maximum length is less than truncation text."); + } + this.truncationText = truncationText; + this.maxLength = maxLength; + } + + @Override + public Supplier supplier() { + return Accumulator::new; + } + + @Override + public BiConsumer accumulator() { + return Accumulator::add; + } + + @Override + public BinaryOperator combiner() { + return Accumulator::combine; + } + + @Override + public Function> finisher() { + return Accumulator::truncate; + } + + @Override + public Set characteristics() { + return Collections.emptySet(); + } + + private class Accumulator { + private final List chunks = new ArrayList<>(); + private int length = 0; + private boolean truncated = false; + + Accumulator combine(final Accumulator other) { + other.chunks.forEach(this::add); + return this; + } + + void add(final String chunk) { + if (truncated) { + return; + } + if (length + chunk.length() > maxLength) { + truncated = true; + return; + } + chunks.add(chunk); + length += chunk.length(); + } + + List truncate() { + if (truncated) { + if (length + truncationText.length() > maxLength) { + chunks.remove(chunks.size() - 1); + } + chunks.add(truncationText); + } + return chunks; + } + } + } + +} diff --git a/src/test/java/io/jenkins/plugins/checks/ArchitectureTest.java b/src/test/java/io/jenkins/plugins/checks/ArchitectureTest.java index 6c292969..e05482eb 100644 --- a/src/test/java/io/jenkins/plugins/checks/ArchitectureTest.java +++ b/src/test/java/io/jenkins/plugins/checks/ArchitectureTest.java @@ -3,8 +3,10 @@ import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.syntax.elements.ClassesShouldConjunction; import edu.hm.hafner.util.ArchitectureRules; import io.jenkins.plugins.util.PluginArchitectureRules; +import org.junit.runner.RunWith; /** * Defines several architecture rules for the static analysis model and parsers. @@ -18,7 +20,8 @@ class ArchitectureTest { static final ArchRule NO_JENKINS_INSTANCE_CALL = PluginArchitectureRules.NO_JENKINS_INSTANCE_CALL; @ArchTest - static final ArchRule NO_PUBLIC_TEST_CLASSES = PluginArchitectureRules.NO_PUBLIC_TEST_CLASSES; + static final ArchRule NO_PUBLIC_TEST_CLASSES = ((ClassesShouldConjunction) PluginArchitectureRules.NO_PUBLIC_TEST_CLASSES) + .andShould().notBeAnnotatedWith(RunWith.class); // Allow for JUnit4 tests. @ArchTest static final ArchRule NO_TEST_API_CALLED = ArchitectureRules.NO_TEST_API_CALLED; diff --git a/src/test/java/io/jenkins/plugins/checks/api/TruncatedStringTest.java b/src/test/java/io/jenkins/plugins/checks/api/TruncatedStringTest.java new file mode 100644 index 00000000..a1ef9f0a --- /dev/null +++ b/src/test/java/io/jenkins/plugins/checks/api/TruncatedStringTest.java @@ -0,0 +1,122 @@ +package io.jenkins.plugins.checks.api; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test behavior of the {@link TruncatedString}. + */ +@SuppressWarnings({"VisibilityModifier", "MissingJavadocMethod"}) +@RunWith(Parameterized.class) +public class TruncatedStringTest { + private static final String MESSAGE = "Truncated"; // length 9 + + /** + * Human readable test name. + */ + @Parameterized.Parameter + public String testName; + + /** + * Parameter for chunking on new lines (or not!). + */ + @Parameterized.Parameter(1) + public boolean chunkOnNewlines; + + @Parameterized.Parameters(name = "{0}") + public static Object[][] parameters() { + return new Object[][]{ + {"Chunks", false}, + {"Newlines", true} + }; + } + + private TruncatedString.Builder getBuilder() { + TruncatedString.Builder builder = new TruncatedString.Builder() + .withTruncationText(MESSAGE); + if (chunkOnNewlines) { + return builder.setChunkOnNewlines(); + } + return builder; + } + + @Test + public void shouldBuildStrings() { + TruncatedString.Builder builder = getBuilder(); + builder.addText("Hello\n"); + assertThat(builder.build()).asString().isEqualTo("Hello\n"); + assertThat(builder.build().build(1000)).isEqualTo("Hello\n"); + builder.addText(", world!"); + assertThat(builder.build()).asString().isEqualTo("Hello\n, world!"); + assertThat(builder.build().build(1000)).isEqualTo("Hello\n, world!"); + } + + @Test + public void shouldTruncateStrings() { + TruncatedString.Builder builder = getBuilder(); + builder.addText("xxxxxxxxx\n"); // 10 + assertThat(builder.build().build(20)).isEqualTo("xxxxxxxxx\n"); + builder.addText("yyyy\n"); // 5, doesn't cause overflow + assertThat(builder.build().build(20)).isEqualTo("xxxxxxxxx\nyyyy\n"); + builder.addText("zzzzzz\n"); // 7, does cause overflow + assertThat(builder.build().build(20)).isEqualTo("xxxxxxxxx\nTruncated"); + } + + @Test + public void shouldHandleEdgeCases() { + TruncatedString.Builder builder = getBuilder(); + assertThat(builder.build().build(10)).isEqualTo(""); + assertThat(builder.build()).asString().isEqualTo(""); + builder.addText("xxxxxxxxxxxxxx\n"); // 15 + assertThat(builder.build().build(10)).isEqualTo("Truncated"); + assertThatThrownBy(() -> { + builder.build().build(5); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Maximum length is less than truncation text."); + } + + @Test + public void shouldHandleReversedChunking() { + TruncatedString.Builder builder = getBuilder() + .setTruncateStart(); + builder.addText("zzzz\n"); // 5 + assertThat(builder.build().build(20)).isEqualTo("zzzz\n"); + builder.addText("xxxx\n"); // 5, doesn't cause overflow + assertThat(builder.build().build(20)).isEqualTo("zzzz\nxxxx\n"); + builder.addText("cccc\n"); // 5, doesn't cause overflow + assertThat(builder.build().build(20)).isEqualTo("zzzz\nxxxx\ncccc\n"); + builder.addText("aaaaaa\n"); // 7, does cause overflow + assertThat(builder.build().build(20)).isEqualTo("Truncatedcccc\naaaaaa\n"); + } + + @Test + public void shouldHandleEdgeCasesReversed() { + TruncatedString.Builder builder = getBuilder() + .setTruncateStart(); + assertThat(builder.build().build(10)).isEqualTo(""); + assertThat(builder.build()).asString().isEqualTo(""); + builder.addText("xxxxxxxxxxxxxx\n"); // 15 + assertThat(builder.build().build(10)).isEqualTo("Truncated"); + assertThatThrownBy(() -> { + builder.build().build(5); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Maximum length is less than truncation text."); + } + + @Test + public void shouldChunkNewlinesDifferently() { + TruncatedString.Builder builder = getBuilder(); + builder.addText("xxxxxxxxxx"); // 10 + builder.addText("yyyyyyyyyyy"); // 11 + assertThat(builder.build().build(20)).isEqualTo(chunkOnNewlines ? "Truncated" : "xxxxxxxxxxTruncated"); + + builder = getBuilder(); + builder.addText("wwww\n"); // 5 + builder.addText("xxxx\nyyyy\nzzzzz\n"); // 16 + assertThat(builder.build().build(20)).isEqualTo(chunkOnNewlines ? "wwww\nxxxx\nTruncated" : "wwww\nTruncated"); + } +}