diff --git a/.prout.json b/.prout.json index 77b0ba2..a11f2f4 100644 --- a/.prout.json +++ b/.prout.json @@ -1,5 +1,6 @@ { "checkpoints": { "PROD": { "url": "https://prout-bot.herokuapp.com/", "overdue": "10M" } - } + }, + "sentry": ["prout"] } diff --git a/app/lib/Config.scala b/app/lib/Config.scala index 686fc3e..58ab01c 100644 --- a/app/lib/Config.scala +++ b/app/lib/Config.scala @@ -6,7 +6,7 @@ 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 @@ -14,7 +14,8 @@ 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) @@ -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] } @@ -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) @@ -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 } diff --git a/app/lib/ConfigFinder.scala b/app/lib/ConfigFinder.scala index 77ba647..e515a7e 100644 --- a/app/lib/ConfigFinder.scala +++ b/app/lib/ConfigFinder.scala @@ -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 @@ -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 diff --git a/app/lib/PullRequestCheckpointsStateChangeSummary.scala b/app/lib/PullRequestCheckpointsStateChangeSummary.scala index 0f0ce22..dd9e33a 100644 --- a/app/lib/PullRequestCheckpointsStateChangeSummary.scala +++ b/app/lib/PullRequestCheckpointsStateChangeSummary.scala @@ -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]) @@ -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) + } diff --git a/app/lib/RepoSnapshot.scala b/app/lib/RepoSnapshot.scala index 92e82bf..29a4c79 100644 --- a/app/lib/RepoSnapshot.scala +++ b/app/lib/RepoSnapshot.scala @@ -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 @@ -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 @@ -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 @@ -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 @@ -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") @@ -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?") } } diff --git a/app/lib/sentry/SentryApiClient.scala b/app/lib/sentry/SentryApiClient.scala new file mode 100644 index 0000000..9fc0d90 --- /dev/null +++ b/app/lib/sentry/SentryApiClient.scala @@ -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) + +} diff --git a/app/lib/sentry/model/Sentry.scala b/app/lib/sentry/model/Sentry.scala new file mode 100644 index 0000000..cc0a308 --- /dev/null +++ b/app/lib/sentry/model/Sentry.scala @@ -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] +} \ No newline at end of file diff --git a/app/views/userPages/repo.scala.html b/app/views/userPages/repo.scala.html index 753c0e9..889e1af 100644 --- a/app/views/userPages/repo.scala.html +++ b/app/views/userPages/repo.scala.html @@ -28,21 +28,22 @@