Skip to content

Commit

Permalink
Add early support for Sentry Releases
Browse files Browse the repository at this point in the history
See #47
  • Loading branch information
rtyley committed May 17, 2017
1 parent 456d960 commit 31dda76
Show file tree
Hide file tree
Showing 9 changed files with 227 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .prout.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"checkpoints": {
"PROD": { "url": "https://prout-bot.herokuapp.com/", "overdue": "10M" }
}
},
"sentry": ["prout"]
}
19 changes: 14 additions & 5 deletions app/lib/Config.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ import com.madgag.git._
import com.madgag.scalagithub.model.PullRequest
import com.madgag.time.Implicits._
import com.netaporter.uri.Uri
import lib.Config.{Checkpoint, CheckpointDetails}
import lib.Config.{Checkpoint, CheckpointDetails, Sentry}
import lib.travis.TravisCI
import org.eclipse.jgit.lib.ObjectId
import org.joda
import org.joda.time.Period
import play.api.data.validation.ValidationError
import play.api.libs.json.{Json, _}

case class ConfigFile(checkpoints: Map[String, CheckpointDetails]) {
case class ConfigFile(checkpoints: Map[String, CheckpointDetails],
sentry: Option[Sentry] = None) {

lazy val checkpointsByName: Map[String, Checkpoint] = checkpoints.map {
case (name, details) => name -> Checkpoint(name, details)
Expand Down Expand Up @@ -45,6 +46,12 @@ object Config {

case class AfterSeen(travis: Option[TravisCI])

case class Sentry(projects: Seq[String])

object Sentry {
implicit val readsSentry = Json.reads[Sentry]
}

object AfterSeen {
implicit val readsAfterSeen = Json.reads[AfterSeen]
}
Expand All @@ -53,7 +60,7 @@ object Config {

implicit val readsConfig = Json.reads[ConfigFile]

def readConfigFrom(configFileObjectId: ObjectId)(implicit repoThreadLocal: ThreadLocalObjectDatabaseResources) = {
def readConfigFrom(configFileObjectId: ObjectId)(implicit repoThreadLocal: ThreadLocalObjectDatabaseResources): JsResult[ConfigFile] = {
implicit val reader = repoThreadLocal.reader()
val fileJson = Json.parse(configFileObjectId.open.getCachedBytes(4096))
Json.fromJson[ConfigFile](fileJson)
Expand All @@ -78,8 +85,10 @@ object Config {
lazy val nameMarkdown = s"[$name](${details.url})"
}

case class RepoConfig(checkpointsByFolder: Map[String, JsResult[ConfigFile]]) {
val validConfigByFolder: Map[String, ConfigFile] = checkpointsByFolder.collect {
case class RepoConfig(
configByFolder: Map[String, JsResult[ConfigFile]]
) {
val validConfigByFolder: Map[String, ConfigFile] = configByFolder.collect {
case (folder, JsSuccess(config, _)) => folder -> config
}

Expand Down
3 changes: 2 additions & 1 deletion app/lib/ConfigFinder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package lib

import com.madgag.git._
import lib.Config.RepoConfig
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
import org.eclipse.jgit.treewalk.TreeWalk

Expand All @@ -13,7 +14,7 @@ object ConfigFinder {
w.isSubtree || w.getNameString == ProutConfigFileName
}

def configIdMapFrom(c: RevCommit)(implicit repoThreadLocal: ThreadLocalObjectDatabaseResources) = {
def configIdMapFrom(c: RevCommit)(implicit repoThreadLocal: ThreadLocalObjectDatabaseResources): Map[String, ObjectId] = {
implicit val reader = repoThreadLocal.reader()
walk(c.getTree)(configFilter).map { tw =>
val configPath = tw.slashPrefixedPath
Expand Down
5 changes: 5 additions & 0 deletions app/lib/PullRequestCheckpointsStateChangeSummary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ case class PRCheckpointState(statusByCheckpoint: Map[String, PullRequestCheckpoi
def changeFrom(oldState: PRCheckpointState) =
(statusByCheckpoint.toSet -- oldState.statusByCheckpoint.toSet).toMap

val isEmpty = statusByCheckpoint.isEmpty

}

case class PRCommitVisibility(seen: Set[RevCommit], unseen: Set[RevCommit])
Expand Down Expand Up @@ -81,9 +83,12 @@ case class PullRequestCheckpointsStateChangeSummary(

override val newPersistableState = checkpointStatuses

val newlyMerged = oldState.isEmpty && !newPersistableState.isEmpty

val changed: Set[EverythingYouWantToKnowAboutACheckpoint] =
checkpointStatuses.changeFrom(oldState).keySet.map(prCheckpointDetails.everythingByCheckpointName)

val changedByState: Map[PullRequestCheckpointStatus, Set[EverythingYouWantToKnowAboutACheckpoint]] =
changed.groupBy(_.checkpointStatus)

}
59 changes: 50 additions & 9 deletions app/lib/RepoSnapshot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ import lib.gitgithub.{IssueUpdater, LabelMapping}
import lib.labels._
import lib.librato.LibratoApiClient
import lib.librato.model.{Annotation, Link}
import lib.sentry.SentryApiClient
import lib.sentry.model.CreateRelease
import lib.travis.TravisApiClient
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.{ObjectId, Repository}
import org.eclipse.jgit.revwalk.{RevCommit, RevWalk}
import play.api.Logger

Expand Down Expand Up @@ -152,11 +154,12 @@ case class RepoSnapshot(

Logger.info(s"${repo.full_name} pullRequestsByAffectedFolder : ${pullRequestsByAffectedFolder.mapValues(_.map(_.number))}")

lazy val activeConfByPullRequest: Map[PullRequest, Set[ConfigFile]] = affectedFoldersByPullRequest.mapValues {
_.map(config.validConfigByFolder(_))
}



lazy val activeConfigByPullRequest: Map[PullRequest, Set[Checkpoint]] = affectedFoldersByPullRequest.mapValues {
_.flatMap(config.validConfigByFolder(_).checkpointSet)
lazy val activeCheckpointsByPullRequest: Map[PullRequest, Set[Checkpoint]] = activeConfByPullRequest.mapValues {
_.flatMap(_.checkpointSet)
}

val allAvailableCheckpoints: Set[Checkpoint] = config.checkpointsByName.values.toSet
Expand All @@ -170,14 +173,14 @@ case class RepoSnapshot(
for {
snapshots <- snapshotOfAllAvailableCheckpoints()
} yield {
Diagnostic(snapshots, mergedPullRequests.map(pr => PRCheckpointDetails(pr, snapshots.filter(s => activeConfigByPullRequest(pr).contains(s.checkpoint)), gitRepo)))
Diagnostic(snapshots, mergedPullRequests.map(pr => PRCheckpointDetails(pr, snapshots.filter(s => activeCheckpointsByPullRequest(pr).contains(s.checkpoint)), gitRepo)))
}
}

def snapshotOfAllAvailableCheckpoints(): Future[Set[CheckpointSnapshot]] =
Future.sequence(allAvailableCheckpoints.map(takeCheckpointSnapshot))

val activeCheckpoints: Set[Checkpoint] = activeConfigByPullRequest.values.flatten.toSet
val activeCheckpoints: Set[Checkpoint] = activeCheckpointsByPullRequest.values.flatten.toSet

lazy val snapshotsOfActiveCheckpointsF: Map[Checkpoint, Future[CheckpointSnapshot]] =
activeCheckpoints.map { c => c -> takeCheckpointSnapshot(c) }.toMap
Expand All @@ -196,7 +199,7 @@ case class RepoSnapshot(
lazy val activeSnapshotsF = Future.sequence(activeCheckpoints.map(snapshotsOfActiveCheckpointsF))

def checkpointSnapshotsFor(pr: PullRequest, oldState: PRCheckpointState): Future[Set[CheckpointSnapshot]] =
Future.sequence(activeConfigByPullRequest(pr).filter(!oldState.hasSeen(_)).map(snapshotsOfActiveCheckpointsF))
Future.sequence(activeCheckpointsByPullRequest(pr).filter(!oldState.hasSeen(_)).map(snapshotsOfActiveCheckpointsF))

val issueUpdater = new IssueUpdater[PullRequest, PRCheckpointState, PullRequestCheckpointsStateChangeSummary] with LazyLogging {
val repo = self.repo
Expand Down Expand Up @@ -226,6 +229,38 @@ case class RepoSnapshot(
val pr = snapshot.prCheckpointDetails.pr
val now = Instant.now()


val sentryProjects = for {
configs <- activeConfByPullRequest.get(pr).toSeq
config <- configs
sentryConf <- config.sentry.toSeq
sentryProject <- sentryConf.projects
} yield sentryProject

if (snapshot.newlyMerged) {
activeCheckpointsByPullRequest
logger.info(s"action taking: ${pr.prId} is newly merged")

for {
sentry <- SentryApiClient.instanceOpt.toSeq if sentryProjects.nonEmpty
mergeCommit <- pr.merge_commit_sha
} {
val mergeRef = lib.sentry.model.Ref(
repo.repoId,
mergeCommit,
Some(pr.base.sha))

sentry.createRelease(CreateRelease(
mergeCommit.name,
Some(mergeCommit.name),
Some(pr.html_url),
sentryProjects.toSeq,
refs=Seq(mergeRef)
))
}
}


val newlySeenSnapshots = snapshot.changedByState.get(Seen).toSeq.flatten

logger.info(s"action taking: ${pr.prId} newlySeenSnapshots = $newlySeenSnapshots")
Expand Down Expand Up @@ -276,7 +311,13 @@ case class RepoSnapshot(
slack.DeployReporter.report(snapshot, hooks)
}

commentOn(Seen, "Please check your changes!")
val sentryDetails: Option[String] = for {
sentry <- SentryApiClient.instanceOpt if sentryProjects.nonEmpty
} yield {
"Sentry Release information:" + sentryProjects.map(project => s"* ${sentry.releasePageMarkdownFor(project)}").mkString("\n")
}

commentOn(Seen, (Seq("Please check your changes!") ++ sentryDetails).mkString("\n\n"))
commentOn(Overdue, "What's gone wrong?")
}
}
Expand Down
56 changes: 56 additions & 0 deletions app/lib/sentry/SentryApiClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package lib.sentry

import com.madgag.okhttpscala._
import com.netaporter.uri.Uri
import com.typesafe.scalalogging.LazyLogging
import lib.sentry.model.CreateRelease
import okhttp3.Request.Builder
import okhttp3._
import play.api.libs.json.Json.{stringify, toJson}

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future

class SentryApiClient(token: String , org: String) extends LazyLogging {

val okHttpClient = new OkHttpClient

val baseEndpoint = Uri.parse("https://sentry.io/api/0/")

val JsonMediaType = MediaType.parse("application/json")

def createRelease(createReleaseCommand: CreateRelease): Future[_] = {

val request = new Builder().url(s"$baseEndpoint/organizations/$org/releases/")
.header("Authorization", s"Bearer $token")
.post(RequestBody.create(JsonMediaType, stringify(toJson(createReleaseCommand))))
.build()

val responseF = okHttpClient.execute(request)(resp => logger.info(resp.body().string()))
responseF.onComplete {
tr => logger.info("Response from Sentry: " + tr)
}
responseF
}

def releasePageUrlFor(project: String)= s"https://sentry.io/$org/$project/releases/"

def releasePageMarkdownFor(project: String)= s"[$project](${releasePageUrlFor(project)})"




}


object SentryApiClient {

import play.api.Play.current
val config = play.api.Play.configuration

lazy val instanceOpt = for {
org <- config.getString("sentry.org")
token <- config.getString("sentry.token")
} yield new SentryApiClient(token,org)

}
76 changes: 76 additions & 0 deletions app/lib/sentry/model/Sentry.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package lib.sentry.model

import java.time.Instant

import com.netaporter.uri.Uri
import org.eclipse.jgit.lib.ObjectId
import play.api.libs.json.{JsString, Json, Writes}
import Sentry._
import com.madgag.scalagithub.model.RepoId

object Sentry {
implicit val writesObjectId = new Writes[ObjectId] {
def writes(oid: ObjectId) = JsString(oid.name)
}

}

/**
commits (array) – an optional list of commit data to be associated with the release.
Commits must include parameters id (the sha of the commit), and can optionally include
repository, message, author_name, author_email, and timestamp.
*/
case class Commit(
id: ObjectId,
repository: Option[String],
message: Option[String],
author_name: Option[String],
author_email: Option[String],
timestamp: Option[Instant]
)

object Commit {
implicit val writesCommit = Json.writes[Commit]
}

case class Ref(
repository: RepoId,
commit: ObjectId,
previousCommit: Option[ObjectId] = None
)

object Ref {
implicit val writesRepoId = new Writes[RepoId] {
def writes(repoId: RepoId) = JsString(repoId.fullName)
}
implicit val writesRef = Json.writes[Ref]
}

/*
https://docs.sentry.io/api/releases/post-organization-releases/
version (string) – a version identifier for this release. Can be a version number, a commit hash etc.
ref (string) – an optional commit reference. This is useful if a tagged version has been provided.
url (url) – a URL that points to the release. This can be the path to an online interface to the sourcecode for instance.
projects (array) – a list of project slugs that are involved in this release
dateReleased (datetime) – an optional date that indicates when the release went live. If not provided the current time is assumed.
commits (array) – an optional list of commit data to be associated with the release. Commits must include parameters id (the sha of the commit), and can optionally include repository, message, author_name, author_email, and timestamp.
refs (array) – an optional way to indicate the start and end commits for each repository included in a release. Head commits must include parameters repository and commit (the HEAD sha). They can optionally include previousCommit (the sha of the HEAD of the previous release), which should be specified if this is the first time you’ve sent commit data.
*/
case class CreateRelease(
version: String,
ref: Option[String] = None,
url: Option[Uri] = None,
projects: Seq[String],
dateReleased: Option[Instant] = None,
commits: Seq[Commit] = Seq.empty,
refs: Seq[Ref] = Seq.empty
)

object CreateRelease {
implicit val writesUri = new Writes[Uri] {
def writes(uri: Uri) = JsString(uri.toString)
}

implicit val writesCreateRelease = Json.writes[CreateRelease]
}
20 changes: 17 additions & 3 deletions app/views/userPages/repo.scala.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,22 @@ <h3>Config files</h3>
@if(!proutPresenceQuickCheck) {
<span class="octicon octicon-alert"></span>
Quick check for @ProutConfigFileName failed!
@if(repoSnapshot.config.checkpointsByFolder.nonEmpty) {
@if(repoSnapshot.config.configByFolder.nonEmpty) {
GitHub seems to be returning data different to the Git repo itself...
}
}

<p>
<ul>
@for((folder, configInFolder) <- repoSnapshot.config.checkpointsByFolder) {
@for((folder, configInFolder) <- repoSnapshot.config.configByFolder) {
<li>@configLink(folder) @configInFolder.asOpt match {
case None => {
<span class="octicon octicon-alert" title="Invalid Prout JSON"></span>
}
case Some(validConfig) => {
<span class="octicon octicon-check" title="Parsed as valid Prout JSON"></span>
<h5>Checkpoints</h5>
<ul>
<h5>Checkpoints</h5>
@for(checkpoint <- validConfig.checkpointSet) {
<li>
<b id="[email protected]">@checkpoint.name</b> - <a href="@checkpoint.details.url">@checkpoint.details.url</a>
Expand All @@ -67,7 +68,20 @@ <h5>Checkpoints</h5>
}
</li>
}

</ul>

@for(sentryConf <- validConfig.sentry) {
<h5>Sentry</h5>
<ul>
@for(sentryProject <- sentryConf.projects) {
<li>@sentryProject</li>
}
</ul>
@if(lib.sentry.SentryApiClient.instanceOpt.isEmpty) {
...but no Sentry credentials are available! <span class="octicon octicon-alert" title="No Sentry Creds"></span>
}
}
}
}
</li>
Expand Down
Loading

0 comments on commit 31dda76

Please sign in to comment.