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
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@
* <li>total number of commits</li>
* <li>time of last commit</li>
* <li>time of first commit</li>
* <li>maximum temporal coupling</li>
* </ul>
*
* @author Ullrich Hafner
*/
public class ForensicsTableModel extends TableModel {
private static final int COUPLING_RESPONSIVE_PRIORITY = 20_000;
private static final double NO_COUPLING = 0.0;

private final RepositoryStatistics statistics;

ForensicsTableModel(final RepositoryStatistics statistics) {
Expand All @@ -43,17 +47,17 @@
@Override
public String getId() {
return ForensicsJobAction.FORENSICS_ID;
}

@Override
public List<TableColumn> getColumns() {
List<TableColumn> columns = new ArrayList<>();

var builder = new ColumnBuilder();

columns.add(builder.withHeaderLabel(Messages.Table_Column_File())
.withDetailedCell()
.withDataPropertyKey("fileName")

Check warning on line 60 in src/main/java/io/jenkins/plugins/forensics/miner/ForensicsTableModel.java

View check run for this annotation

ci.jenkins.io / CPD

CPD

LOW: Found duplicated code.
Raw output
<pre><code>} &#64;Override public List&lt;TableColumn&gt; getColumns() { List&lt;TableColumn&gt; columns &#61; new ArrayList&lt;&gt;(); var builder &#61; new ColumnBuilder(); columns.add(builder.withHeaderLabel(Messages.Table_Column_File()) .withDetailedCell() .withDataPropertyKey(&#34;fileName&#34;)</code></pre>
.withHeaderClass(ColumnCss.NONE)
.build());
columns.add(builder.withHeaderLabel(Messages.Table_Column_AuthorsSize())
Expand Down Expand Up @@ -81,23 +85,42 @@
.withDataPropertyKey("churn")
.withType(ColumnType.NUMBER)
.build());
columns.add(builder.withHeaderLabel(Messages.Table_Column_MaxCoupling())
.withDataPropertyKey("maxCoupling")
.withType(ColumnType.NUMBER)
.withResponsivePriority(COUPLING_RESPONSIVE_PRIORITY)
.build());

return columns;
}

@Override
public List<Object> getRows() {
return statistics.getFileStatistics().stream().map(ForensicsRow::new).collect(Collectors.toList());
var couplings = statistics.getTemporalCouplings();

return statistics.getFileStatistics().stream()
.map(file -> new ForensicsRow(file, findMaxCoupling(couplings, file.getFileName())))
.collect(Collectors.toList());
}

private double findMaxCoupling(final List<TemporalCoupling> couplings, final String fileName) {
return couplings.stream()
.filter(coupling -> coupling.contains(fileName))
.mapToDouble(TemporalCoupling::getCouplingPercentage)
.max()
.orElse(NO_COUPLING);
}

/**
* A table row that shows the source control statistics.
*/
public static class ForensicsRow {
private final FileStatistics fileStatistics;
private final double maxCoupling;

ForensicsRow(final FileStatistics fileStatistics) {
ForensicsRow(final FileStatistics fileStatistics, final double maxCoupling) {
this.fileStatistics = fileStatistics;
this.maxCoupling = maxCoupling;
}

/**
Expand Down Expand Up @@ -140,5 +163,15 @@
public int getChurn() {
return fileStatistics.getAbsoluteChurn();
}

/**
* Returns the strongest temporal coupling of this file with any other repository file, given as percentage in
* the interval {@code [0.0, 100.0]}.
*
* @return the maximum coupling in percent, or {@code 0} if this file is not coupled with another file
*/
public double getMaxCoupling() {
return maxCoupling;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
* @author Ullrich Hafner
*/
public class ForensicsViewModel extends DefaultAsyncTableContentProvider implements ModelObject {
private static final String TEMPORAL_COUPLING_URL = "temporalCoupling";

private final Run<?, ?> owner;
private final RepositoryStatistics repositoryStatistics;
private final String scmKey;
Expand Down Expand Up @@ -85,6 +87,27 @@
FileStatistics::getNumberOfCommits, 5, 10, 25, 50, 100, 250));
}

/**
* Returns whether the mined statistics contain temporal couplings, i.e. whether the temporal coupling view should
* be shown.
*
* @return {@code true} if there are temporal couplings, {@code false} otherwise
*/
@SuppressWarnings("unused") // Called by jelly view
public boolean hasTemporalCouplings() {
return !repositoryStatistics.getTemporalCouplings().isEmpty();
}

/**
* Returns the relative URL of the temporal coupling view.
*
* @return the URL of the temporal coupling view
*/
@SuppressWarnings("unused") // Called by jelly view
public String getTemporalCouplingUrl() {
return TEMPORAL_COUPLING_URL;

Check warning on line 108 in src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 98-108 are not covered by tests
}

/**
* Returns a new subpage for the selected link.
*
Expand All @@ -99,6 +122,10 @@
*/
@SuppressWarnings("unused") //called by jelly view
public Object getDynamic(final String link, final StaplerRequest2 request, final StaplerResponse2 response) {
if (TEMPORAL_COUPLING_URL.equals(link)) {

Check warning on line 125 in src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 125 is only partially covered, one branch is missing
return new TemporalCouplingViewModel(owner, repositoryStatistics);

Check warning on line 126 in src/main/java/io/jenkins/plugins/forensics/miner/ForensicsViewModel.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 126 is not covered by tests
}

try {
CommitDecorator decorator = CommitDecoratorFactory.findCommitDecorator(owner, scmKey);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
*/
public class RepositoryStatistics implements Serializable {
@Serial
private static final long serialVersionUID = 8L; // release 0.8.0
private static final long serialVersionUID = 9L; // release 1.9.0

@CheckForNull
@SuppressWarnings("PMD.LooseCoupling")
Expand All @@ -43,6 +43,9 @@ public class RepositoryStatistics implements Serializable {
private int totalLinesOfCode;
private int totalChurn;

@SuppressWarnings("PMD.LooseCoupling")
private ArrayList<TemporalCoupling> temporalCouplings = new ArrayList<>(); // since 1.9.0

/**
* Creates an empty instance of {@link RepositoryStatistics} with no latest commit ID set.
*/
Expand Down Expand Up @@ -79,6 +82,9 @@ protected Object readResolve() {
statisticsMapping = statisticsPerFile;
statisticsPerFile = null; // set to null to remove the field from serialization
}
if (temporalCouplings == null) { // before 1.9.0: no couplings have been mined
temporalCouplings = new ArrayList<>();
}

return this;
}
Expand Down Expand Up @@ -274,6 +280,26 @@ public CommitStatistics getLatestStatistics() {
return statistics;
}

/**
* Returns the temporal couplings of all repository files, i.e. the pairs of files that have been changed together
* in the same commit.
*
* @return the temporal couplings, or an empty list if the SCM does not provide this information
*/
public List<TemporalCoupling> getTemporalCouplings() {
return Collections.unmodifiableList(temporalCouplings);
}

/**
* Sets the temporal couplings of all repository files.
*
* @param couplings
* the temporal couplings to store
*/
public void setTemporalCouplings(final List<TemporalCoupling> couplings) {
temporalCouplings = new ArrayList<>(couplings);
}

@Override
public boolean equals(final Object o) {
if (this == o) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ protected void configureXStream(final XStream2 xStream) {
xStream.alias("diff", CommitDiffItem.class);
xStream.alias("repo", RepositoryStatistics.class);
xStream.alias("file", FileStatistics.class);
xStream.alias("coupling", TemporalCoupling.class);
}
}
145 changes: 145 additions & 0 deletions src/main/java/io/jenkins/plugins/forensics/miner/TemporalCoupling.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package io.jenkins.plugins.forensics.miner;

import edu.hm.hafner.util.Generated;

import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;
import java.util.StringJoiner;

/**
* Stores the temporal coupling of a pair of repository files. Two files are temporally coupled if they are frequently
* modified together within the same commit: such a coupling reveals a hidden dependency between these files that is
* not visible in the source code itself. See "Your Code as a Crime Scene" by Adam Tornhill, page 72, for details.
*
* <p>
* This model is independent of the actual SCM implementation, so every SCM plugin can compute and store these
* couplings in the {@link RepositoryStatistics} of a build.
* </p>
*
* @author Akash Manna
*/
public final class TemporalCoupling implements Serializable {
@Serial
private static final long serialVersionUID = 1L; // since 1.9.0

private static final double PERCENTAGE_FACTOR = 100.0;
private static final double ROUNDING_FACTOR = 10.0;

private final String leftFile;
private final String rightFile;
private final int coChanges;
private final double couplingRatio;

/**
* Creates a new instance of {@link TemporalCoupling}.
*
* @param leftFile
* the absolute path of the first file of this coupling
* @param rightFile
* the absolute path of the second file of this coupling
* @param coChanges
* the number of commits that changed both files
* @param couplingRatio
* the strength of the coupling in the interval {@code [0.0, 1.0]}: it is defined as the number of shared
* commits divided by the smaller number of total commits of both files
*/
public TemporalCoupling(final String leftFile, final String rightFile, final int coChanges,
final double couplingRatio) {
this.leftFile = leftFile;
this.rightFile = rightFile;
this.coChanges = coChanges;
this.couplingRatio = couplingRatio;
}

/**
* Returns the absolute path of the first file of this coupling.
*
* @return the path of the first file
*/
public String getLeftFile() {
return leftFile;
}

/**
* Returns the absolute path of the second file of this coupling.
*
* @return the path of the second file
*/
public String getRightFile() {
return rightFile;
}

/**
* Returns the number of commits that changed both files of this coupling.
*
* @return the number of shared commits
*/
public int getCoChanges() {
return coChanges;
}

/**
* Returns the strength of this coupling in the interval {@code [0.0, 1.0]}. A value of {@code 1.0} means that both
* files have always been changed together.
*
* @return the coupling ratio
*/
public double getCouplingRatio() {
return couplingRatio;
}

/**
* Returns the strength of this coupling in the interval {@code [0.0, 100.0]}, rounded to one decimal place.
*
* @return the coupling ratio as percentage
*/
public double getCouplingPercentage() {
return Math.round(couplingRatio * PERCENTAGE_FACTOR * ROUNDING_FACTOR) / ROUNDING_FACTOR;
}

/**
* Returns whether the specified file is part of this coupling.
*
* @param fileName
* the absolute path of the file to check
*
* @return {@code true} if the file is the left or right file of this coupling, {@code false} otherwise
*/
public boolean contains(final String fileName) {
return Objects.equals(leftFile, fileName) || Objects.equals(rightFile, fileName);
}

@Override
@Generated
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
var that = (TemporalCoupling) o;
return coChanges == that.coChanges
&& Double.compare(couplingRatio, that.couplingRatio) == 0
&& Objects.equals(leftFile, that.leftFile)
&& Objects.equals(rightFile, that.rightFile);
}

@Override
@Generated
public int hashCode() {
return Objects.hash(leftFile, rightFile, coChanges, couplingRatio);
}

@Override
@Generated
public String toString() {
return new StringJoiner(", ", TemporalCoupling.class.getSimpleName() + "[", "]")
.add("leftFile=" + leftFile)
.add("rightFile=" + rightFile)
.add("coChanges=" + coChanges)
.add("couplingRatio=" + couplingRatio)
.toString();
}
}
Loading
Loading