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.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());
+ }
+}