Skip to content

chore(server): use lib to call GitHub to list available versions #1920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,143 +2,30 @@ package io.github.typesafegithub.workflows.shared.internal

import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
import io.github.oshai.kotlinlogging.KotlinLogging.logger
import io.github.typesafegithub.workflows.shared.internal.model.Version
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel.ALL
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.time.ZonedDateTime
import org.kohsuke.github.GHRef
import org.kohsuke.github.GitHubBuilder

private val logger = logger { }

suspend fun fetchAvailableVersions(
fun fetchAvailableVersions(
owner: String,
name: String,
githubAuthToken: String?,
githubEndpoint: String = "https://api.github.com",
): Either<String, List<Version>> =
either {
buildHttpClient().use { httpClient ->
return listOf(
apiTagsUrl(githubEndpoint = githubEndpoint, owner = owner, name = name),
apiBranchesUrl(githubEndpoint = githubEndpoint, owner = owner, name = name),
).flatMap { url -> fetchGithubRefs(url, githubAuthToken, httpClient).bind() }
.versions(githubAuthToken)
}
}

private fun List<GithubRef>.versions(githubAuthToken: String?): Either<String, List<Version>> =
either {
this@versions.map { githubRef ->
val version = githubRef.ref.substringAfterLast("/")
Version(version) {
val response =
buildHttpClient().use { httpClient ->
httpClient
.get(urlString = githubRef.`object`.url) {
if (githubAuthToken != null) {
bearerAuth(githubAuthToken)
}
}
}
val releaseDate =
when (githubRef.`object`.type) {
"tag" -> response.body<Tag>().tagger
"commit" -> response.body<Commit>().author
else -> error("Unexpected target object type ${githubRef.`object`.type}")
}.date
ZonedDateTime.parse(releaseDate)
}
}
}

private suspend fun fetchGithubRefs(
url: String,
githubAuthToken: String?,
httpClient: HttpClient,
): Either<String, List<GithubRef>> =
either {
val response =
httpClient
.get(urlString = url) {
val github =
GitHubBuilder()
.withEndpoint(githubEndpoint)
.also {
if (githubAuthToken != null) {
bearerAuth(githubAuthToken)
it.withOAuthToken(githubAuthToken)
}
}
ensure(response.status.isSuccess()) {
"Unexpected response when fetching refs from $url. " +
"Status: ${response.status}, response: ${response.bodyAsText()}"
}
response.body()
}
}.build()
val repository = github.getRepository("$owner/$name")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FTR, it turned out that merely getting a reference to a repo makes another API call. I don't like it. I'll see how easy it is to disable this behavior in the lib.

val apiTags = repository.getRefs("tags").refsStartingWithV().map { Version(it) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FTR, it turned out that the lib doesn't support matching-refs API: https://docs.github.com/en/rest/git/refs?apiVersion=2022-11-28#list-matching-references. Even though it's easy to emulate, I'd like to try staying with it. I'll thus look at contributing support for it to the lib.

val apiHeads = repository.getRefs("heads").refsStartingWithV().map { Version(it) }

private fun apiTagsUrl(
githubEndpoint: String,
owner: String,
name: String,
): String = "$githubEndpoint/repos/$owner/$name/git/matching-refs/tags/v"

private fun apiBranchesUrl(
githubEndpoint: String,
owner: String,
name: String,
): String = "$githubEndpoint/repos/$owner/$name/git/matching-refs/heads/v"

@Serializable
private data class GithubRef(
val ref: String,
val `object`: Object,
)

@Serializable
private data class Object(
val type: String,
val url: String,
)

@Serializable
private data class Tag(
val tagger: Person,
)

@Serializable
private data class Commit(
val author: Person,
)

@Serializable
private data class Person(
val date: String,
)

private fun buildHttpClient() =
HttpClient {
val klogger = logger
install(Logging) {
logger =
object : Logger {
override fun log(message: String) {
klogger.trace { message }
}
}
level = ALL
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
apiTags + apiHeads
}

private fun Array<GHRef>.refsStartingWithV() = map { it.ref.substringAfterLast('/') }.filter { it.startsWith("v") }
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,119 @@ class GithubApiTest :

test("branches with major versions and tags with other versions") {
// Given
val repositoryResponse =
"""
{
"id": 429460367,
"node_id": "R_kgDOGZkLjw",
"name": "some-name",
"full_name": "some-owner/some-name",
"private": false,
"owner": {
"login": "some-owner",
"id": 1577251,
"node_id": "MDQ6VXNlcjE1NzcyNTE=",
"avatar_url": "https://avatars.githubusercontent.com/u/1577251?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/LeoColman",
"html_url": "https://github.com/LeoColman",
"followers_url": "https://api.github.com/users/LeoColman/followers",
"following_url": "https://api.github.com/users/LeoColman/following{/other_user}",
"gists_url": "https://api.github.com/users/LeoColman/gists{/gist_id}",
"starred_url": "https://api.github.com/users/LeoColman/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/LeoColman/subscriptions",
"organizations_url": "https://api.github.com/users/LeoColman/orgs",
"repos_url": "https://api.github.com/users/LeoColman/repos",
"events_url": "https://api.github.com/users/LeoColman/events{/privacy}",
"received_events_url": "https://api.github.com/users/LeoColman/received_events",
"type": "User",
"user_view_type": "public",
"site_admin": false
},
"html_url": "https://github.com/LeoColman/MyStack",
"description": null,
"fork": false,
"url": "https://api.github.com/repos/LeoColman/MyStack",
"forks_url": "https://api.github.com/repos/LeoColman/MyStack/forks",
"keys_url": "https://api.github.com/repos/LeoColman/MyStack/keys{/key_id}",
"collaborators_url": "https://api.github.com/repos/LeoColman/MyStack/collaborators{/collaborator}",
"teams_url": "https://api.github.com/repos/LeoColman/MyStack/teams",
"hooks_url": "https://api.github.com/repos/LeoColman/MyStack/hooks",
"issue_events_url": "https://api.github.com/repos/LeoColman/MyStack/issues/events{/number}",
"events_url": "https://api.github.com/repos/LeoColman/MyStack/events",
"assignees_url": "https://api.github.com/repos/LeoColman/MyStack/assignees{/user}",
"branches_url": "https://api.github.com/repos/LeoColman/MyStack/branches{/branch}",
"tags_url": "https://api.github.com/repos/LeoColman/MyStack/tags",
"blobs_url": "https://api.github.com/repos/LeoColman/MyStack/git/blobs{/sha}",
"git_tags_url": "https://api.github.com/repos/LeoColman/MyStack/git/tags{/sha}",
"git_refs_url": "https://api.github.com/repos/LeoColman/MyStack/git/refs{/sha}",
"trees_url": "https://api.github.com/repos/LeoColman/MyStack/git/trees{/sha}",
"statuses_url": "https://api.github.com/repos/LeoColman/MyStack/statuses/{sha}",
"languages_url": "https://api.github.com/repos/LeoColman/MyStack/languages",
"stargazers_url": "https://api.github.com/repos/LeoColman/MyStack/stargazers",
"contributors_url": "https://api.github.com/repos/LeoColman/MyStack/contributors",
"subscribers_url": "https://api.github.com/repos/LeoColman/MyStack/subscribers",
"subscription_url": "https://api.github.com/repos/LeoColman/MyStack/subscription",
"commits_url": "https://api.github.com/repos/LeoColman/MyStack/commits{/sha}",
"git_commits_url": "https://api.github.com/repos/LeoColman/MyStack/git/commits{/sha}",
"comments_url": "https://api.github.com/repos/LeoColman/MyStack/comments{/number}",
"issue_comment_url": "https://api.github.com/repos/LeoColman/MyStack/issues/comments{/number}",
"contents_url": "https://api.github.com/repos/LeoColman/MyStack/contents/{+path}",
"compare_url": "https://api.github.com/repos/LeoColman/MyStack/compare/{base}...{head}",
"merges_url": "https://api.github.com/repos/LeoColman/MyStack/merges",
"archive_url": "https://api.github.com/repos/LeoColman/MyStack/{archive_format}{/ref}",
"downloads_url": "https://api.github.com/repos/LeoColman/MyStack/downloads",
"issues_url": "https://api.github.com/repos/LeoColman/MyStack/issues{/number}",
"pulls_url": "https://api.github.com/repos/LeoColman/MyStack/pulls{/number}",
"milestones_url": "https://api.github.com/repos/LeoColman/MyStack/milestones{/number}",
"notifications_url": "https://api.github.com/repos/LeoColman/MyStack/notifications{?since,all,participating}",
"labels_url": "https://api.github.com/repos/LeoColman/MyStack/labels{/name}",
"releases_url": "https://api.github.com/repos/LeoColman/MyStack/releases{/id}",
"deployments_url": "https://api.github.com/repos/LeoColman/MyStack/deployments",
"created_at": "2021-11-18T14:26:50Z",
"updated_at": "2025-04-23T19:38:18Z",
"pushed_at": "2025-04-23T19:38:15Z",
"git_url": "git://github.com/LeoColman/MyStack.git",
"ssh_url": "git@github.com:LeoColman/MyStack.git",
"clone_url": "https://github.com/LeoColman/MyStack.git",
"svn_url": "https://github.com/LeoColman/MyStack",
"homepage": null,
"size": 24074,
"stargazers_count": 1,
"watchers_count": 1,
"language": null,
"has_issues": true,
"has_projects": false,
"has_downloads": true,
"has_wiki": false,
"has_pages": false,
"has_discussions": false,
"forks_count": 1,
"mirror_url": null,
"archived": false,
"disabled": false,
"open_issues_count": 1,
"license": {
"key": "mit",
"name": "MIT License",
"spdx_id": "MIT",
"url": "https://api.github.com/licenses/mit",
"node_id": "MDc6TGljZW5zZTEz"
},
"allow_forking": true,
"is_template": false,
"web_commit_signoff_required": false,
"topics": [],
"visibility": "public",
"forks": 1,
"open_issues": 1,
"watchers": 1,
"default_branch": "main",
"temp_clone_token": null,
"network_count": 1,
"subscribers_count": 1
}
""".trimIndent()
val tagsResponse =
"""
[
Expand Down Expand Up @@ -74,6 +187,14 @@ class GithubApiTest :
}
]
""".trimIndent()
mockServer
.`when`(request().withPath("/repos/$owner/$name"))
.respond(
response()
.withStatusCode(200)
.withHeader("Content-Type", "application/json")
.withBody(repositoryResponse),
)
mockServer
.`when`(request().withPath("/repos/$owner/$name/git/matching-refs/tags/v"))
.respond(
Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy