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
.idea/
config.json
.env
*.tar
*.tar.gz

View file

@ -40,12 +40,12 @@ class App(
}
private fun execute() {
config.githubRepos.forEach { githubRepo ->
log("Processing releases feed for ${githubRepo.name}...")
val existingReleases = releaseRepo.getExistingReleases(githubRepo)
val feedService = FeedService(githubRepo, httpClient)
config.accounts.forEach { account ->
log("Processing releases feed for ${account.repo.name}...")
val existingReleases = releaseRepo.getExistingReleases(account.repo)
val feedService = FeedService(account.repo, httpClient)
val newReleases = feedService.getNewReleases(existingReleases)
val publisher = publishers.forName(githubRepo.name)
val publisher = publishers.forName(account.name)
val publishedReleases = publisher.sendReleases(newReleases)
releaseRepo.save(publishedReleases)
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.GsonBuilder
import de.rpr.githubreleases.LogLevel.ERROR
import de.rpr.githubreleases.model.GithubRepo
import de.rpr.terminenbg.LocalDateAdapter
import de.rpr.terminenbg.LocalDateTimeAdapter
import java.time.LocalDate
import java.time.LocalDateTime
import java.io.InputStream
import java.nio.file.Files
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
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
data class PublishingCredentials(
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 accounts: List<Account>
val schedulingDelay: Long = (System.getenv("SCHEDULING_DELAY")?.toLong() ?: 120) * 60
init {
val configFile = gson.fromJson(configInputStream.reader(), ConfigFile::class.java)
var valid = configFile.validate()
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(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
}
}
accounts = configFile.accounts
if (schedulingDelay < 300) {
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 {
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.LogLevel
import de.rpr.githubreleases.model.Releases
import de.rpr.githubreleases.log
import de.rpr.githubreleases.model.Releases
import social.bigbone.MastodonClient
import social.bigbone.api.entity.data.Visibility
import social.bigbone.api.exception.BigBoneRequestException
@ -11,55 +11,46 @@ 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()
private val dryRun: Boolean = System.getenv("PUBLISH_DRY_RUN").toBoolean(),
) {
fun sendReleases(releases: Releases): Releases {
log("${releases.size} new releases to publish")
val result = releases
.onEach { release -> log("Publishing release: ${release.title}") }
.mapNotNull { release ->
val request = client.statuses.postStatus(
status = release.text,
language = "en",
visibility = Visibility.PUBLIC
)
try {
if (!dryRun) {
request.execute()
} else {
log("Dry-Run, skipping publishing of events...")
val result =
releases
.onEach { release -> log("Publishing release: ${release.title}") }
.mapNotNull { release ->
val request =
client.statuses.postStatus(
status = release.text,
language = "en",
visibility = Visibility.PUBLIC,
)
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@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,
config: Config,
private val mastodonClientFactory: MastodonClientFactory,
) :
Iterable<Publisher> {
private val instances: List<Publisher>
init {
val mastodonClients = config.githubRepos.associate {
val publishingCredentials = config.mastodonCredentials[it.name]!!
it.name to mastodonClientFactory.getClient(
publishingCredentials.instanceUrl!!,
publishingCredentials.accessToken!!
)
) : Iterable<Publisher> {
private val instances: List<Publisher> =
config.accounts.map {
Publisher(it.name, mastodonClientFactory.getClient(it.publishingInstance, it.publishingAccessToken))
}
instances = config.githubRepos.map { Publisher(it.name, mastodonClients[it.name]!!) }
}
fun forName(name: String) = instances.first { it.name == name }
@ -67,4 +58,3 @@ class Publishers(
return instances.iterator()
}
}

View file

@ -2,49 +2,29 @@ package de.rpr.githubreleases
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.containsOnly
import de.rpr.githubreleases.model.GithubRepo
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.system.withEnvironment
import org.junit.jupiter.api.assertThrows
class ConfigTest : DescribeSpec({
describe("instantiation") {
withEnvironment(
mapOf(
"GITHUB_REPOS" to "https://github.com/navidrome/navidrome/",
"NAVIDROME_MASTODON_INSTANCE_URL" to "example.com",
"NAVIDROME_MASTODON_ACCESS_TOKEN" to "token"
it("should populate accounts from config") {
assertThat(testConfig.accounts).containsExactly(
Config.Account(
accountName = "navidrome",
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 testRelease = Release(
id = "id",
link = "https://example.com",
created = LocalDateTime.of(2023, 11, 29, 12, 11),
githubRepo = testGithubRepo
)
val testRelease =
Release(
id = "id",
link = "https://example.com",
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.isTrue
import assertk.assertions.prop
import de.rpr.githubreleases.Config
import de.rpr.githubreleases.model.Releases
import de.rpr.githubreleases.model.asCollection
import de.rpr.githubreleases.testConfig
import de.rpr.githubreleases.testRelease
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.extensions.system.withEnvironment
@ -37,12 +37,12 @@ class PublishersTest : DescribeSpec({
mapOf(
"GITHUB_REPOS" to "https://github.com/navidrome/navidrome/",
"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") {
val publishers = Publishers(Config(), mastodonClientFactory)
val publishers = Publishers(testConfig, mastodonClientFactory)
assertThat(publishers.iterator().hasNext()).isTrue()
assertThat(publishers.forName("navidrome"))
.prop("name") { it.name }
@ -62,7 +62,7 @@ class PublishersTest : DescribeSpec({
spoilerText = any<String>(),
status = any<String>(),
language = any<String>(),
visibility = any<Visibility>()
visibility = any<Visibility>(),
)
} returns mockRequest
@ -77,7 +77,7 @@ class PublishersTest : DescribeSpec({
sensitive = any(),
spoilerText = any(),
language = "en",
addIdempotencyKey = any()
addIdempotencyKey = any(),
)
}
}
@ -88,7 +88,7 @@ class PublishersTest : DescribeSpec({
spoilerText = any<String>(),
status = any<String>(),
language = any<String>(),
visibility = any<Visibility>()
visibility = any<Visibility>(),
)
} returns mockRequest
@ -105,11 +105,15 @@ class PublishersTest : DescribeSpec({
sensitive = any(),
spoilerText = 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>(),
status = any<String>(),
language = any<String>(),
visibility = any<Visibility>()
visibility = any<Visibility>(),
)
} returns mockRequest
@ -133,4 +137,4 @@ class PublishersTest : DescribeSpec({
verify(exactly = 0) { mockRequest.execute() }
}
}
})
})

View file

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