Rewrite bot configuration

This commit is contained in:
Ryan Harg 2024-02-16 10:50:56 +01:00
parent 46a379d5a5
commit 7314cde1f4
10 changed files with 154 additions and 182 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@
build build
.idea/ .idea/
config.json
.env .env
*.tar *.tar
*.tar.gz *.tar.gz

View file

@ -40,12 +40,12 @@ class App(
} }
private fun execute() { private fun execute() {
config.githubRepos.forEach { githubRepo -> config.accounts.forEach { account ->
log("Processing releases feed for ${githubRepo.name}...") log("Processing releases feed for ${account.repo.name}...")
val existingReleases = releaseRepo.getExistingReleases(githubRepo) val existingReleases = releaseRepo.getExistingReleases(account.repo)
val feedService = FeedService(githubRepo, httpClient) val feedService = FeedService(account.repo, httpClient)
val newReleases = feedService.getNewReleases(existingReleases) val newReleases = feedService.getNewReleases(existingReleases)
val publisher = publishers.forName(githubRepo.name) val publisher = publishers.forName(account.name)
val publishedReleases = publisher.sendReleases(newReleases) val publishedReleases = publisher.sendReleases(newReleases)
releaseRepo.save(publishedReleases) releaseRepo.save(publishedReleases)
log("Finished feed processing...") log("Finished feed processing...")

View file

@ -1,82 +1,63 @@
package de.rpr.githubreleases; package de.rpr.githubreleases
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import de.rpr.githubreleases.LogLevel.ERROR import de.rpr.githubreleases.LogLevel.ERROR
import de.rpr.githubreleases.model.GithubRepo import de.rpr.githubreleases.model.GithubRepo
import de.rpr.terminenbg.LocalDateAdapter import java.io.InputStream
import de.rpr.terminenbg.LocalDateTimeAdapter import java.nio.file.Files
import java.time.LocalDate
import java.time.LocalDateTime
class Config { class Config(configInputStream: InputStream) {
constructor(configFile: String = "config.json") : this(Files.newInputStream(configFile.toPath()))
data class Account(
private val accountName: String?,
private val github: String?,
private val mastodonInstance: String?,
private val mastodonAccessToken: String?,
) {
val repo get() = GithubRepo(github!!)
val name get() = accountName!!
val publishingInstance get() = mastodonInstance!!
val publishingAccessToken get() = mastodonAccessToken!!
fun validate(): Boolean {
var valid = true
if (accountName.isNullOrEmpty()) {
ERROR.log("Account should have a name defined.")
valid = false
}
if (github.isNullOrEmpty()) {
ERROR.log("Account should have a github repository defined.")
valid = false
}
if (mastodonInstance.isNullOrEmpty()) {
ERROR.log("Account should have a mastodon instance defined.")
valid = false
}
if (mastodonAccessToken.isNullOrEmpty()) {
ERROR.log("Account should have a mastodon access token defined.")
valid = false
}
return valid
}
}
class ConfigFile(val accounts: List<Account>) {
fun validate() = accounts.none { !it.validate() }
}
@Transient @Transient
private val gson: Gson = GsonBuilder().setPrettyPrinting().create() private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
data class PublishingCredentials( val accounts: List<Account>
val instanceUrl: String?,
@Transient val accessToken: String?,
@Transient private val instanceUrlEnvName: String,
@Transient private val accessTokenEnvName: String
) {
// Serialization helper
@Suppress("unused")
val token: String? = accessToken?.let { "${it.take(10)}..." }
@Transient
var valid: Boolean = true
init {
validate()
}
private 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<GithubRepo>
val mastodonCredentials: Map<String, PublishingCredentials>
val schedulingDelay: Long = (System.getenv("SCHEDULING_DELAY")?.toLong() ?: 120) * 60 val schedulingDelay: Long = (System.getenv("SCHEDULING_DELAY")?.toLong() ?: 120) * 60
init { init {
val configFile = gson.fromJson(configInputStream.reader(), ConfigFile::class.java)
var valid = configFile.validate()
var valid = true accounts = configFile.accounts
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(it)
}
mastodonCredentials = githubRepos.associate {
val instanceUrlEnvName = "${it.name.uppercase()}_MASTODON_INSTANCE_URL"
val accessTokenEnvName = "${it.name.uppercase()}_MASTODON_ACCESS_TOKEN"
it.name to PublishingCredentials(
instanceUrlEnvName = instanceUrlEnvName,
instanceUrl = System.getenv(instanceUrlEnvName),
accessTokenEnvName = accessTokenEnvName,
accessToken = System.getenv(accessTokenEnvName)
)
}.onEach { (_, credentials) ->
if (!credentials.valid) {
valid = false
}
}
if (schedulingDelay < 300) { if (schedulingDelay < 300) {
ERROR.log("To avoid hammering the source webpage, scheduling delay has to be > 5 minutes") ERROR.log("To avoid hammering the source webpage, scheduling delay has to be > 5 minutes")
@ -91,6 +72,4 @@ class Config {
override fun toString(): String { override fun toString(): String {
return "Config: " + gson.toJson(this) return "Config: " + gson.toJson(this)
} }
}
}

View file

@ -2,8 +2,8 @@ package de.rpr.githubreleases.publishing
import de.rpr.githubreleases.Config import de.rpr.githubreleases.Config
import de.rpr.githubreleases.LogLevel import de.rpr.githubreleases.LogLevel
import de.rpr.githubreleases.model.Releases
import de.rpr.githubreleases.log import de.rpr.githubreleases.log
import de.rpr.githubreleases.model.Releases
import social.bigbone.MastodonClient import social.bigbone.MastodonClient
import social.bigbone.api.entity.data.Visibility import social.bigbone.api.entity.data.Visibility
import social.bigbone.api.exception.BigBoneRequestException import social.bigbone.api.exception.BigBoneRequestException
@ -11,55 +11,46 @@ import social.bigbone.api.exception.BigBoneRequestException
class Publisher( class Publisher(
val name: String, val name: String,
private val client: MastodonClient, private val client: MastodonClient,
private val dryRun: Boolean = System.getenv("PUBLISH_DRY_RUN").toBoolean() private val dryRun: Boolean = System.getenv("PUBLISH_DRY_RUN").toBoolean(),
) { ) {
fun sendReleases(releases: Releases): Releases { fun sendReleases(releases: Releases): Releases {
log("${releases.size} new releases to publish") log("${releases.size} new releases to publish")
val result = releases val result =
.onEach { release -> log("Publishing release: ${release.title}") } releases
.mapNotNull { release -> .onEach { release -> log("Publishing release: ${release.title}") }
val request = client.statuses.postStatus( .mapNotNull { release ->
status = release.text, val request =
language = "en", client.statuses.postStatus(
visibility = Visibility.PUBLIC status = release.text,
) language = "en",
try { visibility = Visibility.PUBLIC,
if (!dryRun) { )
request.execute() try {
} else { if (!dryRun) {
log("Dry-Run, skipping publishing of events...") 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@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) return Releases(result)
} }
} }
class Publishers( class Publishers(
private val config: Config, config: Config,
private val mastodonClientFactory: MastodonClientFactory, private val mastodonClientFactory: MastodonClientFactory,
) : ) : Iterable<Publisher> {
Iterable<Publisher> { private val instances: List<Publisher> =
config.accounts.map {
private val instances: List<Publisher> Publisher(it.name, mastodonClientFactory.getClient(it.publishingInstance, it.publishingAccessToken))
init {
val mastodonClients = config.githubRepos.associate {
val publishingCredentials = config.mastodonCredentials[it.name]!!
it.name to mastodonClientFactory.getClient(
publishingCredentials.instanceUrl!!,
publishingCredentials.accessToken!!
)
} }
instances = config.githubRepos.map { Publisher(it.name, mastodonClients[it.name]!!) }
}
fun forName(name: String) = instances.first { it.name == name } fun forName(name: String) = instances.first { it.name == name }
@ -67,4 +58,3 @@ class Publishers(
return instances.iterator() return instances.iterator()
} }
} }

View file

@ -2,49 +2,29 @@ package de.rpr.githubreleases
import assertk.assertThat import assertk.assertThat
import assertk.assertions.containsExactly import assertk.assertions.containsExactly
import assertk.assertions.containsOnly
import de.rpr.githubreleases.model.GithubRepo
import io.kotest.core.spec.style.DescribeSpec import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.system.withEnvironment
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
class ConfigTest : DescribeSpec({ class ConfigTest : DescribeSpec({
describe("instantiation") { describe("instantiation") {
withEnvironment( it("should populate accounts from config") {
mapOf( assertThat(testConfig.accounts).containsExactly(
"GITHUB_REPOS" to "https://github.com/navidrome/navidrome/", Config.Account(
"NAVIDROME_MASTODON_INSTANCE_URL" to "example.com", accountName = "navidrome",
"NAVIDROME_MASTODON_ACCESS_TOKEN" to "token" github = "navidrome/navidrome",
mastodonInstance = "mastodon.social",
mastodonAccessToken = "token",
),
) )
) {
it("should populate instances from config") {
val config = Config()
assertThat(config.githubRepos).containsExactly(
GithubRepo("https://github.com/navidrome/navidrome/")
)
assertThat(config.mastodonCredentials).containsOnly(
"navidrome" to Config.PublishingCredentials(
instanceUrl = "example.com",
accessToken = "token",
instanceUrlEnvName = "NAVIDROME_MASTODON_INSTANCE_URL",
accessTokenEnvName = "NAVIDROME_MASTODON_ACCESS_TOKEN"
)
)
}
}
withEnvironment(
mapOf("GITHUB_REPOS" to "https://github.com/navidrome/navidrome/")
) {
it("should throw error if env variables for repo are not present") {
assertThrows<IllegalStateException> {
Config()
}
}
} }
} }
})
it("should throw error if account is invalid for repo are not present") {
val configInputStream = Config::class.java.classLoader.getResourceAsStream("invalid-test-config.json")!!
assertThrows<IllegalStateException> {
Config(configInputStream)
}
}
})

View file

@ -6,9 +6,13 @@ import java.time.LocalDateTime
val testGithubRepo = GithubRepo("https://github.com/example/example") val testGithubRepo = GithubRepo("https://github.com/example/example")
val testRelease = Release( val testRelease =
id = "id", Release(
link = "https://example.com", id = "id",
created = LocalDateTime.of(2023, 11, 29, 12, 11), link = "https://example.com",
githubRepo = testGithubRepo created = LocalDateTime.of(2023, 11, 29, 12, 11),
) githubRepo = testGithubRepo,
)
private val configInputStream = Config::class.java.classLoader.getResourceAsStream("test-config.json")!!
val testConfig = Config(configInputStream)

View file

@ -4,9 +4,9 @@ import assertk.assertThat
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import assertk.assertions.isTrue import assertk.assertions.isTrue
import assertk.assertions.prop import assertk.assertions.prop
import de.rpr.githubreleases.Config
import de.rpr.githubreleases.model.Releases import de.rpr.githubreleases.model.Releases
import de.rpr.githubreleases.model.asCollection import de.rpr.githubreleases.model.asCollection
import de.rpr.githubreleases.testConfig
import de.rpr.githubreleases.testRelease import de.rpr.githubreleases.testRelease
import io.kotest.core.spec.style.DescribeSpec import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.system.withEnvironment import io.kotest.extensions.system.withEnvironment
@ -37,12 +37,12 @@ class PublishersTest : DescribeSpec({
mapOf( mapOf(
"GITHUB_REPOS" to "https://github.com/navidrome/navidrome/", "GITHUB_REPOS" to "https://github.com/navidrome/navidrome/",
"NAVIDROME_MASTODON_INSTANCE_URL" to "example.com", "NAVIDROME_MASTODON_INSTANCE_URL" to "example.com",
"NAVIDROME_MASTODON_ACCESS_TOKEN" to "token" "NAVIDROME_MASTODON_ACCESS_TOKEN" to "token",
) ),
) { ) {
it("should populate instances from config") { it("should populate instances from config") {
val publishers = Publishers(Config(), mastodonClientFactory) val publishers = Publishers(testConfig, mastodonClientFactory)
assertThat(publishers.iterator().hasNext()).isTrue() assertThat(publishers.iterator().hasNext()).isTrue()
assertThat(publishers.forName("navidrome")) assertThat(publishers.forName("navidrome"))
.prop("name") { it.name } .prop("name") { it.name }
@ -62,7 +62,7 @@ class PublishersTest : DescribeSpec({
spoilerText = any<String>(), spoilerText = any<String>(),
status = any<String>(), status = any<String>(),
language = any<String>(), language = any<String>(),
visibility = any<Visibility>() visibility = any<Visibility>(),
) )
} returns mockRequest } returns mockRequest
@ -77,7 +77,7 @@ class PublishersTest : DescribeSpec({
sensitive = any(), sensitive = any(),
spoilerText = any(), spoilerText = any(),
language = "en", language = "en",
addIdempotencyKey = any() addIdempotencyKey = any(),
) )
} }
} }
@ -88,7 +88,7 @@ class PublishersTest : DescribeSpec({
spoilerText = any<String>(), spoilerText = any<String>(),
status = any<String>(), status = any<String>(),
language = any<String>(), language = any<String>(),
visibility = any<Visibility>() visibility = any<Visibility>(),
) )
} returns mockRequest } returns mockRequest
@ -105,11 +105,15 @@ class PublishersTest : DescribeSpec({
sensitive = any(), sensitive = any(),
spoilerText = any(), spoilerText = any(),
language = any(), language = any(),
addIdempotencyKey = any() addIdempotencyKey = any(),
) )
} }
assertThat(statusSlot.captured).isEqualTo("\uD83C\uDF89 Example v0.0.0 has been published!\n\nRelease notes are available here: https://example.com\n\n#Example") assertThat(statusSlot.captured)
.isEqualTo(
"\uD83C\uDF89 Example v0.0.0 has been published!" +
"\n\nRelease notes are available here: https://example.com\n\n#Example",
)
} }
} }
@ -124,7 +128,7 @@ class PublishersTest : DescribeSpec({
spoilerText = any<String>(), spoilerText = any<String>(),
status = any<String>(), status = any<String>(),
language = any<String>(), language = any<String>(),
visibility = any<Visibility>() visibility = any<Visibility>(),
) )
} returns mockRequest } returns mockRequest
@ -133,4 +137,4 @@ class PublishersTest : DescribeSpec({
verify(exactly = 0) { mockRequest.execute() } verify(exactly = 0) { mockRequest.execute() }
} }
} }
}) })

View file

@ -20,7 +20,9 @@ import kotlin.io.path.listDirectoryEntries
class ReleaseRepositoryTest : DescribeSpec({ class ReleaseRepositoryTest : DescribeSpec({
val sampleReleaseContent = """{ val sampleReleaseContent =
"""
{
"id": "v0.51.0", "id": "v0.51.0",
"link": "https://github.com/navidrome/navidrome/releases/tag/v0.51.0", "link": "https://github.com/navidrome/navidrome/releases/tag/v0.51.0",
"created": "2024-01-22T23:59:21Z", "created": "2024-01-22T23:59:21Z",
@ -29,7 +31,8 @@ class ReleaseRepositoryTest : DescribeSpec({
"url": "https://github.com/navidrome/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" "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() }
""".trimIndent()
val basePath = System.getProperty("java.io.tmpdir") + "/releases" val basePath = System.getProperty("java.io.tmpdir") + "/releases"
@ -38,18 +41,12 @@ class ReleaseRepositoryTest : DescribeSpec({
} }
afterSpec { afterSpec {
basePath.toPath().listDirectoryEntries() basePath.toPath().listDirectoryEntries().forEach { it.deleteExisting() }
.forEach {
it.deleteExisting()
}
Files.deleteIfExists(basePath.toPath()) Files.deleteIfExists(basePath.toPath())
} }
afterTest { afterTest {
basePath.toPath().listDirectoryEntries() basePath.toPath().listDirectoryEntries().forEach { it.deleteExisting() }
.forEach {
it.deleteExisting()
}
} }
describe("ReleaseRepository is created") { describe("ReleaseRepository is created") {
@ -70,9 +67,9 @@ class ReleaseRepositoryTest : DescribeSpec({
id = "id", id = "id",
link = "https://example.com", link = "https://example.com",
created = LocalDateTime.now(), created = LocalDateTime.now(),
githubRepo = testGithubRepo githubRepo = testGithubRepo,
) ),
) ),
) )
assertThat(basePath.toPath().listDirectoryEntries().map { it.fileName.toString() }) assertThat(basePath.toPath().listDirectoryEntries().map { it.fileName.toString() })
.contains("${LocalDate.now()}-id.example.json") .contains("${LocalDate.now()}-id.example.json")
@ -86,9 +83,7 @@ class ReleaseRepositoryTest : DescribeSpec({
val postRepository = ReleaseRepository(basePath) val postRepository = ReleaseRepository(basePath)
val existingEvents = postRepository.getExistingReleases(testGithubRepo) val existingEvents = postRepository.getExistingReleases(testGithubRepo)
assertThat(existingEvents).contains(testRelease.copy(id="v0.51.0")) assertThat(existingEvents).contains(testRelease.copy(id = "v0.51.0"))
} }
} }
})
})

View file

@ -0,0 +1,9 @@
{
"accounts": [
{
"accountName": "navidrome",
"github": "navidrome/navidrome",
"mastodonAccessToken": "token"
}
]
}

View file

@ -0,0 +1,10 @@
{
"accounts": [
{
"accountName": "navidrome",
"github": "navidrome/navidrome",
"mastodonInstance": "mastodon.social",
"mastodonAccessToken": "token"
}
]
}