├── .scalafmt.conf ├── project ├── build.properties └── plugins.sbt ├── .gitignore └── src ├── test ├── resources │ └── data.txt └── scala │ └── GitHubSpec.scala └── main └── scala └── ScalaInTheCity.scala /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.4.2" 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.3.10 -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.0") -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bloop 2 | .metals 3 | project/.sbt 4 | project/metals.sbt 5 | target 6 | -------------------------------------------------------------------------------- /src/test/resources/data.txt: -------------------------------------------------------------------------------- 1 | zio/zio,jdegoes,703 2 | zio/zio,adamgfraser,429 3 | zio/zio,scala-steward,235 4 | zio/zio,ghostdogpr,184 5 | zio/zio,wi101,137 6 | zio/zio,mijicd,122 7 | zio/zio,neko-kai,104 8 | zio/zio,vasilmkd,100 9 | zio/zio,iravid,97 10 | zio/zio,artempyanykh,84 11 | zio/zio,NeQuissimus,83 12 | zio/zio,ktonga,79 13 | zio/zio,LGLO,63 14 | zio/zio,RaasAhsan,51 15 | zio/zio,Dan-M,43 16 | zio/zio,ioleo,40 17 | zio/zio,mschuwalow,37 18 | zio/zio,saraiva132,32 19 | zio/zio,Vilkina,32 20 | zio/zio,softinio,28 21 | zio/zio,ashabhasa,26 22 | zio/zio,sideeffffect,25 23 | zio/zio,sken77,25 24 | zio/zio,simpadjo,24 25 | zio/zio,luis3m,20 26 | zio/zio,reibitto,20 27 | zio/zio,randbw,19 28 | zio/zio,lbialy,17 29 | zio/zio,edvmorango,17 30 | zio/zio,mlangc,16 31 | ghostdogpr/caliban,ghostdogpr,260 32 | ghostdogpr/caliban,scala-steward,83 33 | ghostdogpr/caliban,adamgfraser,13 34 | ghostdogpr/caliban,phderome,10 35 | ghostdogpr/caliban,javimartinez,6 36 | ghostdogpr/caliban,paulpdaniels,6 37 | ghostdogpr/caliban,joprice,5 38 | ghostdogpr/caliban,vpavkin,3 39 | ghostdogpr/caliban,fokot,2 40 | ghostdogpr/caliban,yoohaemin,2 41 | ghostdogpr/caliban,mitsutaka-takeda,2 42 | ghostdogpr/caliban,rleibman,2 43 | ghostdogpr/caliban,rtimush,2 44 | ghostdogpr/caliban,desbo,2 45 | ghostdogpr/caliban,gjuhasz86,2 46 | ghostdogpr/caliban,mriceron,2 47 | ghostdogpr/caliban,palanga,1 48 | ghostdogpr/caliban,antosha417,1 49 | ghostdogpr/caliban,kiranbayram,1 50 | ghostdogpr/caliban,hderms,1 51 | ghostdogpr/caliban,DJLemkes,1 52 | ghostdogpr/caliban,joroKr21,1 53 | ghostdogpr/caliban,jorge-vasquez-2301,1 54 | ghostdogpr/caliban,kitlangton,1 55 | ghostdogpr/caliban,loicdescotte,1 56 | ghostdogpr/caliban,fkowal,1 57 | ghostdogpr/caliban,ruurtjan,1 58 | ghostdogpr/caliban,sh0hei,1 59 | ghostdogpr/caliban,jona7o,1 60 | ghostdogpr/caliban,TobiasPfeifer,1 61 | zio/zio-kafka,iravid,44 62 | zio/zio-kafka,scala-steward,28 63 | zio/zio-kafka,svroonland,17 64 | zio/zio-kafka,andreamarcolin,3 65 | zio/zio-kafka,guymers,3 66 | zio/zio-kafka,adamgfraser,2 67 | zio/zio-kafka,egast,2 68 | zio/zio-kafka,LGLO,2 69 | zio/zio-kafka,aleksandarskrbic,1 70 | zio/zio-kafka,pierangeloc,1 71 | zio/zio-kafka,ghostdogpr,1 72 | zio/zio-kafka,pgabara,1 73 | zio/zio-kafka,ruurtjan,1 74 | zio/zio-kafka,Wsxqaz,1 75 | zio/zio-kafka,TimPigden,1 76 | zio/zio-kafka,NeQuissimus,1 77 | zio/zio-kafka,lvitaly,1 78 | zio/zio-kafka,sullis,1 -------------------------------------------------------------------------------- /src/main/scala/ScalaInTheCity.scala: -------------------------------------------------------------------------------- 1 | import io.circe.generic.auto._ 2 | 3 | import sttp.client._ 4 | import sttp.client.circe._ 5 | import sttp.client.asynchttpclient.zio._ 6 | import sttp.client.asynchttpclient.WebSocketHandler 7 | 8 | import zio._ 9 | import zio.console._ 10 | 11 | object ScalaInTheCity extends App { 12 | 13 | final case class SearchResult(items: List[Repo]) 14 | 15 | final case class Repo(full_name: String) { 16 | def owner: String = 17 | full_name.split('/')(0) 18 | def name: String = 19 | full_name.split('/')(1) 20 | } 21 | 22 | final case class Contributor(login: String, contributions: Int) 23 | 24 | type Github = Has[Github.Service] 25 | 26 | object Github { 27 | 28 | trait Service { 29 | def getRepos(language: String, limit: Int): Task[List[Repo]] 30 | def getContributors(repo: Repo): Task[List[Contributor]] 31 | } 32 | 33 | val live: ZLayer[SttpClient, Nothing, Github] = 34 | ZLayer.fromService { sttpClient => 35 | new Service { 36 | def getRepos(language: String, limit: Int): Task[List[Repo]] = { 37 | val request = basicRequest 38 | .get( 39 | uri"https://api.github.com/search/repositories?q=language:$language&sort=stars&per_page=$limit" 40 | ) 41 | .response(asJson[SearchResult]) 42 | sttpClient.send(request).map(_.body).absolve.map(_.items) 43 | } 44 | def getContributors(repo: Repo): Task[List[Contributor]] = { 45 | val request = basicRequest 46 | .get( 47 | uri"https://api.github.com/repos/${repo.owner}/${repo.name}/contributors" 48 | ) 49 | .response(asJson[List[Contributor]]) 50 | sttpClient.send(request).map(_.body).absolve 51 | } 52 | } 53 | } 54 | 55 | def getRepos( 56 | language: String, 57 | limit: Int 58 | ): ZIO[Github, Throwable, List[Repo]] = 59 | ZIO.accessM(_.get.getRepos(language, limit)) 60 | 61 | def getContributors(repo: Repo): ZIO[Github, Throwable, List[Contributor]] = 62 | ZIO.accessM(_.get.getContributors(repo)) 63 | } 64 | 65 | def notScalaSteward(contributors: List[Contributor]): List[Contributor] = 66 | contributors.filterNot(_.login == "scala-steward") 67 | 68 | def contributionsByUser(contributors: List[Contributor]): List[Contributor] = 69 | contributors 70 | .groupBy(_.login) 71 | .mapValues(_.map(_.contributions).sum) 72 | .map { 73 | case (login, contributions) => Contributor(login, contributions) 74 | } 75 | .toList 76 | .sortBy(_.contributions) 77 | .reverse 78 | 79 | def topContributors( 80 | language: String, 81 | limit: Int 82 | ): ZIO[Github, Throwable, List[Contributor]] = 83 | Github 84 | .getRepos(language, limit) 85 | .flatMap(ZIO.foreachPar(_)(Github.getContributors)) 86 | .map(_.flatten) 87 | .map(notScalaSteward) 88 | .map(contributionsByUser) 89 | 90 | val liveEnvironment: ZLayer[Any, Throwable, Github] = 91 | AsyncHttpClientZioBackend.layer() >>> Github.live 92 | 93 | def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = 94 | topContributors("scala", 25) 95 | .flatMap(result => console.putStrLn(result.mkString("\n"))) 96 | .provideCustomLayer(liveEnvironment) 97 | .fold(_ => 1, _ => 0) 98 | } 99 | -------------------------------------------------------------------------------- /src/test/scala/GitHubSpec.scala: -------------------------------------------------------------------------------- 1 | import zio.{test => _, _} 2 | import zio.duration._ 3 | import zio.random._ 4 | import zio.stream._ 5 | import zio.test._ 6 | import zio.test.environment._ 7 | import zio.test.Assertion._ 8 | import zio.test.TestAspect._ 9 | import zio.clock._ 10 | 11 | import scala.io.Source 12 | 13 | object ScalaInTheCitySpec extends DefaultRunnableSpec { 14 | import ScalaInTheCity._ 15 | 16 | final case class TestData( 17 | language: String, 18 | repo: Repo, 19 | contributors: List[Contributor] 20 | ) 21 | 22 | val testData: Managed[Nothing, List[TestData]] = 23 | ZManaged 24 | .make { 25 | ZIO.effectTotal(Source.fromResource("data.txt")) 26 | } { source => ZIO.effectTotal(source.close) } 27 | .mapM { source => 28 | ZIO.effectTotal { 29 | source.getLines.toList.init 30 | .map(_.split(',')) 31 | .map { 32 | case Array(full_name, login, contributions) => 33 | ( 34 | "scala", 35 | Repo(full_name), 36 | Contributor(login, contributions.toInt) 37 | ) 38 | } 39 | .groupBy(data => (data._1, data._2)) 40 | .map { 41 | case ((language, repo), grouped) => 42 | TestData(language, repo, grouped.map(_._3)) 43 | } 44 | .toList 45 | } 46 | } 47 | 48 | val testGithub: ZLayer[Has[List[TestData]], Nothing, Github] = 49 | ZLayer.fromService { testData => 50 | new Github.Service { 51 | def getRepos(language: String, limit: Int): Task[List[Repo]] = 52 | Task(testData.filter(_.language == language).take(limit).map(_.repo)) 53 | def getContributors(repo: ScalaInTheCity.Repo): Task[List[Contributor]] = 54 | Task(testData.find(_.repo == repo).get.contributors) 55 | } 56 | } 57 | 58 | val testEnvironment = 59 | testData.toLayer >>> testGithub 60 | 61 | val genContributor: Gen[Random, Contributor] = 62 | for { 63 | name <- Gen.elements("a", "b", "c", "d", "e") 64 | contributions <- Gen.int(1, 100) 65 | } yield Contributor(name, contributions) 66 | 67 | def spec = suite("ScalaInTheCitySpec")( 68 | suite("unit tests")( 69 | test("notScalaSteward excludes Scala Steward") { 70 | val input = List(Contributor("scala-steward", 10)) 71 | val output = notScalaSteward(input) 72 | assert(output)(isEmpty) 73 | } 74 | ), 75 | suite("property based tests")( 76 | testM("result is distinct") { 77 | check(Gen.listOf(genContributor)) { contributors => 78 | val output = contributionsByUser(contributors) 79 | assert(output)(isDistinct) 80 | } 81 | }, 82 | testM("result are sorted") { 83 | check(Gen.listOf(genContributor)) { contributors => 84 | val output = contributionsByUser(contributors) 85 | assert(output.map(_.contributions))(isSortedReverse) 86 | } 87 | }, 88 | ), 89 | suite("with test implementation of Github service")( 90 | testM("program yields expected result with test data") { 91 | val actual = topContributors("scala", 3) 92 | assertM(actual)(contains(Contributor("iravid", 141))) 93 | }, 94 | ).provideCustomLayerShared(testEnvironment) @@ sequential, 95 | suite("with live implementation of Github service")( 96 | testM("program yields expected result with live service") { 97 | val loginResults = topContributors("scala", 1).map(_.map(_.login)) 98 | assertM(loginResults)(contains("mateiz")) 99 | } @@ retry(Schedule.exponential(1.second) && Schedule.recurs(5)) 100 | ).provideCustomLayerShared(liveEnvironment.orDie) @@ ifEnvSet("ci"), 101 | ) @@ timeout(60.seconds), 102 | } 103 | --------------------------------------------------------------------------------