diff --git a/src/main/java/io/spring/issuebot/question/QuestionConfiguration.java b/src/main/java/io/spring/issuebot/question/QuestionConfiguration.java new file mode 100644 index 0000000..0c9b8ad --- /dev/null +++ b/src/main/java/io/spring/issuebot/question/QuestionConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2015-2018 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.question; + +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.spring.issuebot.IssueListener; +import io.spring.issuebot.MonitoringProperties; +import io.spring.issuebot.MonitoringProperties.Repository; +import io.spring.issuebot.MultiRepositoryIssueListener; +import io.spring.issuebot.RoutingMultiRepositoryIssueListener; +import io.spring.issuebot.github.GitHubOperations; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Central configuration for the beans involved in managing issues that are waiting for + * feedback. + * + * @author Spencer Gibb + */ +@Configuration +@EnableConfigurationProperties(QuestionProperties.class) +class QuestionConfiguration { + + @Bean + MultiRepositoryIssueListener questionIssueListener( + MonitoringProperties monitoringProperties, GitHubOperations gitHub, + QuestionProperties questionProperties) { + Map delegates = monitoringProperties.getRepositories() + .stream().collect(Collectors.toMap(Function.identity(), + (repository) -> createListener(gitHub, questionProperties))); + return new RoutingMultiRepositoryIssueListener(delegates); + } + + private QuestionIssueListener createListener(GitHubOperations gitHub, + QuestionProperties questionProperties) { + return new QuestionIssueListener(gitHub, questionProperties.getLabel(), + questionProperties.getCloseComment(), Collections.emptyList()); + } + +} diff --git a/src/main/java/io/spring/issuebot/question/QuestionIssueListener.java b/src/main/java/io/spring/issuebot/question/QuestionIssueListener.java new file mode 100644 index 0000000..954de16 --- /dev/null +++ b/src/main/java/io/spring/issuebot/question/QuestionIssueListener.java @@ -0,0 +1,100 @@ +/* + * Copyright 2015-2018 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.question; + +import java.time.OffsetDateTime; +import java.util.List; + +import io.spring.issuebot.IssueListener; +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 closes questions that are old. + * + * @author Spencer Gibb + */ +final class QuestionIssueListener implements IssueListener { + + private final GitHubOperations gitHub; + + private final String labelName; + + private final String closeComment; + + private final List issueListeners; + + QuestionIssueListener(GitHubOperations gitHub, String labelName, String closeComment, + List issueListeners) { + this.gitHub = gitHub; + this.labelName = labelName; + this.closeComment = closeComment; + this.issueListeners = issueListeners; + } + + @Override + public void onOpenIssue(Issue issue) { + if (labelledAsQuestion(issue)) { + OffsetDateTime questionSince = getQuestionSince(issue); + if (questionSince != null) { + questionSince(issue, questionSince); + } + } + } + + private void questionSince(Issue issue, OffsetDateTime questionSince) { + OffsetDateTime now = OffsetDateTime.now(); + if (questionSince.plusMonths(6).isBefore(now)) { + close(issue); + } + } + + private void close(Issue issue) { + this.gitHub.addComment(issue, this.closeComment); + this.gitHub.close(issue); + this.issueListeners.forEach((listener) -> listener.onIssueClosure(issue)); + } + + private boolean labelledAsQuestion(Issue issue) { + for (Label label : issue.getLabels()) { + if (this.labelName.equals(label.getName())) { + return true; + } + } + return false; + } + + @SuppressWarnings("Duplicates") + private OffsetDateTime getQuestionSince(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; + } + +} diff --git a/src/main/java/io/spring/issuebot/question/QuestionProperties.java b/src/main/java/io/spring/issuebot/question/QuestionProperties.java new file mode 100644 index 0000000..3f2750f --- /dev/null +++ b/src/main/java/io/spring/issuebot/question/QuestionProperties.java @@ -0,0 +1,58 @@ +/* + * Copyright 2015-2018 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.question; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +/** + * {@link EnableConfigurationProperties Configuration properties} for configuring the + * monitoring of old questions. + * + * @author Spencer Gibb + */ +@ConfigurationProperties(prefix = "issuebot.question") +final class QuestionProperties { + + /** + * Name of the label that marks an issue as a question. + */ + private String label; + + /** + * The text of the comment that is added when an issue is clsed as feedback has not + * been provided. + */ + private String closeComment; + + public String getLabel() { + return this.label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getCloseComment() { + return this.closeComment; + } + + public void setCloseComment(String closeComment) { + this.closeComment = closeComment; + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa3b827..74c95dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,4 +66,9 @@ issuebot: will be closed. close_comment: > Closing due to lack of requested feedback. If you would like us to look at this - issue, please provide the requested information and we will re-open the issue. \ No newline at end of file + issue, please provide the requested information and we will re-open the issue. + question: + label: "question" + close_comment: > + Closing due to age of the question. If you would like us to look at this + issue, please comment and we will look at re-opening the issue. diff --git a/src/test/java/io/spring/issuebot/question/QuestionIssueListenerTests.java b/src/test/java/io/spring/issuebot/question/QuestionIssueListenerTests.java new file mode 100644 index 0000000..15ed9f0 --- /dev/null +++ b/src/test/java/io/spring/issuebot/question/QuestionIssueListenerTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2015-2018 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.question; + +import java.time.OffsetDateTime; +import java.util.Arrays; + +import io.spring.issuebot.IssueListener; +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.PullRequest; +import io.spring.issuebot.github.StandardPage; +import org.junit.Test; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link QuestionIssueListener}. + * + * @author Spencer Gibb + */ +public class QuestionIssueListenerTests { + + private final GitHubOperations gitHub = mock(GitHubOperations.class); + + private final IssueListener issueListener = mock(IssueListener.class); + + private final IssueListener listener = new QuestionIssueListener(this.gitHub, + "question", "Closing old question", Arrays.asList(this.issueListener)); + + @Test + public void nonQuestionsAreIgnored() { + Issue issue = new Issue(null, null, null, null, null, + Arrays.asList(new Label("foo")), null, new PullRequest("url")); + this.listener.onOpenIssue(issue); + verifyNoMoreInteractions(this.gitHub, this.issueListener); + } + + @Test + public void questionsLessThanSixMonthsAreIgnored() { + Issue issue = new Issue(null, null, null, null, null, + Arrays.asList(new Label("question")), null, null); + OffsetDateTime requestTime = OffsetDateTime.now(); + given(this.gitHub.getEvents(issue)).willReturn(new StandardPage<>( + Arrays.asList(new Event("labeled", requestTime, new Label("required"))), + () -> null)); + this.listener.onOpenIssue(issue); + verify(this.gitHub).getEvents(issue); + verifyNoMoreInteractions(this.gitHub); + } + + @Test + public void questionsOlderThanSixMonthsAreClosed() { + Issue issue = new Issue("http://github/old/issue", null, null, null, null, + Arrays.asList(new Label("question")), null, null); + OffsetDateTime requestTime = OffsetDateTime.now().minusMonths(7); + given(this.gitHub.getEvents(issue)).willReturn(new StandardPage<>( + Arrays.asList(new Event("labeled", requestTime, new Label("question"))), + () -> null)); + this.listener.onOpenIssue(issue); + verify(this.gitHub).addComment(issue, "Closing old question"); + verify(this.gitHub).close(issue); + verify(this.issueListener).onIssueClosure(issue); + } + +}