Skip to content

Commit b23ac9b

Browse files
committed
Allow setting the in-reply-to header
Typeahead for identifying message-ids in a human-readable way Particularly like linking back to the Gmane article... lotsa jquery Add bootstrap-3-specific styling to the typeahead.js stuff Note the workaround for `typeahead.js-bootstrap3.less` not having quite updated to cope with a class-name change in `typeahead.js` v0.11.1 (made the suggestions background-transparent) - just tell typeahead.js to use the old class name: hyspace/typeahead.js-bootstrap3.less#24 (comment)
1 parent 993a6ff commit b23ac9b

File tree

15 files changed

+401
-27
lines changed

15 files changed

+401
-27
lines changed

app/controllers/Application.scala

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ import lib.actions.Actions._
1010
import lib.actions.Requests._
1111
import lib.aws.SES._
1212
import lib.aws.SesAsyncHelpers._
13-
import lib.model.{PatchBomb, PatchCommit, Patch}
13+
import lib.model.PatchBomb
1414
import org.eclipse.jgit.lib.ObjectId
1515
import org.kohsuke.github._
1616
import play.api.Logger
1717
import play.api.data.Form
1818
import play.api.data.Forms._
19+
import play.api.i18n.Messages.Implicits._
1920
import play.api.libs.json.Json
2021
import play.api.libs.json.Json._
2122
import play.api.mvc._
2223
import views.html.pullRequestSent
23-
import play.api.i18n.Messages.Implicits._
2424

2525
import scala.collection.convert.wrapAll._
2626
import scala.concurrent.ExecutionContext.Implicits.global
@@ -85,7 +85,8 @@ object Application extends Controller {
8585

8686
val mailSettingsForm = Form(
8787
mapping(
88-
"subjectPrefix" -> default(text(maxLength = 20), "PATCH")
88+
"subjectPrefix" -> default(text(maxLength = 20), "PATCH"),
89+
"inReplyTo" -> optional(text)
8990
)(PRMailSettings.apply)(PRMailSettings.unapply)
9091
)
9192

@@ -99,17 +100,26 @@ object Application extends Controller {
99100

100101
for (patchCommits <- req.patchCommitsF) yield {
101102
val patchBomb = PatchBomb(patchCommits, addresses, settings.subjectPrefix, mailType.subjectPrefix, mailType.footer(req.pr))
102-
for (initialMessageId <- ses.send(patchBomb.emails.head)) {
103+
val initialEmail = patchBomb.emails.head
104+
val initialEmailWithReply = settings.inReplyTo.fold(initialEmail)(initialEmail.inReplyTo)
105+
for (initialMessageId <- ses.send(initialEmailWithReply)) {
103106
for (email <- patchBomb.emails.drop(1)) {
104107
ses.send(email.inReplyTo(initialMessageId))
105108
}
106109

107110
mailType.afterSending(req.pr, initialMessageId)
108111
}
109-
Ok(pullRequestSent(req.pr, req.user, mailType)).addingToSession(prId.slug -> Json.toJson(settings).toString)
112+
Ok(pullRequestSent(req.pr, req.user, mailType)).addingToSession(prId.slug -> toJson(settings).toString)
110113
}
111114
}
112115

116+
def messageLookup(repoId: RepoId, query: String) = Action.async {
117+
val archives = Project.byRepoId(repoId).mailingList.archives
118+
for {
119+
messagesOpt <- Future.find(archives.map(_.lookupMessage(query)))(_.nonEmpty)
120+
} yield Ok(toJson(messagesOpt.toSeq.flatten: Seq[MessageSummary]))
121+
}
122+
113123
lazy val gitCommitId = {
114124
val g = gitCommitIdFromHerokuFile
115125
Logger.info(s"Heroku dyno commit id $g")

app/lib/MailArchive.scala

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,67 @@
11
package lib
22

3+
import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder}
4+
import java.time.temporal.ChronoField._
5+
import java.time.{LocalDateTime, ZoneId, ZonedDateTime}
6+
import javax.mail.internet.MailDateFormat
7+
8+
import com.madgag.okhttpscala._
9+
import com.squareup.okhttp
10+
import com.squareup.okhttp.OkHttpClient
11+
import controllers.Application._
12+
import fastparse.core.Result
13+
import lib.Email.Addresses
14+
import lib.model.PatchParsing
15+
import org.jsoup.Jsoup
16+
import org.jsoup.nodes.Element
17+
import play.api.libs.json.Json
18+
19+
import scala.collection.convert.wrapAsScala._
20+
import scala.concurrent.{ExecutionContext, Future}
21+
22+
case class MessageSummary(id: String, subject: String, date: ZonedDateTime, addresses: Addresses, groupLink: String)
23+
24+
object MessageSummary {
25+
implicit val formatsAddresses = Json.format[Addresses]
26+
implicit val formatsMessageSummary = Json.format[MessageSummary]
27+
28+
def fromRawMessage(rawMessage: String, articleUrl: String): MessageSummary = {
29+
val Result.Success(headers, _) = PatchParsing.headers.parse(rawMessage)
30+
val headerMap = headers.toMap
31+
32+
val messageId = headerMap("Message-ID").stripPrefix("<").stripSuffix(">")
33+
val from = headerMap("From")
34+
val date = new MailDateFormat().parse(headerMap("Date")).toInstant.atZone(ZoneId.of("UTC"))
35+
MessageSummary(messageId, headerMap("Subject"), date, Addresses(from), articleUrl)
36+
}
37+
}
38+
39+
object RedirectCapturer {
40+
val okClient = {
41+
val c = new OkHttpClient()
42+
c.setFollowRedirects(false)
43+
c
44+
}
45+
46+
def redirectFor(url: String)(implicit ec: ExecutionContext): Future[Option[String]] = for {
47+
resp <- okClient.execute(new okhttp.Request.Builder().url(url).build())
48+
} yield {
49+
resp.code match {
50+
case FOUND => Some(resp.header(LOCATION))
51+
case _ => None
52+
}
53+
}
54+
}
55+
56+
357
trait MailArchive {
458
val providerName: String
559

660
val url: String
761

862
def linkFor(messageId: String): String
63+
64+
def lookupMessage(query: String)(implicit ec: ExecutionContext): Future[Seq[MessageSummary]] = Future.successful(Seq.empty)
965
}
1066

1167
case class Gmane(groupName: String) extends MailArchive {
@@ -14,6 +70,82 @@ case class Gmane(groupName: String) extends MailArchive {
1470
val url = s"http://dir.gmane.org/gmane.$groupName"
1571

1672
def linkFor(messageId: String) = s"http://mid.gmane.org/$messageId"
73+
74+
override def lookupMessage(query: String)(implicit ec: ExecutionContext) = {
75+
for {
76+
gmaneArticleUrlOpt <- gmaneArticleUrlFor(query)
77+
gmaneRawArticleOpt <- gmaneRawArticleFor(gmaneArticleUrlOpt)
78+
} yield gmaneRawArticleOpt.toSeq
79+
}
80+
81+
def gmaneRawArticleFor(articleUrlOpt: Option[String])(implicit ec: ExecutionContext): Future[Option[MessageSummary]] = {
82+
articleUrlOpt match {
83+
case Some(articleUrl) =>
84+
val okClient = new OkHttpClient()
85+
for {
86+
resp <- okClient.execute(new okhttp.Request.Builder().url(articleUrl+"/raw").build())
87+
} yield Some(MessageSummary.fromRawMessage(resp.body.string, articleUrl))
88+
case None => Future.successful(None)
89+
}
90+
}
91+
92+
def gmaneArticleUrlFor(messageId: String)(implicit ec: ExecutionContext): Future[Option[String]] =
93+
RedirectCapturer.redirectFor(linkFor(messageId))
94+
95+
}
96+
97+
object Marc {
98+
val Git = Marc("git")
99+
100+
def deobfuscate(emailOrMessageId: String) = emailOrMessageId.replace(" () ","@").replace(" ! ", ".")
101+
102+
/*
103+
* Hacks EVERYWHERE - MARC unfortunately doesn't expose this data in a nice format for us
104+
*/
105+
def messageSummaryFor(articleHtml: String): MessageSummary = {
106+
val elements = Jsoup.parse(articleHtml).select("""pre b font[size="+1"]""")
107+
val nodes = elements.get(0).childNodes().toList
108+
val headerMap = (for { header :: value :: Nil <- nodes.grouped(2) } yield {
109+
header.outerHtml.trim.stripSuffix(":") -> value.asInstanceOf[Element].html()
110+
}).toMap
111+
val messageId = deobfuscate(headerMap("Message-ID"))
112+
val from = deobfuscate(headerMap("From"))
113+
114+
val ISO_LOCAL_TIME = new DateTimeFormatterBuilder().appendValue(HOUR_OF_DAY).appendLiteral(':').appendValue(MINUTE_OF_HOUR).optionalStart.appendLiteral(':').appendValue(SECOND_OF_MINUTE).toFormatter
115+
val ISO_LOCAL_DATE_TIME = new DateTimeFormatterBuilder().parseCaseInsensitive.append(DateTimeFormatter.ISO_LOCAL_DATE).appendLiteral('T').append(ISO_LOCAL_TIME).toFormatter
116+
117+
val date = LocalDateTime.parse(headerMap("Date").replace(' ', 'T'), ISO_LOCAL_DATE_TIME).atZone(ZoneId.of("UTC"))
118+
MessageSummary(messageId, headerMap("Subject"), date, Addresses(from), "")
119+
}
120+
}
121+
122+
case class Marc(groupName: String) extends MailArchive {
123+
val providerName = "MARC"
124+
125+
val url = s"http://marc.info/?l=$groupName"
126+
127+
def linkFor(messageId: String) = s"http://marc.info/?i=$messageId"
128+
129+
override def lookupMessage(query: String)(implicit ec: ExecutionContext) = {
130+
for {
131+
articleUrlOpt <- articleUrl(query)
132+
articleMessageSummaryOpt <- messageSummaryFor(articleUrlOpt)
133+
} yield articleMessageSummaryOpt.map(_.copy(id = query)).toSeq
134+
}
135+
136+
def articleUrl(messageId: String)(implicit ec: ExecutionContext): Future[Option[String]] =
137+
RedirectCapturer.redirectFor(linkFor(messageId))
138+
139+
def messageSummaryFor(articleUrlOpt: Option[String])(implicit ec: ExecutionContext): Future[Option[MessageSummary]] = {
140+
articleUrlOpt match {
141+
case Some(articleUrl) =>
142+
val okClient = new OkHttpClient()
143+
for {
144+
resp <- okClient.execute(new okhttp.Request.Builder().url(articleUrl).build())
145+
} yield Some(Marc.messageSummaryFor(resp.body.string).copy(groupLink = articleUrl))
146+
case None => Future.successful(None)
147+
}
148+
}
17149
}
18150

19151
object Gmane {

app/lib/PRMailSettings.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package lib
22

33
import play.api.libs.json.Json
44

5-
case class PRMailSettings(subjectPrefix: String)
5+
case class PRMailSettings(subjectPrefix: String, inReplyTo: Option[String] = None)
66

77
object PRMailSettings {
88
implicit val formats = Json.format[PRMailSettings]

app/lib/ProjectRepo.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ object Project {
88
val Git = Project(
99
RepoId("git", "git"),
1010
MailingList("git@vger.kernel.org", Seq(
11-
Gmane.Git
11+
Gmane.Git,
12+
Marc.Git
1213
// MailArchiveDotCom("git@vger.kernel.org") -- message-id search appears broken
1314
))
1415
)

app/lib/model/Patch.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ object PatchParsing {
1919

2020
val patchHeaderRegion: P[ObjectId] = P(patchFromHeader ~! nonEmptyLine.rep)
2121

22+
val headerKey = P(CharsWhile(_ != ':', min = 1).! ~ ": ")
23+
24+
val headerValue = P((CharsWhile(_ != '\n', min = 1) ~ ("\n" ~ " ".rep(min = 1) ~ CharsWhile(_ != '\n', min = 1)).rep).!)
25+
26+
val header: P[(String, String)] = P(headerKey ~ headerValue)
27+
28+
val headers: P[Seq[(String, String)]] = P(header.rep(sep = "\n"))
29+
2230
val patchBodyRegion = P((line ~ !patchFromHeader).rep.!)
2331

2432
val patch: P[Patch] = P(patchHeaderRegion ~ "\n" ~! patchBodyRegion).map(Patch.tupled)
Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
@(addresses: lib.Email.Addresses)
2-
@display(label: String, ids: Seq[String]) = {
1+
@(addresses: lib.Email.Addresses)(implicit fc : views.html.b3.B3FieldConstructor)
2+
3+
4+
@display(label: String, ids: Seq[String]) = {
35
@if(ids.nonEmpty) {
4-
<label class="col-sm-4 control-label">@label</label>
5-
<div class="col-sm-8">
6-
<p class="form-control-static">@ids.mkString(", ")</p>
7-
</div>
6+
@b3.static(label) { @ids.mkString(", ")}
7+
88
}
99
}
1010

11-
<div class="form-group">
1211
@display("From", Seq(addresses.from))
1312
@display("To", addresses.to)
1413
@display("Cc", addresses.cc)
1514
@display("Bcc", addresses.bcc)
1615
@display("Reply-To", addresses.replyTo.toSeq)
17-
</div>

app/views/fragments/head.scala.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
<link rel="stylesheet" media="screen" href="@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")" />
55
<script type="text/javascript" src="@routes.Assets.at("lib/jquery/jquery.js")"></script>
66
<script type="text/javascript" src="@routes.Assets.at("lib/bootstrap/js/bootstrap.min.js")"></script>
7+
<script type="text/javascript" src="@routes.Assets.at("lib/typeahead.js/dist/typeahead.bundle.js")"></script>
8+
<script type="text/javascript" src="@routes.Assets.at("lib/timeago/jquery.timeago.js")"></script>
9+
<script type="text/javascript" src="@routes.Assets.at("lib/handlebars/dist/handlebars.js")"></script>
10+
<script type="text/javascript" src="@routes.Assets.at("javascript/messageIdPicker.js")"></script>
711
<link rel="stylesheet" href="@routes.Assets.at("lib/octicons/octicons/octicons.css")">
12+
<link rel="stylesheet" href="@routes.Assets.at("lib/typeahead.js-bootstrap3.less/typeahead.css")">
813
<link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
914

1015
@for(googleAnalyticsId <- lib.AnalyticsConfig.googleAnalyticsIdOpt) {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@(messageSummary: lib.MessageSummary)
2+
<small>
3+
<h5 title="@messageSummary.subject"
4+
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; margin: 0px;">@messageSummary.subject</h5>
5+
@messageSummary.addresses.from -
6+
<a target="_blank" href="{{groupLink}}"><time class="timeago" datetime="{{date}}">{{date}}</time></a>
7+
</small>
Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
@import java.time.Instant
2+
@import lib.MessageSummary
3+
@import lib.Email.Addresses
14
@(mailType: lib.MailType, proposedMail: lib.ProposedMail, isDefault: Boolean, btnClass: String
25
)(implicit req: GHPRRequest[_], prMailSettings: Form[lib.PRMailSettings], messages: Messages)
36
@import helper._
4-
@implicitFieldConstructor = @{ b3.horizontal.fieldConstructor("col-md-4", "col-md-8") }
7+
@implicitFieldConstructor = @{ b3.vertical.fieldConstructor }
58

69
<div role="tabpanel" class="tab-pane @if(isDefault) { fade in active }" id="mail-@mailType.slug">
7-
@b3.form(routes.Application.mailPullRequest(req.pr.id, mailType)) {
8-
@CSRF.formField
910

1011
@if(proposedMail.errors.nonEmpty) {
1112
<div class="alert alert-warning" role="alert">
@@ -17,7 +18,12 @@
1718
</div>
1819
}
1920

20-
@fragments.emailAddresses(proposedMail.addresses)
21+
<div class="form-horizontal addresses">
22+
@fragments.emailAddresses(proposedMail.addresses)(b3.horizontal.fieldConstructor("col-md-2", "col-md-10"))
23+
</div>
24+
25+
@b3.form(routes.Application.mailPullRequest(req.pr.id, mailType)) {
26+
@CSRF.formField
2127

2228
@b3.inputWrapped("text", prMailSettings("subjectPrefix"), '_label -> "Subject prefix", 'placeholder -> "PATCH", 'maxlength -> "20") { input =>
2329
<div class="input-group">
@@ -27,14 +33,49 @@
2733
</div>
2834
}
2935

30-
<div class="form-group">
31-
<div class="col-sm-offset-4 col-sm-8">
32-
<button
33-
class="btn @btnClass btn-lg"
34-
value="send"
35-
@if(proposedMail.errors.nonEmpty) { disabled="disabled" }
36-
>Send</button>
36+
@b3.inputWrapped("text", prMailSettings("inReplyTo"), '_label -> "In-reply-to", 'placeholder -> "message-id (optional)", 'class -> "form-control typeahead") { input =>
37+
<div id="@mailType.slug-in-reply-to">
38+
<div class="input-group">
39+
<span class="input-group-addon">&lt;</span>
40+
@input
41+
<span class="input-group-addon">&gt;</span>
42+
</div>
43+
<div class="message-ident panel panel-default" style="padding: 3%; display: none;">
44+
<button type="button" class="close" aria-label="Remove"><span aria-hidden="true">&times;</span></button>
45+
<div class="message-ident-content"></div>
46+
</div>
3747
</div>
38-
</div>
48+
}
49+
50+
<script id="entry-template" type="text/x-handlebars-template">
51+
@fragments.mailIdent(MessageSummary("{{id}}", "{{subject}}", java.time.ZonedDateTime.now, Addresses("{{addresses.from}}"), "{{groupLink}}"))
52+
</script>
53+
<script>
54+
jQuery("#@mailType.slug-in-reply-to").messageIdPicker({
55+
hint: true,
56+
highlight: true,
57+
minLength: 3,
58+
classNames: {menu: 'tt-dropdown-menu'}
59+
}, {
60+
name: 'messageIds',
61+
source: new Bloodhound({
62+
datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
63+
queryTokenizer: Bloodhound.tokenizers.whitespace,
64+
remote: {
65+
url: '@routes.Application.messageLookup(req.repo.id, "QUERY_STRING")',
66+
wildcard: 'QUERY_STRING'
67+
}
68+
}),
69+
display: 'id',
70+
templates: {
71+
empty: '<div class="empty-message">Unable to find any Messages that match that Id</div>',
72+
suggestion: Handlebars.compile(jQuery("#entry-template").html())
73+
}
74+
});
75+
</script>
76+
77+
@b3.submit('class -> s"btn $btnClass btn-lg", 'disabled -> proposedMail.errors.nonEmpty) {
78+
Send
79+
}
3980
}
4081
</div>

build.sbt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@ libraryDependencies ++= Seq(
3838
"org.webjars" % "bootstrap" % "3.3.4",
3939
"com.adrianhurt" %% "play-bootstrap3" % "0.4.4-P24",
4040
"org.webjars.bower" % "octicons" % "2.2.3",
41+
"org.webjars.bower" % "timeago" % "1.4.1",
42+
"org.webjars.bower" % "typeahead.js" % "0.11.1",
43+
"org.webjars.bower" % "typeahead.js-bootstrap3.less" % "0.2.3",
44+
"org.webjars.npm" % "handlebars" % "3.0.3",
4145
"com.lihaoyi" %% "fastparse" % "0.1.7",
46+
"org.jsoup" % "jsoup" % "1.8.2",
4247
"com.github.nscala-time" %% "nscala-time" % "2.0.0",
4348
"com.github.scala-incubator.io" %% "scala-io-file" % "0.4.3-1",
4449
"org.specs2" %% "specs2-core" % "2.4.17" % "test",

0 commit comments

Comments
 (0)