Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/twbs/no-carrier.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorChris Rebert <code@rebertia.com>2015-04-13 22:50:05 +0300
committerChris Rebert <code@rebertia.com>2015-04-13 22:50:05 +0300
commit0cb8f2e4e7debf746440c39240fb13b9aa7e316c (patch)
tree0739091806dfb259488c57e98ecd5762267238f3 /src
parent704cf9282ef0e2297118bf3d89c3aac8e5312919 (diff)
Alpha version
Diffstat (limited to 'src')
-rw-r--r--src/main/resources/logback.xml11
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/Main.scala95
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/Credentials.scala10
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/FancyIssue.scala20
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/IssueFilters.scala12
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/IssueLabel.scala3
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/IssueNumber.scala3
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/issues_filter/IssuesFilter.scala20
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/util/RepositoryId.scala14
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/github/util/package.scala52
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/util/IntFromStr.scala7
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/util/NonEmptyStr.scala5
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/util/PositiveInt.scala5
-rw-r--r--src/main/scala/com/getbootstrap/no_carrier/util/package.scala20
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)
+ }
+ }
+}