├── .github └── workflows │ ├── scala.yml │ └── snyk.yml ├── .gitignore ├── .jvmopts ├── .scalafix.conf ├── .scalafmt.conf ├── LICENSE ├── README.md ├── build.sbt ├── cloud ├── README.md ├── deploy.sh ├── postman_collection.json ├── tip-cloud.yaml ├── tip-create-board.js ├── tip-get-board.js ├── tip-get-head-board.js ├── tip-verify-head-path.js └── tip-verify-path.js ├── docs ├── how-it-works.md ├── how-to-release.md └── tip-architecture.png ├── examples ├── tip-assert │ ├── .gitignore │ ├── README.md │ ├── build.sbt │ ├── project │ │ └── build.properties │ └── src │ │ └── main │ │ ├── resources │ │ └── logback.xml │ │ └── scala │ │ └── example │ │ ├── Main.scala │ │ ├── PetService.scala │ │ └── validatePetCreation.scala └── tip-minimal │ ├── .gitignore │ ├── README.md │ ├── build.sbt │ ├── project │ └── build.properties │ └── src │ └── main │ ├── resources │ ├── logback.xml │ └── tip.yaml │ └── scala │ └── example │ └── Hello.scala ├── project ├── Dependencies.scala ├── build.properties └── plugins.sbt ├── scalastyle-config.xml ├── sonatype.sbt ├── src ├── main │ ├── resources │ │ └── application.conf │ └── scala │ │ └── com │ │ └── gu │ │ └── tip │ │ ├── Configuration.scala │ │ ├── GitHubApi.scala │ │ ├── HttpClient.scala │ │ ├── Log.scala │ │ ├── Notifier.scala │ │ ├── PathsActor.scala │ │ ├── Tip.scala │ │ ├── assertion │ │ └── TipAssert.scala │ │ └── cloud │ │ └── TipCloudApi.scala └── test │ ├── resources │ ├── application.conf │ ├── tip-bad.yaml │ └── tip.yaml │ └── scala │ └── com │ └── gu │ └── tip │ ├── HttpClientTest.scala │ ├── NotifierTest.scala │ ├── PathsActorTest.scala │ ├── TipTest.scala │ └── assertion │ └── TipAssertTest.scala └── version.sbt /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up JDK 1.8 17 | uses: actions/setup-java@v1 18 | with: 19 | java-version: 1.8 20 | - name: Run tests 21 | run: sbt test 22 | -------------------------------------------------------------------------------- /.github/workflows/snyk.yml: -------------------------------------------------------------------------------- 1 | name: snyk monitor 2 | on: push 3 | jobs: 4 | security: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: Run Snyk to check for vulnerabilities 9 | uses: snyk/actions/scala@master 10 | env: 11 | SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} 12 | with: 13 | args: --org=the-guardian-cuu --project-name=guardian/tip 14 | command: monitor 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .bsp 3 | .metals 4 | target/ 5 | *.zip 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.jvmopts: -------------------------------------------------------------------------------- 1 | -Xmx1G 2 | -XX:MaxMetaspaceSize=1G 3 | -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | RemoveUnusedImports 3 | RemoveUnusedTerms 4 | ExplicitResultTypes 5 | ] 6 | 7 | ExplicitResultTypes { 8 | unsafeShortenNames = true 9 | } -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = default 2 | align = true # For pretty alignment. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Testing in Production (TiP) 2 | 3 | 4 | How to verify user journeys are not broken without writing a single test? 5 | 6 | * First time a production user completes a path the corresponding square on the board lights up green: 7 | ![board_example](https://user-images.githubusercontent.com/13835317/43644305-342da90c-9726-11e8-8563-026403792153.png) 8 | 9 | * Once all paths have been completed, label is set on the corresponding pull request: 10 | ![pr_label_example](https://user-images.githubusercontent.com/13835317/43644948-5ec1e7bc-9728-11e8-9b49-f4f095522811.png) 11 | and a message is written to logs `All tests in production passed.` 12 | 13 | ## User Guide 14 | 15 | ### Tip.verify 16 | 17 | #### [Minimal configuration](examples/tip-minimal/README.md) 18 | 19 | 1. Add [library](https://maven-badges.herokuapp.com/maven-central/com.gu/tip_2.13) to your application's dependencies: 20 | ``` 21 | libraryDependencies += "com.gu" %% "tip" % "0.6.4" 22 | ``` 23 | 1. List paths to be covered in `tip.yaml` file and make sure it is on the classpath: 24 | ``` 25 | - name: Register 26 | description: User creates an account 27 | 28 | - name: Update User 29 | description: User changes account details 30 | ``` 31 | 1. Instantiate `Tip` with `TipConfig`: 32 | ```scala 33 | val tipConfig = TipConfig(repo = "guardian/identity", cloudEanbled = false) 34 | TipFactory.create(tipConfig) 35 | ``` 36 | 1. Call `tip.verify("My Path Name"")` at the point where you consider path has been successfully completed. 37 | 38 | #### Configuration with cloud enabled - single board 39 | 40 | 1. Instantiate `Tip` with `TipConfig` (which by default enables cloud):: 41 | ```scala 42 | val tipConfig = TipConfig("guardian/identity") 43 | TipFactory.create(tipConfig) 44 | ``` 45 | 1. Call `tip.verify("My Path Name"")` at the point where you consider path has been successfully completed. 46 | 1. Access board at `/{owner}/{repo}/boards/head` to monitor verification in real-time. 47 | 48 | #### Setting a label on PR 49 | Optionally, if you want Tip to notify when all paths have been hit by setting a label on the corresponding merged PR, then 50 | 1. [Create a GitHub label](https://help.github.com/articles/creating-and-editing-labels-for-issues-and-pull-requests/), for instance, a green label with name `Verified in PROD`: 51 | ![label_example](https://cloud.githubusercontent.com/assets/13835317/24609160/a1332296-1871-11e7-8bc7-e325c0be7b93.png) 52 | 1. [Create a GitHub personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with at least `public_repo` scope. **Keep this secret!** 53 | 1. Set `personalAccessToken` in `TipConfig`: 54 | ```scala 55 | TipConfig( 56 | repo = "guardian/identity", 57 | personalAccessToken = some-secret-token 58 | ) 59 | ``` 60 | 61 | #### Board by merge commit SHA 62 | Optionally, if you want to have a separate board for each merged PR, then 63 | 1. Set `boardSha` in `TipConfig`: 64 | ``` 65 | TipConfig( 66 | repo = "guardian/identity", 67 | boardSha = some-sha-value 68 | ) 69 | ``` 70 | 1. Example Tip configuration which uses [`sbt-buildinfo`](https://github.com/sbt/sbt-buildinfo) to set `boardSha`: 71 | ```scala 72 | TipConfig( 73 | repo = "guardian/identity", 74 | personalAccessToken = config.Tip.personalAccessToken, // remove if you do not need GitHub label functionality 75 | label = "Verified in PROD", // remove if you do not need GitHub label functionality 76 | boardSha = BuildInfo.GitHeadSha // remove if you need only one board instead of board per sha 77 | ) 78 | ``` 79 | build.sbt: 80 | ```scala 81 | buildInfoKeys := Seq[BuildInfoKey]( 82 | BuildInfoKey.constant("GitHeadSha", "git rev-parse HEAD".!!.trim) 83 | ) 84 | buildInfoPackage := "com.gu.identity.api" 85 | buildInfoOptions += BuildInfoOption.ToMap 86 | ) 87 | ``` 88 | 1. Access board at `/board/{sha}` 89 | 90 | ### [TipAssert](examples/tip-assert/README.md) 91 | 92 | `TipAssert` runs an assertion on a pass-by-name value and simply logs an error on failed 93 | assertion. The idea is to have assertions run on production behaviour off the main thread in a **separate** execution context which should not 94 | affect main business logic, whilst being used in combination with crash monitoring software (for example, Sentry) 95 | which can alert on `log.error` statement. 96 | 97 | Users should use a separate `ExecutionContext` dedicated just to assertions to make sure 98 | assertions are not starving main business logic thread pool: 99 | 100 | ```scala 101 | import com.gu.tip.assertion.ExecutionContext.assertionExecutionContext 102 | Future(/* request we will assert on */)(assertionExecutionContext) 103 | ``` 104 | 105 | _(Note Because `ExecutionContext` of a `Future` [cannot be changed](https://medium.com/@sderosiaux/are-scala-futures-the-past-69bd62b9c001#a10c) 106 | after `Future` definition, TiP cannot take care of this for the user.)_ 107 | 108 | For example, say we have a scenario where we take payments from user and want to make sure we have not double 109 | charged them. Given the following requests returning `Future`s 110 | 111 | ```scala 112 | def chargeUser(implicit ec: ExecutionContext): Future[_] 113 | def getNumberOfCharges(implicit ec: ExecutionContext): Future[Int] 114 | ``` 115 | 116 | then we could check if user has been double charged with 117 | ```scala 118 | import com.gu.tip.assertion.TipAssert 119 | import com.gu.tip.assertion.ExecutionContext.assertionExecutionContext 120 | 121 | chargeUser(mainExecutionContext) andThen { case _ => 122 | TipAssert( 123 | getNumberOfCharges(assertionExecutionContext), 124 | (num: Int) => num == 1, 125 | "User should be charged only once. Fix ASAP!" 126 | ) 127 | } 128 | ``` 129 | `TipAssert` can also handle eventually semantics via `max` and `delay` parameters for scenarios where 130 | database mutation is only eventually consistent. Here is the full signature: 131 | 132 | ```scala 133 | def apply[T]( 134 | f: => Future[T], 135 | p: T => Boolean, 136 | msg: String, 137 | max: Int = 1, 138 | delay: FiniteDuration = 0.seconds 139 | ): Future[AssertionResult] 140 | ``` 141 | 142 | Note currently `TipAssert` is not related to `Tip.verify` functionality in any way. One major semantic 143 | difference between the two is that `TipAssert` checks failed paths whilst `Tip.verify` checks successful paths. 144 | 145 | ## Releasing latest version of the library 146 | See [How to make a release](docs/how-to-release.md) 147 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import sbtrelease.ReleaseStateTransformations._ 2 | 3 | lazy val root = (project in file(".")).settings( 4 | name := "tip", 5 | description := "Scala library for testing in production.", 6 | organization := "com.gu", 7 | scalaVersion := "2.13.4", 8 | libraryDependencies ++= Dependencies.all, 9 | sources in doc in Compile := List(), 10 | releasePublishArtifactsAction := PgpKeys.publishSigned.value, 11 | releaseProcess := Seq[ReleaseStep]( 12 | checkSnapshotDependencies, 13 | inquireVersions, 14 | runClean, 15 | runTest, 16 | setReleaseVersion, 17 | commitReleaseVersion, 18 | tagRelease, 19 | publishArtifacts, 20 | setNextVersion, 21 | commitNextVersion, 22 | releaseStepCommand("sonatypeReleaseAll"), 23 | pushChanges 24 | ), 25 | (compile in Compile) := ((compile in Compile) dependsOn compileScalastyle).value, 26 | scalafmtTestOnCompile := true, 27 | scalafmtShowDiff := true, 28 | scalacOptions ++= Seq( 29 | "-Ywarn-unused:imports", 30 | "-Yrangepos" 31 | ), 32 | addCompilerPlugin(scalafixSemanticdb) 33 | ) 34 | 35 | lazy val compileScalastyle = taskKey[Unit]("compileScalastyle") 36 | compileScalastyle := scalastyle.in(Compile).toTask("").value 37 | -------------------------------------------------------------------------------- /cloud/README.md: -------------------------------------------------------------------------------- 1 | 1. Deploy lambdas by running `deploy.sh` 2 | 2. Cloudform API with `tip-cloud.yaml` 3 | 3. Update `tipCloudApiRoot` in `TipCloudApi.scala` 4 | 4. Publish library and update clients -------------------------------------------------------------------------------- /cloud/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | S3_BUCKET=deploy-tools-dist/deploy/PROD/tip 4 | S3_KEY=tip.zip 5 | 6 | parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P ) 7 | cd "$parent_path" 8 | 9 | echo "Cleaning previous package..." 10 | rm $S3_KEY 11 | 12 | echo "Zipping code..." 13 | zip -r $S3_KEY * 14 | 15 | echo "Copying to S3 bucket..." 16 | aws s3 cp $S3_KEY "s3://${S3_BUCKET}/" 17 | 18 | echo "Updating Lambda code" 19 | aws lambda update-function-code --function-name tip-create-board --s3-bucket $S3_BUCKET --s3-key $S3_KEY 20 | aws lambda update-function-code --function-name tip-get-board --s3-bucket $S3_BUCKET --s3-key $S3_KEY 21 | aws lambda update-function-code --function-name tip-get-head-board --s3-bucket $S3_BUCKET --s3-key $S3_KEY 22 | aws lambda update-function-code --function-name tip-verify-path --s3-bucket $S3_BUCKET --s3-key $S3_KEY 23 | aws lambda update-function-code --function-name tip-verify-head-path --s3-bucket $S3_BUCKET --s3-key $S3_KEY 24 | 25 | echo "Done." -------------------------------------------------------------------------------- /cloud/postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "86fbd04a-795e-4505-b9ce-9beb49e10380", 4 | "name": "Tip Cloud", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "create board", 10 | "request": { 11 | "method": "POST", 12 | "header": [ 13 | { 14 | "key": "Content-Type", 15 | "value": "application/json" 16 | } 17 | ], 18 | "body": { 19 | "mode": "raw", 20 | "raw": "{\n \"sha\": \"testsha6\",\n \"repo\": \"guardian/identity\",\n \"board\": [\n\n {\n \"name\": \"Register\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Register Guest\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Get User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update User\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Web Sign In\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"App Sign in\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Consents Set\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Validation Email\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Delete Account\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Update Password\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Join Group\",\n \"verified\": false\n }\n ,\n {\n \"name\": \"Change Email\",\n \"verified\": false\n }\n\n ]\n }" 21 | }, 22 | "url": { 23 | "raw": "{{tipCloudApiUrl}}/board", 24 | "host": [ 25 | "{{tipCloudApiUrl}}" 26 | ], 27 | "path": [ 28 | "board" 29 | ] 30 | } 31 | }, 32 | "response": [] 33 | }, 34 | { 35 | "name": "get board", 36 | "request": { 37 | "method": "GET", 38 | "header": [ 39 | { 40 | "key": "Accept", 41 | "value": "text/html", 42 | "disabled": true 43 | } 44 | ], 45 | "body": {}, 46 | "url": { 47 | "raw": "{{tipCloudApiUrl}}/board/testsha2", 48 | "host": [ 49 | "{{tipCloudApiUrl}}" 50 | ], 51 | "path": [ 52 | "board", 53 | "testsha2" 54 | ] 55 | } 56 | }, 57 | "response": [] 58 | }, 59 | { 60 | "name": "verify path", 61 | "request": { 62 | "method": "POST", 63 | "header": [ 64 | { 65 | "key": "Content-Type", 66 | "value": "application/json" 67 | } 68 | ], 69 | "body": { 70 | "mode": "raw", 71 | "raw": "{\n\t\"sha\": \"testsha2\",\n\t\"name\": \"Register Guest\"\n}" 72 | }, 73 | "url": { 74 | "raw": "{{tipCloudApiUrl}}/board/path", 75 | "host": [ 76 | "{{tipCloudApiUrl}}" 77 | ], 78 | "path": [ 79 | "board", 80 | "path" 81 | ] 82 | } 83 | }, 84 | "response": [] 85 | }, 86 | { 87 | "name": "head board", 88 | "request": { 89 | "method": "GET", 90 | "header": [], 91 | "body": {}, 92 | "url": { 93 | "raw": "https://i2i2l4x9kl.execute-api.eu-west-1.amazonaws.com/PROD/guardian/identity/boards/head", 94 | "protocol": "https", 95 | "host": [ 96 | "i2i2l4x9kl", 97 | "execute-api", 98 | "eu-west-1", 99 | "amazonaws", 100 | "com" 101 | ], 102 | "path": [ 103 | "PROD", 104 | "guardian", 105 | "identity", 106 | "boards", 107 | "head" 108 | ] 109 | } 110 | }, 111 | "response": [] 112 | }, 113 | { 114 | "name": "verify head path", 115 | "request": { 116 | "method": "PATCH", 117 | "header": [ 118 | { 119 | "key": "Content-Type", 120 | "value": "application/json" 121 | } 122 | ], 123 | "body": { 124 | "mode": "raw", 125 | "raw": "{\n\t\"name\": \"Get User\"\n}" 126 | }, 127 | "url": { 128 | "raw": "{{tipCloudApiUrl}}/guardian/identity/boards/head/paths", 129 | "host": [ 130 | "{{tipCloudApiUrl}}" 131 | ], 132 | "path": [ 133 | "guardian", 134 | "identity", 135 | "boards", 136 | "head", 137 | "paths" 138 | ] 139 | } 140 | }, 141 | "response": [] 142 | } 143 | ] 144 | } -------------------------------------------------------------------------------- /cloud/tip-cloud.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Description: CloudFormation template for Tip Cloud 3 | Parameters: 4 | s3Bucket: 5 | Type: String 6 | Description: Bucket where zip for lambdas resides 7 | 8 | s3Key: 9 | Type: String 10 | Description: Path to zip for lambdas 11 | 12 | certificateId: 13 | Type: String 14 | Description: SSL certificate 15 | 16 | domainName: 17 | Type: String 18 | Description: Domain name 19 | Resources: 20 | # **************************************************************************** 21 | # API 22 | # **************************************************************************** 23 | Api: 24 | Type: "AWS::ApiGateway::RestApi" 25 | Properties: 26 | Description: Tip Cloud API that provides status board by merge commit sha for paths specified by tip.yaml 27 | Name: TipCloud-PROD 28 | 29 | ApiBoardResource: 30 | Type: AWS::ApiGateway::Resource 31 | Properties: 32 | RestApiId: !Ref Api 33 | ParentId: !GetAtt Api.RootResourceId 34 | PathPart: board 35 | DependsOn: Api 36 | 37 | ApiBoardMethod: 38 | Type: AWS::ApiGateway::Method 39 | Properties: 40 | ApiKeyRequired: false 41 | AuthorizationType: NONE 42 | RestApiId: !Ref Api 43 | ResourceId: !Ref ApiBoardResource 44 | HttpMethod: POST 45 | Integration: 46 | Type: AWS_PROXY 47 | IntegrationHttpMethod: POST 48 | Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${tipCreateBoardLambda.Arn}/invocations 49 | DependsOn: 50 | - Api 51 | - ApiBoardResource 52 | - tipCreateBoardLambda 53 | 54 | ApiBoardPathResource: 55 | Type: AWS::ApiGateway::Resource 56 | Properties: 57 | RestApiId: !Ref Api 58 | ParentId: !Ref ApiBoardResource 59 | PathPart: path 60 | DependsOn: 61 | - Api 62 | - ApiBoardResource 63 | 64 | ApiBoardPathMethod: 65 | Type: AWS::ApiGateway::Method 66 | Properties: 67 | ApiKeyRequired: false 68 | AuthorizationType: NONE 69 | RestApiId: !Ref Api 70 | ResourceId: !Ref ApiBoardPathResource 71 | HttpMethod: POST 72 | Integration: 73 | Type: AWS_PROXY 74 | IntegrationHttpMethod: POST 75 | Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${tipVerifyPathLambda.Arn}/invocations 76 | DependsOn: 77 | - Api 78 | - ApiBoardPathResource 79 | - tipVerifyPathLambda 80 | 81 | ApiBoardShaResource: 82 | Type: AWS::ApiGateway::Resource 83 | Properties: 84 | RestApiId: !Ref Api 85 | ParentId: !Ref ApiBoardResource 86 | PathPart: '{sha}' 87 | DependsOn: 88 | - Api 89 | - ApiBoardResource 90 | 91 | ApiBoardShaMethod: 92 | Type: AWS::ApiGateway::Method 93 | Properties: 94 | ApiKeyRequired: false 95 | AuthorizationType: NONE 96 | RestApiId: !Ref Api 97 | ResourceId: !Ref ApiBoardShaResource 98 | HttpMethod: GET 99 | Integration: 100 | Type: AWS_PROXY 101 | IntegrationHttpMethod: POST 102 | IntegrationResponses: 103 | - StatusCode: '200' 104 | Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${tipGetBoardLambda.Arn}/invocations 105 | MethodResponses: 106 | - StatusCode: '200' 107 | ResponseModels: { 'text/html': 'Empty' } 108 | DependsOn: 109 | - Api 110 | - ApiBoardShaResource 111 | - tipGetBoardLambda 112 | 113 | ApiOwnerResource: 114 | Type: AWS::ApiGateway::Resource 115 | Properties: 116 | RestApiId: !Ref Api 117 | ParentId: !GetAtt Api.RootResourceId 118 | PathPart: '{owner}' 119 | DependsOn: Api 120 | 121 | ApiRepoResource: 122 | Type: AWS::ApiGateway::Resource 123 | Properties: 124 | RestApiId: !Ref Api 125 | ParentId: !Ref ApiOwnerResource 126 | PathPart: '{repo}' 127 | DependsOn: 128 | - Api 129 | - ApiOwnerResource 130 | 131 | ApiBoardsResource: 132 | Type: AWS::ApiGateway::Resource 133 | Properties: 134 | RestApiId: !Ref Api 135 | ParentId: !Ref ApiRepoResource 136 | PathPart: boards 137 | DependsOn: 138 | - Api 139 | - ApiOwnerResource 140 | - ApiRepoResource 141 | 142 | ApiHeadResource: 143 | Type: AWS::ApiGateway::Resource 144 | Properties: 145 | RestApiId: !Ref Api 146 | ParentId: !Ref ApiBoardsResource 147 | PathPart: head 148 | DependsOn: 149 | - Api 150 | - ApiOwnerResource 151 | - ApiRepoResource 152 | - ApiBoardsResource 153 | 154 | ApiHeadMethod: 155 | Type: AWS::ApiGateway::Method 156 | Properties: 157 | ApiKeyRequired: false 158 | AuthorizationType: NONE 159 | RestApiId: !Ref Api 160 | ResourceId: !Ref ApiHeadResource 161 | HttpMethod: GET 162 | Integration: 163 | Type: AWS_PROXY 164 | IntegrationHttpMethod: POST 165 | IntegrationResponses: 166 | - StatusCode: '200' 167 | Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${tipGetHeadBoardLambda.Arn}/invocations 168 | MethodResponses: 169 | - StatusCode: '200' 170 | ResponseModels: { 'text/html': 'Empty' } 171 | DependsOn: 172 | - Api 173 | - ApiOwnerResource 174 | - ApiRepoResource 175 | - ApiBoardsResource 176 | - ApiHeadResource 177 | - tipGetHeadBoardLambda 178 | 179 | ApiHeadPathsResource: 180 | Type: AWS::ApiGateway::Resource 181 | Properties: 182 | RestApiId: !Ref Api 183 | ParentId: !Ref ApiHeadResource 184 | PathPart: paths 185 | DependsOn: 186 | - Api 187 | - ApiOwnerResource 188 | - ApiRepoResource 189 | - ApiBoardsResource 190 | - ApiHeadResource 191 | 192 | ApiHeadPathsMethod: 193 | Type: AWS::ApiGateway::Method 194 | Properties: 195 | ApiKeyRequired: false 196 | AuthorizationType: NONE 197 | RestApiId: !Ref Api 198 | ResourceId: !Ref ApiHeadPathsResource 199 | HttpMethod: PATCH 200 | Integration: 201 | Type: AWS_PROXY 202 | IntegrationHttpMethod: POST 203 | IntegrationResponses: 204 | - StatusCode: '200' 205 | Uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${tipVerifyHeadPathLambda.Arn}/invocations 206 | MethodResponses: 207 | - StatusCode: '200' 208 | ResponseModels: { 'text/html': 'Empty' } 209 | DependsOn: 210 | - Api 211 | - ApiOwnerResource 212 | - ApiRepoResource 213 | - ApiBoardsResource 214 | - ApiHeadResource 215 | - ApiHeadPathsResource 216 | - tipVerifyHeadPathLambda 217 | 218 | AllowApiGatewayToInvokeCreateBoardLambdaPermission: 219 | Type: AWS::Lambda::Permission 220 | Properties: 221 | Action: lambda:InvokeFunction 222 | Principal: apigateway.amazonaws.com 223 | FunctionName: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:tip-create-board 224 | DependsOn: 225 | - tipCreateBoardLambda 226 | - ApiBoardMethod 227 | 228 | AllowApiGatewayToInvokeVerifyPathLambdaPermission: 229 | Type: AWS::Lambda::Permission 230 | Properties: 231 | Action: lambda:InvokeFunction 232 | Principal: apigateway.amazonaws.com 233 | FunctionName: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:tip-verify-path 234 | DependsOn: 235 | - tipVerifyPathLambda 236 | - ApiBoardPathMethod 237 | 238 | AllowApiGatewayToInvokeVerifyHeadPathLambdaPermission: 239 | Type: AWS::Lambda::Permission 240 | Properties: 241 | Action: lambda:InvokeFunction 242 | Principal: apigateway.amazonaws.com 243 | FunctionName: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:tip-verify-head-path 244 | DependsOn: 245 | - tipVerifyHeadPathLambda 246 | - ApiHeadPathsMethod 247 | 248 | AllowApiGatewayToInvokeGetBoardLambdaPermission: 249 | Type: AWS::Lambda::Permission 250 | Properties: 251 | Action: lambda:InvokeFunction 252 | Principal: apigateway.amazonaws.com 253 | FunctionName: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:tip-get-board 254 | DependsOn: 255 | - tipGetBoardLambda 256 | - ApiBoardShaMethod 257 | 258 | AllowApiGatewayToInvokeGetHeadBoardLambdaPermission: 259 | Type: AWS::Lambda::Permission 260 | Properties: 261 | Action: lambda:InvokeFunction 262 | Principal: apigateway.amazonaws.com 263 | FunctionName: !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:tip-get-head-board 264 | DependsOn: 265 | - tipGetHeadBoardLambda 266 | - ApiHeadMethod 267 | 268 | ApiDeployment: 269 | Type: AWS::ApiGateway::Deployment 270 | Properties: 271 | RestApiId: !Ref Api 272 | Description: Production deployment environment stage 273 | StageName: PROD 274 | DependsOn: 275 | - Api 276 | - ApiBoardShaMethod 277 | - ApiBoardPathMethod 278 | - ApiBoardMethod 279 | - AllowApiGatewayToInvokeCreateBoardLambdaPermission 280 | - AllowApiGatewayToInvokeVerifyPathLambdaPermission 281 | - AllowApiGatewayToInvokeGetBoardLambdaPermission 282 | - AllowApiGatewayToInvokeGetHeadBoardLambdaPermission 283 | - AllowApiGatewayToInvokeVerifyHeadPathLambdaPermission 284 | 285 | TipLambdaRole: 286 | Type: AWS::IAM::Role 287 | Properties: 288 | AssumeRolePolicyDocument: 289 | Statement: 290 | - Effect: Allow 291 | Principal: 292 | Service: 293 | - lambda.amazonaws.com 294 | Action: 295 | - sts:AssumeRole 296 | Path: / 297 | Policies: 298 | - PolicyName: Tip-Lambda-Policy 299 | PolicyDocument: 300 | Version: '2012-10-17' 301 | Statement: 302 | - Effect: Allow 303 | Action: 304 | - logs:CreateLogGroup 305 | - logs:CreateLogStream 306 | - logs:PutLogEvents 307 | Resource: 308 | - '*' 309 | - Effect: Allow 310 | Action: 311 | - dynamodb:* 312 | Resource: 313 | - !GetAtt tipCloudDynamoDBTable.Arn 314 | 315 | TipDomainName: 316 | Type: "AWS::ApiGateway::DomainName" 317 | Properties: 318 | RegionalCertificateArn: !Sub arn:aws:acm:${AWS::Region}:${AWS::AccountId}:certificate/${certificateId} 319 | DomainName: !Ref domainName 320 | EndpointConfiguration: 321 | Types: 322 | - REGIONAL 323 | 324 | TipPathMapping: 325 | Type: "AWS::ApiGateway::BasePathMapping" 326 | Properties: 327 | RestApiId: !Ref Api 328 | DomainName: !Ref TipDomainName 329 | Stage: PROD 330 | DependsOn: 331 | - Api 332 | - TipDomainName 333 | - ApiDeployment 334 | 335 | # **************************************************************************** 336 | # Lambdas 337 | # **************************************************************************** 338 | tipCreateBoardLambda: 339 | Type: "AWS::Lambda::Function" 340 | Properties: 341 | FunctionName: tip-create-board 342 | Description: Create board 343 | Handler: "tip-create-board.handler" 344 | Role: !GetAtt TipLambdaRole.Arn 345 | Code: 346 | S3Bucket: !Ref s3Bucket 347 | S3Key: !Ref s3Key 348 | Runtime: nodejs12.x 349 | MemorySize: "128" 350 | Timeout: "10" 351 | 352 | tipGetBoardLambda: 353 | Type: "AWS::Lambda::Function" 354 | Properties: 355 | FunctionName: tip-get-board 356 | Description: Get board 357 | Handler: "tip-get-board.handler" 358 | Role: !GetAtt TipLambdaRole.Arn 359 | Code: 360 | S3Bucket: !Ref s3Bucket 361 | S3Key: !Ref s3Key 362 | Runtime: nodejs12.x 363 | MemorySize: "128" 364 | Timeout: "10" 365 | 366 | tipGetHeadBoardLambda: 367 | Type: "AWS::Lambda::Function" 368 | Properties: 369 | FunctionName: tip-get-head-board 370 | Description: Get repo's latest board 371 | Handler: "tip-get-head-board.handler" 372 | Role: !GetAtt TipLambdaRole.Arn 373 | Code: 374 | S3Bucket: !Ref s3Bucket 375 | S3Key: !Ref s3Key 376 | Runtime: nodejs12.x 377 | MemorySize: "128" 378 | Timeout: "10" 379 | 380 | tipVerifyPathLambda: 381 | Type: "AWS::Lambda::Function" 382 | Properties: 383 | FunctionName: tip-verify-path 384 | Description: Verify path 385 | Handler: "tip-verify-path.handler" 386 | Role: !GetAtt TipLambdaRole.Arn 387 | Code: 388 | S3Bucket: !Ref s3Bucket 389 | S3Key: !Ref s3Key 390 | Runtime: nodejs12.x 391 | MemorySize: "128" 392 | Timeout: "10" 393 | 394 | tipVerifyHeadPathLambda: 395 | Type: "AWS::Lambda::Function" 396 | Properties: 397 | FunctionName: tip-verify-head-path 398 | Description: Verify path on head (latest) board 399 | Handler: "tip-verify-head-path.handler" 400 | Role: !GetAtt TipLambdaRole.Arn 401 | Code: 402 | S3Bucket: !Ref s3Bucket 403 | S3Key: !Ref s3Key 404 | Runtime: nodejs12.x 405 | MemorySize: "128" 406 | Timeout: "10" 407 | 408 | # **************************************************************************** 409 | # Database 410 | # **************************************************************************** 411 | tipCloudDynamoDBTable: 412 | Type: AWS::DynamoDB::Table 413 | Properties: 414 | AttributeDefinitions: 415 | - 416 | AttributeName: "sha" 417 | AttributeType: "S" 418 | KeySchema: 419 | - 420 | AttributeName: "sha" 421 | KeyType: "HASH" 422 | ProvisionedThroughput: 423 | ReadCapacityUnits: "1" 424 | WriteCapacityUnits: "1" 425 | TableName: "TipCloud-PROD" -------------------------------------------------------------------------------- /cloud/tip-create-board.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({region: 'eu-west-1'}); 3 | const ddb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | function registerBoard(sha, board, repo) { 6 | return ddb.put( 7 | { 8 | TableName: 'TipCloud-PROD', 9 | Item: { 10 | sha: sha, 11 | board: board, 12 | repo: repo, 13 | deployTime: (new Date()).toISOString() 14 | } 15 | } 16 | ).promise(); 17 | } 18 | 19 | exports.handler = (event, context, callback) => { 20 | const body = JSON.parse(event.body); 21 | 22 | const board = body.board; 23 | const sha = body.sha; 24 | const repo = body.repo; 25 | 26 | registerBoard(sha, board, repo) 27 | .then(() => callback(null, {statusCode: 200, body: `{"field": "value"}`})); 28 | }; 29 | -------------------------------------------------------------------------------- /cloud/tip-get-board.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({region: 'eu-west-1'}); 3 | const ddb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | function getBoard(sha) { 6 | return ddb.get( 7 | { 8 | TableName: 'TipCloud-PROD', 9 | Key: { 10 | sha: sha 11 | } 12 | } 13 | ).promise(); 14 | } 15 | 16 | function renderBoard(data) { 17 | return new Promise((resolve, reject) => { 18 | const pathsWithStatusColour = 19 | data.Item.board 20 | .map(path => { 21 | let colour; 22 | if (path.verified) 23 | colour = `style="background-color:green"`; 24 | else 25 | colour = `style="background-color:grey"`; 26 | 27 | return `${path.name}`; 28 | }) 29 | .toString() 30 | .replace (/,/g, ""); 31 | 32 | const sha = data.Item.sha; 33 | const repo = data.Item.repo; 34 | const numberOfVerifiedPaths = data.Item.board.filter( path => path.verified == true).length; 35 | const coverage = Math.round((100 * numberOfVerifiedPaths) / data.Item.board.length); 36 | const deployTime = data.Item.deployTime; 37 | const elapsedTimeSinceDeploy = Date.now() - Date.parse(deployTime); 38 | 39 | const linkToCommit = `https://github.com/${repo}/commit/${sha}`; 40 | 41 | const html = ` 42 | 43 | 44 | 45 | 46 | 47 | 104 | 105 | 106 | 107 | 108 |
109 |

110 | ${repo} ${sha} 111 |

112 | 113 |
114 | 115 |

116 | Elapsed time since deploy: 117 |

118 | 119 |
120 |
${coverage}%
121 |
122 | 123 |
124 | 125 | ${pathsWithStatusColour} 126 |
127 | 128 | 129 | `; 130 | 131 | const response = { 132 | statusCode: 200, 133 | headers: { 134 | 'Content-Type': 'text/html', 135 | }, 136 | body: html, 137 | }; 138 | 139 | resolve(response); 140 | }); 141 | } 142 | 143 | function msToTime(s) { 144 | var ms = s % 1000; 145 | s = (s - ms) / 1000; 146 | var secs = s % 60; 147 | s = (s - secs) / 60; 148 | var mins = s % 60; 149 | var hrs = (s - mins) / 60; 150 | 151 | return hrs + ':' + mins + ':' + secs; 152 | } 153 | 154 | exports.handler = (event, context, callback) => { 155 | const sha = event.pathParameters.sha; 156 | 157 | getBoard(sha) 158 | .then(data => renderBoard(data)) 159 | .then(boardAsHtml => callback(null, boardAsHtml)); 160 | }; 161 | -------------------------------------------------------------------------------- /cloud/tip-get-head-board.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({region: 'eu-west-1'}); 3 | const ddb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | const getBoard = (repo) => { 6 | return ddb.scan( 7 | { 8 | TableName: 'TipCloud-PROD', 9 | FilterExpression : 'repo = :repo', 10 | ExpressionAttributeValues : {':repo' : `${repo}`} 11 | } 12 | ).promise(); 13 | } 14 | 15 | const getLatestBoard = (boards) => 16 | boards 17 | .Items 18 | .sort((a,b) => new Date(b.deployTime) - new Date(a.deployTime))[0]; 19 | 20 | const buildLinkToCommit = (repo, sha) => { 21 | if (repo == sha) 22 | return `https://github.com/${repo}/commit/master`; 23 | else 24 | return `https://github.com/${repo}/commit/${sha}`; 25 | } 26 | 27 | const buildLinkToCommitHeader = (repo, sha) => { 28 | if (repo == sha) 29 | return `${repo}`; 30 | else 31 | return `${repo} ${sha}`; 32 | } 33 | 34 | const renderBoard = (item) => { 35 | return new Promise((resolve, reject) => { 36 | const pathsWithStatusColour = 37 | item.board 38 | .map(path => { 39 | let colour; 40 | if (path.verified) 41 | colour = `style="background-color:green"`; 42 | else 43 | colour = `style="background-color:grey"`; 44 | 45 | return `${path.name}`; 46 | }) 47 | .toString() 48 | .replace (/,/g, ""); 49 | 50 | const sha = item.sha; 51 | const repo = item.repo; 52 | const numberOfVerifiedPaths = item.board.filter( path => path.verified == true).length; 53 | const coverage = Math.round((100 * numberOfVerifiedPaths) / item.board.length); 54 | const deployTime = item.deployTime; 55 | const elapsedTimeSinceDeploy = Date.now() - Date.parse(deployTime); 56 | 57 | const html = ` 58 | 59 | 60 | 61 | 62 | 63 | 122 | 123 | 124 | 125 | 126 |
127 |

128 | ${buildLinkToCommitHeader(repo, sha)} 129 |

130 | 131 |
132 | 133 |

134 | Elapsed time since deploy: 135 |

136 | 137 |
138 |
${coverage}%
139 |
140 | 141 |
142 | 143 | ${pathsWithStatusColour} 144 |
145 | 146 | 147 | `; 148 | 149 | const response = { 150 | statusCode: 200, 151 | headers: { 152 | 'Content-Type': 'text/html', 153 | }, 154 | body: html, 155 | }; 156 | 157 | resolve(response); 158 | }); 159 | } 160 | 161 | const msToTime = (s) => { 162 | var ms = s % 1000; 163 | s = (s - ms) / 1000; 164 | var secs = s % 60; 165 | s = (s - secs) / 60; 166 | var mins = s % 60; 167 | var hrs = (s - mins) / 60; 168 | 169 | return withZero(hrs) + ':' + withZero(mins) + ':' + withZero(secs); 170 | } 171 | 172 | const withZero = (i) => { 173 | if (i < 10) {i = "0" + i}; // add zero in front of numbers < 10 174 | return i; 175 | } 176 | 177 | exports.handler = (event, context, callback) => { 178 | const owner = event.pathParameters.owner; 179 | const repo = event.pathParameters.repo; 180 | const slug = `${owner}/${repo}`; 181 | 182 | getBoard(slug) 183 | .then(boards => getLatestBoard(boards)) 184 | .then(latestBoard => renderBoard(latestBoard)) 185 | .then(boardAsHtml => callback(null, boardAsHtml)); 186 | }; -------------------------------------------------------------------------------- /cloud/tip-verify-head-path.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({region: 'eu-west-1'}); 3 | const ddb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | function updateBoard(dbItem) { 6 | ddb.put( 7 | { 8 | TableName: 'TipCloud-PROD', 9 | Item: dbItem 10 | } 11 | ).promise(); 12 | } 13 | 14 | const getBoards = (repo) => { 15 | return ddb.scan( 16 | { 17 | TableName: 'TipCloud-PROD', 18 | FilterExpression : 'repo = :repo', 19 | ExpressionAttributeValues : {':repo' : `${repo}`} 20 | } 21 | ).promise(); 22 | } 23 | 24 | function verifyPath(boards, path) { 25 | return new Promise((resolve, reject) => { 26 | const sortedBoards = boards.Items.sort((a,b) => new Date(b.deployTime) - new Date(a.deployTime)); 27 | const headBoard = sortedBoards[0]; 28 | const index = headBoard.board.findIndex(element => element.name === path); 29 | headBoard.board[index] = { name: path, verified: true }; 30 | resolve(headBoard); 31 | }); 32 | } 33 | 34 | exports.handler = (event, context, callback) => { 35 | const owner = event.pathParameters.owner; 36 | const repo = event.pathParameters.repo; 37 | const slug = `${owner}/${repo}`; 38 | 39 | const body = JSON.parse(event.body); 40 | const name = body.name; 41 | 42 | getBoards(slug) 43 | .then(data => verifyPath(data, name)) 44 | .then(dbItem => updateBoard(dbItem)) 45 | .then(() => callback(null, {statusCode: 200, body: null})); 46 | }; -------------------------------------------------------------------------------- /cloud/tip-verify-path.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | AWS.config.update({region: 'eu-west-1'}); 3 | const ddb = new AWS.DynamoDB.DocumentClient(); 4 | 5 | function updateBoard(dbItem) { 6 | ddb.put( 7 | { 8 | TableName: 'TipCloud-PROD', 9 | Item: dbItem 10 | } 11 | ).promise(); 12 | } 13 | 14 | function getBoard(sha) { 15 | return ddb.get( 16 | { 17 | TableName : 'TipCloud-PROD', 18 | Key: { 19 | sha: sha 20 | } 21 | } 22 | ).promise(); 23 | } 24 | 25 | function verifyPath(data, path) { 26 | return new Promise((resolve, reject) => { 27 | const index = data.Item.board.findIndex(element => element.name === path); 28 | data.Item.board[index] = { name: path, verified: true }; 29 | resolve(data.Item); 30 | }); 31 | } 32 | 33 | exports.handler = (event, context, callback) => { 34 | const body = JSON.parse(event.body); 35 | const sha = body.sha; 36 | const name = body.name; 37 | 38 | getBoard(sha) 39 | .then(data => verifyPath(data, name)) 40 | .then(dbItem => updateBoard(dbItem)) 41 | .then(() => callback(null, {statusCode: 200, body: null})); 42 | }; 43 | -------------------------------------------------------------------------------- /docs/how-it-works.md: -------------------------------------------------------------------------------- 1 | # How it works? 2 | 3 | ## How setting the GitHub label works? 4 | 5 | When users successfully complete all the defined MCPs, then `Tip.verify()` call sends two HTTP requests to [GitHub API](https://developer.github.com/v3/): 6 | 7 | 1. [GET request](https://developer.github.com/v3/repos/commits/#get-a-single-commit) to find out the identification number of the latest merged pull request, 8 | 1. [POST request](https://developer.github.com/v3/issues/labels/#add-labels-to-an-issue) to actually add the label to the pull request 9 | 10 | TiP will do this only once - the first time users complete all MCPs. 11 | 12 | TiP does not care how many users it took to complete all the MCPs, that is, they could all be completed by a single user or multiple users could complete different subsets of MCPs at different points in time. 13 | 14 | You can also use TiP if your web app is load balanced across multiple instances. In this case TiP will 15 | trigger once per instance, thus if you have 3 instances, then GitHub API will be hit six times, however 16 | once a label is set on the PR, trying to set it again has no ill effect. 17 | 18 | TiP is designed to mitigate risk in a continuous deployment workflow, by employing production users to provide fast verification feedback on production code. 19 | 20 | ![tip_workflow_diagram](https://cloud.githubusercontent.com/assets/13835317/24617884/2a5eee18-188d-11e7-94d9-bc6ff694ff91.jpg) 21 | -------------------------------------------------------------------------------- /docs/how-to-release.md: -------------------------------------------------------------------------------- 1 | # How to make a release 2 | This repo is configured to release an artefact to the central Maven repository, following the semantic versioning policy. 3 | 4 | ## Prerequisites 5 | To make a release you will need: 6 | 1. To be in the main branch of the repo 7 | 1. A pair of pgp keys set up locally 8 | See https://www.scala-sbt.org/release/docs/Using-Sonatype.html#step+1%3A+PGP+Signatures 9 | 1. Sonatype credentials stored in your local sbt configuration. 10 | See https://www.scala-sbt.org/release/docs/Using-Sonatype.html#step+3%3A+Credentials for details. 11 | 12 | ## Releasing library 13 | Command is `sbt release` 14 | Follow the interactive prompts to set the version number of the release. 15 | 16 | If successful, the release will eventually be found here: 17 | https://repo.maven.apache.org/maven2/com/gu/tip_2.12/ 18 | It can take several hours before the new release is generally available from the central maven repo. 19 | 20 | For reference: 21 | https://stackoverflow.com/questions/45963559/how-to-release-a-scala-library-to-maven-central-using-sbt 22 | 23 | ## Troubleshooting 24 | 25 | ### Reverting failed release 26 | 27 | 1. Login to https://oss.sonatype.org/#stagingRepositories 28 | 1. Drop staging repository 29 | 1. Remove version bump commit from main branch: `git reset --hard sha` 30 | 1. Remove tag: eg. `git tag -d v0.6.2` 31 | 32 | ### Problems with signing 33 | 34 | #### Expired keys 35 | - https://github.com/sbt/sbt-pgp/issues/158 36 | - `gpg --list-secret-keys` 37 | - `gpg --list-keys` 38 | 39 | #### `gpg: signing failed: Inappropriate ioctl for device` 40 | - https://github.com/keybase/keybase-issues/issues/2798 41 | - `export GPG_TTY=$(tty)` 42 | -------------------------------------------------------------------------------- /docs/tip-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guardian/tip/09c2835862ca2e7587490003d39871bc22a814d4/docs/tip-architecture.png -------------------------------------------------------------------------------- /examples/tip-assert/.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | target/ 3 | lib_managed/ 4 | src_managed/ 5 | project/boot/ 6 | project/plugins/project/ 7 | .history 8 | .cache 9 | .lib/ 10 | .idea/ 11 | .idea_modules/ -------------------------------------------------------------------------------- /examples/tip-assert/README.md: -------------------------------------------------------------------------------- 1 | # TipAssert example 2 | 3 | Create a pet and then as a side effect perform assertion on mutated database by fetching the state 4 | and checking the correct name was written. 5 | 6 | Note how we run assertions on a separate thread pool `assertionExecutionContext` to be extra safe: 7 | 8 | ```sbtshell 9 | TipAssert( 10 | PetService.getPetName(petId)(assertionExecutionContext), // Note how we are using dedicated thread pool here just to be extra safe 11 | (actualName: String) => actualName == expectedPet.name, 12 | s"Failed to create pet $petId with correct name! Expected: $expectedPet", 13 | max = 3, 14 | delay = Duration(250, TimeUnit.MILLISECONDS) 15 | ) 16 | ``` 17 | 18 | Run with `sbt run`. 19 | 20 | 21 | -------------------------------------------------------------------------------- /examples/tip-assert/build.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / scalaVersion := "2.12.8" 2 | ThisBuild / version := "0.1.0-SNAPSHOT" 3 | ThisBuild / organization := "com.example" 4 | ThisBuild / organizationName := "example" 5 | 6 | val circeVersion = "0.12.0-M4" 7 | 8 | lazy val root = (project in file(".")) 9 | .settings( 10 | name := "tip-assert", 11 | libraryDependencies ++= Seq( 12 | "com.gu" %% "tip" % "0.6.1", 13 | "ch.qos.logback" % "logback-classic" % "1.2.3", 14 | "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2", 15 | "org.scalaj" %% "scalaj-http" % "2.4.2", 16 | ), 17 | libraryDependencies ++= Seq( 18 | "io.circe" %% "circe-core", 19 | "io.circe" %% "circe-generic", 20 | "io.circe" %% "circe-parser", 21 | "io.circe" %% "circe-generic-extras", 22 | ).map(_ % circeVersion), 23 | ) 24 | -------------------------------------------------------------------------------- /examples/tip-assert/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /examples/tip-assert/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %date %level - %message%n%xException 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/tip-assert/src/main/scala/example/Main.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import scala.concurrent.ExecutionContext.Implicits.global 4 | import scala.util.Success 5 | 6 | object Main extends App { 7 | val pet = Pet("Scary Dino") 8 | PetService.createPet(pet) 9 | .andThen { case Success(petId) => validatePetCreation(petId, pet) } 10 | 11 | Thread.sleep(10000) 12 | } 13 | 14 | -------------------------------------------------------------------------------- /examples/tip-assert/src/main/scala/example/PetService.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import scalaj.http.Http 4 | import io.circe.generic.auto._ 5 | import io.circe.parser._ 6 | import scala.concurrent.{ExecutionContext, Future} 7 | 8 | case class Pet(name: String) 9 | case class PetCreateResponse(id: Long, name: String) 10 | case class PetGetResponse(id: Long, name: String) 11 | 12 | object PetService { 13 | def createPet(pet: Pet)(implicit executionContext: ExecutionContext): Future[Long] = Future { 14 | val response = Http("https://petstore.swagger.io/v2/pet") 15 | .header("Content-Type", "application/json") 16 | .header("accept", "application/json") 17 | .postData( 18 | s""" 19 | |{ 20 | | "name": "${pet.name}" 21 | |} 22 | """.stripMargin) 23 | .asString 24 | .body 25 | 26 | decode[PetCreateResponse](response).getOrElse(throw new RuntimeException).id 27 | }(executionContext) 28 | 29 | def getPetName(petId: Long)(implicit executionContext: ExecutionContext): Future[String] = Future { 30 | val response = Http(s"https://petstore.swagger.io/v2/pet/$petId") 31 | .header("accept", "application/json") 32 | .header("api_key", "special-key") 33 | .asString 34 | .body 35 | 36 | decode[PetGetResponse](response).getOrElse(throw new RuntimeException).name 37 | }(executionContext) 38 | 39 | } 40 | -------------------------------------------------------------------------------- /examples/tip-assert/src/main/scala/example/validatePetCreation.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import com.gu.tip.assertion.TipAssert 4 | import com.gu.tip.assertion.ExecutionContext.assertionExecutionContext 5 | import java.util.concurrent.TimeUnit 6 | import com.gu.tip.assertion.AssertionResult 7 | import scala.concurrent.duration.Duration 8 | import scala.concurrent.Future 9 | 10 | object validatePetCreation { 11 | def apply(petId: Long, expectedPet: Pet): Future[AssertionResult] = { 12 | TipAssert( 13 | PetService.getPetName(petId)(assertionExecutionContext), // Note how we are using dedicated thread pool here just to be extra safe 14 | (actualName: String) => actualName == expectedPet.name, 15 | // (actualName: String) => "badName" == expectedPet.name, // uncomment this to see what happens if assertion fails 16 | s"Failed to create pet $petId with correct name! Expected: $expectedPet", 17 | max = 3, 18 | delay = Duration(250, TimeUnit.MILLISECONDS) 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/tip-minimal/.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | target/ 3 | lib_managed/ 4 | src_managed/ 5 | project/boot/ 6 | project/plugins/project/ 7 | .history 8 | .cache 9 | .lib/ 10 | .idea/ 11 | .idea_modules/ -------------------------------------------------------------------------------- /examples/tip-minimal/README.md: -------------------------------------------------------------------------------- 1 | # Tip example with minimal configuration 2 | 3 | This configuration does not use cloud board or set labels on pull requests. It simply logs when all paths have been verified. 4 | 5 | Run with `sbt run` which should output 6 | 7 | ```sbtshell 8 | [info] Running example.Hello 9 | 2019-07-21 14:44:05,392 INFO - Register is now verified 10 | 2019-07-21 14:44:05,396 INFO - Update User is now verified 11 | 2019-07-21 14:44:05,397 INFO - All tests in production passed. 12 | 2019-07-21 14:44:05,399 INFO - Terminating Actor System... 13 | 2019-07-21 14:44:05,420 INFO - Successfully terminated actor system 14 | ``` 15 | 16 | -------------------------------------------------------------------------------- /examples/tip-minimal/build.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / scalaVersion := "2.12.8" 2 | ThisBuild / version := "0.1.0-SNAPSHOT" 3 | ThisBuild / organization := "com.example" 4 | ThisBuild / organizationName := "example" 5 | 6 | lazy val root = (project in file(".")) 7 | .settings( 8 | name := "tip-minimal", 9 | libraryDependencies ++= Seq( 10 | "com.gu" %% "tip" % "0.6.1", 11 | "ch.qos.logback" % "logback-classic" % "1.2.3", 12 | "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2", 13 | ) 14 | ) 15 | -------------------------------------------------------------------------------- /examples/tip-minimal/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.2.8 2 | -------------------------------------------------------------------------------- /examples/tip-minimal/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %date %level - %message%n%xException 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/tip-minimal/src/main/resources/tip.yaml: -------------------------------------------------------------------------------- 1 | - name: Register 2 | description: User creates an account 3 | 4 | - name: Update User 5 | description: User changes account details -------------------------------------------------------------------------------- /examples/tip-minimal/src/main/scala/example/Hello.scala: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import com.gu.tip.{TipConfig, TipFactory} 4 | 5 | object Hello extends App { 6 | val tipConfig = TipConfig( 7 | repo = "mario-galic/sandbox", 8 | cloudEnabled = false 9 | ) 10 | val tip = TipFactory.create(tipConfig) 11 | tip.verify("Register") 12 | tip.verify("Update User") 13 | } 14 | 15 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val http4sVersion = "0.21.3" 5 | val akkaVersion = "2.6.4" 6 | 7 | lazy val test = Seq("org.scalatest" %% "scalatest" % "3.1.1" % Test, 8 | "org.mockito" %% "mockito-scala" % "1.13.9" % Test) 9 | lazy val scalaLogging = "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2" 10 | lazy val listJson = "net.liftweb" %% "lift-json" % "3.4.1" 11 | lazy val ficus = "com.iheart" %% "ficus" % "1.4.7" 12 | lazy val yaml = "net.jcazevedo" %% "moultingyaml" % "0.4.2" 13 | lazy val akka = Seq("com.typesafe.akka" %% "akka-actor" % akkaVersion, 14 | "com.typesafe.akka" %% "akka-slf4j" % akkaVersion) 15 | lazy val http4s = Seq("org.http4s" %% "http4s-dsl" % http4sVersion, 16 | "org.http4s" %% "http4s-blaze-client" % http4sVersion, 17 | "org.http4s" %% "http4s-circe" % http4sVersion) 18 | lazy val retry = Seq("com.softwaremill.retry" %% "retry" % "0.3.3", 19 | "com.softwaremill.odelay" %% "odelay-core" % "0.3.2") 20 | 21 | val all = 22 | Seq( 23 | scalaLogging, 24 | listJson, 25 | ficus, 26 | yaml 27 | ) 28 | .++(http4s) 29 | .++(akka) 30 | .++(retry) 31 | .++(test) 32 | } 33 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.4.4 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.11") 2 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "2.0.0") 3 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.5") 4 | addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") 5 | addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % "1.15") 6 | addSbtPlugin("ch.epfl.scala" %% "sbt-scalafix" % "0.9.24") 7 | -------------------------------------------------------------------------------- /scalastyle-config.xml: -------------------------------------------------------------------------------- 1 | 2 | Scalastyle standard configuration 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /sonatype.sbt: -------------------------------------------------------------------------------- 1 | sonatypeProfileName := "com.gu" 2 | publishMavenStyle := true 3 | licenses := Seq("LGPL3" -> url("https://www.gnu.org/licenses/lgpl-3.0.txt")) 4 | homepage := Some(url("https://github.com/guardian/tip")) 5 | scmInfo := Some(ScmInfo(url("https://github.com/guardian/tip"), "scm:git@github.com:guardian/tip.git")) 6 | developers := List( 7 | Developer(id="mario-galic", name="Mario Galic", email="", url=url("https://github.com/mario-galic")), 8 | Developer(id="jacobwinch", name="Jacob Winch", email="", url=url("https://github.com/jacobwinch")) 9 | ) 10 | publishTo := sonatypePublishTo.value 11 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guardian/tip/09c2835862ca2e7587490003d39871bc22a814d4/src/main/resources/application.conf -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/Configuration.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import java.io.FileNotFoundException 4 | 5 | import com.typesafe.config.{Config, ConfigFactory} 6 | import net.ceedubs.ficus.Ficus._ 7 | import net.ceedubs.ficus.readers.ArbitraryTypeReader._ 8 | import net.jcazevedo.moultingyaml._ 9 | 10 | import scala.util.Try 11 | import scala.io.Source 12 | 13 | // $COVERAGE-OFF$ 14 | case class TipConfig(repo: String, 15 | cloudEnabled: Boolean = true, 16 | boardSha: String = "", 17 | personalAccessToken: String = "", 18 | label: String = "") 19 | 20 | class TipConfigurationException( 21 | msg: String = "Missing TiP config. Please refer to README.") 22 | extends RuntimeException(msg) 23 | class MissingPathConfigurationFile( 24 | msg: String = "Missing tip.yaml. Please refer to README") 25 | extends RuntimeException(msg) 26 | class PathConfigurationSyntaxError( 27 | msg: String = "Bad syntax in tip.yaml. Please refer to README") 28 | extends RuntimeException(msg) 29 | 30 | class Configuration(config: TipConfig) { 31 | def this() { 32 | this( 33 | ConfigFactory 34 | .load() 35 | .as[Option[TipConfig]]("tip") 36 | .getOrElse(throw new TipConfigurationException) 37 | ) 38 | } 39 | 40 | def this(typesafeConfig: Config) { 41 | this( 42 | typesafeConfig 43 | .as[Option[TipConfig]]("tip") 44 | .getOrElse(throw new TipConfigurationException) 45 | ) 46 | } 47 | 48 | object TipYamlProtocol extends DefaultYamlProtocol { 49 | implicit val pathFormat: YamlFormat[Path] = yamlFormat2(Path) 50 | } 51 | 52 | import TipYamlProtocol._ 53 | 54 | val tipConfig = config 55 | 56 | private def readFile(filename: String): String = 57 | Try(Source.fromResource(filename).getLines.mkString("\n")).getOrElse( 58 | throw new FileNotFoundException( 59 | s"Path definition file not found on the classpath: $filename")) 60 | 61 | def readPaths(filename: String): List[Path] = 62 | Try(readFile(filename).parseYaml.convertTo[List[Path]]).recover { 63 | case e: FileNotFoundException => throw new MissingPathConfigurationFile 64 | case _ => throw new PathConfigurationSyntaxError 65 | }.get 66 | } 67 | 68 | trait ConfigurationIf { 69 | val configuration: Configuration 70 | } 71 | 72 | trait ConfigFromTypesafe extends ConfigurationIf { 73 | override val configuration: Configuration = new Configuration() 74 | } 75 | 76 | // $COVERAGE-ON$ 77 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/GitHubApi.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import cats.data.WriterT 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import com.typesafe.scalalogging.LazyLogging 7 | import net.liftweb.json._ 8 | import net.liftweb.json.DefaultFormats 9 | 10 | trait GitHubApiIf { this: HttpClientIf with ConfigurationIf => 11 | def getLastMergeCommitMessage: WriterT[IO, List[Log], String] 12 | def setLabel(prNumber: String): WriterT[IO, List[Log], String] 13 | 14 | val githubApiRoot = "https://api.github.com" 15 | } 16 | 17 | trait GitHubApi extends GitHubApiIf with LazyLogging { 18 | this: HttpClientIf with ConfigurationIf => 19 | 20 | import configuration.tipConfig._ 21 | 22 | private implicit val formats: DefaultFormats.type = DefaultFormats 23 | /* 24 | Latest commit message to master has the following form: 25 | "Merge pull request #118 from ${owner}/hackday-2017-tip-test-1" 26 | 27 | So we try to pick out pull request number #118 28 | */ 29 | override def getLastMergeCommitMessage: WriterT[IO, List[Log], String] = 30 | get(s"$githubApiRoot/repos/$repo/commits/master", authHeader) 31 | .map( 32 | response => (parse(response) \ "commit" \ "message").extract[String] 33 | ) 34 | .tell( 35 | List(Log("INFO", 36 | s"Successfully retrieved commit message of last merged PR"))) 37 | 38 | override def setLabel(prNumber: String): WriterT[IO, List[Log], String] = 39 | post(s"$githubApiRoot/repos/$repo/issues/$prNumber/labels", 40 | authHeader, 41 | s"""["$label"]""") 42 | .tell( 43 | List(Log("INFO", s"Successfully set label '$label' on PR $prNumber"))) 44 | 45 | private lazy val authHeader = "Authorization" -> s"token $personalAccessToken" 46 | } 47 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/HttpClient.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import cats.data.WriterT 4 | import cats.effect._ 5 | import org.http4s._ 6 | import org.http4s.client.blaze.BlazeClientBuilder 7 | import org.http4s.client.dsl.Http4sClientDsl 8 | import org.http4s.dsl.io._ 9 | 10 | import scala.concurrent.ExecutionContext 11 | 12 | trait HttpClientIf { 13 | def get(endpoint: String, 14 | authHeader: (String, String)): WriterT[IO, List[Log], String] 15 | def post(endpoint: String, 16 | authHeader: (String, String), 17 | jsonBody: String): WriterT[IO, List[Log], String] 18 | def patch(endpoint: String, 19 | authHeader: (String, String), 20 | jsonBody: String): WriterT[IO, List[Log], String] 21 | } 22 | 23 | trait HttpClient extends HttpClientIf with Http4sClientDsl[IO] { 24 | override def get( 25 | endpoint: String, 26 | authHeader: (String, String)): WriterT[IO, List[Log], String] = { 27 | val request = Request[IO]( 28 | uri = Uri.unsafeFromString(endpoint), 29 | headers = Headers.of(Header(authHeader._1, authHeader._2)), 30 | method = Method.GET 31 | ) 32 | 33 | WriterT( 34 | client.expect[String](request).map { response => 35 | ( 36 | List( 37 | Log("INFO", 38 | s"Successfully executed HTTP GET request to $endpoint")), 39 | response 40 | ) 41 | } 42 | ) 43 | } 44 | 45 | override def post(endpoint: String, 46 | authHeader: (String, String), 47 | jsonBody: String): WriterT[IO, List[Log], String] = { 48 | val request = POST.apply(jsonBody, 49 | Uri.unsafeFromString(endpoint), 50 | Header(authHeader._1, authHeader._2)) 51 | 52 | WriterT( 53 | client.expect[String](request).map { response => 54 | ( 55 | List( 56 | Log("INFO", 57 | s"Successfully executed HTTP POST request to $endpoint")), 58 | response 59 | ) 60 | } 61 | ) 62 | } 63 | 64 | override def patch(endpoint: String, 65 | authHeader: (String, String), 66 | jsonBody: String): WriterT[IO, List[Log], String] = { 67 | val request = PATCH(jsonBody, 68 | Uri.unsafeFromString(endpoint), 69 | Header(authHeader._1, authHeader._2)) 70 | 71 | WriterT( 72 | client.expect[String](request).map { response => 73 | ( 74 | List( 75 | Log("INFO", 76 | s"Successfully executed HTTP PATCH request to $endpoint")), 77 | response 78 | ) 79 | } 80 | ) 81 | } 82 | 83 | implicit def ec: ExecutionContext 84 | 85 | // Lazy val to avoid a null pointer exception from using the execution context before its defined. 86 | implicit lazy val cs: ContextShift[IO] = IO.contextShift(ec) 87 | 88 | // Lazy val to avoid a null pointer exception from using the execution context before its defined. 89 | private lazy val client = { 90 | // Right-hand value is used to close the client, which we are not concerned about. 91 | val (_client, _) = 92 | BlazeClientBuilder[IO](ec).resource.allocated.unsafeRunSync() 93 | _client 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/Log.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | case class Log(level: String, message: String) 4 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/Notifier.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import cats.data.WriterT 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import com.typesafe.scalalogging.LazyLogging 7 | 8 | trait NotifierIf { this: GitHubApiIf => 9 | def setLabelOnLatestMergedPr(): WriterT[IO, List[Log], String] 10 | } 11 | 12 | trait Notifier extends NotifierIf with LazyLogging { this: GitHubApiIf => 13 | def setLabelOnLatestMergedPr(): WriterT[IO, List[Log], String] = 14 | for { 15 | prNumber <- getLastMergedPullRequestNumber() 16 | responseBody <- setGitHubLabel(prNumber) 17 | } yield { 18 | responseBody 19 | } 20 | 21 | /* 22 | Latest commit message to master has the following form: 23 | "Merge pull request #118 from ${owner}/hackday-2017-tip-test-1" 24 | 25 | So we try to pick out pull request number #118 26 | */ 27 | private def getLastMergedPullRequestNumber(): WriterT[IO, List[Log], String] = 28 | getLastMergeCommitMessage 29 | .map { commitMessage => 30 | val prNumberPattern = """#\d+""".r 31 | val prNumber = prNumberPattern.findFirstIn(commitMessage).get.tail // Using get() because currently we just swallow any exception in Tip.verify() 32 | prNumber 33 | } 34 | .tell(List(Log( 35 | "INFO", 36 | s"Successfully extracted PR number from the commit message of the last merged PR"))) 37 | 38 | private def setGitHubLabel(prNumber: String): WriterT[IO, List[Log], String] = 39 | setLabel(prNumber).tell( 40 | List(Log("INFO", s"Successfully set verification label on PR $prNumber"))) 41 | } 42 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/PathsActor.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | import scala.util.Try 5 | import akka.actor.{Actor, ActorRef, ActorSystem, Props} 6 | 7 | case class Path(name: String, description: String) 8 | 9 | class EnrichedPath(path: Path) extends LazyLogging { 10 | def verify(): Unit = { 11 | logger.info(s"${path.name} is now verified") 12 | _verified = true 13 | } 14 | 15 | def verified(): Boolean = _verified 16 | 17 | private var _verified = false 18 | } 19 | 20 | sealed trait PathsActorMessage 21 | 22 | // OK 23 | case class Verify(pathName: String) extends PathsActorMessage 24 | case class PathIsVerified(pathName: String) extends PathsActorMessage 25 | case class PathIsUnverified(pathName: String) extends PathsActorMessage 26 | case class PathIsAlreadyVerified(pathName: String) extends PathsActorMessage 27 | case object AllPathsVerified extends PathsActorMessage 28 | case object AllPathsAlreadyVerified extends PathsActorMessage 29 | case object NumberOfPaths extends PathsActorMessage 30 | case class NumberOfPathsAnswer(value: Int) extends PathsActorMessage 31 | case object NumberOfVerifiedPaths extends PathsActorMessage 32 | case class NumberOfVerifiedPathsAnswer(value: Int) extends PathsActorMessage 33 | case object Stop extends PathsActorMessage 34 | 35 | // NOK 36 | case class PathDoesNotExist(pathName: String) extends PathsActorMessage 37 | 38 | class PathsActor(val paths: Map[String, EnrichedPath]) 39 | extends Actor 40 | with LazyLogging { 41 | private def allVerified: Boolean = _unverifiedPathCount == 0 42 | 43 | private var _unverifiedPathCount = paths.size 44 | 45 | def receive: Receive = { 46 | case Verify(pathname) => 47 | logger.trace("Paths got Verify message") 48 | 49 | Try(paths(pathname)).map { path => 50 | if (allVerified) { 51 | logger.trace(s"All paths are already verified") 52 | sender() ! AllPathsAlreadyVerified 53 | } else if (path.verified()) { 54 | logger.trace(s"Path ${pathname} is already verified") 55 | sender() ! PathIsAlreadyVerified(pathname) 56 | } else { 57 | path.verify() 58 | _unverifiedPathCount = _unverifiedPathCount - 1 59 | if (allVerified) { 60 | logger.trace("All paths are verified!") 61 | sender() ! AllPathsVerified 62 | } else { 63 | logger.trace(s"Path ${pathname} is verified!") 64 | sender() ! PathIsVerified(pathname) 65 | } 66 | } 67 | } recover { 68 | case e: NoSuchElementException => 69 | logger.error( 70 | s"Unrecognized path name. Available paths: ${paths.keys.mkString(",")}") 71 | sender() ! PathDoesNotExist(pathname) 72 | } 73 | 74 | case NumberOfPaths => sender() ! NumberOfPathsAnswer(paths.size) 75 | 76 | case NumberOfVerifiedPaths => 77 | sender() ! NumberOfVerifiedPathsAnswer(paths.size - _unverifiedPathCount) 78 | 79 | case Stop => 80 | logger.info("Terminating Actor System...") 81 | context.system.terminate() 82 | } 83 | } 84 | 85 | trait PathsActorIf { 86 | def apply(filename: String, configuration: Configuration)( 87 | implicit system: ActorSystem): ActorRef 88 | } 89 | 90 | /** 91 | * Reader for path config file with the following syntax: 92 | * 93 | * - name: Buy Subscription 94 | * description: User completes subscription purchase journey 95 | * 96 | * - name: Register Account 97 | * description: User completes account registration journey 98 | */ 99 | object PathsActor extends PathsActorIf with LazyLogging { 100 | 101 | override def apply(filename: String, configuration: Configuration)( 102 | implicit system: ActorSystem): ActorRef = { 103 | val pathList = configuration.readPaths(filename) 104 | val paths: Map[String, EnrichedPath] = 105 | pathList.map(path => path.name -> new EnrichedPath(path)).toMap 106 | system.actorOf(Props[PathsActor](new PathsActor(paths))) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/Tip.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import com.typesafe.scalalogging.LazyLogging 4 | 5 | import scala.concurrent.Future 6 | import scala.concurrent.duration._ 7 | import akka.actor.{ActorRef, ActorSystem} 8 | import akka.pattern.{AskTimeoutException, ask} 9 | import akka.util.Timeout 10 | import com.gu.tip.cloud.{TipCloudApi, TipCloudApiIf} 11 | import com.typesafe.config.Config 12 | 13 | import scala.concurrent.ExecutionContextExecutor 14 | 15 | sealed trait TipResponse 16 | 17 | // OK 18 | case object LabelSet extends TipResponse 19 | case object TipFinished extends TipResponse 20 | case class PathsActorResponse(msg: PathsActorMessage) extends TipResponse 21 | case object CloudPathVerified extends TipResponse 22 | case object AllTestsInProductionPassed extends TipResponse 23 | 24 | // NOK 25 | case object FailedToSetLabel extends TipResponse 26 | case class PathNotFound(name: String) extends TipResponse 27 | case object UnclassifiedError extends TipResponse 28 | case object FailedToVerifyCloudPath extends TipResponse 29 | 30 | trait TipIf { this: NotifierIf with TipCloudApiIf with ConfigurationIf => 31 | def verify(pathName: String): Future[TipResponse] 32 | 33 | val pathConfigFilename = "tip.yaml" 34 | 35 | implicit val system: ActorSystem = ActorSystem() 36 | implicit val ec: ExecutionContextExecutor = system.dispatcher 37 | implicit val timeout: Timeout = Timeout(10.second) 38 | lazy val pathsActor: ActorRef = PathsActor(pathConfigFilename, configuration) 39 | } 40 | 41 | trait Tip extends TipIf with LazyLogging { 42 | this: NotifierIf with TipCloudApiIf with ConfigurationIf => 43 | system.registerOnTermination( 44 | logger.info("Successfully terminated actor system")) 45 | 46 | private def fireAndForgetSetLabelOnLatestMergedPr() = 47 | if (configuration.tipConfig.personalAccessToken.nonEmpty) { 48 | setLabelOnLatestMergedPr().run.attempt 49 | .map({ 50 | case Left(error) => 51 | logger.error("Failed to set label on PR!", error) 52 | FailedToSetLabel 53 | 54 | case Right((logs, result)) => 55 | logs.foreach(log => logger.info(log.toString)) 56 | LabelSet 57 | }) 58 | .unsafeRunSync() 59 | } 60 | 61 | private def inMemoryVerify(pathName: String): Future[TipResponse] = { 62 | pathsActor ? Verify(pathName) map { 63 | case AllPathsVerified => 64 | fireAndForgetSetLabelOnLatestMergedPr() 65 | logger.info("All tests in production passed.") 66 | pathsActor ? Stop 67 | AllTestsInProductionPassed 68 | 69 | case PathDoesNotExist(pathname) => 70 | logger.error(s"Unrecognized path name: $pathname") 71 | PathNotFound(pathname) 72 | 73 | case response: PathsActorMessage => 74 | logger.trace(s"Tip received response from PathsActor: $response") 75 | PathsActorResponse(response) 76 | 77 | } recover { 78 | case e: AskTimeoutException if e.getMessage.contains("terminated") => 79 | TipFinished 80 | 81 | case e => 82 | logger.error(s"Unclassified Tip error: ", e) 83 | UnclassifiedError 84 | } 85 | } 86 | 87 | private def cloudVerify(pathname: String, 88 | inMemoryResult: TipResponse): Future[TipResponse] = 89 | Future { 90 | inMemoryResult match { 91 | case PathsActorResponse(PathIsVerified(_)) | AllTestsInProductionPassed 92 | if configuration.tipConfig.cloudEnabled => 93 | val action = if (configuration.tipConfig.boardSha.nonEmpty) { 94 | verifyPath(configuration.tipConfig.boardSha, pathname) 95 | } else { 96 | verifyHeadPath(configuration.tipConfig.repo, pathname) 97 | } 98 | 99 | action.run.attempt 100 | .map({ 101 | case Left(error) => 102 | logger.error(s"Failed to cloud verify path $pathname", error) 103 | FailedToVerifyCloudPath 104 | 105 | case Right((logs, result)) => 106 | logs.foreach(log => logger.info(log.toString)) 107 | logger.info( 108 | s"Successfully verified cloud path $pathname on board ${configuration.tipConfig.boardSha}!") 109 | CloudPathVerified 110 | }) 111 | .unsafeRunSync() 112 | 113 | case _ => inMemoryResult 114 | } 115 | } 116 | 117 | def verify(pathname: String): Future[TipResponse] = { 118 | for { 119 | inMemoryResult <- inMemoryVerify(pathname) 120 | result <- cloudVerify(pathname, inMemoryResult) 121 | } yield { 122 | result 123 | } 124 | } 125 | } 126 | 127 | object Tip 128 | extends Tip 129 | with Notifier 130 | with GitHubApi 131 | with TipCloudApi 132 | with HttpClient 133 | with ConfigFromTypesafe { 134 | if (configuration.tipConfig.cloudEnabled) { 135 | initCloud 136 | } 137 | } 138 | 139 | object TipFactory { 140 | def create(tipConfig: TipConfig): Tip = 141 | new Tip with Notifier with GitHubApi with TipCloudApi with HttpClient 142 | with ConfigurationIf { 143 | override val configuration: Configuration = new Configuration(tipConfig) 144 | if (configuration.tipConfig.cloudEnabled) { 145 | initCloud 146 | } 147 | } 148 | 149 | def create(typesafeConfig: Config): Tip = 150 | new Tip with Notifier with GitHubApi with TipCloudApi with HttpClient 151 | with ConfigurationIf { 152 | override val configuration: Configuration = new Configuration( 153 | typesafeConfig) 154 | if (configuration.tipConfig.cloudEnabled) { 155 | initCloud 156 | } 157 | } 158 | 159 | def createDummy(config: TipConfig): Tip = { 160 | new Tip with Notifier with GitHubApi with TipCloudApi with HttpClient 161 | with ConfigurationIf { 162 | override val configuration: Configuration = new Configuration(config) 163 | override def verify(pathname: String): Future[TipResponse] = 164 | Future.successful(AllTestsInProductionPassed) 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/assertion/TipAssert.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip.assertion 2 | 3 | import java.util.concurrent.Executors 4 | 5 | import com.typesafe.scalalogging.{LazyLogging, Logger} 6 | import retry.Defaults 7 | 8 | import scala.concurrent.duration.FiniteDuration 9 | import scala.concurrent.{ExecutionContextExecutor, Future} 10 | import scala.util.{Failure, Random, Success, Try} 11 | 12 | object ExecutionContext { 13 | private val threadPool = Executors.newFixedThreadPool( 14 | 2, 15 | (r: Runnable) => 16 | new Thread(r, s"tip-assertion-pool-thread-${Random.nextInt(2)}") 17 | ) 18 | val assertionExecutionContext: ExecutionContextExecutor = 19 | scala.concurrent.ExecutionContext.fromExecutor(threadPool) 20 | } 21 | 22 | sealed trait AssertionResult 23 | case object TipAssertPass extends AssertionResult 24 | case object TipAssertFail extends AssertionResult 25 | 26 | trait TipAssertIf { 27 | protected val logger: Logger 28 | 29 | def apply[T]( 30 | state: => Future[T], 31 | predicate: T => Boolean, 32 | msg: String, 33 | max: Int = 1, 34 | delay: FiniteDuration = Defaults.delay 35 | ): Future[AssertionResult] = { 36 | 37 | implicit val ec = ExecutionContext.assertionExecutionContext 38 | retry.Pause(max, delay)(odelay.Timer.default) { 39 | state.map { actualState => 40 | Try(assert(predicate(actualState))) 41 | } 42 | } map { 43 | case Failure(_) => 44 | logger.error(msg) 45 | TipAssertFail 46 | 47 | case Success(_) => 48 | TipAssertPass 49 | } 50 | 51 | } 52 | 53 | } 54 | 55 | object TipAssert extends TipAssertIf with LazyLogging 56 | -------------------------------------------------------------------------------- /src/main/scala/com/gu/tip/cloud/TipCloudApi.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip.cloud 2 | 3 | import cats.data.WriterT 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import com.gu.tip.{ConfigurationIf, HttpClientIf, Log} 7 | import com.typesafe.scalalogging.LazyLogging 8 | import net.liftweb.json._ 9 | 10 | trait TipCloudApiIf { this: HttpClientIf with ConfigurationIf => 11 | def createBoard(sha: String, repo: String): WriterT[IO, List[Log], String] 12 | def verifyPath(sha: String, name: String): WriterT[IO, List[Log], String] 13 | def verifyHeadPath(repo: String, name: String): WriterT[IO, List[Log], String] 14 | def getBoard(sha: String): WriterT[IO, List[Log], String] 15 | } 16 | 17 | trait TipCloudApi extends TipCloudApiIf with LazyLogging { 18 | this: HttpClientIf with ConfigurationIf => 19 | 20 | val tipCloudApiRoot = 21 | "https://be9p0izsnc.execute-api.eu-west-1.amazonaws.com/PROD" 22 | 23 | override def createBoard(sha: String, 24 | repo: String): WriterT[IO, List[Log], String] = { 25 | val paths = configuration.readPaths("tip.yaml") 26 | 27 | val board = paths.map { path => 28 | s""" 29 | | { 30 | | "name": "${path.name}", 31 | | "verified": false 32 | | } 33 | """.stripMargin 34 | } 35 | 36 | val body = 37 | s""" 38 | |{ 39 | | "sha": "${if (sha.nonEmpty) sha else repo}", 40 | | "repo": "$repo", 41 | | "board": [ 42 | | ${board.mkString(",")} 43 | | ] 44 | | } 45 | """.stripMargin 46 | 47 | post(s"$tipCloudApiRoot/board", auth, compactRender(parse(body))) 48 | .tell(List(Log("INFO", s"Successfully created board $sha"))) 49 | } 50 | 51 | override def verifyPath(sha: String, 52 | name: String): WriterT[IO, List[Log], String] = { 53 | val body = 54 | s""" 55 | |{ 56 | | "sha": "$sha", 57 | | "name": "$name" 58 | |} 59 | """.stripMargin 60 | 61 | post(s"$tipCloudApiRoot/board/path", auth, compactRender(parse(body))) 62 | .tell( 63 | List(Log("INFO", s"Successfully verified path $name on board $sha"))) 64 | } 65 | 66 | override def verifyHeadPath(repo: String, 67 | name: String): WriterT[IO, List[Log], String] = { 68 | val body = 69 | s""" 70 | |{ 71 | | "name": "$name" 72 | |} 73 | """.stripMargin 74 | 75 | patch(s"$tipCloudApiRoot/$repo/boards/head/paths", 76 | auth, 77 | compactRender(parse(body))) 78 | .tell( 79 | List( 80 | Log("INFO", s"Successfully verified path $name on head board $repo"))) 81 | } 82 | 83 | override def getBoard(sha: String): WriterT[IO, List[Log], String] = 84 | get(s"$tipCloudApiRoot/board/$sha", auth) 85 | .tell(List(Log("INFO", s"Successfully retrieved board $sha"))) 86 | 87 | def initCloud: Unit = { 88 | val sha = configuration.tipConfig.boardSha 89 | val repo = configuration.tipConfig.repo 90 | createBoard(sha, repo).run.attempt.unsafeRunSync() 91 | } 92 | 93 | private lazy val auth = "Authorization" -> "Hello world" 94 | } 95 | -------------------------------------------------------------------------------- /src/test/resources/application.conf: -------------------------------------------------------------------------------- 1 | tip { 2 | repo = "mario-galic/sandbox" 3 | cloudEnabled = "false" 4 | personalAccessToken = "testtoken" 5 | label = "Verified in PROD" 6 | boardSha = "testsha" 7 | } -------------------------------------------------------------------------------- /src/test/resources/tip-bad.yaml: -------------------------------------------------------------------------------- 1 | name: Name A 2 | description: Description A 3 | 4 | -------------------------------------------------------------------------------- /src/test/resources/tip.yaml: -------------------------------------------------------------------------------- 1 | - name: Name A 2 | description: Description A 3 | 4 | - name: Name B 5 | description: Description B 6 | -------------------------------------------------------------------------------- /src/test/scala/com/gu/tip/HttpClientTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import org.scalatest.{FlatSpec, MustMatchers} 4 | 5 | import scala.concurrent.ExecutionContext 6 | 7 | class HttpClientTest extends FlatSpec with MustMatchers { 8 | 9 | object HttpClient extends HttpClient { 10 | override implicit val ec: ExecutionContext = 11 | scala.concurrent.ExecutionContext.global 12 | } 13 | 14 | behavior of "HttpClient" 15 | 16 | it should "be able to make a GET request" in { 17 | HttpClient 18 | .get("https://duckduckgo.com", ("Authorization", "test")) 19 | .run 20 | .attempt 21 | .map(_.fold(error => fail, _ => succeed)) 22 | .unsafeRunSync() 23 | } 24 | 25 | it should "be able to make a POST request" in { 26 | HttpClient 27 | .post( 28 | "https://duckduckgo.com", 29 | ("", ""), 30 | "test body" 31 | ) 32 | .run 33 | .attempt 34 | .map(_.fold(error => fail, _ => succeed)) 35 | .unsafeRunSync() 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/test/scala/com/gu/tip/NotifierTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import cats.data.WriterT 4 | import cats.effect.IO 5 | import org.http4s.Status.InternalServerError 6 | import org.http4s.client.UnexpectedStatus 7 | import org.scalatest.{FlatSpec, MustMatchers} 8 | 9 | import scala.concurrent.ExecutionContext 10 | 11 | class NotifierTest extends FlatSpec with MustMatchers { 12 | 13 | val mockCommitMessageResponse = 14 | """ 15 | |{ 16 | | "commit": { 17 | | "message": "Merge pull request #118 from mario-galic/hackday-2017-tip-test-1" 18 | | } 19 | |} 20 | """.stripMargin 21 | 22 | behavior of "unhappy Notifier" 23 | 24 | it should "return non-200 if cannot set the label" in { 25 | trait MockHttpClient extends HttpClient { 26 | override def get(endpoint: String = "", 27 | authHeader: (String, String) = ("", "")) 28 | : WriterT[IO, List[Log], String] = 29 | WriterT.putT(IO(mockCommitMessageResponse))(List(Log("", ""))) 30 | 31 | override def post(endpoint: String = "", 32 | authHeader: (String, String) = ("", ""), 33 | jsonBody: String = ""): WriterT[IO, List[Log], String] = 34 | WriterT( 35 | IO.raiseError(UnexpectedStatus(InternalServerError)) 36 | .map(_ => (List(Log("", "")), ""))) 37 | } 38 | 39 | object Notifier 40 | extends Notifier 41 | with GitHubApi 42 | with MockHttpClient 43 | with ConfigFromTypesafe { 44 | override implicit val ec: ExecutionContext = 45 | scala.concurrent.ExecutionContext.global 46 | } 47 | 48 | Notifier.setLabelOnLatestMergedPr.run.attempt 49 | .map(_.fold(error => succeed, _ => fail)) 50 | .unsafeRunSync() 51 | } 52 | 53 | behavior of "happy Notifier" 54 | 55 | it should "return 200 if successfully set the label on the latest merged pull request" in { 56 | trait MockHttpClient extends HttpClient { 57 | override def get(endpoint: String = "", 58 | authHeader: (String, String) = ("", "")) 59 | : WriterT[IO, List[Log], String] = 60 | WriterT.putT(IO(mockCommitMessageResponse))(List(Log("", ""))) 61 | 62 | override def post(endpoint: String = "", 63 | authHeader: (String, String) = ("", ""), 64 | jsonBody: String = ""): WriterT[IO, List[Log], String] = 65 | WriterT.putT(IO(""))(List(Log("", ""))) 66 | } 67 | 68 | object Notifier 69 | extends Notifier 70 | with GitHubApi 71 | with MockHttpClient 72 | with ConfigFromTypesafe { 73 | override implicit val ec: ExecutionContext = 74 | scala.concurrent.ExecutionContext.global 75 | } 76 | 77 | Notifier.setLabelOnLatestMergedPr.run.attempt 78 | .map(_.fold(error => fail, _ => succeed)) 79 | .unsafeRunSync() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/scala/com/gu/tip/PathsActorTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import org.scalatest.{AsyncFlatSpec, Matchers} 4 | import scala.concurrent.duration._ 5 | import akka.actor.{ActorRef, ActorSystem} 6 | import akka.pattern.ask 7 | import akka.util.Timeout 8 | import scala.concurrent.ExecutionContextExecutor 9 | 10 | class PathsActorTest 11 | extends AsyncFlatSpec 12 | with Matchers 13 | with ConfigFromTypesafe { 14 | 15 | implicit val system: ActorSystem = ActorSystem("HelloWorld") 16 | implicit val ec: ExecutionContextExecutor = system.dispatcher 17 | implicit val timeout: Timeout = Timeout(10.second) 18 | 19 | behavior of "PathsActor factory" 20 | 21 | it should "convert tip.yaml definition to Paths type" in { 22 | PathsActor("tip.yaml", configuration) shouldBe a[ActorRef] 23 | } 24 | 25 | it should "read correct number of paths from tip.yaml" in { 26 | PathsActor("tip.yaml", configuration) ? NumberOfPaths map { 27 | _ shouldBe NumberOfPathsAnswer(2) 28 | } 29 | } 30 | 31 | it should "throw exception if tip.yaml does not exist" in { 32 | assertThrows[MissingPathConfigurationFile]( 33 | PathsActor("tipppp.yaml", configuration)) 34 | } 35 | 36 | it should "throw exception if tip.yaml has bad syntax" in { 37 | assertThrows[PathConfigurationSyntaxError]( 38 | PathsActor("tip-bad.yaml", configuration)) 39 | } 40 | 41 | behavior of "PathsActor" 42 | 43 | it should "have no verified paths initially" in { 44 | PathsActor("tip.yaml", configuration) ? NumberOfVerifiedPaths map { 45 | _ shouldBe NumberOfVerifiedPathsAnswer(0) 46 | } 47 | } 48 | 49 | it should "verify an existing path" in { 50 | PathsActor("tip.yaml", configuration) ? Verify("Name A") map { 51 | _ shouldBe PathIsVerified("Name A") 52 | } 53 | } 54 | 55 | it should "verify an existing path only once" in { 56 | val paths = PathsActor("tip.yaml", configuration) 57 | 58 | val pathA1 = paths ? Verify("Name A") 59 | val pathA2 = paths ? Verify("Name A") 60 | 61 | for { 62 | _ <- pathA1 63 | result <- pathA2 64 | } yield { result shouldBe PathIsAlreadyVerified("Name A") } 65 | 66 | PathsActor("tip.yaml", configuration) ? Verify("Name A") map { 67 | _ shouldBe PathIsVerified("Name A") 68 | } 69 | } 70 | 71 | it should "indicate when all paths are verified" in { 72 | val paths = PathsActor("tip.yaml", configuration) 73 | 74 | val pathA = paths ? Verify("Name A") 75 | val pathB = paths ? Verify("Name B") 76 | 77 | for { 78 | _ <- pathA 79 | result <- pathB 80 | } yield { result shouldBe AllPathsVerified } 81 | } 82 | 83 | it should "respond with PathDoesNotExist message when it cannot find the path" in { 84 | PathsActor("tip.yaml", configuration) ? Verify("Misspelled Path Name") map { 85 | _ shouldBe PathDoesNotExist("Misspelled Path Name") 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/test/scala/com/gu/tip/TipTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip 2 | 3 | import cats.data.WriterT 4 | import cats.effect.IO 5 | import com.gu.tip.cloud.TipCloudApi 6 | import org.http4s.Status.InternalServerError 7 | import org.http4s.client.UnexpectedStatus 8 | import org.scalatest.{AsyncFlatSpec, MustMatchers} 9 | 10 | class TipTest extends AsyncFlatSpec with MustMatchers { 11 | 12 | private val mockOkResponse: WriterT[IO, List[Log], String] = 13 | WriterT( 14 | IO( 15 | ( 16 | List(Log("", "")), 17 | "" 18 | ) 19 | ) 20 | ) 21 | 22 | trait MockHttpClient extends HttpClient { 23 | override def get(endpoint: String = "", 24 | authHeader: (String, String) = ("", "")) 25 | : WriterT[IO, List[Log], String] = mockOkResponse 26 | 27 | override def post(endpoint: String = "", 28 | authHeader: (String, String) = ("", ""), 29 | jsonBody: String = ""): WriterT[IO, List[Log], String] = 30 | mockOkResponse 31 | } 32 | 33 | object Tip 34 | extends Tip 35 | with Notifier 36 | with GitHubApi 37 | with TipCloudApi 38 | with MockHttpClient 39 | with ConfigFromTypesafe 40 | 41 | behavior of "unhappy Tip" 42 | 43 | it should "throw an exception on missing TiP configuration" 44 | 45 | it should "throw an exception on bad syntax in path definition file" in { 46 | object Tip 47 | extends Tip 48 | with Notifier 49 | with GitHubApi 50 | with TipCloudApi 51 | with MockHttpClient 52 | with ConfigFromTypesafe { 53 | override val pathConfigFilename: String = "tip-bad.yaml" 54 | } 55 | 56 | assertThrows[Throwable](Tip.verify("")) 57 | } 58 | 59 | it should "throw an exception on missing path definition file" in { 60 | object Tip 61 | extends Tip 62 | with Notifier 63 | with GitHubApi 64 | with TipCloudApi 65 | with MockHttpClient 66 | with ConfigFromTypesafe { 67 | override val pathConfigFilename: String = "tip-not-found.yaml" 68 | } 69 | assertThrows[MissingPathConfigurationFile](Tip.verify("")) 70 | } 71 | 72 | it should "handle bad path name" in { 73 | Tip.verify("badName").map(_ mustBe PathNotFound("badName")) 74 | } 75 | 76 | it should "handle exceptions thrown from Notifier" in { 77 | trait MockNotifier extends NotifierIf { this: GitHubApiIf => 78 | override def setLabelOnLatestMergedPr: WriterT[IO, List[Log], String] = 79 | WriterT( 80 | IO.raiseError(throw new RuntimeException) 81 | .map(_ => (List(Log("", "")), ""))) 82 | } 83 | 84 | object Tip 85 | extends Tip 86 | with MockNotifier 87 | with GitHubApi 88 | with TipCloudApi 89 | with MockHttpClient 90 | with ConfigFromTypesafe 91 | 92 | for { 93 | _ <- Tip.verify("Name A") 94 | result <- Tip.verify("Name B") 95 | } yield result mustBe UnclassifiedError 96 | } 97 | 98 | it should "not affect verification result when setting of PR label fails" in { 99 | trait MockNotifier extends NotifierIf { this: GitHubApiIf => 100 | override def setLabelOnLatestMergedPr: WriterT[IO, List[Log], String] = 101 | WriterT( 102 | IO.raiseError(UnexpectedStatus(InternalServerError)) 103 | .map(_ => (List(Log("", "")), ""))) 104 | } 105 | 106 | object Tip 107 | extends Tip 108 | with MockNotifier 109 | with GitHubApi 110 | with TipCloudApi 111 | with MockHttpClient 112 | with ConfigFromTypesafe 113 | 114 | for { 115 | _ <- Tip.verify("Name A") 116 | result <- Tip.verify("Name B") 117 | } yield result mustBe AllTestsInProductionPassed 118 | } 119 | 120 | behavior of "happy Tip" 121 | 122 | it should "verify a path" in { 123 | Tip 124 | .verify("Name A") 125 | .map(_ mustBe PathsActorResponse(PathIsVerified("Name A"))) 126 | } 127 | 128 | it should "return AllTestsInProductionPassed when all paths have been verified" in { 129 | trait MockNotifier extends NotifierIf { this: GitHubApiIf => 130 | override def setLabelOnLatestMergedPr(): WriterT[IO, List[Log], String] = 131 | mockOkResponse 132 | } 133 | 134 | object Tip 135 | extends Tip 136 | with MockNotifier 137 | with GitHubApi 138 | with TipCloudApi 139 | with MockHttpClient 140 | with ConfigFromTypesafe 141 | 142 | for { 143 | _ <- Tip.verify("Name A") 144 | result <- Tip.verify("Name B") 145 | } yield result mustBe AllTestsInProductionPassed 146 | } 147 | 148 | it should "not return AllTestsInProductionPassed if the same path is verified multiple times concurrently (no race conditions)" in { 149 | trait MockNotifier extends NotifierIf { this: GitHubApiIf => 150 | override def setLabelOnLatestMergedPr: WriterT[IO, List[Log], String] = 151 | mockOkResponse 152 | } 153 | 154 | object Tip 155 | extends Tip 156 | with MockNotifier 157 | with GitHubApi 158 | with TipCloudApi 159 | with MockHttpClient 160 | with ConfigFromTypesafe 161 | 162 | val path1 = Tip.verify("Name A") // execute in parallel 163 | val path2 = Tip.verify("Name A") 164 | val path3 = Tip.verify("Name A") 165 | val path4 = Tip.verify("Name A") 166 | 167 | for { 168 | _ <- path1 169 | _ <- path2 170 | _ <- path3 171 | result <- path4 172 | } yield result must not be PathsActorResponse(AllPathsAlreadyVerified) 173 | } 174 | 175 | it should "shutdown its actor system after all paths have been verified" in { 176 | trait MockNotifier extends NotifierIf { this: GitHubApiIf => 177 | override def setLabelOnLatestMergedPr: WriterT[IO, List[Log], String] = 178 | mockOkResponse 179 | } 180 | 181 | object Tip 182 | extends Tip 183 | with MockNotifier 184 | with GitHubApi 185 | with TipCloudApi 186 | with MockHttpClient 187 | with ConfigFromTypesafe 188 | 189 | val path1 = Tip.verify("Name A") // execute these in parallel 190 | val path2 = Tip.verify("Name B") 191 | val path3 = Tip.verify("Name A") 192 | val path4 = Tip.verify("Name B") 193 | val path5 = Tip.verify("Name A") 194 | val path6 = Tip.verify("Name B") 195 | 196 | for { 197 | _ <- Tip.verify("Name A") // execute these in sequence 198 | _ <- Tip.verify("Name B") 199 | _ <- Tip.verify("Name A") 200 | _ <- Tip.verify("Name B") 201 | _ <- Tip.verify("Name A") 202 | result <- Tip.verify("Name B") 203 | } yield result mustBe TipFinished 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/test/scala/com/gu/tip/assertion/TipAssertTest.scala: -------------------------------------------------------------------------------- 1 | package com.gu.tip.assertion 2 | 3 | import com.typesafe.scalalogging.Logger 4 | import org.mockito.IdiomaticMockito 5 | import org.scalatest.{AsyncFlatSpec, MustMatchers} 6 | import scala.concurrent.Future 7 | 8 | class TipAssertTest 9 | extends AsyncFlatSpec 10 | with MustMatchers 11 | with IdiomaticMockito { 12 | 13 | "TipAssert" should "succeed" in { 14 | val f = Future(1) 15 | TipAssert( 16 | f, 17 | (v: Int) => v == 1, 18 | "Duplicate DD mandates created! Fix ASAP!" 19 | ) map (_ must be(TipAssertPass)) 20 | } 21 | 22 | it should "fail" in { 23 | val f = Future(3) 24 | TipAssert( 25 | f, 26 | (v: Int) => v == 1, 27 | "Duplicate DD mandates created! Fix ASAP!" 28 | ) map (_ must be(TipAssertFail)) 29 | } 30 | 31 | it should "log error on failed assertion" in { 32 | val underlying = mock[org.slf4j.Logger] 33 | underlying.isErrorEnabled() shouldReturn true 34 | val loggerMock = Logger(underlying) 35 | 36 | object TipAssertWithMockLogger extends TipAssertIf { 37 | override val logger: Logger = loggerMock 38 | } 39 | 40 | val f = Future(3) 41 | TipAssertWithMockLogger( 42 | f, 43 | (v: Int) => v == 1, 44 | "User double-charged. Fix ASAP!" 45 | ) map { result => 46 | underlying.error("User double-charged. Fix ASAP!") was called 47 | result must be(TipAssertFail) 48 | } 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | version in ThisBuild := "0.6.5-SNAPSHOT" 2 | --------------------------------------------------------------------------------