diff options
author | Chris Rebert <code@rebertia.com> | 2015-04-13 22:50:05 +0300 |
---|---|---|
committer | Chris Rebert <code@rebertia.com> | 2015-04-13 22:50:05 +0300 |
commit | 0cb8f2e4e7debf746440c39240fb13b9aa7e316c (patch) | |
tree | 0739091806dfb259488c57e98ecd5762267238f3 /src | |
parent | 704cf9282ef0e2297118bf3d89c3aac8e5312919 (diff) |
Alpha version
Diffstat (limited to 'src')
14 files changed, 188 insertions, 89 deletions
diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..6c94489 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,11 @@ +<configuration> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{ISO8601, UTC} [%-5level] %logger{36} - %msg%n</pattern> + </encoder> + </appender> + + <root level="info"> + <appender-ref ref="STDOUT" /> + </root> +</configuration> diff --git a/src/main/scala/com/getbootstrap/no_carrier/Main.scala b/src/main/scala/com/getbootstrap/no_carrier/Main.scala index 6d08e54..0983de5 100644 --- a/src/main/scala/com/getbootstrap/no_carrier/Main.scala +++ b/src/main/scala/com/getbootstrap/no_carrier/Main.scala @@ -1,41 +1,78 @@ package com.getbootstrap.no_carrier +import java.time.Duration import scala.util.{Success,Failure} import scala.util.Try -import org.eclipse.egit.github.core.RepositoryId -import com.getbootstrap.no_carrier.github.{IssueFilters, IssueLabel, Credentials} -import com.getbootstrap.no_carrier.github.issues_filter.{All=>AllIssues} -import com.getbootstrap.no_carrier.github.issue_state.{All=>OpenOrClosed} +import com.jcabi.github.Issue +import com.jcabi.github.Coordinates.{Simple=>RepoId} +import com.typesafe.scalalogging.StrictLogging +import com.getbootstrap.no_carrier.util._ +import com.getbootstrap.no_carrier.github.{Credentials, FancyIssue} import com.getbootstrap.no_carrier.github.util._ +case class Arguments( + credentials: Credentials, + repoId: RepoId, + label: String, + timeout: Duration +) -object Main extends App { - val arguments = args.toSeq - val argsPort = arguments match { - case Seq(portStr: String) => { - Try{ portStr.toInt } match { - case Failure(_) => { - System.err.println("USAGE: no-carrier <username> <password> <owner/repo> <label> <duration>") - System.exit(1) - None // dead code - } - case Success(portNum) => Some(portNum) - } +object Main extends App with StrictLogging { + val enabled = false + val arguments = (args.toSeq match { + case Seq(username, password, RepositoryId(repoId), NonEmptyStr(label), IntFromStr(PositiveInt(dayCount))) => { + Some(Arguments( + Credentials(username = username, password = password), + repoId = repoId, + label = label, + timeout = java.time.Duration.ofDays(dayCount) + )) + } + case _ => { + System.err.println("USAGE: no-carrier <username> <password> <owner/repo> <label> <days>") + System.exit(1) + None // dead code } - case Seq() => None + }).get + + main(arguments) + + def main(args: Arguments) { + logger.info("Started session.") + val github = args.credentials.github + val repo = github.repos.get(args.repoId) + + val waitingOnOp = repo.issues.openWithLabel(args.label) + val opNeverDelivered = waitingOnOp.filter{ issue => new FancyIssue(issue = issue, label = args.label, timeout = args.timeout).opNeverDelivered } + val totalClosed = opNeverDelivered.map { issue => + if (closeOut(issue, args.timeout)) 1 else 0 + }.sum + logger.info(s"Closed ${totalClosed} issues.") + logger.info("Session complete; exiting.") } - implicit val repoId = RepositoryId.createFromId("twbs/bootstrap") - val credentials = Credentials("username", "pass") - val client = credentials.client - implicit val issueService = client.issuesService - val labels = Set(new IssueLabel("awaiting reply")) - val filters = IssueFilters(filter = AllIssues, state = OpenOrClosed, labels = labels) - val issues = issueService.issuesWhere(repoId, filters) - for { issue <- issues } { - for { event <- issue.events } { - event.getEvent - "labeled" - "unlabeled" + + def closeOut(issue: Issue, timeout: Duration): Boolean = { + logger.info(s"OP never delivered on issue #${issue.number}. Going to close it out.") + if (enabled) { + val explanatoryComment = + s"""This issue is being automatically closed since the original poster (or another relevant commenter) hasn't responded to the question or request made to them ${timeout.toDays} days ago. + |We are therefore assuming that the user has lost interest in this issue or was able to resolve their problem on their own. + |If the user does later end up responding, a team member will be happy to reopen this issue. + |After a long period of further inactivity, this issue may get automatically locked. + |""".stripMargin + issue.smart.createdAt.toInstant + val attempt = Try{ issue.comments.post(explanatoryComment) }.flatMap{ comment => { + logger.info(s"Posted comment #${comment.number}") + Try{ issue.smart.close() } + }} + attempt match { + case Success(_) => logger.info(s"Closed issue #${issue.number}") + case Failure(exc) => logger.error(s"Error when trying to close out issue #${issue.number}", exc) + } + attempt.isSuccess + } + else { + false } } } diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/Credentials.scala b/src/main/scala/com/getbootstrap/no_carrier/github/Credentials.scala index 6dc73fc..2e22aaf 100644 --- a/src/main/scala/com/getbootstrap/no_carrier/github/Credentials.scala +++ b/src/main/scala/com/getbootstrap/no_carrier/github/Credentials.scala @@ -1,11 +1,9 @@ package com.getbootstrap.no_carrier.github -import org.eclipse.egit.github.core.client.GitHubClient +import com.jcabi.github.{Github, RtGithub} +import com.jcabi.http.wire.RetryWire case class Credentials(username: String, password: String) { - def client = { - val c = new GitHubClient() - c.setCredentials(username, password) - c - } + private def basicGithub: Github = new RtGithub(username, password) + def github: Github = new RtGithub(basicGithub.entry.through(classOf[RetryWire])) // FIXME: use RetryCarefulWire once it's available } diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/FancyIssue.scala b/src/main/scala/com/getbootstrap/no_carrier/github/FancyIssue.scala new file mode 100644 index 0000000..ba5f210 --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/github/FancyIssue.scala @@ -0,0 +1,20 @@ +package com.getbootstrap.no_carrier.github + +import java.time.{Instant, Duration} +import com.jcabi.github.Issue +import com.getbootstrap.no_carrier.util._ +import com.getbootstrap.no_carrier.github.util._ +import InstantOrdering._ + +class FancyIssue(val issue: Issue, val label: String, val timeout: Duration) { + lazy val lastLabelledAt: Instant = issue.lastLabelledWithAt(label).get + lazy val lastCommentedOnAt: Instant = issue.smartComments.map{ _.createdAt.toInstant }.max + lazy val lastClosedAt: Option[Instant] = issue.smartEvents.filter{ _.isClosed }.map{ _.createdAt.toInstant }.maxOption + lazy val hasSubsequentComment: Boolean = lastLabelledAt < lastCommentedOnAt + lazy val wasClosedAfterLabelling: Boolean = lastClosedAt match { + case None => false + case Some(closedAt) => lastLabelledAt < closedAt + } + lazy val isPastDeadline: Boolean = lastLabelledAt isBeyondTimeout timeout + lazy val opNeverDelivered: Boolean = isPastDeadline && !wasClosedAfterLabelling && !hasSubsequentComment +} diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/IssueFilters.scala b/src/main/scala/com/getbootstrap/no_carrier/github/IssueFilters.scala deleted file mode 100644 index 816294b..0000000 --- a/src/main/scala/com/getbootstrap/no_carrier/github/IssueFilters.scala +++ /dev/null @@ -1,12 +0,0 @@ -package com.getbootstrap.no_carrier.github - -import com.getbootstrap.no_carrier.github.issue_state.IssueStateForSearch -import com.getbootstrap.no_carrier.github.issues_filter.IssuesFilter - -case class IssueFilters(filter: IssuesFilter, state: IssueStateForSearch, labels: Set[IssueLabel]) { - def asFilterData: Map[String, String] = Map( - "filter" -> filter.codename, - "state" -> state.codename, - "labels" -> labels.map{ label => label.name }.mkString(",") - ) -} diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/IssueLabel.scala b/src/main/scala/com/getbootstrap/no_carrier/github/IssueLabel.scala deleted file mode 100644 index 745852e..0000000 --- a/src/main/scala/com/getbootstrap/no_carrier/github/IssueLabel.scala +++ /dev/null @@ -1,3 +0,0 @@ -package com.getbootstrap.no_carrier.github - -class IssueLabel(val name: String) extends AnyVal diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/IssueNumber.scala b/src/main/scala/com/getbootstrap/no_carrier/github/IssueNumber.scala deleted file mode 100644 index 8a26771..0000000 --- a/src/main/scala/com/getbootstrap/no_carrier/github/IssueNumber.scala +++ /dev/null @@ -1,3 +0,0 @@ -package com.getbootstrap.no_carrier.github - -class IssueNumber(val number: Int) extends AnyVal diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/issues_filter/IssuesFilter.scala b/src/main/scala/com/getbootstrap/no_carrier/github/issues_filter/IssuesFilter.scala deleted file mode 100644 index 4db0497..0000000 --- a/src/main/scala/com/getbootstrap/no_carrier/github/issues_filter/IssuesFilter.scala +++ /dev/null @@ -1,20 +0,0 @@ -package com.getbootstrap.no_carrier.github.issues_filter - -sealed trait IssuesFilter { - def codename: String -} -object AssignedToYou extends IssuesFilter { - override val codename = "assigned" -} -object CreatedByYou extends IssuesFilter { - override val codename = "created" -} -object MentioningYou extends IssuesFilter { - override val codename = "mentioned" -} -object SubscribedToByYou extends IssuesFilter { - override val codename = "subscribed" -} -object All extends IssuesFilter { - override val codename = "all" -} diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/util/RepositoryId.scala b/src/main/scala/com/getbootstrap/no_carrier/github/util/RepositoryId.scala new file mode 100644 index 0000000..d870b62 --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/github/util/RepositoryId.scala @@ -0,0 +1,14 @@ +package com.getbootstrap.no_carrier.github.util + +import com.jcabi.github.Coordinates.{Simple=>RepoId} + +object RepositoryId { + private val OwnerSlashRepo = "([a-zA-Z0-9_-]+)/([a-zA-Z0-9_-]+)".r + + def unapply(ownerRepo: String): Option[RepoId] = { + ownerRepo match { + case OwnerSlashRepo(owner, repo) => Some(new RepoId(owner, repo)) + case _ => None + } + } +} diff --git a/src/main/scala/com/getbootstrap/no_carrier/github/util/package.scala b/src/main/scala/com/getbootstrap/no_carrier/github/util/package.scala index be6da50..0984afa 100644 --- a/src/main/scala/com/getbootstrap/no_carrier/github/util/package.scala +++ b/src/main/scala/com/getbootstrap/no_carrier/github/util/package.scala @@ -1,26 +1,46 @@ package com.getbootstrap.no_carrier.github +import java.util.EnumMap +import java.time.Instant +import javax.json.JsonObject +import scala.util.{Try,Success} import scala.collection.JavaConverters._ -import org.eclipse.egit.github.core.{RepositoryId, Issue} -import org.eclipse.egit.github.core.client.GitHubClient -import org.eclipse.egit.github.core.service.IssueService +import com.jcabi.github.{Event=>IssueEvent, Issue, Issues, Search} +import com.jcabi.github.Issue.{Smart=>SmartIssue} +import com.jcabi.github.Event.{Smart=>SmartIssueEvent} +import com.jcabi.github.Comment.{Smart=>SmartComment} +import com.getbootstrap.no_carrier.util._ package object util { - implicit class RichClient(client: GitHubClient) { - def issuesService = new IssueService(client) - } - implicit class RichIssueService(issueService: IssueService) { - private def pageIssues(repo: RepositoryId, filters: IssueFilters) = issueService.pageIssues(repo, filters.asFilterData.asJava) - def issuesWhere(repo: RepositoryId, filters: IssueFilters): Iterator[Issue] = { - val pageIter = pageIssues(repo, filters) - val issuesIter = pageIter.iterator().asScala.flatten - issuesIter + implicit class RichIssues(issues: Issues) { + private def openWithLabelQuery(label: String) = { + val params = new EnumMap[Issues.Qualifier, String](classOf[Issues.Qualifier]) + params.put(Issues.Qualifier.STATE, issue_state.Open.codename) + params.put(Issues.Qualifier.LABELS, label) + params } - private def eventPages(repo: RepositoryId, issueNum: IssueNumber) = issueService.pageIssueEvents(repo.getOwner, repo.getName, issueNum.number) - def eventsFor(repo: RepositoryId, issueNum: IssueNumber) = issueService.eventPages(repo, issueNum).iterator().asScala.flatten + def openWithLabel(label: String): Iterable[Issue] = issues.search(Issues.Sort.UPDATED, Search.Order.ASC, openWithLabelQuery(label)).asScala } + implicit class RichIssue(issue: Issue) { - def number: IssueNumber = new IssueNumber(issue.getNumber) - def events(implicit issueService: IssueService, repo: RepositoryId) = issueService.eventsFor(repo, issue.number) + def smart: SmartIssue = new SmartIssue(issue) + def smartEvents: Iterable[SmartIssueEvent] = issue.events.asScala.map{ new SmartIssueEvent(_) } + def smartComments: Iterable[SmartComment] = issue.comments.iterate.asScala.map{ new SmartComment(_) } + + def lastLabelledWithAt(label: String): Option[Instant] = { + val labellings = issue.smartEvents.filter{ event => event.isLabeled && event.label == Some(label) } + labellings.map{ _.createdAt.toInstant }.maxOption + } + } + + implicit class RichSmartIssueEvent(event: SmartIssueEvent) { + def isLabeled: Boolean = event.`type` == IssueEvent.LABELED + def isClosed: Boolean = event.`type` == IssueEvent.CLOSED + + def label: Option[String] = { + Try {Option[JsonObject](event.json.getJsonObject("label")).map {_.getString("name")}}.recoverWith { + case _: ClassCastException => Success(None) + }.get + } } } diff --git a/src/main/scala/com/getbootstrap/no_carrier/util/IntFromStr.scala b/src/main/scala/com/getbootstrap/no_carrier/util/IntFromStr.scala new file mode 100644 index 0000000..0f35f42 --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/util/IntFromStr.scala @@ -0,0 +1,7 @@ +package com.getbootstrap.no_carrier.util + +import scala.util.Try + +object IntFromStr { + def unapply(str: String): Option[Int] = Try{ Integer.parseInt(str) }.toOption +} diff --git a/src/main/scala/com/getbootstrap/no_carrier/util/NonEmptyStr.scala b/src/main/scala/com/getbootstrap/no_carrier/util/NonEmptyStr.scala new file mode 100644 index 0000000..f60f824 --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/util/NonEmptyStr.scala @@ -0,0 +1,5 @@ +package com.getbootstrap.no_carrier.util + +object NonEmptyStr { + def unapply(str: String): Option[String] = if (str.nonEmpty) Some(str) else None +} diff --git a/src/main/scala/com/getbootstrap/no_carrier/util/PositiveInt.scala b/src/main/scala/com/getbootstrap/no_carrier/util/PositiveInt.scala new file mode 100644 index 0000000..de01da2 --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/util/PositiveInt.scala @@ -0,0 +1,5 @@ +package com.getbootstrap.no_carrier.util + +object PositiveInt { + def unapply(int: Int): Option[Int] = if (int > 0) Some(int) else None +} diff --git a/src/main/scala/com/getbootstrap/no_carrier/util/package.scala b/src/main/scala/com/getbootstrap/no_carrier/util/package.scala new file mode 100644 index 0000000..88a811d --- /dev/null +++ b/src/main/scala/com/getbootstrap/no_carrier/util/package.scala @@ -0,0 +1,20 @@ +package com.getbootstrap.no_carrier + +import java.time.{Duration, Instant} + +package object util { + val InstantOrdering = implicitly[Ordering[Instant]] + + implicit class RichInstant(instant: Instant) { + import InstantOrdering._ + + def +(duration: Duration) = instant.plus(duration) + def isBeyondTimeout(timeout: Duration): Boolean = (instant + timeout) < Instant.now() + } + + implicit class RichTraversableOnce[T](trav: TraversableOnce[T]) { + def maxOption(implicit cmp: Ordering[T]): Option[T] = { + if (trav.isEmpty) None else Some(trav.max) + } + } +} |