diff --git a/pom.xml b/pom.xml index 7a1688e..c53d9c1 100644 --- a/pom.xml +++ b/pom.xml @@ -45,9 +45,9 @@ UTF-8 - 17 + 21 - 2.18.1 + 2.18.1 5.11.3 3.26.3 5.14.2 @@ -127,6 +127,13 @@ ${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 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..517d80f --- /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.homepage()) + .starsCount(repository.stargazersCount()) + .forksCount(repository.forksCount()) + .subscribersCount(repository.subscribersCount()) + .contributors(Arrays.stream(contributors).map( + contributor -> Contributor.builder() + .username(contributor.author().login()) + .commitsCount(contributor.total()) + .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.url()), ContributorStat[].class, true); + } + + private CompletableFuture getCommitActivity(Repository repository) throws InterruptedException { + return requestAsync("%s/stats/commit_activity".formatted(repository.url()), 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.week()).atZone(ZoneOffset.UTC).toOffsetDateTime()) + .total(entry.total()) + .build()) + .toList(); + + var ranges = pointsToRanges(points, bucketsFromBreakpoints(List.of(7, 30, 90, 180, 365))); + + return ranges.stream() + .map(range -> { + int count = range.points().stream() + .mapToInt(Point::total) + .sum(); + + return RangeSummary.builder() + .start(range.start()) + .end(range.end()) + .count(count) + .build(); + }) + .toList(); + } + + private static List pointsToRanges(List points, List buckets) { + return buckets.stream().map(bucket -> { + List filteredPoints = points.stream() + .filter(point -> !point.date().isBefore(bucket.start()) && point.date().isBefore(bucket.end())) + .collect(Collectors.toList()); + + return Range.builder() + .start(bucket.start()) + .end(bucket.end()) + .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..679e385 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Author.java @@ -0,0 +1,8 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record Author(@JsonProperty("login") String 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..c7d5fca --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/CommitActivity.java @@ -0,0 +1,14 @@ +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 record CommitActivity( + @JsonProperty("days") List days, + @JsonProperty("total") int total, + @JsonProperty("week") long week +) { +} 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..5c1d64c --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/ContributorStat.java @@ -0,0 +1,11 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ContributorStat( + @JsonProperty("author") Author author, + @JsonProperty("total") int total +) { +} 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..4398d97 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/IssueStats.java @@ -0,0 +1,4 @@ +package com.giovds.dto.github.extenal; + +public record IssueStats(int count, int 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..462cb44 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Owner.java @@ -0,0 +1,32 @@ +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 record Owner( + @JsonProperty("avatar_url") String avatarUrl, + @JsonProperty("email") String email, + @JsonProperty("events_url") String eventsUrl, + @JsonProperty("followers_url") String followersUrl, + @JsonProperty("following_url") String followingUrl, + @JsonProperty("gists_url") String gistsUrl, + @JsonProperty("gravatar_id") String gravatarId, + @JsonProperty("html_url") String htmlUrl, + @JsonProperty("id") long id, + @JsonProperty("login") String login, + @JsonProperty("name") String name, + @JsonProperty("node_id") String nodeId, + @JsonProperty("organizations_url") String organizationsUrl, + @JsonProperty("received_events_url") String receivedEventsUrl, + @JsonProperty("repos_url") String reposUrl, + @JsonProperty("site_admin") boolean siteAdmin, + @JsonProperty("starred_at") OffsetDateTime starredAt, + @JsonProperty("starred_url") String starredUrl, + @JsonProperty("subscriptions_url") String subscriptionsUrl, + @JsonProperty("type") String type, + @JsonProperty("url") String url +) { +} 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..1a88536 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/extenal/Repository.java @@ -0,0 +1,22 @@ +package com.giovds.dto.github.extenal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record Repository( + @JsonProperty("contributors_url") String contributorsUrl, + @JsonProperty("forks_count") int forksCount, + @JsonProperty("full_name") String fullName, + @JsonProperty("has_issues") boolean hasIssues, + @JsonProperty("homepage") String homepage, + @JsonProperty("id") long id, + @JsonProperty("name") String name, + @JsonProperty("node_id") String nodeId, + @JsonProperty("owner") Owner owner, + @JsonProperty("private") boolean _private, + @JsonProperty("stargazers_count") int stargazersCount, + @JsonProperty("subscribers_count") int subscribersCount, + @JsonProperty("url") String 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..daa93a6 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Bucket.java @@ -0,0 +1,31 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public record Bucket(OffsetDateTime start, OffsetDateTime 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); + } + } +} 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..b738eff --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Collected.java @@ -0,0 +1,69 @@ +package com.giovds.dto.github.internal; + +import java.util.List; + +public record Collected( + String homepage, + int starsCount, + int forksCount, + int subscribersCount, + int issues, + List contributors, + List 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() { + return new Collected(homepage, starsCount, forksCount, subscribersCount, issues, contributors, 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..7f2bd73 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Contributor.java @@ -0,0 +1,29 @@ +package com.giovds.dto.github.internal; + +public record Contributor(String username, int 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() { + return new Contributor(username, 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..1f2f51d --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Point.java @@ -0,0 +1,31 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public record Point(OffsetDateTime date, int 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() { + return new Point(date, 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..1a7d798 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/Range.java @@ -0,0 +1,38 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; +import java.util.List; + +public record Range(OffsetDateTime start, OffsetDateTime end, List 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() { + return new Range(start, end, 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..f289a10 --- /dev/null +++ b/src/main/java/com/giovds/dto/github/internal/RangeSummary.java @@ -0,0 +1,50 @@ +package com.giovds.dto.github.internal; + +import java.time.OffsetDateTime; + +public record RangeSummary( + OffsetDateTime start, + OffsetDateTime end, + int 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() { + return new RangeSummary(start, end, count); + } + + @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..c89b062 --- /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.commits(); + if (commits.isEmpty()) { + return 0; + } + + var range30 = findRange(commits, 30); + var range180 = findRange(commits, 180); + var range365 = findRange(commits, 365); + + var mean30 = range30.count(); + var mean180 = range180.count() / (180.0d / 30.0d); + var mean365 = range365.count() / (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.start(), range.end()).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..8d7b40b --- /dev/null +++ b/src/main/java/com/giovds/http/JsonBodyHandler.java @@ -0,0 +1,44 @@ +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(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()); + } +}