From 16a0740679b604ed63a203008b76ec1f74d47119 Mon Sep 17 00:00:00 2001 From: Ryan Harg Date: Mon, 12 Feb 2024 09:39:56 +0100 Subject: [PATCH] Rename base package --- .../main/kotlin/de/rpr/githubreleases/App.kt | 32 +++++++ .../kotlin/de/rpr/githubreleases/Config.kt | 79 +++++++++++++++++ .../de/rpr/githubreleases/Extensions.kt | 7 ++ .../de/rpr/githubreleases/FeedReader.kt | 8 ++ .../de/rpr/githubreleases/FeedService.kt | 36 ++++++++ .../de/rpr/githubreleases/GithubRepo.kt | 6 ++ .../de/rpr/githubreleases/LocalDateAdapter.kt | 21 +++++ .../githubreleases/LocalDateTimeAdapter.kt | 24 +++++ .../kotlin/de/rpr/githubreleases/LogLevel.kt | 43 +++++++++ .../kotlin/de/rpr/githubreleases/Publisher.kt | 58 ++++++++++++ .../rpr/githubreleases/ReleaseRepository.kt | 47 ++++++++++ .../kotlin/de/rpr/githubreleases/Releases.kt | 69 +++++++++++++++ .../githubreleases/ReleaseRepositoryTest.kt | 88 +++++++++++++++++++ .../kotlin/de/rpr/githubreleases/TestData.kt | 12 +++ 14 files changed, 530 insertions(+) create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/App.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/Config.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/Extensions.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/FeedReader.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/FeedService.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/GithubRepo.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/LocalDateAdapter.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/LocalDateTimeAdapter.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/LogLevel.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/Publisher.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/ReleaseRepository.kt create mode 100644 app/src/main/kotlin/de/rpr/githubreleases/Releases.kt create mode 100644 app/src/test/kotlin/de/rpr/githubreleases/ReleaseRepositoryTest.kt create mode 100644 app/src/test/kotlin/de/rpr/githubreleases/TestData.kt diff --git a/app/src/main/kotlin/de/rpr/githubreleases/App.kt b/app/src/main/kotlin/de/rpr/githubreleases/App.kt new file mode 100644 index 0000000..d2145b1 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/App.kt @@ -0,0 +1,32 @@ +package de.rpr.githubreleases + +import okhttp3.OkHttpClient + +fun main() { + val config = Config() + val publishers = Publishers(config) + val releaseRepository = ReleaseRepository() + val app = App(config, releaseRepository, OkHttpClient(), publishers) + app.execute() +} + +class App( + private val config: Config, + private val releaseRepo: ReleaseRepository, + private val httpClient: OkHttpClient, + private val publishers: Publishers +) { + + fun execute() { + + config.githubRepos.forEach { githubRepo -> + val existingReleases = releaseRepo.getExistingReleases(githubRepo) + val feedService = FeedService(githubRepo, httpClient) + val newReleases = feedService.getNewReleases(existingReleases) + val publisher = publishers.forName(githubRepo.name) + val publishedReleases = publisher.sendReleases(newReleases) + releaseRepo.save(publishedReleases) + } + } +} + diff --git a/app/src/main/kotlin/de/rpr/githubreleases/Config.kt b/app/src/main/kotlin/de/rpr/githubreleases/Config.kt new file mode 100644 index 0000000..af1ddaf --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/Config.kt @@ -0,0 +1,79 @@ +package de.rpr.githubreleases; + +import de.rpr.githubreleases.LogLevel.ERROR + +class Config { + + data class PublishingCredentials( + val instanceUrl: String?, + val accessToken: String?, + private val instanceUrlEnvName: String, + private val accessTokenEnvName: String + ) { + + var valid: Boolean = true + + init { + validate() + } + + fun validate() { + if (instanceUrl.isNullOrBlank()) { + ERROR.log("No instance url available, please set the environment variable $instanceUrlEnvName") + valid = false + } + if (accessToken.isNullOrBlank()) { + ERROR.log("No access token available, please set the environment variable $accessTokenEnvName") + valid = false + } + } + } + + val githubRepos: List + val mastodonCredentials: Map + val schedulingDelay: Long = (System.getenv("SCHEDULING_DELAY")?.toLong() ?: 120) * 60 + + init { + + var valid = true + + val githubReposString = System.getenv("GITHUB_REPOS") + if (githubReposString == null) { + ERROR.log("No github repos defined, please set repo urls in environment variable GITHUB_REPOS") + throw IllegalStateException("Invalid configuration") + + } + + githubRepos = githubReposString.split(",").map { + GithubRepo(repoName(it), it) + } + + mastodonCredentials = githubRepos.associate { + it.name to PublishingCredentials( + instanceUrlEnvName = "${it.name.uppercase()}_INSTANCE_URL", + instanceUrl = System.getenv("${it.name.uppercase()}_INSTANCE_URL"), + accessTokenEnvName = "${it.name.uppercase()}_ACCESS_TOKEN", + accessToken = System.getenv("${it.name.uppercase()}_ACCESS_TOKEN") + ) + }.onEach { (_, credentials) -> + if (!credentials.valid) { + valid = false + } + } + + if (schedulingDelay < 300) { + ERROR.log("To avoid hammering the source webpage, scheduling delay has to be > 5 minutes") + valid = false + } + + if (!valid) { + throw IllegalStateException("Invalid configuration") + } + } + + private fun repoName(url: String): String { + val usernameEnd = url.indexOf("/", startIndex = 20) + return url.substring(usernameEnd + 1).trimEnd { ch -> ch == '/' } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/Extensions.kt b/app/src/main/kotlin/de/rpr/githubreleases/Extensions.kt new file mode 100644 index 0000000..a16f3cc --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/Extensions.kt @@ -0,0 +1,7 @@ +package de.rpr.githubreleases + +import java.io.File +import java.nio.file.Path + +fun String.toFile(): File = File(this) +fun String.toPath(): Path = this.toFile().toPath() diff --git a/app/src/main/kotlin/de/rpr/githubreleases/FeedReader.kt b/app/src/main/kotlin/de/rpr/githubreleases/FeedReader.kt new file mode 100644 index 0000000..842542a --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/FeedReader.kt @@ -0,0 +1,8 @@ +package de.rpr.githubreleases + +import com.ouattararomuald.syndication.atom.AtomFeed + +interface FeedReader { + + fun readAtom(): AtomFeed +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/FeedService.kt b/app/src/main/kotlin/de/rpr/githubreleases/FeedService.kt new file mode 100644 index 0000000..cfa22f0 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/FeedService.kt @@ -0,0 +1,36 @@ +package de.rpr.githubreleases + +import com.ouattararomuald.syndication.Syndication +import de.rpr.terminenbg.LocalDateTimeAdapter +import okhttp3.OkHttpClient +import java.time.LocalDateTime + +class FeedService( + private val githubRepo: GithubRepo, + httpClient: OkHttpClient +) { + private val syndication: Syndication = Syndication( + url = "${githubRepo.url}/releases.atom", + httpClient = httpClient + ) + + fun getNewReleases(existingReleases: Releases): Releases { + log("Consuming releases feed for ${githubRepo.url}") + + val feedReader = syndication.create(FeedReader::class.java) + return feedReader.readAtom() + .items + ?.map { + val created = LocalDateTime.parse(it.lastUpdatedTime, LocalDateTimeAdapter.formatter) + val link = it.links!!.first().href!! + Release( + id = it.title, + link = link, + created = created, + githubRepo = githubRepo + ) + } + ?.filter { !existingReleases.contains(it) } + ?.asCollection() ?: Releases() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/GithubRepo.kt b/app/src/main/kotlin/de/rpr/githubreleases/GithubRepo.kt new file mode 100644 index 0000000..564889a --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/GithubRepo.kt @@ -0,0 +1,6 @@ +package de.rpr.githubreleases + +data class GithubRepo( + val name: String, + val url: String +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/LocalDateAdapter.kt b/app/src/main/kotlin/de/rpr/githubreleases/LocalDateAdapter.kt new file mode 100644 index 0000000..00851e3 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/LocalDateAdapter.kt @@ -0,0 +1,21 @@ +package de.rpr.terminenbg + +import com.google.gson.* +import java.lang.reflect.Type +import java.time.LocalDate +import java.time.format.DateTimeFormatter + + +class LocalDateAdapter : JsonSerializer, JsonDeserializer { + + private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + override fun serialize(src: LocalDate?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + return JsonPrimitive(src?.format(formatter)) + } + + override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): LocalDate? { + return LocalDate.parse(json?.asString, formatter) + } + +} diff --git a/app/src/main/kotlin/de/rpr/githubreleases/LocalDateTimeAdapter.kt b/app/src/main/kotlin/de/rpr/githubreleases/LocalDateTimeAdapter.kt new file mode 100644 index 0000000..57e2306 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/LocalDateTimeAdapter.kt @@ -0,0 +1,24 @@ +package de.rpr.terminenbg + +import com.google.gson.* +import java.lang.reflect.Type +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + + +class LocalDateTimeAdapter : JsonSerializer, JsonDeserializer { + + companion object { + val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + } + + override fun serialize(src: LocalDateTime?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + return JsonPrimitive(src?.format(formatter)) + } + + override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): LocalDateTime? { + return LocalDateTime.parse(json?.asString, formatter) + } + +} diff --git a/app/src/main/kotlin/de/rpr/githubreleases/LogLevel.kt b/app/src/main/kotlin/de/rpr/githubreleases/LogLevel.kt new file mode 100644 index 0000000..1a13f2c --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/LogLevel.kt @@ -0,0 +1,43 @@ +package de.rpr.githubreleases + +import java.io.PrintStream +import java.time.LocalDateTime + +fun log(message: String, logLevel: LogLevel = LogLevel.INFO) { + logLevel.log(message) +} + +enum class LogLevel(val order: Int, val out: PrintStream) { + DEBUG(1, System.out), + INFO(2, System.out), + ERROR(3, System.err); + + + + fun log(message: String) { + val activeLogLevel = System.getenv("LOG_LEVEL") + ?.let { LogLevel.valueOf(it.uppercase()) } + ?: LogLevel.valueOf("INFO") + if (activeLogLevel.order <= this.order) { + out.println("${LocalDateTime.now().format(dateTimeFormatter)} $this - $message") + } + } + + private val timeFormatter = java.time.format.DateTimeFormatterBuilder() + .appendValue(java.time.temporal.ChronoField.HOUR_OF_DAY, 2) + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.MINUTE_OF_HOUR, 2) + .optionalStart() + .appendLiteral(':') + .appendValue(java.time.temporal.ChronoField.SECOND_OF_MINUTE, 2) + .appendLiteral(".") + .appendValue(java.time.temporal.ChronoField.MILLI_OF_SECOND, 3) + .toFormatter() + + private val dateTimeFormatter = java.time.format.DateTimeFormatterBuilder() + .parseCaseInsensitive() + .append(java.time.format.DateTimeFormatter.ISO_LOCAL_DATE) + .appendLiteral('T') + .append(timeFormatter) + .toFormatter(); +} diff --git a/app/src/main/kotlin/de/rpr/githubreleases/Publisher.kt b/app/src/main/kotlin/de/rpr/githubreleases/Publisher.kt new file mode 100644 index 0000000..ac73a8c --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/Publisher.kt @@ -0,0 +1,58 @@ +package de.rpr.githubreleases + +import social.bigbone.MastodonClient +import social.bigbone.api.entity.data.Visibility +import social.bigbone.api.exception.BigBoneRequestException + +class Publisher( + val name: String, + private val client: MastodonClient, + private val dryRun: Boolean = System.getenv("PUBLISH_DRY_RUN").toBoolean() +) { + + fun sendReleases(releases: Releases): Releases { + val result = releases + .onEach { release -> log("Publishing release: ${release.title}") } + .mapNotNull { release -> + val request = client.statuses.postStatus( + status = release.text, + language = "en", + visibility = Visibility.PRIVATE + ) + try { + if (!dryRun) { + request.execute() + } else { + log("Dry-Run, skipping publishing of events...") + } + return@mapNotNull release + } catch (ex: BigBoneRequestException) { + log("ERROR: Event with id ${release.id} couldn't be published: " + ex.httpStatusCode) + LogLevel.ERROR.log("Cause: ${ex.message}") + LogLevel.ERROR.log("Root cause: ${ex.cause?.message}") + return@mapNotNull null + } + } + return Releases(result) + } +} + +class Publishers(private val config: Config) : Iterable { + + private val instances: List + + init { + val mastodonClients = config.githubRepos.associate { + val publishingCredentials = config.mastodonCredentials[it.name]!! + it.name to MastodonClient.Builder(publishingCredentials.instanceUrl!!) + .accessToken(publishingCredentials.accessToken!!).build() + } + instances = config.githubRepos.map { Publisher(it.name, mastodonClients[it.name]!!) } + } + + fun forName(name: String) = instances.first() { it.name == name } + + override fun iterator(): Iterator { + return instances.iterator() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/ReleaseRepository.kt b/app/src/main/kotlin/de/rpr/githubreleases/ReleaseRepository.kt new file mode 100644 index 0000000..a17dc07 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/ReleaseRepository.kt @@ -0,0 +1,47 @@ +package de.rpr.githubreleases + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import de.rpr.terminenbg.LocalDateAdapter +import de.rpr.terminenbg.LocalDateTimeAdapter +import java.io.File +import java.nio.file.Files +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name + +class ReleaseRepository(private val basePath: String = System.getenv("REPO_PATH") ?: "releases/") { + + init { + val basePath = basePath.toFile() + if (!basePath.exists()) { + basePath.mkdir() + } + } + + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .setPrettyPrinting() + .create() + + fun save(releases: Releases) = releases.forEach { writeEvent(it) } + + private fun writeEvent(release: Release) { + val file = File(basePath, "${LocalDate.now()}-${release.id}.${release.githubRepo.name.lowercase()}.json") + Files.writeString(file.toPath(), gson.toJson(release)) + } + + fun getExistingReleases(repository: GithubRepo): Releases { + return basePath.toPath() + .listDirectoryEntries() + .filter { it.fileName.name.endsWith(".${repository.name}.json") } + .map { + Files.readString(it).let { content -> + gson.fromJson(content, Release::class.java) + } + } + .asCollection() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/rpr/githubreleases/Releases.kt b/app/src/main/kotlin/de/rpr/githubreleases/Releases.kt new file mode 100644 index 0000000..05c17b6 --- /dev/null +++ b/app/src/main/kotlin/de/rpr/githubreleases/Releases.kt @@ -0,0 +1,69 @@ +package de.rpr.githubreleases + +import java.time.LocalDateTime + +data class Release( + val id: String, + val link: String, + val created: LocalDateTime, + val githubRepo: GithubRepo +) { + val title: String get() = id + val text: String = + "\uD83C\uDF89 Navidrome $title has been published!\n\nRelease notes are available here: $link\n\n#Navidrome" +} + +fun List.asCollection() = Releases(this) + +class Releases(releases: List) : List { + + constructor(vararg posts: Release) : this(posts.toList()) + + private val content: MutableList = mutableListOf() + + init { + content.addAll(releases) + } + + override val size: Int get() = content.size + + override fun get(index: Int): Release { + return content[0] + } + + override fun isEmpty(): Boolean { + return content.isEmpty() + } + + override fun iterator(): Iterator { + return content.iterator() + } + + override fun listIterator(): ListIterator { + return content.listIterator() + } + + override fun listIterator(index: Int): ListIterator { + return content.listIterator(index) + } + + override fun subList(fromIndex: Int, toIndex: Int): List { + return content.subList(fromIndex, toIndex) + } + + override fun lastIndexOf(element: Release): Int { + return content.lastIndexOf(element) + } + + override fun indexOf(element: Release): Int { + return content.indexOf(element) + } + + override fun containsAll(elements: Collection): Boolean { + return content.containsAll(elements) + } + + override fun contains(element: Release): Boolean { + return content.map { it.id }.contains(element.id) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/de/rpr/githubreleases/ReleaseRepositoryTest.kt b/app/src/test/kotlin/de/rpr/githubreleases/ReleaseRepositoryTest.kt new file mode 100644 index 0000000..fe8cf06 --- /dev/null +++ b/app/src/test/kotlin/de/rpr/githubreleases/ReleaseRepositoryTest.kt @@ -0,0 +1,88 @@ +package de.rpr.githubreleases + +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isTrue +import io.kotest.core.spec.style.DescribeSpec +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths +import java.time.LocalDate +import java.time.LocalDateTime +import kotlin.io.path.deleteExisting +import kotlin.io.path.listDirectoryEntries + +class ReleaseRepositoryTest : DescribeSpec({ + + val sampleReleaseContent = """{ + "id": "v0.51.0", + "link": "https://github.com/navidrome/navidrome/releases/tag/v0.51.0", + "created": "2024-01-22T23:59:21Z", + "githubRepo": { + "name": "navidrome", + "url": "https://github.com/navidrome/navidrome/" + }, + "text": "🎉 Navidrome v0.51.0 has been published!\n\nRelease notes are available here: https://github.com/navidrome/navidrome/releases/tag/v0.51.0\n\n#Navidrome" + }""".trimIndent() + + val basePath = System.getProperty("java.io.tmpdir") + "/releases" + + beforeSpec { + basePath.toFile().mkdir() + } + + afterSpec { + basePath.toPath().listDirectoryEntries() + .forEach { + it.deleteExisting() + } + Files.deleteIfExists(basePath.toPath()) + } + + afterTest { + basePath.toPath().listDirectoryEntries() + .forEach { + it.deleteExisting() + } + } + + describe("ReleaseRepository is created") { + + it("should create the repository folder") { + ReleaseRepository(basePath) + assertThat(File(basePath).exists()).isTrue() + } + } + + describe("ReleaseRepository") { + + it("should write events to repository folder") { + val eventRepository = ReleaseRepository(basePath) + eventRepository.save( + Releases( + Release( + id = "id", + link = "https://example.com", + created = LocalDateTime.now(), + githubRepo = testGithubRepo + ) + ) + ) + assertThat(basePath.toPath().listDirectoryEntries().map { it.fileName.toString() }) + .contains("${LocalDate.now()}-id.example.json") + } + + it("should read existing events from repository folder") { + + val eventPath = Paths.get(basePath.toPath().toString(), "sample-post.${testGithubRepo.name}.json") + eventPath.toFile().createNewFile() + Files.writeString(eventPath, sampleReleaseContent) + + val postRepository = ReleaseRepository(basePath) + val existingEvents = postRepository.getExistingReleases(testGithubRepo) + assertThat(existingEvents).contains(testRelease.copy(id="v0.51.0")) + } + } + + +}) \ No newline at end of file diff --git a/app/src/test/kotlin/de/rpr/githubreleases/TestData.kt b/app/src/test/kotlin/de/rpr/githubreleases/TestData.kt new file mode 100644 index 0000000..4bde219 --- /dev/null +++ b/app/src/test/kotlin/de/rpr/githubreleases/TestData.kt @@ -0,0 +1,12 @@ +package de.rpr.githubreleases + +import java.time.LocalDateTime + +val testGithubRepo = GithubRepo("example", "https://github.com/example/example") + +val testRelease = Release( + id = "id", + link = "https://example.com", + created = LocalDateTime.of(2023, 11, 29, 12, 11), + githubRepo = testGithubRepo +) \ No newline at end of file