From f053b0ea2b044ca6aa6c9da5874b6af7a2968fae Mon Sep 17 00:00:00 2001 From: Juul Hobert Date: Fri, 18 Oct 2024 14:43:39 +0200 Subject: [PATCH] Maintenance score based on commit frequency --- pom.xml | 65 ++--- src/main/java/com/giovds/PomClient.java | 104 ++++++++ .../java/com/giovds/PomClientInterface.java | 9 + .../java/com/giovds/UnmaintainedMojo.java | 112 ++++++++ .../collector/github/GithubCollector.java | 183 +++++++++++++ .../github/GithubCollectorInterface.java | 9 + .../collector/github/GithubGuesser.java | 20 ++ src/main/java/com/giovds/dto/PomResponse.java | 11 + src/main/java/com/giovds/dto/Scm.java | 7 + .../com/giovds/dto/github/extenal/Author.java | 14 + .../dto/github/extenal/CommitActivity.java | 47 ++++ .../dto/github/extenal/ContributorStat.java | 30 +++ .../giovds/dto/github/extenal/IssueStats.java | 22 ++ .../com/giovds/dto/github/extenal/Owner.java | 240 ++++++++++++++++++ .../giovds/dto/github/extenal/Repository.java | 151 +++++++++++ .../giovds/dto/github/internal/Bucket.java | 67 +++++ .../giovds/dto/github/internal/Collected.java | 159 ++++++++++++ .../dto/github/internal/Contributor.java | 67 +++++ .../com/giovds/dto/github/internal/Point.java | 69 +++++ .../com/giovds/dto/github/internal/Range.java | 88 +++++++ .../dto/github/internal/RangeSummary.java | 87 +++++++ .../evaluator/MaintenanceEvaluator.java | 46 ++++ .../java/com/giovds/http/JsonBodyHandler.java | 48 ++++ .../giovds/normalizer/DefaultNormalizer.java | 40 +++ .../evaluator/MaintenanceEvaluatorTest.java | 95 +++++++ .../normalizer/DefaultNormalizerTest.java | 40 +++ .../giovds/poc/github/GithubGuesserTest.java | 34 +++ 27 files changed, 1821 insertions(+), 43 deletions(-) create mode 100644 src/main/java/com/giovds/PomClient.java create mode 100644 src/main/java/com/giovds/PomClientInterface.java create mode 100644 src/main/java/com/giovds/UnmaintainedMojo.java create mode 100644 src/main/java/com/giovds/collector/github/GithubCollector.java create mode 100644 src/main/java/com/giovds/collector/github/GithubCollectorInterface.java create mode 100644 src/main/java/com/giovds/collector/github/GithubGuesser.java create mode 100644 src/main/java/com/giovds/dto/PomResponse.java create mode 100644 src/main/java/com/giovds/dto/Scm.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/Author.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/CommitActivity.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/ContributorStat.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/IssueStats.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/Owner.java create mode 100644 src/main/java/com/giovds/dto/github/extenal/Repository.java create mode 100644 src/main/java/com/giovds/dto/github/internal/Bucket.java create mode 100644 src/main/java/com/giovds/dto/github/internal/Collected.java create mode 100644 src/main/java/com/giovds/dto/github/internal/Contributor.java create mode 100644 src/main/java/com/giovds/dto/github/internal/Point.java create mode 100644 src/main/java/com/giovds/dto/github/internal/Range.java create mode 100644 src/main/java/com/giovds/dto/github/internal/RangeSummary.java create mode 100644 src/main/java/com/giovds/evaluator/MaintenanceEvaluator.java create mode 100644 src/main/java/com/giovds/http/JsonBodyHandler.java create mode 100644 src/main/java/com/giovds/normalizer/DefaultNormalizer.java create mode 100644 src/test/java/com/giovds/evaluator/MaintenanceEvaluatorTest.java create mode 100644 src/test/java/com/giovds/normalizer/DefaultNormalizerTest.java create mode 100644 src/test/java/com/giovds/poc/github/GithubGuesserTest.java diff --git a/pom.xml b/pom.xml index 6c14b86..0727cfa 100644 --- a/pom.xml +++ b/pom.xml @@ -45,9 +45,9 @@ UTF-8 - 17 + 21 - 2.18.0 + 2.18.1 5.11.2 3.26.3 5.14.2 @@ -70,7 +70,6 @@ 2.0.16 - @@ -127,6 +126,14 @@ ${maven-dependencies.version} provided + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + import + pom + @@ -143,13 +150,14 @@ ${maven-plugin-tools.version} provided - com.fasterxml.jackson.jr jackson-jr-objects - ${jackson-jr-objects.version} - + + com.fasterxml.jackson.jr + jackson-jr-annotation-support + org.junit.jupiter junit-jupiter @@ -255,6 +263,14 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + org.apache.maven.plugins maven-plugin-plugin @@ -370,42 +386,5 @@ - - release - - - - org.jreleaser - jreleaser-maven-plugin - - - - ALWAYS - true - - - - Giovds - ${project.artifactId} - true - - - - - - - ALWAYS - https://central.sonatype.com/api/v1/publisher - target/staging-deploy - - - - - - - - - - diff --git a/src/main/java/com/giovds/PomClient.java b/src/main/java/com/giovds/PomClient.java new file mode 100644 index 0000000..d9339e4 --- /dev/null +++ b/src/main/java/com/giovds/PomClient.java @@ -0,0 +1,104 @@ +package com.giovds; + +import com.giovds.dto.PomResponse; +import com.giovds.dto.Scm; +import org.apache.maven.plugin.logging.Log; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +public class PomClient implements PomClientInterface { + + private final String basePath; + private final String pomPathTemplate; + + private final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + + private final HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .build(); + + private final Log log; + + public PomClient(Log log) { + this("https://repo1.maven.org", "/maven2/%s/%s/%s/%s-%s.pom", log); + } + + public PomClient(String basePath, String pomPathTemplate, Log log) { + this.basePath = basePath; + this.pomPathTemplate = pomPathTemplate; + this.log = log; + } + + public PomResponse getPom(String group, String artifact, String version) throws IOException, InterruptedException { + final String path = String.format(pomPathTemplate, group.replace(".", "/"), artifact, version, artifact, version); + final HttpRequest request = HttpRequest.newBuilder() + .GET() + .uri(URI.create(basePath + path)) + .build(); + + return client.send(request, new PomResponseBodyHandler()).body(); + } + + private class PomResponseBodyHandler implements HttpResponse.BodyHandler { + + @Override + public HttpResponse.BodySubscriber apply(final HttpResponse.ResponseInfo responseInfo) { + int statusCode = responseInfo.statusCode(); + + if (statusCode < 200 || statusCode >= 300) { + return HttpResponse.BodySubscribers.mapping(HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8), s -> { + throw new RuntimeException("Search failed: status: %d body: %s".formatted(responseInfo.statusCode(), s)); + }); + } + + HttpResponse.BodySubscriber stream = HttpResponse.BodySubscribers.ofInputStream(); + + return HttpResponse.BodySubscribers.mapping(stream, this::toPomResponse); + } + + private PomResponse toPomResponse(final InputStream inputStream) { + try (final InputStream input = inputStream) { + DocumentBuilder documentBuilder = PomClient.this.documentBuilderFactory.newDocumentBuilder(); + Document doc = documentBuilder.parse(input); + + doc.getDocumentElement().normalize(); + + Element root = doc.getDocumentElement(); + NodeList urlNodes = root.getElementsByTagName("url"); + + if (urlNodes.getLength() == 0) { + return PomResponse.empty(); + } + String url = urlNodes.item(0).getTextContent(); + + Scm scm = Scm.empty(); + NodeList scmNodes = root.getElementsByTagName("scm"); + if (scmNodes.getLength() > 0) { + Element scmElement = (Element) scmNodes.item(0); + NodeList scmUrlNodes = scmElement.getElementsByTagName("url"); + if (scmUrlNodes.getLength() > 0) { + String scmUrl = scmUrlNodes.item(0).getTextContent(); + scm = new Scm(scmUrl); + } + } + + return new PomResponse(url, scm); + } catch (IOException | ParserConfigurationException | SAXException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/main/java/com/giovds/PomClientInterface.java b/src/main/java/com/giovds/PomClientInterface.java new file mode 100644 index 0000000..94ad8f5 --- /dev/null +++ b/src/main/java/com/giovds/PomClientInterface.java @@ -0,0 +1,9 @@ +package com.giovds; + +import com.giovds.dto.PomResponse; + +import java.io.IOException; + +public interface PomClientInterface { + PomResponse getPom(String group, String artifact, String version) throws IOException, InterruptedException; +} diff --git a/src/main/java/com/giovds/UnmaintainedMojo.java b/src/main/java/com/giovds/UnmaintainedMojo.java new file mode 100644 index 0000000..1dbc3a7 --- /dev/null +++ b/src/main/java/com/giovds/UnmaintainedMojo.java @@ -0,0 +1,112 @@ +package com.giovds; + +import com.giovds.collector.github.GithubCollector; +import com.giovds.collector.github.GithubGuesser; +import com.giovds.dto.PomResponse; +import com.giovds.dto.github.internal.Collected; +import com.giovds.evaluator.MaintenanceEvaluator; +import org.apache.maven.model.Dependency; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.MavenProject; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +@Mojo(name = "unmaintained", + defaultPhase = LifecyclePhase.TEST_COMPILE, + requiresOnline = true, + requiresDependencyResolution = ResolutionScope.TEST) +public class UnmaintainedMojo extends AbstractMojo { + + private final PomClientInterface client; + private final GithubGuesser githubGuesser; + private final GithubCollector githubCollector; + private final MaintenanceEvaluator maintenanceEvaluator; + + @Parameter(readonly = true, required = true, defaultValue = "${project}") + private MavenProject project; + + /** + * Required for initialization by Maven + */ + public UnmaintainedMojo() { + this(new SystemStreamLog()); + } + + public UnmaintainedMojo(Log log) { + this(new PomClient(log), new GithubGuesser(), new GithubCollector(log), new MaintenanceEvaluator()); + } + + public UnmaintainedMojo( + final PomClientInterface client, + final GithubGuesser githubGuesser, + final GithubCollector githubCollector, + final MaintenanceEvaluator maintenanceEvaluator) { + this.client = client; + this.githubGuesser = githubGuesser; + this.githubCollector = githubCollector; + this.maintenanceEvaluator = maintenanceEvaluator; + } + + @Override + public void execute() throws MojoFailureException { + final List dependencies = project.getDependencies(); + + if (dependencies.isEmpty()) { + // When building a POM without any dependencies there will be nothing to query. + return; + } + + final Map pomResponses = dependencies.stream() + .map(dependency -> { + try { + PomResponse pomResponse = client.getPom(dependency.getGroupId(), dependency.getArtifactId(), dependency.getVersion()); + + return new DependencyPomResponsePair(dependency, pomResponse); + } catch (Exception e) { + getLog().error("Failed to fetch POM for %s:%s:%s".formatted(dependency.getGroupId(), dependency.getArtifactId(), dependency.getVersion()), e); + return new DependencyPomResponsePair(dependency, PomResponse.empty()); + } + }) + .collect(Collectors.toMap(DependencyPomResponsePair::dependency, DependencyPomResponsePair::pomResponse)); + + for (Dependency dependency : pomResponses.keySet()) { + final PomResponse pomResponse = pomResponses.get(dependency); + final String projectUrl = pomResponse.url(); + final String projectScmUrl = pomResponse.scmUrl(); + + // First try to get the Github owner and repo from the url otherwise try to get it from the SCM url + var guess = projectUrl != null ? githubGuesser.guess(projectUrl) : null; + if (guess == null && projectScmUrl != null) { + guess = githubGuesser.guess(projectScmUrl); + } + + if (guess == null) { + getLog().warn("Could not guess Github owner and repo for %s".formatted(dependency.getManagementKey())); + continue; + } + + Collected collected; + try { + collected = githubCollector.collect(guess.owner(), guess.repo()); + } catch (ExecutionException | InterruptedException e) { + throw new MojoFailureException("Failed to collect Github data for %s".formatted(dependency.getManagementKey()), e); + } + + double score = maintenanceEvaluator.evaluateCommitsFrequency(collected); + getLog().info("Maintenance score for %s: %f".formatted(dependency.getManagementKey(), score)); + } + } + + private record DependencyPomResponsePair(Dependency dependency, PomResponse pomResponse) { + } +} diff --git a/src/main/java/com/giovds/collector/github/GithubCollector.java b/src/main/java/com/giovds/collector/github/GithubCollector.java new file mode 100644 index 0000000..7569a9e --- /dev/null +++ b/src/main/java/com/giovds/collector/github/GithubCollector.java @@ -0,0 +1,183 @@ +package com.giovds.collector.github; + +import com.fasterxml.jackson.jr.annotationsupport.JacksonAnnotationExtension; +import com.fasterxml.jackson.jr.ob.JSON; +import com.giovds.dto.github.extenal.CommitActivity; +import com.giovds.dto.github.extenal.ContributorStat; +import com.giovds.dto.github.extenal.Repository; +import com.giovds.dto.github.internal.*; +import com.giovds.http.JsonBodyHandler; +import org.apache.maven.plugin.logging.Log; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +public class GithubCollector implements GithubCollectorInterface { + private static final String GITHUB_API = "https://api.github.com"; + private static final String GITHUB_TOKEN = System.getenv("GITHUB_TOKEN"); + + private final String baseUrl; + private final HttpClient httpClient; + private final Log log; + + public GithubCollector(Log log) { + this(GITHUB_API, HttpClient.newHttpClient(), log); + } + + public GithubCollector(String baseUrl, HttpClient httpClient, Log log) { + this.baseUrl = baseUrl; + this.httpClient = httpClient; + this.log = log; + + if (GITHUB_TOKEN == null || GITHUB_TOKEN.isEmpty()) { + log.warn("No GitHub token provided, rate limits will be enforced. Provide a token by setting the GITHUB_TOKEN environment variable."); + } else { + log.info("GitHub token provided"); + } + } + + @Override + public Collected collect(String owner, String repo) throws InterruptedException, ExecutionException { + var repository = getRepository(owner, repo).get(); + var contributors = getContributors(repository).get(); + var commitActivity = getCommitActivity(repository).get(); + + var summary = extractCommits(commitActivity); + + return Collected.builder() + .homepage(repository.getHomepage()) + .starsCount(repository.getStargazersCount()) + .forksCount(repository.getForksCount()) + .subscribersCount(repository.getSubscribersCount()) + .contributors(Arrays.stream(contributors).map( + contributor -> Contributor.builder() + .username(contributor.getAuthor().getLogin()) + .commitsCount(contributor.getTotal()) + .build() + ).toList().reversed()) + .commits(summary) + .build(); + } + + private CompletableFuture getRepository(String owner, String repo) throws InterruptedException { + return requestAsync(String.format("repos/%s/%s", owner, repo), Repository.class); + } + + private CompletableFuture getContributors(Repository repository) throws InterruptedException { + return requestAsync("%s/stats/contributors".formatted(repository.getUrl()), ContributorStat[].class, true); + } + + private CompletableFuture getCommitActivity(Repository repository) throws InterruptedException { + return requestAsync("%s/stats/commit_activity".formatted(repository.getUrl()), CommitActivity[].class, true); + } + + private CompletableFuture requestAsync(String path, Class responseType) throws InterruptedException { + return requestAsync(path, responseType, false); + } + + private CompletableFuture requestAsync(String path, Class responseType, boolean isUri) throws InterruptedException { + var res = sendRequestAsync(path, responseType, isUri); + + var remaining = Integer.parseInt(res.join().headers().firstValue("X-RateLimit-Remaining").orElse("0")); + if (remaining == 0) { + long delay = Math.max(0, Long.parseLong(res.join().headers().firstValue("X-RateLimit-Reset").orElse("0")) - System.currentTimeMillis()); + + log.info("Rate limit exceeded, waiting for %s ms".formatted(delay)); + Thread.sleep(delay); + + return requestAsync(path, responseType, isUri); + } + + if (res.join().statusCode() == 202) { + Thread.sleep(10); + return requestAsync(path, responseType, isUri); + } + + return res.thenApply(HttpResponse::body); + } + + private CompletableFuture> sendRequestAsync(String pathOrUri, Class responseType) { + return sendRequestAsync(pathOrUri, responseType, false); + } + + private CompletableFuture> sendRequestAsync(String pathOrUri, Class responseType, boolean isUri) { + String url = isUri ? pathOrUri : String.format("%s/%s", baseUrl, pathOrUri); + var requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/vnd.github.v3+json") + .timeout(Duration.ofSeconds(30)); + + if (GITHUB_TOKEN != null && !GITHUB_TOKEN.isEmpty()) { + requestBuilder.header("Authorization", "Bearer %s".formatted(GITHUB_TOKEN)); + } + + var request = requestBuilder.build(); + + var json = JSON.builder() + .register(JacksonAnnotationExtension.std) + .build(); + + return httpClient.sendAsync(request, new JsonBodyHandler<>(json, responseType)); + } + + private static List extractCommits(CommitActivity[] commitActivity) { + List points = Arrays.stream(commitActivity) + .map(entry -> Point.builder() + .date(Instant.ofEpochSecond(entry.getWeek()).atZone(ZoneOffset.UTC).toOffsetDateTime()) + .total(entry.getTotal()) + .build()) + .toList(); + + var ranges = pointsToRanges(points, bucketsFromBreakpoints(List.of(7, 30, 90, 180, 365))); + + return ranges.stream() + .map(range -> { + int count = range.getPoints().stream() + .mapToInt(Point::getTotal) + .sum(); + + return RangeSummary.builder() + .start(range.getStart()) + .end(range.getEnd()) + .count(count) + .build(); + }) + .toList(); + } + + private static List pointsToRanges(List points, List buckets) { + return buckets.stream().map(bucket -> { + List filteredPoints = points.stream() + .filter(point -> !point.getDate().isBefore(bucket.getStart()) && point.getDate().isBefore(bucket.getEnd())) + .collect(Collectors.toList()); + + return Range.builder() + .start(bucket.getStart()) + .end(bucket.getEnd()) + .points(filteredPoints) + .build(); + }).collect(Collectors.toList()); + } + + private static List bucketsFromBreakpoints(List breakpoints) { + OffsetDateTime referenceDate = OffsetDateTime.now(ZoneOffset.UTC).toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC); + + return breakpoints.stream() + .map(breakpoint -> Bucket.builder() + .start(referenceDate.minusDays(breakpoint)) + .end(referenceDate) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/giovds/collector/github/GithubCollectorInterface.java b/src/main/java/com/giovds/collector/github/GithubCollectorInterface.java new file mode 100644 index 0000000..b49a315 --- /dev/null +++ b/src/main/java/com/giovds/collector/github/GithubCollectorInterface.java @@ -0,0 +1,9 @@ +package com.giovds.collector.github; + +import com.giovds.dto.github.internal.Collected; + +import java.util.concurrent.ExecutionException; + +public interface GithubCollectorInterface { + Collected collect(String owner, String repo) throws ExecutionException, InterruptedException; +} diff --git a/src/main/java/com/giovds/collector/github/GithubGuesser.java b/src/main/java/com/giovds/collector/github/GithubGuesser.java new file mode 100644 index 0000000..f2cf5cb --- /dev/null +++ b/src/main/java/com/giovds/collector/github/GithubGuesser.java @@ -0,0 +1,20 @@ +package com.giovds.collector.github; + +import java.util.regex.Pattern; + +public class GithubGuesser { + + private final Pattern githubRepoPattern = Pattern.compile("^[a-zA-Z]+://github\\.com/([^/]+)/([^/]+)(/.*)?"); + + public Repository guess(String url) { + var matcher = githubRepoPattern.matcher(url); + if (matcher.matches()) { + return new Repository(matcher.group(1), matcher.group(2)); + } + + return null; + } + + public record Repository(String owner, String repo) { + } +} diff --git a/src/main/java/com/giovds/dto/PomResponse.java b/src/main/java/com/giovds/dto/PomResponse.java new file mode 100644 index 0000000..f85baa7 --- /dev/null +++ b/src/main/java/com/giovds/dto/PomResponse.java @@ -0,0 +1,11 @@ +package com.giovds.dto; + +public record PomResponse(String url, Scm scm) { + public static PomResponse empty() { + return new PomResponse(null, Scm.empty()); + } + + public String scmUrl() { + return scm.url(); + } +} diff --git a/src/main/java/com/giovds/dto/Scm.java b/src/main/java/com/giovds/dto/Scm.java new file mode 100644 index 0000000..f331b1a --- /dev/null +++ b/src/main/java/com/giovds/dto/Scm.java @@ -0,0 +1,7 @@ +package com.giovds.dto; + +public record Scm(String url) { + public static Scm empty() { + return new Scm(null); + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/Author.java b/src/main/java/com/giovds/dto/github/extenal/Author.java new file mode 100644 index 0000000..371b5f9 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Author.java @@ -0,0 +1,14 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Author { + @JsonProperty("login") + private String login = ""; + + public String getLogin() { + return login; + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/CommitActivity.java b/src/main/java/com/giovds/dto/github/extenal/CommitActivity.java new file mode 100644 index 0000000..2b41189 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/CommitActivity.java @@ -0,0 +1,47 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class CommitActivity { + /** + * The total number of commits for the week. + */ + @JsonProperty("total") + private int total; + + /** + * The start of the week as a UNIX timestamp. + */ + @JsonProperty("week") + private long week; + + /** + * The number of commits for each day of the week. 0 = Sunday, 1 = Monday, etc. + */ + @JsonProperty("days") + private final List days = List.of(); + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public long getWeek() { + return week; + } + + public void setWeek(long week) { + this.week = week; + } + + public List getDays() { + return days; + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/ContributorStat.java b/src/main/java/com/giovds/dto/github/extenal/ContributorStat.java new file mode 100644 index 0000000..e233ae4 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/ContributorStat.java @@ -0,0 +1,30 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ContributorStat { + + @JsonProperty("total") + private int total; + + @JsonProperty("author") + private Author author = new Author(); + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public Author getAuthor() { + return author; + } + + public void setAuthor(Author author) { + this.author = author; + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/IssueStats.java b/src/main/java/com/giovds/dto/github/extenal/IssueStats.java new file mode 100644 index 0000000..1850731 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/IssueStats.java @@ -0,0 +1,22 @@ +package com.giovds.dto.github.extenal; + +public class IssueStats { + private int count; + private int openCount; + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public int getOpenCount() { + return openCount; + } + + public void setOpenCount(int openCount) { + this.openCount = openCount; + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/Owner.java b/src/main/java/com/giovds/dto/github/extenal/Owner.java new file mode 100644 index 0000000..2353106 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Owner.java @@ -0,0 +1,240 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.OffsetDateTime; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Owner { + @JsonProperty("name") + private String name; + + @JsonProperty("email") + private String email; + + @JsonProperty("login") + private String login = ""; + + @JsonProperty("id") + private long id; + + @JsonProperty("node_id") + private String nodeId = ""; + + @JsonProperty("avatar_url") + private String avatarUrl = ""; + + @JsonProperty("gravatar_id") + private String gravatarId; + + @JsonProperty("url") + private String url = ""; + + @JsonProperty("html_url") + private String htmlUrl = ""; + + @JsonProperty("followers_url") + private String followersUrl = ""; + + @JsonProperty("following_url") + private String followingUrl = ""; + + @JsonProperty("gists_url") + private String gistsUrl = ""; + + @JsonProperty("starred_url") + private String starredUrl = ""; + + @JsonProperty("subscriptions_url") + private String subscriptionsUrl = ""; + + @JsonProperty("organizations_url") + private String organizationsUrl = ""; + + @JsonProperty("repos_url") + private String reposUrl = ""; + + @JsonProperty("events_url") + private String eventsUrl = ""; + + @JsonProperty("received_events_url") + private String receivedEventsUrl = ""; + + @JsonProperty("type") + private String type = ""; + + @JsonProperty("site_admin") + private boolean siteAdmin; + + @JsonProperty("starred_at") + private OffsetDateTime starredAt; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getLogin() { + return login; + } + + public void setLogin(String login) { + this.login = login; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public String getGravatarId() { + return gravatarId; + } + + public void setGravatarId(String gravatarId) { + this.gravatarId = gravatarId; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getHtmlUrl() { + return htmlUrl; + } + + public void setHtmlUrl(String htmlUrl) { + this.htmlUrl = htmlUrl; + } + + public String getFollowersUrl() { + return followersUrl; + } + + public void setFollowersUrl(String followersUrl) { + this.followersUrl = followersUrl; + } + + public String getFollowingUrl() { + return followingUrl; + } + + public void setFollowingUrl(String followingUrl) { + this.followingUrl = followingUrl; + } + + public String getGistsUrl() { + return gistsUrl; + } + + public void setGistsUrl(String gistsUrl) { + this.gistsUrl = gistsUrl; + } + + public String getStarredUrl() { + return starredUrl; + } + + public void setStarredUrl(String starredUrl) { + this.starredUrl = starredUrl; + } + + public String getSubscriptionsUrl() { + return subscriptionsUrl; + } + + public void setSubscriptionsUrl(String subscriptionsUrl) { + this.subscriptionsUrl = subscriptionsUrl; + } + + public String getOrganizationsUrl() { + return organizationsUrl; + } + + public void setOrganizationsUrl(String organizationsUrl) { + this.organizationsUrl = organizationsUrl; + } + + public String getReposUrl() { + return reposUrl; + } + + public void setReposUrl(String reposUrl) { + this.reposUrl = reposUrl; + } + + public String getEventsUrl() { + return eventsUrl; + } + + public void setEventsUrl(String eventsUrl) { + this.eventsUrl = eventsUrl; + } + + public String getReceivedEventsUrl() { + return receivedEventsUrl; + } + + public void setReceivedEventsUrl(String receivedEventsUrl) { + this.receivedEventsUrl = receivedEventsUrl; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public boolean isSiteAdmin() { + return siteAdmin; + } + + public void setSiteAdmin(boolean siteAdmin) { + this.siteAdmin = siteAdmin; + } + + public OffsetDateTime getStarredAt() { + return starredAt; + } + + public void setStarredAt(OffsetDateTime starredAt) { + this.starredAt = starredAt; + } +} diff --git a/src/main/java/com/giovds/dto/github/extenal/Repository.java b/src/main/java/com/giovds/dto/github/extenal/Repository.java new file mode 100644 index 0000000..19ffd44 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Repository.java @@ -0,0 +1,151 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Repository { + @JsonProperty("contributors_url") + private String contributorsUrl = ""; + + @JsonProperty("forks_count") + private int forksCount; + + @JsonProperty("full_name") + private String fullName = ""; + + @JsonProperty("has_issues") + private boolean hasIssues; + + @JsonProperty("homepage") + private String homepage; + + @JsonProperty("id") + private long id; + + @JsonProperty("node_id") + private String nodeId = ""; + + @JsonProperty("name") + private String name = ""; + + @JsonProperty("owner") + private Owner owner = new Owner(); + + @JsonProperty("private") + private boolean _private; + + @JsonProperty("stargazers_count") + private int stargazersCount; + + @JsonProperty("subscribers_count") + private int subscribersCount; + + @JsonProperty("url") + private String url = ""; + + // Getters and Setters + public String getContributorsUrl() { + return contributorsUrl; + } + + public void setContributorsUrl(String contributorsUrl) { + this.contributorsUrl = contributorsUrl; + } + + public int getForksCount() { + return forksCount; + } + + public void setForksCount(int forksCount) { + this.forksCount = forksCount; + } + + public String getFullName() { + return fullName; + } + + public void setFullName(String fullName) { + this.fullName = fullName; + } + + public boolean isHasIssues() { + return hasIssues; + } + + public void setHasIssues(boolean hasIssues) { + this.hasIssues = hasIssues; + } + + public String getHomepage() { + return homepage; + } + + public void setHomepage(String homepage) { + this.homepage = homepage; + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Owner getOwner() { + return owner; + } + + public void setOwner(Owner owner) { + this.owner = owner; + } + + public boolean isPrivate() { + return _private; + } + + public void setPrivate(boolean _private) { + this._private = _private; + } + + public int getStargazersCount() { + return stargazersCount; + } + + public void setStargazersCount(int stargazersCount) { + this.stargazersCount = stargazersCount; + } + + public int getSubscribersCount() { + return subscribersCount; + } + + public void setSubscribersCount(int subscribersCount) { + this.subscribersCount = subscribersCount; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/Bucket.java b/src/main/java/com/giovds/dto/github/internal/Bucket.java new file mode 100644 index 0000000..f1a09a4 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Bucket.java @@ -0,0 +1,67 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public class Bucket { + private OffsetDateTime start; + private OffsetDateTime end; + + public OffsetDateTime getStart() { + return start; + } + + public void setStart(OffsetDateTime start) { + this.start = start; + } + + public OffsetDateTime getEnd() { + return end; + } + + public void setEnd(OffsetDateTime end) { + this.end = end; + } + + @Override + public String toString() { + return "Bucket{" + + "start=" + start + + ", end=" + end + + '}'; + } + + public static BucketBuilder builder() { + return new BucketBuilder(); + } + + public static class BucketBuilder { + private OffsetDateTime start; + private OffsetDateTime end; + + BucketBuilder() { + } + + public BucketBuilder start(OffsetDateTime start) { + this.start = start; + return this; + } + + public BucketBuilder end(OffsetDateTime end) { + this.end = end; + return this; + } + + public Bucket build() { + return new Bucket(start, end); + } + + public String toString() { + return "Bucket.BucketBuilder(start=" + this.start + ", end=" + this.end + ")"; + } + } + + public Bucket(OffsetDateTime start, OffsetDateTime end) { + this.start = start; + this.end = end; + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/Collected.java b/src/main/java/com/giovds/dto/github/internal/Collected.java new file mode 100644 index 0000000..b1eac38 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Collected.java @@ -0,0 +1,159 @@ +package com.giovds.dto.github.internal; + +import java.util.List; + +public class Collected { + private String homepage; + private int starsCount; + private int forksCount; + private int subscribersCount; + private int issues; + private List contributors; + private List commits; + + public String getHomepage() { + return homepage; + } + + public void setHomepage(String homepage) { + this.homepage = homepage; + } + + public int getStarsCount() { + return starsCount; + } + + public void setStarsCount(int starsCount) { + this.starsCount = starsCount; + } + + public int getForksCount() { + return forksCount; + } + + public void setForksCount(int forksCount) { + this.forksCount = forksCount; + } + + public int getSubscribersCount() { + return subscribersCount; + } + + public void setSubscribersCount(int subscribersCount) { + this.subscribersCount = subscribersCount; + } + + public int getIssues() { + return issues; + } + + public void setIssues(int issues) { + this.issues = issues; + } + + public List getContributors() { + return contributors; + } + + public void setContributors(List contributors) { + this.contributors = contributors; + } + + public List getCommits() { + return commits; + } + + public void setCommits(List commits) { + this.commits = commits; + } + + @Override + public String toString() { + return "Collected{" + + "homepage='" + homepage + '\'' + + ", starsCount=" + starsCount + + ", forksCount=" + forksCount + + ", subscribersCount=" + subscribersCount + + ", issues=" + issues + + ", contributors=" + contributors + + ", commits=" + commits + + '}'; + } + + public static CollectedBuilder builder() { + return new CollectedBuilder(); + } + + public static class CollectedBuilder { + private String homepage; + private int starsCount; + private int forksCount; + private int subscribersCount; + private int issues; + private List contributors; + private List commits; + + CollectedBuilder() { + } + + public CollectedBuilder homepage(String homepage) { + this.homepage = homepage; + return this; + } + + public CollectedBuilder starsCount(int starsCount) { + this.starsCount = starsCount; + return this; + } + + public CollectedBuilder forksCount(int forksCount) { + this.forksCount = forksCount; + return this; + } + + public CollectedBuilder subscribersCount(int subscribersCount) { + this.subscribersCount = subscribersCount; + return this; + } + + public CollectedBuilder issues(int issues) { + this.issues = issues; + return this; + } + + public CollectedBuilder contributors(List contributors) { + this.contributors = contributors; + return this; + } + + public CollectedBuilder commits(List commits) { + this.commits = commits; + return this; + } + + public Collected build() { + Collected collected = new Collected(); + collected.setHomepage(this.homepage); + collected.setStarsCount(this.starsCount); + collected.setForksCount(this.forksCount); + collected.setSubscribersCount(this.subscribersCount); + collected.setIssues(this.issues); + collected.setContributors(this.contributors); + collected.setCommits(this.commits); + return collected; + } + + @Override + public String toString() { + return "Collected.CollectedBuilder{" + + "homepage='" + homepage + '\'' + + ", starsCount=" + starsCount + + ", forksCount=" + forksCount + + ", subscribersCount=" + subscribersCount + + ", issues=" + issues + + ", contributors=" + contributors + + ", commits=" + commits + + '}'; + } + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/Contributor.java b/src/main/java/com/giovds/dto/github/internal/Contributor.java new file mode 100644 index 0000000..1557f6c --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Contributor.java @@ -0,0 +1,67 @@ +package com.giovds.dto.github.internal; + +public class Contributor { + private String username; + private int commitsCount; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getCommitsCount() { + return commitsCount; + } + + public void setCommitsCount(int commitsCount) { + this.commitsCount = commitsCount; + } + + @Override + public String toString() { + return "Contributor{" + + "username='" + username + '\'' + + ", commitsCount=" + commitsCount + + '}'; + } + + public static ContributorBuilder builder() { + return new ContributorBuilder(); + } + + public static class ContributorBuilder { + private String username; + private int commitsCount; + + ContributorBuilder() { + } + + public ContributorBuilder username(String username) { + this.username = username; + return this; + } + + public ContributorBuilder commitsCount(int commitsCount) { + this.commitsCount = commitsCount; + return this; + } + + public Contributor build() { + Contributor contributor = new Contributor(); + contributor.setUsername(this.username); + contributor.setCommitsCount(this.commitsCount); + return contributor; + } + + @Override + public String toString() { + return "Contributor.ContributorBuilder{" + + "username='" + username + '\'' + + ", commitsCount=" + commitsCount + + '}'; + } + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/Point.java b/src/main/java/com/giovds/dto/github/internal/Point.java new file mode 100644 index 0000000..253e034 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Point.java @@ -0,0 +1,69 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public class Point { + private OffsetDateTime date; + private int total; + + public OffsetDateTime getDate() { + return date; + } + + public void setDate(OffsetDateTime date) { + this.date = date; + } + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + @Override + public String toString() { + return "Point{" + + "date=" + date + + ", total=" + total + + '}'; + } + + public static PointBuilder builder() { + return new PointBuilder(); + } + + public static class PointBuilder { + private OffsetDateTime date; + private int total; + + PointBuilder() { + } + + public PointBuilder date(OffsetDateTime date) { + this.date = date; + return this; + } + + public PointBuilder total(int total) { + this.total = total; + return this; + } + + public Point build() { + Point point = new Point(); + point.setDate(this.date); + point.setTotal(this.total); + return point; + } + + @Override + public String toString() { + return "Point.PointBuilder{" + + "date=" + date + + ", total=" + total + + '}'; + } + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/Range.java b/src/main/java/com/giovds/dto/github/internal/Range.java new file mode 100644 index 0000000..bf4ad94 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Range.java @@ -0,0 +1,88 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; +import java.util.List; + +public class Range { + private OffsetDateTime start; + private OffsetDateTime end; + private List points; + + public OffsetDateTime getStart() { + return start; + } + + public void setStart(OffsetDateTime start) { + this.start = start; + } + + public OffsetDateTime getEnd() { + return end; + } + + public void setEnd(OffsetDateTime end) { + this.end = end; + } + + public List getPoints() { + return points; + } + + public void setPoints(List points) { + this.points = points; + } + + @Override + public String toString() { + return "Range{" + + "start=" + start + + ", end=" + end + + ", points=" + points + + '}'; + } + + public static RangeBuilder builder() { + return new RangeBuilder(); + } + + public static class RangeBuilder { + private OffsetDateTime start; + private OffsetDateTime end; + private List points; + + RangeBuilder() { + } + + public RangeBuilder start(OffsetDateTime start) { + this.start = start; + return this; + } + + public RangeBuilder end(OffsetDateTime end) { + this.end = end; + return this; + } + + public RangeBuilder points(List points) { + this.points = points; + return this; + } + + public Range build() { + Range range = new Range(); + range.setStart(this.start); + range.setEnd(this.end); + range.setPoints(this.points); + return range; + } + + @Override + public String toString() { + return "Range.RangeBuilder{" + + "start=" + start + + ", end=" + end + + ", points=" + points + + '}'; + } + } +} diff --git a/src/main/java/com/giovds/dto/github/internal/RangeSummary.java b/src/main/java/com/giovds/dto/github/internal/RangeSummary.java new file mode 100644 index 0000000..ce45ca0 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/RangeSummary.java @@ -0,0 +1,87 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public class RangeSummary { + private OffsetDateTime start; + private OffsetDateTime end; + private int count; + + public OffsetDateTime getStart() { + return start; + } + + public void setStart(OffsetDateTime start) { + this.start = start; + } + + public OffsetDateTime getEnd() { + return end; + } + + public void setEnd(OffsetDateTime end) { + this.end = end; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + @Override + public String toString() { + return "RangeSummary{" + + "start=" + start + + ", end=" + end + + ", count=" + count + + '}'; + } + + public static RangeSummaryBuilder builder() { + return new RangeSummaryBuilder(); + } + + public static class RangeSummaryBuilder { + private OffsetDateTime start; + private OffsetDateTime end; + private int count; + + RangeSummaryBuilder() { + } + + public RangeSummaryBuilder start(OffsetDateTime start) { + this.start = start; + return this; + } + + public RangeSummaryBuilder end(OffsetDateTime end) { + this.end = end; + return this; + } + + public RangeSummaryBuilder count(int count) { + this.count = count; + return this; + } + + public RangeSummary build() { + RangeSummary rangeSummary = new RangeSummary(); + rangeSummary.setStart(this.start); + rangeSummary.setEnd(this.end); + rangeSummary.setCount(this.count); + return rangeSummary; + } + + @Override + public String toString() { + return "RangeSummary.RangeSummaryBuilder{" + + "start=" + start + + ", end=" + end + + ", count=" + count + + '}'; + } + } +} diff --git a/src/main/java/com/giovds/evaluator/MaintenanceEvaluator.java b/src/main/java/com/giovds/evaluator/MaintenanceEvaluator.java new file mode 100644 index 0000000..d84c764 --- /dev/null +++ b/src/main/java/com/giovds/evaluator/MaintenanceEvaluator.java @@ -0,0 +1,46 @@ +package com.giovds.evaluator; + +import com.giovds.dto.github.internal.Collected; +import com.giovds.dto.github.internal.RangeSummary; +import com.giovds.normalizer.DefaultNormalizer; + +import java.time.Duration; +import java.util.List; + +public class MaintenanceEvaluator { + public double evaluateCommitsFrequency(Collected collected) { + var commits = collected.getCommits(); + if (commits.isEmpty()) { + return 0; + } + + var range30 = findRange(commits, 30); + var range180 = findRange(commits, 180); + var range365 = findRange(commits, 365); + + var mean30 = range30.getCount(); + var mean180 = range180.getCount() / (180.0d / 30.0d); + var mean365 = range365.getCount() / (365.0d / 30.0d); + + var monthlyMean = (mean30 * 0.35d) + + (mean180 * 0.45d) + + (mean365 * 0.2d); + + var normalizer = new DefaultNormalizer(); + + return normalizer.normalizeValue(monthlyMean, List.of( + new DefaultNormalizer.NormalizeStep(0d, 0d), + new DefaultNormalizer.NormalizeStep(1d, 0.7d), + new DefaultNormalizer.NormalizeStep(5d, 0.9d), + new DefaultNormalizer.NormalizeStep(10d, 1d) + )); + } + + private RangeSummary findRange(List commits, int days) { + return commits.stream() + .filter(range -> Duration.between(range.getStart(), range.getEnd()).toDays() == days) + .limit(1) + .toList() + .getFirst(); + } +} diff --git a/src/main/java/com/giovds/http/JsonBodyHandler.java b/src/main/java/com/giovds/http/JsonBodyHandler.java new file mode 100644 index 0000000..1380437 --- /dev/null +++ b/src/main/java/com/giovds/http/JsonBodyHandler.java @@ -0,0 +1,48 @@ +package com.giovds.http; + +import com.fasterxml.jackson.jr.ob.JSON; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +public class JsonBodyHandler implements HttpResponse.BodyHandler { + private final JSON json; + private final Class resultClass; + + public JsonBodyHandler(Class resultClass) { + this(JSON.std, resultClass); + } + + public JsonBodyHandler(JSON json, Class resultClass) { + this.json = json; + this.resultClass = resultClass; + } + + @Override + public HttpResponse.BodySubscriber apply(HttpResponse.ResponseInfo res) { + if (res.statusCode() == 202) { + return HttpResponse.BodySubscribers.replacing(null); + } + + var remaining = res.headers().firstValue("X-RateLimit-Remaining").orElse("0"); + if ("0".equals(remaining)) { + return HttpResponse.BodySubscribers.replacing(null); + } + + return asJSON(res, resultClass); + } + + public HttpResponse.BodySubscriber asJSON(HttpResponse.ResponseInfo res, Class targetType) { + HttpResponse.BodySubscriber upstream = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8); + return HttpResponse.BodySubscribers.mapping( + upstream, + (String body) -> { + try { + return json.beanFrom(targetType, body); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/src/main/java/com/giovds/normalizer/DefaultNormalizer.java b/src/main/java/com/giovds/normalizer/DefaultNormalizer.java new file mode 100644 index 0000000..60e4aea --- /dev/null +++ b/src/main/java/com/giovds/normalizer/DefaultNormalizer.java @@ -0,0 +1,40 @@ +package com.giovds.normalizer; + +import java.util.List; +import java.util.function.Predicate; + +public class DefaultNormalizer { + public double normalizeValue(double value, List steps) { + var index = findLastIndex(steps, step -> step.value() <= value); + + if (index == -1) { + return steps.getFirst().norm(); + } + if (index == steps.size() - 1) { + return steps.getLast().norm(); + } + + var stepLow = steps.get(index); + var stepHigh = steps.get(index + 1); + + return stepLow.norm() + ((stepHigh.norm - stepLow.norm) * (value - stepLow.value)) / (stepHigh.value - stepLow.value); + } + + private static int findLastIndex(List list, Predicate predicate) { + List reversed = list.reversed(); + int reverseIdx = -1; + + for (int i = 0; i < reversed.size(); i++) { + if (predicate.test(reversed.get(i))) { + reverseIdx = i; + break; + + } + } + + return reverseIdx == -1 ? -1 : reversed.size() - (reverseIdx + 1); + } + + public record NormalizeStep(double value, double norm) { + } +} diff --git a/src/test/java/com/giovds/evaluator/MaintenanceEvaluatorTest.java b/src/test/java/com/giovds/evaluator/MaintenanceEvaluatorTest.java new file mode 100644 index 0000000..c5b0b92 --- /dev/null +++ b/src/test/java/com/giovds/evaluator/MaintenanceEvaluatorTest.java @@ -0,0 +1,95 @@ +package com.giovds.evaluator; + +import com.giovds.dto.github.internal.Collected; +import com.giovds.dto.github.internal.RangeSummary; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MaintenanceEvaluatorTest { + @Test + void evaluateCommitsFrequency_withLowMaintenance_expectLowScore() { + // Arrange + var evaluator = new MaintenanceEvaluator(); + var collected = Collected.builder() + .commits(List.of( + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 11, 15, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(0) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 10, 23, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(0) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 8, 24, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(0) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 5, 26, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(0) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2023, 11, 23, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(3) + .build() + )) + .build(); + + // Act + var result = evaluator.evaluateCommitsFrequency(collected); + + // Assert + assertThat(result).isEqualTo(0.03452054794520548); + } + + @Test + void evaluateCommitsFrequency_withHighMaintenance_expectHighScore() { + // Arrange + var evaluator = new MaintenanceEvaluator(); + var collected = Collected.builder() + .commits(List.of( + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 11, 15, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(0) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 10, 23, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(6) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 8, 24, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(36) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2024, 5, 26, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(79) + .build(), + RangeSummary.builder() + .start(OffsetDateTime.of(2023, 11, 23, 0, 0, 0, 0, ZoneOffset.UTC)) + .end(OffsetDateTime.of(2024, 11, 22, 0, 0, 0, 0, ZoneOffset.UTC)) + .count(79) + .build() + )) + .build(); + + // Act + var result = evaluator.evaluateCommitsFrequency(collected); + + // Assert + assertThat(result).isEqualTo(0.986472602739726); + } +} diff --git a/src/test/java/com/giovds/normalizer/DefaultNormalizerTest.java b/src/test/java/com/giovds/normalizer/DefaultNormalizerTest.java new file mode 100644 index 0000000..09e5b44 --- /dev/null +++ b/src/test/java/com/giovds/normalizer/DefaultNormalizerTest.java @@ -0,0 +1,40 @@ +package com.giovds.normalizer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultNormalizerTest { + private DefaultNormalizer normalizer; + + @BeforeEach + void setUp() { + normalizer = new DefaultNormalizer(); + } + + @Test + void normalizeValue_withLowMaintenance_expectLowValueGreaterThanZero() { + var value = 0.04931506849315069d; + + assertThat(normalizer.normalizeValue(value, createDefaultSteps())).isEqualTo(0.03452054794520548d); + } + + @Test + void normalizeValue_withHighMaintenance_expectHighValueLowerThanOne() { + var value = 9.3236301369863d; + + assertThat(normalizer.normalizeValue(value, createDefaultSteps())).isEqualTo(0.986472602739726d); + } + + private List createDefaultSteps() { + return List.of( + new DefaultNormalizer.NormalizeStep(0, 0), + new DefaultNormalizer.NormalizeStep(1, 0.7), + new DefaultNormalizer.NormalizeStep(5, 0.9), + new DefaultNormalizer.NormalizeStep(10, 1) + ); + } +} diff --git a/src/test/java/com/giovds/poc/github/GithubGuesserTest.java b/src/test/java/com/giovds/poc/github/GithubGuesserTest.java new file mode 100644 index 0000000..678449c --- /dev/null +++ b/src/test/java/com/giovds/poc/github/GithubGuesserTest.java @@ -0,0 +1,34 @@ +package com.giovds.poc.github; + +import com.giovds.collector.github.GithubGuesser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class GithubGuesserTest { + @Test + public void non_github_url_returns_null() { + GithubGuesser githubGuesser = new GithubGuesser(); + assertNull(githubGuesser.guess("https://gitlab.com/owner/repo")); + } + + @Test + public void github_url_returns_owner_and_repository() { + GithubGuesser githubGuesser = new GithubGuesser(); + GithubGuesser.Repository repository = githubGuesser.guess("https://github.com/Giovds/outdated-maven-plugin"); + + assertNotNull(repository); + assertEquals("Giovds", repository.owner()); + assertEquals("outdated-maven-plugin", repository.repo()); + } + + @Test + public void github_url_additional_slash_returns_owner_and_repository() { + GithubGuesser githubGuesser = new GithubGuesser(); + GithubGuesser.Repository repository = githubGuesser.guess("https://github.com/Giovds/outdated-maven-plugin/"); + + assertNotNull(repository); + assertEquals("Giovds", repository.owner()); + assertEquals("outdated-maven-plugin", repository.repo()); + } +}