Rename base package

This commit is contained in:
Ryan Harg 2024-02-12 09:39:56 +01:00
parent 7dadc66748
commit 16a0740679
14 changed files with 530 additions and 0 deletions

View file

@ -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)
}
}
}

View file

@ -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<GithubRepo>
val mastodonCredentials: Map<String, PublishingCredentials>
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 == '/' }
}
}

View file

@ -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()

View file

@ -0,0 +1,8 @@
package de.rpr.githubreleases
import com.ouattararomuald.syndication.atom.AtomFeed
interface FeedReader {
fun readAtom(): AtomFeed
}

View file

@ -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()
}
}

View file

@ -0,0 +1,6 @@
package de.rpr.githubreleases
data class GithubRepo(
val name: String,
val url: String
)

View file

@ -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<LocalDate?>, JsonDeserializer<LocalDate?> {
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)
}
}

View file

@ -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<LocalDateTime?>, JsonDeserializer<LocalDateTime?> {
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)
}
}

View file

@ -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();
}

View file

@ -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<Publisher> {
private val instances: List<Publisher>
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<Publisher> {
return instances.iterator()
}
}

View file

@ -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()
}
}

View file

@ -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<Release>.asCollection() = Releases(this)
class Releases(releases: List<Release>) : List<Release> {
constructor(vararg posts: Release) : this(posts.toList())
private val content: MutableList<Release> = 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<Release> {
return content.iterator()
}
override fun listIterator(): ListIterator<Release> {
return content.listIterator()
}
override fun listIterator(index: Int): ListIterator<Release> {
return content.listIterator(index)
}
override fun subList(fromIndex: Int, toIndex: Int): List<Release> {
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<Release>): Boolean {
return content.containsAll(elements)
}
override fun contains(element: Release): Boolean {
return content.map { it.id }.contains(element.id)
}
}

View file

@ -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"))
}
}
})

View file

@ -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
)