diff --git a/pom.xml b/pom.xml index 0817777..63c550e 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,10 @@ + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + org.apache.httpcomponents httpclient diff --git a/src/main/java/io/spring/issuebot/GitHubProperties.java b/src/main/java/io/spring/issuebot/GitHubProperties.java index 0639c3f..e84df57 100644 --- a/src/main/java/io/spring/issuebot/GitHubProperties.java +++ b/src/main/java/io/spring/issuebot/GitHubProperties.java @@ -16,6 +16,8 @@ package io.spring.issuebot; +import java.util.List; + import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -54,6 +56,11 @@ public class GitHubProperties { */ private String name; + /** + * The names of the repository's collaborators. + */ + private List collaborators; + } /** diff --git a/src/main/java/io/spring/issuebot/IssueBotApplication.java b/src/main/java/io/spring/issuebot/IssueBotApplication.java index 64cc18f..b92cc13 100644 --- a/src/main/java/io/spring/issuebot/IssueBotApplication.java +++ b/src/main/java/io/spring/issuebot/IssueBotApplication.java @@ -22,6 +22,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.scheduling.annotation.EnableScheduling; import io.spring.issuebot.github.GitHubOperations; import io.spring.issuebot.github.GitHubTemplate; @@ -33,6 +34,7 @@ import io.spring.issuebot.github.RegexLinkParser; * @author Andy Wilkinson */ @SpringBootApplication +@EnableScheduling @EnableConfigurationProperties(GitHubProperties.class) public class IssueBotApplication { diff --git a/src/main/java/io/spring/issuebot/feedback/FeedbackConfiguration.java b/src/main/java/io/spring/issuebot/feedback/FeedbackConfiguration.java new file mode 100644 index 0000000..d50aafa --- /dev/null +++ b/src/main/java/io/spring/issuebot/feedback/FeedbackConfiguration.java @@ -0,0 +1,48 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.feedback; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.spring.issuebot.GitHubProperties; +import io.spring.issuebot.github.GitHubOperations; + +/** + * Central configuration for the beans involved in managing issues that are waiting for + * feedback. + * + * @author Andy Wilkinson + */ +@Configuration +@EnableConfigurationProperties(FeedbackProperties.class) +class FeedbackConfiguration { + + @Bean + FeedbackIssueListener feedbackIssueListener(GitHubOperations gitHub, + GitHubProperties githubProperties, FeedbackProperties feedbackProperties) { + return new FeedbackIssueListener(gitHub, feedbackProperties.getRequiredLabel(), + githubProperties.getRepository().getCollaborators(), + new StandardFeedbackListener(gitHub, + feedbackProperties.getProvidedLabel(), + feedbackProperties.getRequiredLabel(), + feedbackProperties.getReminderComment(), + feedbackProperties.getCloseComment())); + } + +} diff --git a/src/main/java/io/spring/issuebot/feedback/FeedbackIssueListener.java b/src/main/java/io/spring/issuebot/feedback/FeedbackIssueListener.java new file mode 100644 index 0000000..0118e87 --- /dev/null +++ b/src/main/java/io/spring/issuebot/feedback/FeedbackIssueListener.java @@ -0,0 +1,114 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.feedback; + +import java.time.OffsetDateTime; +import java.util.List; + +import io.spring.issuebot.IssueListener; +import io.spring.issuebot.github.Comment; +import io.spring.issuebot.github.Event; +import io.spring.issuebot.github.GitHubOperations; +import io.spring.issuebot.github.Issue; +import io.spring.issuebot.github.Label; +import io.spring.issuebot.github.Page; + +/** + * An {@link IssueListener} that processes issues that are waiting for feedback. + * + * @author Andy Wilkinson + */ +final class FeedbackIssueListener implements IssueListener { + + private final GitHubOperations gitHub; + + private final String labelName; + + private final List collaborators; + + private final FeedbackListener feedbackListener; + + FeedbackIssueListener(GitHubOperations gitHub, String labelName, + List collaborators, FeedbackListener feedbackListener) { + this.gitHub = gitHub; + this.labelName = labelName; + this.collaborators = collaborators; + this.feedbackListener = feedbackListener; + } + + @Override + public void onOpenIssue(Issue issue) { + if (waitingForFeedback(issue)) { + OffsetDateTime waitingSince = getWaitingSince(issue); + if (waitingSince != null) { + processWaitingIssue(issue, waitingSince); + } + } + } + + private void processWaitingIssue(Issue issue, OffsetDateTime waitingSince) { + if (commentedSince(waitingSince, issue)) { + this.feedbackListener.feedbackProvided(issue); + } + else { + this.feedbackListener.feedbackRequired(issue, waitingSince); + } + } + + private boolean waitingForFeedback(Issue issue) { + return issue.getPullRequest() == null && labelledAsWaitingForFeedback(issue); + } + + private boolean labelledAsWaitingForFeedback(Issue issue) { + for (Label label : issue.getLabels()) { + if (this.labelName.equals(label.getName())) { + return true; + } + } + return false; + } + + private OffsetDateTime getWaitingSince(Issue issue) { + OffsetDateTime createdAt = null; + Page page = this.gitHub.getEvents(issue); + while (page != null) { + for (Event event : page.getContent()) { + if (Event.Type.LABELED.equals(event.getType()) + && this.labelName.equals(event.getLabel().getName())) { + createdAt = event.getCreationTime(); + } + } + page = page.next(); + } + return createdAt; + } + + private boolean commentedSince(OffsetDateTime waitingForFeedbackSince, Issue issue) { + Page page = this.gitHub.getComments(issue); + while (page != null) { + for (Comment comment : page.getContent()) { + if (!this.collaborators.contains(comment.getUser().getLogin()) + && comment.getCreationTime().isAfter(waitingForFeedbackSince)) { + return true; + } + } + page = page.next(); + } + return false; + } + +} diff --git a/src/main/java/io/spring/issuebot/feedback/FeedbackListener.java b/src/main/java/io/spring/issuebot/feedback/FeedbackListener.java new file mode 100644 index 0000000..ea76f13 --- /dev/null +++ b/src/main/java/io/spring/issuebot/feedback/FeedbackListener.java @@ -0,0 +1,47 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.feedback; + +import java.time.OffsetDateTime; + +import io.spring.issuebot.github.Issue; + +/** + * A {@code FeedbackListener} is notified when feedback has been provided or is still + * required for an {@link Issue}. + * + * @author Andy Wilkinson + */ +public interface FeedbackListener { + + /** + * Notification that feedback has been provided for the given {@code issue}. + * + * @param issue the issue + */ + void feedbackProvided(Issue issue); + + /** + * Notification that feedback is still required for the given {@code issue} having + * been requested at the given {@code requestTime}. + * + * @param issue the issue + * @param requestTime the time when feedback was requested + */ + void feedbackRequired(Issue issue, OffsetDateTime requestTime); + +} diff --git a/src/main/java/io/spring/issuebot/feedback/FeedbackProperties.java b/src/main/java/io/spring/issuebot/feedback/FeedbackProperties.java new file mode 100644 index 0000000..b941f23 --- /dev/null +++ b/src/main/java/io/spring/issuebot/feedback/FeedbackProperties.java @@ -0,0 +1,56 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.feedback; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +/** + * {@link EnableConfigurationProperties Configuration properties} for configuring the + * monitoring of issues that require user feedback. + * + * @author Andy Wilkinson + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "issuebot.feedback") +final class FeedbackProperties { + + /** + * Name of the label that is applied when feedback is required. + */ + private String requiredLabel; + + /** + * Name of the label that is applied when feedback has been provided. + */ + private String providedLabel; + + /** + * The text of the comment that is added as a reminder that feedback is required. + */ + private String reminderComment; + + /** + * The text of the comment that is added when an issue is clsed as feedback has not + * been provided. + */ + private String closeComment; + +} diff --git a/src/main/java/io/spring/issuebot/feedback/StandardFeedbackListener.java b/src/main/java/io/spring/issuebot/feedback/StandardFeedbackListener.java new file mode 100644 index 0000000..9212637 --- /dev/null +++ b/src/main/java/io/spring/issuebot/feedback/StandardFeedbackListener.java @@ -0,0 +1,76 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.feedback; + +import java.time.OffsetDateTime; + +import io.spring.issuebot.github.GitHubOperations; +import io.spring.issuebot.github.Issue; + +/** + * Standard implementation of {@link FeedbackListener}. + * + * @author Andy Wilkinson + */ +final class StandardFeedbackListener implements FeedbackListener { + + private final GitHubOperations gitHub; + + private final String providedLabel; + + private final String requiredLabel; + + private final String reminderComment; + + private final String closeComment; + + StandardFeedbackListener(GitHubOperations gitHub, String providedLabel, + String requiredLabel, String reminderComment, String closeComment) { + this.gitHub = gitHub; + this.providedLabel = providedLabel; + this.requiredLabel = requiredLabel; + this.reminderComment = reminderComment; + this.closeComment = closeComment; + } + + @Override + public void feedbackProvided(Issue issue) { + this.gitHub.addLabel(issue, this.providedLabel); + this.gitHub.removeLabel(issue, this.requiredLabel); + } + + @Override + public void feedbackRequired(Issue issue, OffsetDateTime requestTime) { + OffsetDateTime now = OffsetDateTime.now(); + if (requestTime.plusDays(14).isBefore(now)) { + close(issue); + } + else if (requestTime.plusDays(7).isBefore(now)) { + remind(issue); + } + } + + private void close(Issue issue) { + this.gitHub.addComment(issue, this.closeComment); + this.gitHub.close(issue); + } + + private void remind(Issue issue) { + this.gitHub.addComment(issue, this.reminderComment); + } + +} diff --git a/src/main/java/io/spring/issuebot/github/Comment.java b/src/main/java/io/spring/issuebot/github/Comment.java index 481fc56..d872b3a 100644 --- a/src/main/java/io/spring/issuebot/github/Comment.java +++ b/src/main/java/io/spring/issuebot/github/Comment.java @@ -16,6 +16,8 @@ package io.spring.issuebot.github; +import java.time.OffsetDateTime; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; @@ -30,14 +32,20 @@ public final class Comment { private final User user; + private final OffsetDateTime creationTime; + /** - * Creates a new comment that was authored by the given {@code user}. + * Creates a new comment that was authored by the given {@code user} at the given + * {@code creationTime}. * * @param user the user + * @param creationTime the creation time */ @JsonCreator - public Comment(@JsonProperty("user") User user) { + public Comment(@JsonProperty("user") User user, + @JsonProperty("created_at") OffsetDateTime creationTime) { this.user = user; + this.creationTime = creationTime; } } diff --git a/src/main/java/io/spring/issuebot/github/Event.java b/src/main/java/io/spring/issuebot/github/Event.java new file mode 100644 index 0000000..d58f114 --- /dev/null +++ b/src/main/java/io/spring/issuebot/github/Event.java @@ -0,0 +1,164 @@ +/* + * Copyright 2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.spring.issuebot.github; + +import java.time.OffsetDateTime; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +/** + * An event that has been performed on an {@link Issue}. + * + * @author Andy Wilkinson + */ +@Getter +public class Event { + + private final Type type; + + private final OffsetDateTime creationTime; + + private final Label label; + + /** + * Creates a new {@code Event}. + * + * @param type the type of the event + * @param creationTime the timestamp of when the event was created + * @param label the label associated with the event + */ + @JsonCreator + public Event(@JsonProperty("event") String type, + @JsonProperty("created_at") OffsetDateTime creationTime, + @JsonProperty("label") Label label) { + this.type = Type.valueFrom(type); + this.creationTime = creationTime; + this.label = label; + } + + /** + * The type of an {@link Event}. + * + * @author Andy Wilkinson + */ + public enum Type { + + /** + * The issue was closed by the actor. + */ + CLOSED("closed"), + + /** + * The issue was reopened by the actor. + */ + REOPENED("reopened"), + + /** + * The actor subscribed to receive notifications for an issue. + */ + SUBSCRIBED("subscribed"), + + /** + * The issue was merged by the actor. + */ + MERGED("merged"), + + /** + * The issue was referenced from a commit message. + */ + REFERENCED("referenced"), + + /** + * The actor was {@code @mentioned} in an issue body. + */ + MENTIONED("mentioned"), + + /** + * The issue was assigned to the actor. + */ + ASSIGNED("assigned"), + + /** + * The actor was unassigned from the issue. + */ + UNASSIGNED("unassigned"), + + /** + * A label was added to the issue. + */ + LABELED("labeled"), + + /** + * A label was removed from the issue. + */ + UNLABELED("unlabeled"), + + /** + * The issue was added to a milestone. + */ + MILESTONED("milestoned"), + + /** + * The issue was removed from a milestone. + */ + DEMILESTONED("demilestoned"), + + /** + * The issue title was changed. + */ + RENAMED("renamed"), + + /** + * The issue was locked by the actor. + */ + LOCKED("locked"), + + /** + * The issue was unlocked by the actor. + */ + UNLOCKED("unlocked"), + + /** + * The pull request's branch was deleted. + */ + HEAD_REF_DELETED("head_ref_deleted"), + + /** + * The pull request's branch was restored. + */ + HEAD_REF_RESTORED("head_ref_restored"); + + private String type; + + Type(String type) { + this.type = type; + } + + static Type valueFrom(String type) { + for (Type value : values()) { + if (type.equals(value.type)) { + return value; + } + } + throw new IllegalArgumentException( + "'" + type + "' is not a valid event type"); + } + } + +} diff --git a/src/main/java/io/spring/issuebot/github/GitHubOperations.java b/src/main/java/io/spring/issuebot/github/GitHubOperations.java index f273455..10311fe 100644 --- a/src/main/java/io/spring/issuebot/github/GitHubOperations.java +++ b/src/main/java/io/spring/issuebot/github/GitHubOperations.java @@ -45,9 +45,43 @@ public interface GitHubOperations { * Adds the given {@code label} to the given {@code issue}. * * @param issue the issue - * @param label the label; - * @return the updated issue + * @param label the label + * @return the modified issue */ Issue addLabel(Issue issue, String label); + /** + * Removes the given {@code label} from the given {@code issue}. + * + * @param issue the issue + * @param label the label + * @return the modified issue + */ + Issue removeLabel(Issue issue, String label); + + /** + * Adds the given {@code comment} to the given {@code issue}. + * + * @param issue the issue + * @param comment the comment + * @return the added comment + */ + Comment addComment(Issue issue, String comment); + + /** + * Closes the given {@code issue}. + * + * @param issue the issue + * @return the modified issue + */ + Issue close(Issue issue); + + /** + * Returns the events that have occurred on the given {@code issue}. + * + * @param issue the issue + * @return the events + */ + Page getEvents(Issue issue); + } diff --git a/src/main/java/io/spring/issuebot/github/GitHubTemplate.java b/src/main/java/io/spring/issuebot/github/GitHubTemplate.java index a619096..eca652e 100644 --- a/src/main/java/io/spring/issuebot/github/GitHubTemplate.java +++ b/src/main/java/io/spring/issuebot/github/GitHubTemplate.java @@ -73,10 +73,6 @@ public class GitHubTemplate implements GitHubOperations { this.linkParser = linkParser; } - RestOperations getRestOperations() { - return this.rest; - } - static RestTemplate createDefaultRestTemplate(String username, String password) { RestTemplate rest = new RestTemplate(); rest.setErrorHandler(new DefaultResponseErrorHandler() { @@ -103,48 +99,83 @@ public class GitHubTemplate implements GitHubOperations { public Page getIssues(String organization, String repository) { String url = "https://api.github.com/repos/" + organization + "/" + repository + "/issues"; - return getIssues(url); + return getPage(url, Issue[].class); } - private Page getIssues(String url) { + @Override + public Page getComments(Issue issue) { + return getPage(issue.getCommentsUrl(), Comment[].class); + } + + @Override + public Page getEvents(Issue issue) { + return getPage(issue.getEventsUrl(), Event[].class); + } + + private Page getPage(String url, Class type) { if (!StringUtils.hasText(url)) { return null; } - ResponseEntity issues = this.rest.getForEntity(url, Issue[].class); - return new StandardPage(Arrays.asList(issues.getBody()), - () -> getIssues(getNextUrl(issues))); + ResponseEntity contents = this.rest.getForEntity(url, type); + return new StandardPage(Arrays.asList(contents.getBody()), + () -> getPage(getNextUrl(contents), type)); } private String getNextUrl(ResponseEntity response) { return this.linkParser.parse(response.getHeaders().getFirst("Link")).get("next"); } - @Override - public Page getComments(Issue issue) { - return getComments(issue.getCommentsUrl()); - } - - private Page getComments(String url) { - if (!StringUtils.hasText(url)) { - return null; - } - ResponseEntity comments = this.rest.getForEntity(url, Comment[].class); - return new StandardPage(Arrays.asList(comments.getBody()), - () -> getComments(getNextUrl(comments))); - } - @Override public Issue addLabel(Issue issue, String labelName) { Map body = new HashMap<>(); body.put("labels", Arrays.asList(labelName)); - ResponseEntity exchange = this.rest.exchange( + ResponseEntity response = this.rest.exchange( new RequestEntity<>(body, HttpMethod.POST, URI.create(issue.getUrl())), - Issue.class); - if (exchange.getStatusCode() != HttpStatus.OK) { + Label[].class); + if (response.getStatusCode() != HttpStatus.OK) { log.warn("Failed to add label to issue. Response status: " - + exchange.getStatusCode()); + + response.getStatusCode()); } - return exchange.getBody(); + return new Issue(issue.getUrl(), issue.getCommentsUrl(), issue.getEventsUrl(), + issue.getLabelsUrl(), issue.getUser(), Arrays.asList(response.getBody()), + issue.getMilestone(), issue.getPullRequest()); + } + + @Override + public Issue removeLabel(Issue issue, String labelName) { + ResponseEntity response = this.rest.exchange( + new RequestEntity(HttpMethod.DELETE, URI.create( + issue.getLabelsUrl().replace("{/name}", "/" + labelName))), + Label[].class); + if (response.getStatusCode() != HttpStatus.OK) { + log.warn("Failed to remove label from issue. Response status: " + + response.getStatusCode()); + } + return new Issue(issue.getUrl(), issue.getCommentsUrl(), issue.getEventsUrl(), + issue.getLabelsUrl(), issue.getUser(), Arrays.asList(response.getBody()), + issue.getMilestone(), issue.getPullRequest()); + } + + @Override + public Comment addComment(Issue issue, String comment) { + Map body = new HashMap<>(); + body.put("body", comment); + return this.rest.postForEntity(issue.getCommentsUrl(), body, Comment.class) + .getBody(); + } + + @Override + public Issue close(Issue issue) { + Map body = new HashMap<>(); + body.put("state", "closed"); + ResponseEntity response = this.rest.exchange( + new RequestEntity<>(body, HttpMethod.PATCH, URI.create(issue.getUrl())), + Issue.class); + if (response.getStatusCode() != HttpStatus.OK) { + log.warn("Failed to close issue. Response status: " + + response.getStatusCode()); + } + return response.getBody(); } private static class BasicAuthorizationInterceptor diff --git a/src/main/java/io/spring/issuebot/github/Issue.java b/src/main/java/io/spring/issuebot/github/Issue.java index c296855..f9b63ac 100644 --- a/src/main/java/io/spring/issuebot/github/Issue.java +++ b/src/main/java/io/spring/issuebot/github/Issue.java @@ -20,6 +20,7 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; import lombok.Getter; /** @@ -27,38 +28,65 @@ import lombok.Getter; * * @author Andy Wilkinson */ -@Getter public class Issue { + @Getter(AccessLevel.PACKAGE) private final String url; + @Getter(AccessLevel.PACKAGE) private final String commentsUrl; + @Getter(AccessLevel.PACKAGE) + private final String eventsUrl; + + @Getter(AccessLevel.PACKAGE) + private final String labelsUrl; + + @Getter private final User user; - private final List