Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

<properties>
<revision>2.4.1</revision>
<changelist>-SNAPSHOT</changelist>
<changelist>-SNAPSHOT-20260302</changelist>
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing <changelist> to -SNAPSHOT-20260302 removes Maven's snapshot semantics (Maven only treats versions ending in -SNAPSHOT as snapshots). This can break CI/release tooling and artifact publication expectations for Jenkins plugins. Keep -SNAPSHOT as the suffix (e.g., -20260302-SNAPSHOT if a date is required) or avoid encoding build dates in the version entirely.

Suggested change
<changelist>-SNAPSHOT-20260302</changelist>
<changelist>-20260302-SNAPSHOT</changelist>

Copilot uses AI. Check for mistakes.
<gitHubRepo>jenkinsci/plot-plugin</gitHubRepo>
<hpi.bundledArtifacts>commons-collections4,opencsv</hpi.bundledArtifacts>
<hpi.strictBundledArtifacts>true</hpi.strictBundledArtifacts>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ public boolean prebuild(AbstractBuild<?, ?> build, BuildListener listener) {
p.isLogarithmic(),
p.yaxisMinimum,
p.yaxisMaximum,
p.description);
p.description,
p.chartWidth,
p.chartHeight,
p.skipZeroValues,
p.useDecimalFormat);
plot.series = p.series;
plot.setProject(build.getProject());
addPlot(plot);
Expand Down
113 changes: 107 additions & 6 deletions src/main/java/hudson/plugins/plot/Plot.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.io.OutputStreamWriter;
import java.io.PrintStream;
import java.nio.charset.Charset;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
Expand Down Expand Up @@ -260,6 +261,30 @@
@SuppressWarnings("visibilitymodifier")
public String yaxisMaximum;

/**
* Custom chart width. When empty or null, DEFAULT_WIDTH (750) is used.
*/
@SuppressWarnings("visibilitymodifier")
public String chartWidth;

/**
* Custom chart height. When empty or null, DEFAULT_HEIGHT (450) is used.
*/
@SuppressWarnings("visibilitymodifier")
public String chartHeight;

/**
* Whether to skip data points with zero or NaN Y values.
*/
@SuppressWarnings("visibilitymodifier")
public boolean skipZeroValues;

/**
* Whether to use decimal format on Y-axis to avoid scientific notation.
*/
@SuppressWarnings("visibilitymodifier")
public boolean useDecimalFormat;

static class Label implements Comparable<Label> {
private final Integer buildNum;
private final String buildDate;
Expand Down Expand Up @@ -348,7 +373,11 @@
boolean logarithmic,
String yaxisMinimum,
String yaxisMaximum,
String description) {
String description,
String chartWidth,
String chartHeight,
boolean skipZeroValues,
boolean useDecimalFormat) {
this.title = title;
this.yaxis = yaxis;
this.group = group;
Expand All @@ -362,6 +391,10 @@
this.yaxisMinimum = yaxisMinimum;
this.yaxisMaximum = yaxisMaximum;
this.description = description;
this.chartWidth = chartWidth;
this.chartHeight = chartHeight;
this.skipZeroValues = skipZeroValues;
this.useDecimalFormat = useDecimalFormat;
}

/**
Expand All @@ -376,261 +409,316 @@
String csvFileName,
String style,
boolean useDescr) {
this(title, yaxis, group, numBuilds, csvFileName, style, useDescr, false, false, false, null, null, null);
this(
title,
yaxis,
group,
numBuilds,
csvFileName,
style,
useDescr,
false,
false,
false,
null,
null,
null,
null,
null,
false,
false);
}

// needed for serialization
public Plot() {}

public boolean getKeepRecords() {
return keepRecords;
}

public boolean getExclZero() {
return exclZero;
}

public boolean isLogarithmic() {
return logarithmic;
}

public boolean hasYaxisMinimum() {
return (getYaxisMinimum() != null);
}

public Double getYaxisMinimum() {
return getDoubleFromString(yaxisMinimum);
}

public boolean hasYaxisMaximum() {
return (getYaxisMaximum() != null);
}

public Double getYaxisMaximum() {
return getDoubleFromString(yaxisMaximum);
}

public String getChartWidth() {
return chartWidth;
}

public String getChartHeight() {
return chartHeight;
}

public boolean getSkipZeroValues() {
return skipZeroValues;
}

public boolean getUseDecimalFormat() {
return useDecimalFormat;
}

public int getEffectiveWidth() {
if (!StringUtils.isEmpty(chartWidth)) {
try {
int w = Integer.parseInt(chartWidth.trim());
if (w > 0) return w;
} catch (NumberFormatException ignored) {
}
}
return DEFAULT_WIDTH;
}

public int getEffectiveHeight() {
if (!StringUtils.isEmpty(chartHeight)) {
try {
int h = Integer.parseInt(chartHeight.trim());
if (h > 0) return h;
} catch (NumberFormatException ignored) {
}
}
return DEFAULT_HEIGHT;
}

public Double getDoubleFromString(String input) {
Double result = null;
if (!StringUtils.isEmpty(input)) {
try {
result = Double.parseDouble(input);
} catch (NumberFormatException nfe) {
LOGGER.log(
Level.INFO,
"Failed to parse double from '" + input + "'. Not a problem, result already set",
nfe);
}
}
return result;
}

public int compareTo(Plot o) {
if (title == null) {
return o == null || o.getTitle() == null ? 0 : -1;
}
if (o == null || o.getTitle() == null) {
return 1;
}
return title.compareTo(o.getTitle());
}

public boolean equals(Object o) {
return o instanceof Plot && this.compareTo((Plot) o) == 0;
}

@Override
public int hashCode() {
return this.title.hashCode();
}

@Override
public String toString() {
return "TITLE(" + getTitle() + "),YAXIS(" + yaxis + "),NUMSERIES("
+ CollectionUtils.size(series) + "),GROUP(" + group
+ "),NUMBUILDS(" + numBuilds + "),RIGHTBUILDNUM("
+ getRightBuildNum() + "),HASLEGEND(" + hasLegend()
+ "),ISLOGARITHMIC(" + isLogarithmic() + "),YAXISMINIMUM("
+ yaxisMinimum + "),YAXISMAXIMUM(" + yaxisMaximum
+ "),FILENAME(" + getCsvFileName() + "),DESCRIPTION("
+ getDescription() + ")";
}

public String getYaxis() {
return yaxis;
}

public List<Series> getSeries() {
return series;
}

public String getGroup() {
return group;
}

public String getCsvFileName() {
if (StringUtils.isBlank(csvFileName) && project != null) {
try {
csvFileName = File.createTempFile("plot-", ".csv", project.getRootDir())
.getName();
LOGGER.log(Level.WARNING, "Loading " + csvFileName);
} catch (IOException e) {
LOGGER.log(Level.SEVERE, "Unable to create temporary CSV file.", e);
}
}
return csvFileName;
}

/**
* Sets the title for the plot from the "title" parameter in the given
* StaplerRequest.
*/
private void setTitle(StaplerRequest2 req) {
urlTitle = req.getParameter("title");
}

private String getURLTitle() {
return urlTitle != null ? urlTitle : title;
}

public String getTitle() {
return title;
}

private void setStyle(StaplerRequest2 req) {
urlStyle = req.getParameter("style");
}

private String getUrlStyle() {
return urlStyle != null ? urlStyle : (style != null ? style : "");
}

private void setUseDescr(StaplerRequest2 req) {
String u = req.getParameter("usedescr");
if (u == null) {
urlUseDescr = null;
} else {
urlUseDescr = "on".equalsIgnoreCase(u) || "true".equalsIgnoreCase(u);
}
}

private boolean getUrlUseDescr() {
return urlUseDescr != null ? urlUseDescr : useDescr;
}

/**
* Sets the "hasLegend" parameter in the given StaplerRequest. If the
* parameter doesn't exist then a default is used.
*/
private void setHasLegend(StaplerRequest2 req) {
String legend = req.getParameter("haslegend");
hasLegend = legend == null || "on".equalsIgnoreCase(legend) || "true".equalsIgnoreCase(legend);
}

public boolean hasLegend() {
return hasLegend;
}

/**
* Sets the number of builds to plot from the "numbuilds" parameter in the
* given StaplerRequest. If the parameter doesn't exist or isn't an integer
* then a default is used.
*/
private void setNumBuilds(StaplerRequest2 req) {
urlNumBuilds = req.getParameter("numbuilds");
if (urlNumBuilds != null) {
try {
// simply try and parse the string to see if it's a valid
// number, throw away the result.
Integer.parseInt(urlNumBuilds);
} catch (NumberFormatException nfe) {
urlNumBuilds = null;
}
}
}

public String getURLNumBuilds() {
return urlNumBuilds != null ? urlNumBuilds : numBuilds;
}

public String getNumBuilds() {
return numBuilds;
}

/**
* Sets the description of the plot from the "description" parameter in the
* given StaplerRequest. If the parameter doesn't exist or isn't an string
* then a default is used.
*/
private void setDescription(StaplerRequest2 req) {
description = req.getParameter("description");
}

public String getDescription() {
return description;
}

/**
* Sets the right-most build number shown on the plot from the
* "rightbuildnum" parameter in the given StaplerRequest. If the parameter
* doesn't exist or isn't an integer then a default is used.
*/
private void setRightBuildNum(StaplerRequest2 req) {
String build = req.getParameter("rightbuildnum");
if (StringUtils.isBlank(build)) {
rightBuildNum = Integer.MAX_VALUE;
} else {
try {
rightBuildNum = Integer.parseInt(build);
} catch (NumberFormatException nfe) {
LOGGER.log(Level.SEVERE, "Exception converting to integer", nfe);
rightBuildNum = Integer.MAX_VALUE;
}
}
}

private int getRightBuildNum() {
return rightBuildNum;
}

/**
* Sets the plot width from the "width" parameter in the given
* StaplerRequest. If the parameter doesn't exist or isn't an integer then a
* default is used.
*/
private void setWidth(StaplerRequest2 req) {
String w = req.getParameter("width");
if (w == null) {
width = DEFAULT_WIDTH;
width = getEffectiveWidth();
} else {
try {
width = Integer.parseInt(w);
} catch (NumberFormatException nfe) {
LOGGER.log(Level.SEVERE, "Exception converting to integer", nfe);
width = DEFAULT_WIDTH;
width = getEffectiveWidth();
}
}
}

private int getWidth() {
return width;
}

/**
* Sets the plot height from the "height" parameter in the given
* StaplerRequest. If the parameter doesn't exist or isn't an integer then a
* default is used.
*/
private void setHeight(StaplerRequest2 req) {
String h = req.getParameter("height");
if (h == null) {
height = DEFAULT_HEIGHT;
height = getEffectiveHeight();
} else {
try {
height = Integer.parseInt(h);
} catch (NumberFormatException nfe) {
LOGGER.log(Level.SEVERE, "Exception converting to integer", nfe);
height = DEFAULT_HEIGHT;
height = getEffectiveHeight();

Check warning on line 721 in src/main/java/hudson/plugins/plot/Plot.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 412-721 are not covered by tests
}
}
}
Expand Down Expand Up @@ -753,127 +841,140 @@
continue;
}

if (skipZeroValues) {

Check warning on line 844 in src/main/java/hudson/plugins/plot/Plot.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 844 is only partially covered, one branch is missing
try {
double y = Double.parseDouble(point.getYvalue());
if (y == 0.0 || Double.isNaN(y)) continue;
} catch (NumberFormatException ignored) {
continue;
}
}

Comment on lines +844 to +852
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior is introduced here to drop points when skipZeroValues is enabled (including silently skipping non-numeric Y values). There are existing unit/integration tests in PlotTest for plot data retention, but none covering this new filtering logic. Add test coverage to confirm that zero/NaN points are excluded (and that normal non-zero points still appear).

Copilot uses AI. Check for mistakes.
rawPlotData.add(new String[] {
point.getYvalue(),
point.getLabel(),
run.getNumber() + "", // convert to a string
run.getTimestamp().getTimeInMillis() + "",
point.getUrl()
});
}
}
}

// save the updated plot data to disk
savePlotData();
}

/**
* Generates the plot and stores it in the plot instance variable.
*
* @param forceGenerate if true, force the plot to be re-generated even if the on-disk
* data hasn't changed
*/
private void generatePlot(boolean forceGenerate) {
// LOGGER.info("Determining if we should generate plot " +
// getCsvFileName());
File csvFile = new File(project.getRootDir(), getCsvFileName());
if (csvFile.lastModified() == csvLastModification && plot != null && !forceGenerate) {
// data hasn't changed so don't regenerate the plot
return;
}
if (rawPlotData == null || csvFile.lastModified() > csvLastModification) {
// data has changed or has not been loaded so load it now
loadPlotData();
}
// LOGGER.info("Generating plot " + getCsvFileName());
csvLastModification = csvFile.lastModified();
PlotCategoryDataset dataset = new PlotCategoryDataset();
for (String[] record : rawPlotData) {
// record: series y-value, series label, build number, build date,
// url
int buildNum;
try {
buildNum = Integer.parseInt(record[2]);
if (!reportBuild(buildNum) || buildNum > getRightBuildNum()) {
continue; // skip this record
}
} catch (NumberFormatException nfe) {
LOGGER.log(Level.SEVERE, "Exception converting to integer", nfe);
continue; // skip this record all together
}
Number value;
try {
value = Integer.parseInt(record[0]);
} catch (NumberFormatException nfe) {
try {
value = Double.parseDouble(record[0]);
} catch (NumberFormatException nfe2) {
LOGGER.log(Level.SEVERE, "Exception converting to number", nfe2);
continue; // skip this record all together
}
}
Label columnXLabel = getUrlUseDescr()
? new Label(record[2], record[3], descriptionForBuild(buildNum))
: new Label(record[2], record[3]);
String url = null;
if (record.length >= 5) {
url = record[4];
}
String rowSeries = record[1];
dataset.setValue(value, url, rowSeries, columnXLabel);
}

String builds = getURLNumBuilds();
int buildsNumber;
if (StringUtils.isBlank(builds)) {
buildsNumber = Integer.MAX_VALUE;
} else {
try {
buildsNumber = Integer.parseInt(builds);
} catch (NumberFormatException nfe) {
LOGGER.log(Level.SEVERE, "Exception converting to integer", nfe);
buildsNumber = Integer.MAX_VALUE;
}
}

dataset.clipDataset(buildsNumber);
plot = createChart(dataset);
CategoryPlot categoryPlot = (CategoryPlot) plot.getPlot();
categoryPlot.setDomainGridlinePaint(Color.black);
categoryPlot.setRangeGridlinePaint(Color.black);
categoryPlot.setDrawingSupplier(Plot.SUPPLIER);
CategoryAxis domainAxis = new ShiftedCategoryAxis(Messages.Plot_Build());
categoryPlot.setDomainAxis(domainAxis);
domainAxis.setCategoryLabelPositions(CategoryLabelPositions.UP_90);
domainAxis.setLowerMargin(0.0);
domainAxis.setUpperMargin(0.03);
domainAxis.setCategoryMargin(0.0);
for (Object category : dataset.getColumnKeys()) {
Label label = (Label) category;
if (label.text != null) {
domainAxis.addCategoryLabelToolTip(label, label.numDateString());
} else {
domainAxis.addCategoryLabelToolTip(label, descriptionForBuild(label.buildNum));
}
}
// Replace the range axis by a logarithmic axis if the option is
// selected
if (isLogarithmic()) {
LogarithmicAxis logAxis = new LogarithmicAxis(getYaxis());
categoryPlot.setRangeAxis(logAxis);
}

// optionally exclude zero as default y-axis value
ValueAxis rangeAxis = categoryPlot.getRangeAxis();
if ((rangeAxis != null) && (rangeAxis instanceof NumberAxis)) {
if (hasYaxisMinimum()) {
rangeAxis.setLowerBound(getYaxisMinimum());
}
if (hasYaxisMaximum()) {
rangeAxis.setUpperBound(getYaxisMaximum());
}
((NumberAxis) rangeAxis).setAutoRangeIncludesZero(!getExclZero());

if (useDecimalFormat) {
((NumberAxis) rangeAxis).setNumberFormatOverride(new DecimalFormat("#.##"));

Check warning on line 976 in src/main/java/hudson/plugins/plot/Plot.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 846-976 are not covered by tests
}
}
Comment on lines +975 to 978
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useDecimalFormat changes the Y-axis label formatting via NumberFormatOverride, but there is no test coverage ensuring this flag is honored (and that the default scientific-notation behavior remains when disabled). Since the repo already has PlotTest, please add a test exercising this option (e.g., generate a chart with large values and assert formatted labels/axis formatter behavior).

Copilot uses AI. Check for mistakes.

AbstractCategoryItemRenderer renderer = (AbstractCategoryItemRenderer) categoryPlot.getRenderer();
Expand Down
53 changes: 52 additions & 1 deletion src/main/java/hudson/plugins/plot/PlotBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
private boolean logarithmic;
private boolean keepRecords;

@CheckForNull
private String chartWidth;

@CheckForNull
private String chartHeight;

private boolean skipZeroValues;
private boolean useDecimalFormat;

// Generated?
@SuppressWarnings("visibilitymodifier")
public String csvFileName;
Expand Down Expand Up @@ -194,6 +203,44 @@
this.description = Util.fixEmptyAndTrim(description);
}

@CheckForNull
public String getChartWidth() {
return chartWidth;
}

@DataBoundSetter
public final void setChartWidth(@CheckForNull String chartWidth) {
this.chartWidth = Util.fixEmptyAndTrim(chartWidth);
}

Check warning on line 214 in src/main/java/hudson/plugins/plot/PlotBuilder.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 213-214 are not covered by tests

@CheckForNull
public String getChartHeight() {
return chartHeight;
}

@DataBoundSetter
public final void setChartHeight(@CheckForNull String chartHeight) {
this.chartHeight = Util.fixEmptyAndTrim(chartHeight);
}

Check warning on line 224 in src/main/java/hudson/plugins/plot/PlotBuilder.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 223-224 are not covered by tests

public boolean getSkipZeroValues() {
return skipZeroValues;
}

@DataBoundSetter
public void setSkipZeroValues(boolean skipZeroValues) {
this.skipZeroValues = skipZeroValues;
}

Check warning on line 233 in src/main/java/hudson/plugins/plot/PlotBuilder.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 232-233 are not covered by tests

public boolean getUseDecimalFormat() {
return useDecimalFormat;
}

@DataBoundSetter
public void setUseDecimalFormat(boolean useDecimalFormat) {
this.useDecimalFormat = useDecimalFormat;
}

Check warning on line 242 in src/main/java/hudson/plugins/plot/PlotBuilder.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 241-242 are not covered by tests

public List<CSVSeries> getCsvSeries() {
return csvSeries;
}
Expand Down Expand Up @@ -241,7 +288,11 @@
logarithmic,
yaxisMinimum,
yaxisMaximum,
description);
description,
chartWidth,
chartHeight,
skipZeroValues,
useDecimalFormat);

List<Series> series = new ArrayList<>();
if (csvSeries != null) {
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/hudson/plugins/plot/PlotReport.java
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@
return plot.getDescription();
}

// called from PlotReport/index.jelly
public int getPlotWidth(int i) {
return getPlot(i).getEffectiveWidth();
}

// called from PlotReport/index.jelly
public int getPlotHeight(int i) {
return getPlot(i).getEffectiveHeight();

Check warning on line 107 in src/main/java/hudson/plugins/plot/PlotReport.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 102-107 are not covered by tests
}

// called from PlotReport/index.jelly
public void doGetPlot(StaplerRequest2 req, StaplerResponse2 rsp) {
String i = req.getParameter("index");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@
<f:entry title="${%Keep records for deleted builds}" help="/plugin/plot/help-keepRecords.html">
<f:checkbox name="keepRecords" checked="${plot.keepRecords}" />
</f:entry>
<f:entry title="${%Chart width}" help="/plugin/plot/help-chartWidth.html">
<f:textbox name="chartWidth" value="${plot.chartWidth}" />
</f:entry>
<f:entry title="${%Chart height}" help="/plugin/plot/help-chartHeight.html">
<f:textbox name="chartHeight" value="${plot.chartHeight}" />
</f:entry>
<f:entry title="${%Skip zero and NaN values}" help="/plugin/plot/help-skipZeroValues.html">
<f:checkbox name="skipZeroValues" checked="${plot.skipZeroValues}" />
</f:entry>
<f:entry title="${%Use decimal format on Y-axis}" help="/plugin/plot/help-useDecimalFormat.html">
<f:checkbox name="useDecimalFormat" checked="${plot.useDecimalFormat}" />
</f:entry>

<f:entry title="" description="${%A new data series definition}">
<f:repeatable var="series" items="${plot.series}" minimum="1">
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/hudson/plugins/plot/PlotBuilder/config.jelly
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@
<f:entry title="${%Y-axis maximum}" field="yaxisMaximum" help="/plugin/plot/help-yaxisMaximum.html">
<f:textbox name="yaxisMaximum" value="${plot.yaxisMaximum}"/>
</f:entry>
<f:entry title="${%Chart width}" field="chartWidth" help="/plugin/plot/help-chartWidth.html">
<f:textbox name="chartWidth" value="${plot.chartWidth}"/>
</f:entry>
<f:entry title="${%Chart height}" field="chartHeight" help="/plugin/plot/help-chartHeight.html">
<f:textbox name="chartHeight" value="${plot.chartHeight}"/>
</f:entry>
<f:entry title="${%Skip zero and NaN values}" field="skipZeroValues" help="/plugin/plot/help-skipZeroValues.html">
<f:checkbox name="skipZeroValues" checked="${plot.skipZeroValues}"/>
</f:entry>
<f:entry title="${%Use decimal format on Y-axis}" field="useDecimalFormat" help="/plugin/plot/help-useDecimalFormat.html">
<f:checkbox name="useDecimalFormat" checked="${plot.useDecimalFormat}"/>
</f:entry>
<f:entry title="" description="${%A new data series definition}">
<f:repeatableProperty field="csvSeries" add="Add CSV series"/>
</f:entry>
Expand Down
Loading
Loading