From ac5d03c573882ccebb46f68201da6f3caaeba603 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 17:37:38 +0000 Subject: [PATCH 01/22] chore: move versions in a single place, which is the version catalogue --- gradle/libs.versions.toml | 1 + plugin/build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97fb257..f4d013e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.20" } +gradle-plugin-publish = { id = "com.gradle.plugin-publish", version = "1.3.1" } [libraries] mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index a0c7cec..aa4d985 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode plugins { `kotlin-dsl` alias(libs.plugins.kotlin.jvm) - id("com.gradle.plugin-publish") version "1.3.1" + alias(libs.plugins.gradle.plugin.publish) } group = "io.github.adelinosousa" From 1bf962f9779916b04a5c7fbe7d01e33997327ada Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 17:43:27 +0000 Subject: [PATCH 02/22] refactor: the project version is set either via the environment variable or the properties file, a static default can be forgotten and can lead to accidentally setting the wrong version, instead enforce the availability of the gradle.publish.version property by default --- plugin/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index aa4d985..710a1e0 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -7,7 +7,9 @@ plugins { } group = "io.github.adelinosousa" -version = System.getenv("GRADLE_PUBLISH_VERSION") ?: project.findProperty("gradle.publish.version") ?: "1.0.1" +version = System + .getenv("GRADLE_PUBLISH_VERSION") + ?: requireNotNull(project.property("gradle.publish.version")) kotlin { explicitApi = ExplicitApiMode.Strict From 247ece945cf1e19d443004d19f40643a965ddedd Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:07:06 +0000 Subject: [PATCH 03/22] feat: add provider extension functions for plugin dependency mapping and multi-module inclusion --- .../org/gradle/kotlin/dsl/ProviderExt.kt | 11 ++++ .../org/gradle/kotlin/dsl/SettingsExt.kt | 24 ++++++++ .../org/gradle/kotlin/dsl/ProviderExtTest.kt | 34 +++++++++++ .../org/gradle/kotlin/dsl/SettingsExtTest.kt | 57 +++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 plugin/src/main/kotlin/org/gradle/kotlin/dsl/ProviderExt.kt create mode 100644 plugin/src/main/kotlin/org/gradle/kotlin/dsl/SettingsExt.kt create mode 100644 plugin/src/test/kotlin/org/gradle/kotlin/dsl/ProviderExtTest.kt create mode 100644 plugin/src/test/kotlin/org/gradle/kotlin/dsl/SettingsExtTest.kt diff --git a/plugin/src/main/kotlin/org/gradle/kotlin/dsl/ProviderExt.kt b/plugin/src/main/kotlin/org/gradle/kotlin/dsl/ProviderExt.kt new file mode 100644 index 0000000..b4e0695 --- /dev/null +++ b/plugin/src/main/kotlin/org/gradle/kotlin/dsl/ProviderExt.kt @@ -0,0 +1,11 @@ +package org.gradle.kotlin.dsl + +import org.gradle.api.provider.Provider +import org.gradle.plugin.use.PluginDependency + +/** + * Converts a [Provider] of [PluginDependency] to a [Provider] of [String], + * mapping to Gradle dependency notation to depend on the plugin directly. + */ +public fun Provider.asDependency(): Provider = + this.map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" } diff --git a/plugin/src/main/kotlin/org/gradle/kotlin/dsl/SettingsExt.kt b/plugin/src/main/kotlin/org/gradle/kotlin/dsl/SettingsExt.kt new file mode 100644 index 0000000..d85f45c --- /dev/null +++ b/plugin/src/main/kotlin/org/gradle/kotlin/dsl/SettingsExt.kt @@ -0,0 +1,24 @@ +package org.gradle.kotlin.dsl + +import org.gradle.api.initialization.Settings + +/** + * Includes a module located in the `modules` directory to create + * a clean multi-module project structure. + * + * @param moduleDir The name of the module directory inside `modules`. + * @param prefix An optional prefix to prepend to the module name. + */ +public fun Settings.includeModule(moduleDir: String, prefix: String = "") { + val path = ":$moduleDir" + include(path) + + val module = project(path) + module.name = prefix + moduleDir + module.projectDir = + settings.rootDir + .toPath() + .resolve("modules") + .resolve(moduleDir) + .toFile() +} diff --git a/plugin/src/test/kotlin/org/gradle/kotlin/dsl/ProviderExtTest.kt b/plugin/src/test/kotlin/org/gradle/kotlin/dsl/ProviderExtTest.kt new file mode 100644 index 0000000..498f78c --- /dev/null +++ b/plugin/src/test/kotlin/org/gradle/kotlin/dsl/ProviderExtTest.kt @@ -0,0 +1,34 @@ +package org.gradle.kotlin.dsl + +import io.mockk.CapturingSlot +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlin.test.assertEquals +import org.gradle.api.Transformer +import org.gradle.api.artifacts.VersionConstraint +import org.gradle.api.provider.Provider +import org.gradle.plugin.use.PluginDependency +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +class ProviderExtTest { + @Test + fun `asDependency maps plugin dependency to gradle notation and returns mapped provider`() { + val provider = mockk>() + val mappedProvider = mockk>() + val captured: CapturingSlot> = slot() + every { provider.map(capture(captured)) } returns mappedProvider + + val plugin = mockk() + every { plugin.pluginId } returns "com.acme.plugin" + every { plugin.version } returns mockk().also { + every { it.toString() } returns "1.2.3" + } + + assertSame(mappedProvider, provider.asDependency()) + + val notation = captured.captured.transform(plugin) + assertEquals("com.acme.plugin:com.acme.plugin.gradle.plugin:1.2.3", notation) + } +} diff --git a/plugin/src/test/kotlin/org/gradle/kotlin/dsl/SettingsExtTest.kt b/plugin/src/test/kotlin/org/gradle/kotlin/dsl/SettingsExtTest.kt new file mode 100644 index 0000000..d007a5e --- /dev/null +++ b/plugin/src/test/kotlin/org/gradle/kotlin/dsl/SettingsExtTest.kt @@ -0,0 +1,57 @@ +package org.gradle.kotlin.dsl + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.gradle.api.initialization.ProjectDescriptor +import org.gradle.api.initialization.Settings +import org.junit.jupiter.api.Test +import java.io.File +import java.nio.file.Path + +class SettingsExtTest { + @Test + fun `includeModule includes project and sets name and directory`() { + val settings = mockk(relaxed = true) + val projectDescriptor = mockk(relaxed = true) + val rootDir = mockk() + val rootPath = mockk() + val modulesPath = mockk() + val moduleDirPath = mockk() + val moduleFile = mockk() + + every { settings.rootDir } returns rootDir + every { rootDir.toPath() } returns rootPath + every { rootPath.resolve("modules") } returns modulesPath + every { modulesPath.resolve("my-module") } returns moduleDirPath + every { moduleDirPath.toFile() } returns moduleFile + every { settings.project(":my-module") } returns projectDescriptor + + settings.includeModule("my-module", "prefix-") + + verify { settings.include(":my-module") } + verify { projectDescriptor.name = "prefix-my-module" } + } + + @Test + fun `includeModule without prefix uses module name as is`() { + val settings = mockk(relaxed = true) + val projectDescriptor = mockk(relaxed = true) + val rootDir = mockk() + val rootPath = mockk() + val modulesPath = mockk() + val moduleDirPath = mockk() + val moduleFile = mockk() + + every { settings.rootDir } returns rootDir + every { rootDir.toPath() } returns rootPath + every { rootPath.resolve("modules") } returns modulesPath + every { modulesPath.resolve("core") } returns moduleDirPath + every { moduleDirPath.toFile() } returns moduleFile + every { settings.project(":core") } returns projectDescriptor + + settings.includeModule("core") + + verify { projectDescriptor.name = "core" } + } +} From 8e5a5821d161f5d2ead0a391acd970e75ee784d4 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:16:44 +0000 Subject: [PATCH 04/22] refactor: move classes to appropriate packages, set plugins to public as it enable users to build on top of the plugin if desired --- .../gradle/extensions/GhCliAuthExtension.kt | 13 +++++++++++++ .../gradle/{plugins => internal}/Config.kt | 2 +- .../gradle/{plugins => internal}/Environment.kt | 2 +- .../gradle/{plugins => internal}/GhCliAuth.kt | 2 +- .../{plugins => internal}/GitHubCLIProcess.kt | 11 +++++------ .../{plugins => internal}/RepositoryCredentials.kt | 2 +- .../gradle/plugins/GhCliAuthProjectPlugin.kt | 12 +++++++----- .../gradle/plugins/GhCliAuthSettingsPlugin.kt | 7 ++++++- .../gradle/plugins/GhCliAuthProjectPluginTest.kt | 6 ++++++ .../gradle/plugins/GhCliAuthSettingsPluginTest.kt | 4 ++++ .../adelinosousa/gradle/plugins/GhCliAuthTest.kt | 2 ++ 11 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/extensions/GhCliAuthExtension.kt rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{plugins => internal}/Config.kt (83%) rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{plugins => internal}/Environment.kt (88%) rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{plugins => internal}/GhCliAuth.kt (97%) rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{plugins => internal}/GitHubCLIProcess.kt (97%) rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{plugins => internal}/RepositoryCredentials.kt (81%) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/extensions/GhCliAuthExtension.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/extensions/GhCliAuthExtension.kt new file mode 100644 index 0000000..1955cf3 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/extensions/GhCliAuthExtension.kt @@ -0,0 +1,13 @@ +package io.github.adelinosousa.gradle.extensions + +import org.gradle.api.provider.Property + +/** + * Extension to configure GitHub CLI authentication. + */ +public interface GhCliAuthExtension { + /** + * The GitHub token to use for authentication. + */ + public val token: Property +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Config.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt similarity index 83% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Config.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt index 7f70f36..b87ec84 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Config.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt @@ -1,4 +1,4 @@ -package io.github.adelinosousa.gradle.plugins +package io.github.adelinosousa.gradle.internal internal object Config { internal const val GITHUB_ORG: String = "gh.cli.auth.github.org" diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Environment.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt similarity index 88% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Environment.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt index 44ee680..3f0ee5b 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Environment.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt @@ -1,4 +1,4 @@ -package io.github.adelinosousa.gradle.plugins +package io.github.adelinosousa.gradle.internal internal object Environment { internal fun getEnv(name: String): String? { diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuth.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt similarity index 97% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuth.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt index 0dce650..e36cf70 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuth.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt @@ -1,4 +1,4 @@ -package io.github.adelinosousa.gradle.plugins +package io.github.adelinosousa.gradle.internal import org.gradle.api.GradleException import org.gradle.api.provider.Provider diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt similarity index 97% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt index 0963c8a..a8619dd 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt @@ -1,11 +1,11 @@ -package io.github.adelinosousa.gradle.plugins +package io.github.adelinosousa.gradle.internal -import org.gradle.api.provider.ValueSource -import org.gradle.api.provider.ValueSourceParameters -import org.gradle.process.ExecOperations import java.io.ByteArrayOutputStream import java.io.File import javax.inject.Inject +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.process.ExecOperations internal abstract class GitHubCLIProcess : ValueSource { @@ -41,5 +41,4 @@ internal abstract class GitHubCLIProcess : ValueSource { +public class GhCliAuthProjectPlugin : Plugin { private companion object { private val logger = Logging.getLogger(GhCliAuthSettingsPlugin::class.java) } @@ -49,6 +54,3 @@ internal class GhCliAuthProjectPlugin : Plugin { } } -public interface GhCliAuthExtension { - public val token: Property -} diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt index 558ee90..6967912 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt @@ -1,5 +1,10 @@ package io.github.adelinosousa.gradle.plugins +import io.github.adelinosousa.gradle.internal.Config +import io.github.adelinosousa.gradle.internal.Environment +import io.github.adelinosousa.gradle.internal.GhCliAuth +import io.github.adelinosousa.gradle.internal.GitHubCLIProcess +import io.github.adelinosousa.gradle.internal.RepositoryCredentials import org.gradle.api.Plugin import org.gradle.api.artifacts.dsl.RepositoryHandler import org.gradle.api.initialization.Settings @@ -7,7 +12,7 @@ import org.gradle.api.logging.Logging import org.gradle.kotlin.dsl.extra import java.net.URI -internal class GhCliAuthSettingsPlugin : Plugin { +public class GhCliAuthSettingsPlugin : Plugin { private companion object { private val logger = Logging.getLogger(GhCliAuthSettingsPlugin::class.java) } diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt index 0f15a8c..690d3a7 100644 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt +++ b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt @@ -1,5 +1,11 @@ package io.github.adelinosousa.gradle.plugins +import io.github.adelinosousa.gradle.extensions.GhCliAuthExtension +import io.github.adelinosousa.gradle.internal.Config +import io.github.adelinosousa.gradle.internal.Environment +import io.github.adelinosousa.gradle.internal.GhCliAuth +import io.github.adelinosousa.gradle.internal.GitHubCLIProcess +import io.github.adelinosousa.gradle.internal.RepositoryCredentials import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt index 1018caa..96c0e0a 100644 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt +++ b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt @@ -1,5 +1,9 @@ package io.github.adelinosousa.gradle.plugins +import io.github.adelinosousa.gradle.internal.Config +import io.github.adelinosousa.gradle.internal.Environment +import io.github.adelinosousa.gradle.internal.GhCliAuth +import io.github.adelinosousa.gradle.internal.RepositoryCredentials import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt index 2f26995..2eb49da 100644 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt +++ b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt @@ -1,5 +1,7 @@ package io.github.adelinosousa.gradle.plugins +import io.github.adelinosousa.gradle.internal.GhCliAuth +import io.github.adelinosousa.gradle.internal.GitHubCLIProcess import io.mockk.every import io.mockk.mockk import io.mockk.spyk From d8da03b437cafbaed251ecd30f4f56849258c038 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:19:56 +0000 Subject: [PATCH 05/22] fix: replace print statement to a logger to enable flexibility of what appender can be used for this log, plus set the log level to error --- .../io/github/adelinosousa/gradle/internal/GhCliAuth.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt index e36cf70..d5df5ac 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt @@ -1,10 +1,12 @@ package io.github.adelinosousa.gradle.internal import org.gradle.api.GradleException +import org.gradle.api.logging.Logging import org.gradle.api.provider.Provider internal object GhCliAuth { - val requiredScopes: Set = setOf("read:packages", "read:org") + private val logger = Logging.getLogger(GhCliAuth::class.java) + internal val requiredScopes: Set = setOf("read:packages", "read:org") internal fun checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider: Provider): Provider { return authStatusProvider.map { output -> @@ -33,7 +35,7 @@ internal object GhCliAuth { return RepositoryCredentials(user, token) } } catch (e: Exception) { - println("Failed to get credentials from 'gh' CLI: ${e.message}") + logger.error("Failed to get credentials from 'gh' CLI: ${e.message}") } throw GradleException("'gh' CLI is authenticated but failed to extract user or token") From 4cf78ef8258f0daf7d304f412aaed7b393e51a18 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:23:32 +0000 Subject: [PATCH 06/22] refactor: change RepositoryCredentials class to a data class, see: https://kotlinlang.org/docs/data-classes.html --- .../adelinosousa/gradle/internal/RepositoryCredentials.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt index df179ef..b0bd199 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt @@ -1,6 +1,6 @@ package io.github.adelinosousa.gradle.internal -internal class RepositoryCredentials( +internal data class RepositoryCredentials( internal val username: String?, internal val token: String? ) { From b6b68bf8bcc3ad121c94de8e1a14f578299c6a49 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:30:46 +0000 Subject: [PATCH 07/22] refactor: rename GitHubCLIProcess to GhCLIProcess for consistency as all other classes abbreviate GitHub to Gh --- .../gradle/internal/{GitHubCLIProcess.kt => GhCLIProcess.kt} | 2 +- .../adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt | 4 ++-- .../adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/{GitHubCLIProcess.kt => GhCLIProcess.kt} (94%) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt similarity index 94% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt index a8619dd..33e2d49 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GitHubCLIProcess.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt @@ -7,7 +7,7 @@ import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.gradle.process.ExecOperations -internal abstract class GitHubCLIProcess : ValueSource { +internal abstract class GhCLIProcess : ValueSource { @get:Inject internal abstract val execOperations: ExecOperations diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt index a94bbfb..31bdaa6 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt @@ -4,7 +4,7 @@ import io.github.adelinosousa.gradle.extensions.GhCliAuthExtension import io.github.adelinosousa.gradle.internal.Config import io.github.adelinosousa.gradle.internal.Environment import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GitHubCLIProcess +import io.github.adelinosousa.gradle.internal.GhCLIProcess import io.github.adelinosousa.gradle.internal.RepositoryCredentials import org.gradle.api.Project import org.gradle.api.Plugin @@ -44,7 +44,7 @@ public class GhCliAuthProjectPlugin : Plugin { } private fun getGhCliCredentials(project: Project): RepositoryCredentials { - val authStatusProvider = project.providers.of(GitHubCLIProcess::class.java) {} + val authStatusProvider = project.providers.of(GhCLIProcess::class.java) {} val output = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider) return GhCliAuth.getGitHubCredentials(output.get()) } diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt index 6967912..67c0435 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt @@ -3,7 +3,7 @@ package io.github.adelinosousa.gradle.plugins import io.github.adelinosousa.gradle.internal.Config import io.github.adelinosousa.gradle.internal.Environment import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GitHubCLIProcess +import io.github.adelinosousa.gradle.internal.GhCLIProcess import io.github.adelinosousa.gradle.internal.RepositoryCredentials import org.gradle.api.Plugin import org.gradle.api.artifacts.dsl.RepositoryHandler @@ -37,7 +37,7 @@ public class GhCliAuthSettingsPlugin : Plugin { } private fun getGhCliCredentials(settings: Settings): RepositoryCredentials { - val authStatusProvider = settings.providers.of(GitHubCLIProcess::class.java) {} + val authStatusProvider = settings.providers.of(GhCLIProcess::class.java) {} val output = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider) return GhCliAuth.getGitHubCredentials(output.get()) } From 095965398b69cd62925a2615cc446cf01cb865d9 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Mon, 3 Nov 2025 18:32:14 +0000 Subject: [PATCH 08/22] fix: suppress unstable API usage warning in GhCliAuthSettingsPlugin --- .../adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt index 67c0435..123f12d 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt @@ -30,6 +30,7 @@ public class GhCliAuthSettingsPlugin : Plugin { // Set the token to share with other settings plugins settings.gradle.extra.set(Config.EXTRA_TOKEN_NAME, repoCredentials.token) settings.pluginManagement.repositories.addRepositoriesWithDefaults(githubOrg, repoCredentials) + @Suppress("UnstableApiUsage") settings.dependencyResolutionManagement.repositories.addRepositoriesWithDefaults(githubOrg, repoCredentials) } else { throw IllegalStateException("Token not found in environment variable '${gitEnvTokenName}' or 'gh' CLI. Unable to configure GitHub Packages repository.") From aad6757071d61c13db80083eea7d0be58fb0a2f6 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Tue, 4 Nov 2025 17:24:18 +0000 Subject: [PATCH 09/22] refactor: create a GH auth base for the plugin, extract duplication, improve single responsibility, and encapsulation --- gradle.properties | 2 +- .../gradle/github/GhCliAuthParser.kt | 46 ++++++++++ .../GhCliProcess.kt} | 4 +- .../gradle/github/GhCredentials.kt | 6 ++ .../adelinosousa/gradle/internal/Config.kt | 7 -- .../gradle/internal/Environment.kt | 15 ---- .../adelinosousa/gradle/internal/GhCliAuth.kt | 43 --------- .../gradle/internal/RepositoryCredentials.kt | 10 --- .../gradle/plugins/GhCliAuthBase.kt | 90 +++++++++++++++++++ .../gradle/plugins/GhCliAuthProjectPlugin.kt | 57 +++--------- .../gradle/plugins/GhCliAuthSettingsPlugin.kt | 77 +++------------- settings.gradle.kts | 1 + 12 files changed, 172 insertions(+), 186 deletions(-) create mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt rename plugin/src/main/kotlin/io/github/adelinosousa/gradle/{internal/GhCLIProcess.kt => github/GhCliProcess.kt} (93%) create mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCredentials.kt delete mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt delete mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt delete mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt delete mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt create mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt diff --git a/gradle.properties b/gradle.properties index 944cea9..1ec7360 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ org.gradle.configuration-cache=true -gradle.publish.version=1.1.0 +gradle.publish.version=2.0.0 diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt new file mode 100644 index 0000000..e9d7b62 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt @@ -0,0 +1,46 @@ +package io.github.adelinosousa.gradle.github + +import org.gradle.api.logging.Logging + +internal object GhCliAuthParser { + private val logger = Logging.getLogger(GhCliAuthParser::class.java) + private val REQUIRED_SCOPES: Set = setOf("read:packages", "read:org") + + internal fun parse(output: String): GhCredentials = this + .validate(output) + .runCatching { + val user = output.lines() + .firstOrNull { it.contains("Logged in to github.com account") } + ?.substringAfterLast("account") + ?.replace("(keyring)", "") + ?.trim() + .let { requireNotNull(it) { "'gh' CLI output: failed to extract username" } } + + val token = output.lines() + .firstOrNull { it.contains("Token:") } + ?.substringAfter(":") + ?.trim() + .let { requireNotNull(it) { "'gh' CLI output: failed to extract token" } } + + GhCredentials(user, token) + }.onFailure { e -> + logger.error("Failed to get credentials from 'gh' CLI: ${e.message}") + }.getOrElse { + error("'gh' CLI is authenticated but failed to extract user or token") + } + + private fun validate(output: String) { + val scopes = output + .lines() + .firstOrNull { it.contains("Token scopes:") } + ?.substringAfter(":") + ?.trim() + ?.split(",") + ?.map { it.replace("'", "").trim() } + ?: emptyList() + + check(scopes.containsAll(REQUIRED_SCOPES)) { + "GitHub CLI token is missing required scopes. Required: $REQUIRED_SCOPES, Found: $scopes" + } + } +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt similarity index 93% rename from plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt rename to plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt index 33e2d49..8128e73 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCLIProcess.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt @@ -1,4 +1,4 @@ -package io.github.adelinosousa.gradle.internal +package io.github.adelinosousa.gradle.github import java.io.ByteArrayOutputStream import java.io.File @@ -7,7 +7,7 @@ import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.gradle.process.ExecOperations -internal abstract class GhCLIProcess : ValueSource { +internal abstract class GhCliProcess : ValueSource { @get:Inject internal abstract val execOperations: ExecOperations diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCredentials.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCredentials.kt new file mode 100644 index 0000000..3f89c1f --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCredentials.kt @@ -0,0 +1,6 @@ +package io.github.adelinosousa.gradle.github + +public data class GhCredentials( + internal val username: String, + internal val token: String, +) \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt deleted file mode 100644 index b87ec84..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Config.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.adelinosousa.gradle.internal - -internal object Config { - internal const val GITHUB_ORG: String = "gh.cli.auth.github.org" - internal const val ENV_PROPERTY_NAME: String = "gh.cli.auth.env.name" - internal const val EXTRA_TOKEN_NAME: String = "gh-cli-auth-token" -} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt deleted file mode 100644 index 3f0ee5b..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/Environment.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.adelinosousa.gradle.internal - -internal object Environment { - internal fun getEnv(name: String): String? { - return System.getenv(name) - } - - internal fun getEnvCredentials(gitEnvTokenName: String) : RepositoryCredentials? { - val token = getEnv(gitEnvTokenName) - if (!token.isNullOrEmpty()) { - return RepositoryCredentials("", token) - } - return null - } -} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt deleted file mode 100644 index d5df5ac..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/GhCliAuth.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.adelinosousa.gradle.internal - -import org.gradle.api.GradleException -import org.gradle.api.logging.Logging -import org.gradle.api.provider.Provider - -internal object GhCliAuth { - private val logger = Logging.getLogger(GhCliAuth::class.java) - internal val requiredScopes: Set = setOf("read:packages", "read:org") - - internal fun checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider: Provider): Provider { - return authStatusProvider.map { output -> - if (output.contains("Token:")) { - val scopesLine = output.lines().firstOrNull { it.contains("Token scopes:") } - val scopes = scopesLine?.substringAfter(":")?.trim()?.split(",")?.map { it.replace("'", "").trim() } - ?: emptyList() - - if (scopes.containsAll(requiredScopes)) { - return@map output - } - } - throw GradleException("GitHub CLI is not authenticated or does not have the required scopes $requiredScopes") - } - } - - internal fun getGitHubCredentials(output: String): RepositoryCredentials { - try { - val userLine = output.lines().firstOrNull { it.contains("Logged in to github.com account") } - val tokenLine = output.lines().firstOrNull { it.contains("Token:") } - - val user = userLine?.substringAfterLast("account")?.replace("(keyring)", "")?.trim() - val token = tokenLine?.substringAfter(":")?.trim() - - if (user != null && token != null) { - return RepositoryCredentials(user, token) - } - } catch (e: Exception) { - logger.error("Failed to get credentials from 'gh' CLI: ${e.message}") - } - - throw GradleException("'gh' CLI is authenticated but failed to extract user or token") - } -} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt deleted file mode 100644 index b0bd199..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/internal/RepositoryCredentials.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.adelinosousa.gradle.internal - -internal data class RepositoryCredentials( - internal val username: String?, - internal val token: String? -) { - internal fun isValid(): Boolean { - return username != null && !token.isNullOrEmpty() - } -} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt new file mode 100644 index 0000000..1721295 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -0,0 +1,90 @@ +package io.github.adelinosousa.gradle.plugins + +import io.github.adelinosousa.gradle.github.GhCliProcess +import io.github.adelinosousa.gradle.github.GhCliAuthParser +import io.github.adelinosousa.gradle.github.GhCredentials +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.api.logging.Logging +import org.gradle.api.provider.ProviderFactory +import java.net.URI +import java.util.concurrent.ConcurrentHashMap +import org.gradle.api.logging.Logger + +public abstract class GhCliAuthBase { + internal companion object { + const val GH_CLI_EXTENSION_NAME: String = "ghCliAuth" + const val GH_ORG_SETTER_PROPERTY: String = "gh.cli.auth.github.org" + const val GH_ENV_KEY_SETTER_PROPERTY: String = "gh.cli.auth.env.name" + // FIXME: This needs to match the same convention (dots vs dashes) + provide a way to override the preferred extra key name + const val GH_EXTRA_TOKEN_KEY: String = "gh-cli-auth-token" + + const val DEFAULT_TOKEN_ENV_KEY: String = "GITHUB_TOKEN" + const val DEFAULT_TOKEN_USERNAME: String = "" + } + + private val githubOrgCache = ConcurrentHashMap() + private val credentialsCache = ConcurrentHashMap() + + protected val logger: Logger = Logging.getLogger(GH_CLI_EXTENSION_NAME) + + protected val ProviderFactory.githubOrg: String + get() = githubOrgCache.getOrPut(this) { + this + .gradleProperty(GH_ORG_SETTER_PROPERTY) + .orNull + .let { requireNotNull(it) { "Please set ${GH_ORG_SETTER_PROPERTY}' in gradle.properties." } } + .also { require(it.isNotBlank()) { "Property '${GH_ORG_SETTER_PROPERTY}' MUST not be blank." } } + } + + protected val ProviderFactory.credentials: GhCredentials + get() = credentialsCache.getOrPut(this) { + val tokenEnvKey = this + .gradleProperty(GH_ENV_KEY_SETTER_PROPERTY) + .orNull ?: DEFAULT_TOKEN_ENV_KEY + + val credentialsFromEnv = System + .getenv(tokenEnvKey) + .let { token -> if (token.isNullOrEmpty().not()) GhCredentials(DEFAULT_TOKEN_USERNAME, token) else null } + + if (credentialsFromEnv != null) { + logger.debug("Attempting to use GitHub credentials from environment variable: $tokenEnvKey") + return@getOrPut credentialsFromEnv + } else { + logger.debug("Falling back to gh CLI for GitHub credentials.") + + val authStatusProvider = this + .of(GhCliProcess::class.java) {} + + // We collect (i.e., `.get()`) the value before validation to ensure + // the final side effects of the provider are executed + return@getOrPut GhCliAuthParser.parse(authStatusProvider.get()) + } + } + + protected fun RepositoryHandler.addTrustedRepositoriesIfMissing() { + if (this.findByName("MavenRepo") == null) { + logger.info("Adding Maven Central repository") + this.mavenCentral() + } + if (this.findByName("Google") == null) { + logger.info("Adding Google repository") + this.google() + } + if (this.findByName("Gradle Central Plugin Repository") == null) { + logger.info("Adding Gradle Plugin Portal repository") + this.gradlePluginPortal() + } + } + + protected fun RepositoryHandler.addUserConfiguredOrgGhPackagesRepository(providers: ProviderFactory) { + logger.info("Registering GitHub Packages repo for org: ${providers.githubOrg}") + maven { + name = "GitHubPackages" + url = URI("https://maven.pkg.github.com/${providers.githubOrg}/*") + credentials { + username = providers.credentials.username + password = providers.credentials.token + } + } + } +} diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt index 31bdaa6..20de9fc 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPlugin.kt @@ -1,56 +1,23 @@ package io.github.adelinosousa.gradle.plugins import io.github.adelinosousa.gradle.extensions.GhCliAuthExtension -import io.github.adelinosousa.gradle.internal.Config -import io.github.adelinosousa.gradle.internal.Environment -import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GhCLIProcess -import io.github.adelinosousa.gradle.internal.RepositoryCredentials -import org.gradle.api.Project import org.gradle.api.Plugin -import org.gradle.api.logging.Logging - -public class GhCliAuthProjectPlugin : Plugin { - private companion object { - private val logger = Logging.getLogger(GhCliAuthSettingsPlugin::class.java) - } +import org.gradle.api.Project +public class GhCliAuthProjectPlugin : GhCliAuthBase(), Plugin { override fun apply(project: Project) { - val extension = project.extensions.create("ghCliAuth", GhCliAuthExtension::class.java) + val provider = project.providers - val githubOrg = getGradleProperty(project, Config.GITHUB_ORG) - val gitEnvTokenName = getGradleProperty(project, Config.ENV_PROPERTY_NAME) ?: "GITHUB_TOKEN" + val extension = (project + .extensions.findByType(GhCliAuthExtension::class.java) + ?: project.extensions.create(GH_CLI_EXTENSION_NAME, GhCliAuthExtension::class.java)) - if (githubOrg.isNullOrEmpty()) { - throw IllegalStateException("GitHub organization not specified. Please set the '${Config.GITHUB_ORG}' in your gradle.properties file.") - } + extension + .token + .set(provider.credentials.token) - val repoCredentials = Environment.getEnvCredentials(gitEnvTokenName) ?: getGhCliCredentials(project) - if (repoCredentials.isValid()) { - logger.info("Registering Maven GitHub repository for organization: $githubOrg") - // Set the extension token to share with other tasks - extension.token.set(repoCredentials.token) - project.repositories.maven { - name = "GitHubPackages" - url = project.uri("https://maven.pkg.github.com/$githubOrg/*") - credentials { - this.username = repoCredentials.username - this.password = repoCredentials.token - } - } - } else { - throw IllegalStateException("Token not found in environment variable '${gitEnvTokenName}' or 'gh' CLI. Unable to configure GitHub Packages repository.") - } - } - - private fun getGhCliCredentials(project: Project): RepositoryCredentials { - val authStatusProvider = project.providers.of(GhCLIProcess::class.java) {} - val output = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider) - return GhCliAuth.getGitHubCredentials(output.get()) - } - - private fun getGradleProperty(project: Project, propertyName: String): String? { - return project.providers.gradleProperty(propertyName).orNull + project + .repositories + .addUserConfiguredOrgGhPackagesRepository(provider) } } - diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt index 123f12d..8622259 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPlugin.kt @@ -1,76 +1,27 @@ package io.github.adelinosousa.gradle.plugins -import io.github.adelinosousa.gradle.internal.Config -import io.github.adelinosousa.gradle.internal.Environment -import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GhCLIProcess -import io.github.adelinosousa.gradle.internal.RepositoryCredentials import org.gradle.api.Plugin -import org.gradle.api.artifacts.dsl.RepositoryHandler import org.gradle.api.initialization.Settings -import org.gradle.api.logging.Logging import org.gradle.kotlin.dsl.extra -import java.net.URI - -public class GhCliAuthSettingsPlugin : Plugin { - private companion object { - private val logger = Logging.getLogger(GhCliAuthSettingsPlugin::class.java) - } +public class GhCliAuthSettingsPlugin : GhCliAuthBase(), Plugin { override fun apply(settings: Settings) { - val githubOrg = getGradleProperty(settings, Config.GITHUB_ORG) - val gitEnvTokenName = getGradleProperty(settings, Config.ENV_PROPERTY_NAME) ?: "GITHUB_TOKEN" - - if (githubOrg.isNullOrEmpty()) { - throw IllegalStateException("GitHub organization not specified. Please set the '${Config.GITHUB_ORG}' in your gradle.properties file.") - } - - val repoCredentials = Environment.getEnvCredentials(gitEnvTokenName) ?: getGhCliCredentials(settings) - if (repoCredentials.isValid()) { - // Set the token to share with other settings plugins - settings.gradle.extra.set(Config.EXTRA_TOKEN_NAME, repoCredentials.token) - settings.pluginManagement.repositories.addRepositoriesWithDefaults(githubOrg, repoCredentials) - @Suppress("UnstableApiUsage") - settings.dependencyResolutionManagement.repositories.addRepositoriesWithDefaults(githubOrg, repoCredentials) - } else { - throw IllegalStateException("Token not found in environment variable '${gitEnvTokenName}' or 'gh' CLI. Unable to configure GitHub Packages repository.") - } - } + val provider = settings.providers - private fun getGhCliCredentials(settings: Settings): RepositoryCredentials { - val authStatusProvider = settings.providers.of(GhCLIProcess::class.java) {} - val output = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider) - return GhCliAuth.getGitHubCredentials(output.get()) - } - - private fun getGradleProperty(settings: Settings, propertyName: String): String? { - return settings.providers.gradleProperty(propertyName).orNull - } - - private fun RepositoryHandler.addRepositoriesWithDefaults(githubOrg: String, repoCredentials: RepositoryCredentials) { - if (this.findByName("MavenRepo") == null) { - logger.info("Adding Maven Central repository") - this.mavenCentral() - } - - if (this.findByName("Google") == null) { - logger.info("Adding Google repository") - this.google() - } + settings.gradle.extra.set( + GH_EXTRA_TOKEN_KEY, + provider.credentials.token + ) - if (this.findByName("Gradle Central Plugin Repository") == null) { - logger.info("Adding Gradle Plugin Portal repository") - this.gradlePluginPortal() + settings.pluginManagement.repositories.apply { + addTrustedRepositoriesIfMissing() + addUserConfiguredOrgGhPackagesRepository(provider) } - logger.info("Registering Maven GitHub repository for organization: $githubOrg") - this.maven { - name = "GitHubPackages" - url = URI("https://maven.pkg.github.com/$githubOrg/*") - credentials { - this.username = repoCredentials.username - this.password = repoCredentials.token - } + @Suppress("UnstableApiUsage") + settings.dependencyResolutionManagement.repositories.apply { + addTrustedRepositoriesIfMissing() + addUserConfiguredOrgGhPackagesRepository(provider) } } -} \ No newline at end of file +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 597ad7c..d40e8a8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ rootProject.name = "gh-cli-auth" + include("plugin") From d4a4cd3b06ec9a3928dbcbe262650ffa90eaf2a5 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Tue, 4 Nov 2025 17:40:46 +0000 Subject: [PATCH 10/22] refactor: rename GhCliProcess to GhCliAuthProcessor for clarity, improve error handling, and improve dynamic GH binary collector logic --- .../gradle/github/GhCliAuthProcessor.kt | 51 +++++++++++++++++++ .../gradle/github/GhCliProcess.kt | 44 ---------------- .../gradle/plugins/GhCliAuthBase.kt | 4 +- 3 files changed, 53 insertions(+), 46 deletions(-) create mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt delete mode 100644 plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt new file mode 100644 index 0000000..e772780 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt @@ -0,0 +1,51 @@ +package io.github.adelinosousa.gradle.github + +import java.io.ByteArrayOutputStream +import java.io.File +import javax.inject.Inject +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.process.ExecOperations + +internal abstract class GhCliAuthProcessor : ValueSource { + + @get:Inject + internal abstract val execOperations: ExecOperations + + override fun obtain(): String? = runCatching { + val outputStream = ByteArrayOutputStream() + + execOperations + .exec { + commandLine(dynamicGhBin(), "auth", "status", "--show-token") + standardOutput = outputStream + isIgnoreExitValue = true + } + .assertNormalExitValue() + + outputStream.toString().trim() + }.getOrElse { e -> + throw IllegalStateException( + "Failed to authenticate: ${e.message}. " + + "GitHub CLI is probably not installed or not found in PATH. " + + "Please install it before using this plugin, more information visit: https://gh-cli-auth.digibit.uk.", + e + ) + } + + private fun dynamicGhBin(): String { + val osName = System.getProperty("os.name").lowercase() + return when { + "mac" in osName -> { + // NOTE: In theory, this shouldn't be needed if the user has set up their PATH correctly. + listOf( + "/opt/homebrew/bin/gh", // Apple Silicon + "/usr/local/bin/gh", // Intel + "/usr/bin/gh" // System install + ).firstOrNull { File(it).exists() } ?: "gh" + } + "windows" in osName -> "gh.exe" + else -> "gh" + } + } +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt deleted file mode 100644 index 8128e73..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliProcess.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.adelinosousa.gradle.github - -import java.io.ByteArrayOutputStream -import java.io.File -import javax.inject.Inject -import org.gradle.api.provider.ValueSource -import org.gradle.api.provider.ValueSourceParameters -import org.gradle.process.ExecOperations - -internal abstract class GhCliProcess : ValueSource { - - @get:Inject - internal abstract val execOperations: ExecOperations - - override fun obtain(): String? { - return try { - val ghPath = findGhPath() - val outputStream = ByteArrayOutputStream() - val process = execOperations.exec { - commandLine(ghPath, "auth", "status", "--show-token") - standardOutput = outputStream - isIgnoreExitValue = true - } - - if (process.exitValue == 0) { - outputStream.toString().trim() - } else { - throw IllegalStateException("Failed to process GitHub CLI command.") - } - } catch (e: Exception) { - throw IllegalStateException("Failed to authenticate: ${e.message}. GitHub CLI is probably not installed or not found in PATH. Please install it before using this plugin, more information visit: https://gh-cli-auth.digibit.uk.", e) - } - } - - private fun findGhPath(): String { - // Path fix for macOS - if ("mac" in System.getProperty("os.name").lowercase()) { - val homebrewPaths = listOf("/opt/homebrew/bin/gh", "/usr/local/bin/gh") - return homebrewPaths.firstOrNull { File(it).exists() } ?: "gh" - } - - return "gh" - } -} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index 1721295..dde5e9b 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -1,6 +1,6 @@ package io.github.adelinosousa.gradle.plugins -import io.github.adelinosousa.gradle.github.GhCliProcess +import io.github.adelinosousa.gradle.github.GhCliAuthProcessor import io.github.adelinosousa.gradle.github.GhCliAuthParser import io.github.adelinosousa.gradle.github.GhCredentials import org.gradle.api.artifacts.dsl.RepositoryHandler @@ -53,7 +53,7 @@ public abstract class GhCliAuthBase { logger.debug("Falling back to gh CLI for GitHub credentials.") val authStatusProvider = this - .of(GhCliProcess::class.java) {} + .of(GhCliAuthProcessor::class.java) {} // We collect (i.e., `.get()`) the value before validation to ensure // the final side effects of the provider are executed From e55eb85265ed9ebda1d580922d234e32d28dcaac Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Tue, 4 Nov 2025 22:20:35 +0000 Subject: [PATCH 11/22] fix: set the GitHub Packages repository name to use the configured organization for clarity and avoid naming clash --- .../io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index dde5e9b..6f62fc0 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -79,7 +79,7 @@ public abstract class GhCliAuthBase { protected fun RepositoryHandler.addUserConfiguredOrgGhPackagesRepository(providers: ProviderFactory) { logger.info("Registering GitHub Packages repo for org: ${providers.githubOrg}") maven { - name = "GitHubPackages" + name = providers.githubOrg url = URI("https://maven.pkg.github.com/${providers.githubOrg}/*") credentials { username = providers.credentials.username From 8423c27bc38c7859687b39e322c2d7f3e7458436 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Wed, 5 Nov 2025 12:07:34 +0000 Subject: [PATCH 12/22] fix: change create to register for plugin definitions in build.gradle.kts to remove IDE marked errors, and remove empty block for functional test sources --- plugin/build.gradle.kts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 710a1e0..329acd9 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -29,7 +29,7 @@ gradlePlugin { website = "https://gh-cli-auth.digibit.uk" vcsUrl = "https://github.com/adelinosousa/gh-cli-auth" plugins { - create("ghCliAuthProject") { + register("ghCliAuthProject") { id = "io.github.adelinosousa.gradle.plugins.project.gh-cli-auth" implementationClass = "io.github.adelinosousa.gradle.plugins.GhCliAuthProjectPlugin" displayName = "Gradle GitHub CLI Auth Project Plugin" @@ -37,7 +37,7 @@ gradlePlugin { "Automatically configures access to GitHub Maven Packages for project using gh CLI. CI/CD friendly." tags.set(listOf("github", "packages", "maven", "repository")) } - create("ghCliAuthSettings") { + register("ghCliAuthSettings") { id = "io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth" implementationClass = "io.github.adelinosousa.gradle.plugins.GhCliAuthSettingsPlugin" displayName = "Gradle GitHub CLI Auth Settings Plugin" @@ -49,8 +49,7 @@ gradlePlugin { } // Add a source set for the functional test suite -val functionalTestSourceSet = sourceSets.create("functionalTest") { -} +val functionalTestSourceSet = sourceSets.create("functionalTest") configurations["functionalTestImplementation"].extendsFrom(configurations["testImplementation"]) configurations["functionalTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"]) From 6a2bd44ebb6de8b1bd85f257eeed287de2eb8b3b Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 11:13:14 +0000 Subject: [PATCH 13/22] fix: get rid of all the project warnings, align kotlin version to embedded version, and correctly set functional tests as test sources rather than main --- gradle/libs.versions.toml | 3 +- plugin/build.gradle.kts | 69 +++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f4d013e..a4af140 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [plugins] -kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.1.20" } +# The kotlin version here is aligned to the Gradle embedded Kotlin version. +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.0.21" } gradle-plugin-publish = { id = "com.gradle.plugin-publish", version = "1.3.1" } [libraries] diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 329acd9..82c9f9f 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode - plugins { `kotlin-dsl` alias(libs.plugins.kotlin.jvm) @@ -7,22 +5,48 @@ plugins { } group = "io.github.adelinosousa" + version = System .getenv("GRADLE_PUBLISH_VERSION") ?: requireNotNull(project.property("gradle.publish.version")) -kotlin { - explicitApi = ExplicitApiMode.Strict +dependencies { + testImplementation(libs.mockk) + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } repositories { mavenCentral() } -dependencies { - testImplementation(libs.mockk) - testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") +@Suppress("UnstableApiUsage") +testing.suites.register("functionalTest").configure { + dependencies { implementation(gradleTestKit()) } + targets { all { testTask.configure { useJUnitPlatform() } } } + configurations["functionalTestImplementation"].extendsFrom(configurations["testImplementation"]) + configurations["functionalTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"]) + tasks.named("check") { dependsOn(this@configure) } +} + +/** + * Enable strict explicit API mode for this project as this is a public plugin. + */ +kotlin.explicitApi = org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode.Strict + +tasks.named("test") { + /** + * Byte-buddy-agent is a dependency of mockk, and it is not a serviceability tool. + * So, we need to enable dynamic agent loading to hide the warning and make it work in future JDK versions. + */ + jvmArgs("-XX:+EnableDynamicAgentLoading") + /** + * > OpenJDK 64-Bit Server VM warning: + * > Sharing is only supported for bootloader classes because bootstrap classpath has been appended + * For further details, see: https://github.com/gradle/gradle/issues/19989 is resolved. + */ + jvmArgs("-Xshare:off") + useJUnitPlatform() } gradlePlugin { @@ -33,40 +57,15 @@ gradlePlugin { id = "io.github.adelinosousa.gradle.plugins.project.gh-cli-auth" implementationClass = "io.github.adelinosousa.gradle.plugins.GhCliAuthProjectPlugin" displayName = "Gradle GitHub CLI Auth Project Plugin" - description = - "Automatically configures access to GitHub Maven Packages for project using gh CLI. CI/CD friendly." + description = "Automatically configures access to GitHub Maven Packages for project using gh CLI. CI/CD friendly." tags.set(listOf("github", "packages", "maven", "repository")) } register("ghCliAuthSettings") { id = "io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth" implementationClass = "io.github.adelinosousa.gradle.plugins.GhCliAuthSettingsPlugin" displayName = "Gradle GitHub CLI Auth Settings Plugin" - description = - "Automatically configures access to GitHub Maven Plugins for settings using gh CLI. CI/CD friendly." + description = "Automatically configures access to GitHub Maven Plugins for settings using gh CLI. CI/CD friendly." tags.set(listOf("github", "packages", "maven", "repository")) } } } - -// Add a source set for the functional test suite -val functionalTestSourceSet = sourceSets.create("functionalTest") - -configurations["functionalTestImplementation"].extendsFrom(configurations["testImplementation"]) -configurations["functionalTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"]) - -// Add a task to run the functional tests -val functionalTest by tasks.registering(Test::class) { - testClassesDirs = functionalTestSourceSet.output.classesDirs - classpath = functionalTestSourceSet.runtimeClasspath - useJUnitPlatform() -} - -gradlePlugin.testSourceSets.add(functionalTestSourceSet) - -tasks.named("check") { - dependsOn(functionalTest) -} - -tasks.named("test") { - useJUnitPlatform() -} From 79a0ddad463f0859755b7872639a085774901637 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 16:47:15 +0000 Subject: [PATCH 14/22] refactor: enable support for custom GH binary path so users will always have an option to override, improve logging, and encapsulate GhCliAuthProcessor provider creation --- .../gradle/github/GhCliAuthParser.kt | 3 +- .../gradle/github/GhCliAuthProcessor.kt | 47 ++++++++++++++----- .../gradle/plugins/GhCliAuthBase.kt | 15 +++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt index e9d7b62..cd82163 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt @@ -1,9 +1,10 @@ package io.github.adelinosousa.gradle.github +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_CLI_EXTENSION_NAME import org.gradle.api.logging.Logging internal object GhCliAuthParser { - private val logger = Logging.getLogger(GhCliAuthParser::class.java) + private val logger = Logging.getLogger(GH_CLI_EXTENSION_NAME) private val REQUIRED_SCOPES: Set = setOf("read:packages", "read:org") internal fun parse(output: String): GhCredentials = this diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt index e772780..2518730 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt @@ -1,13 +1,30 @@ package io.github.adelinosousa.gradle.github +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_CLI_EXTENSION_NAME import java.io.ByteArrayOutputStream import java.io.File import javax.inject.Inject +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.gradle.process.ExecOperations internal abstract class GhCliAuthProcessor : ValueSource { + companion object { + private val logger = Logging.getLogger(GH_CLI_EXTENSION_NAME) + + /** + * System property that can be used to override the path to the `gh` binary. + * This skips the default detection logic in place which can be incorrect in some environments. + */ + internal const val GH_CLI_BINARY_PATH: String = "gh.cli.binary.path" + + @JvmStatic + internal fun create(factory: ProviderFactory): Provider = + factory.of(GhCliAuthProcessor::class.java) {} + } @get:Inject internal abstract val execOperations: ExecOperations @@ -34,18 +51,26 @@ internal abstract class GhCliAuthProcessor : ValueSource { - // NOTE: In theory, this shouldn't be needed if the user has set up their PATH correctly. - listOf( - "/opt/homebrew/bin/gh", // Apple Silicon - "/usr/local/bin/gh", // Intel - "/usr/bin/gh" // System install - ).firstOrNull { File(it).exists() } ?: "gh" + val customGhBinaryPath = System.getProperty(GH_CLI_BINARY_PATH) + + if (customGhBinaryPath != null) { + logger.debug("Using custom gh binary path from system property: $customGhBinaryPath") + return customGhBinaryPath + } else { + val osName = System.getProperty("os.name").lowercase() + logger.debug("Detecting gh binary for OS: $osName") + return when { + "mac" in osName -> { + // NOTE: In theory, this shouldn't be needed if the user has set up their PATH correctly. + listOf( + "/opt/homebrew/bin/gh", // Apple Silicon + "/usr/local/bin/gh", // Intel + "/usr/bin/gh" // System install + ).firstOrNull { File(it).exists() } ?: "gh" + } + "windows" in osName -> "gh.exe" + else -> "gh" } - "windows" in osName -> "gh.exe" - else -> "gh" } } } \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index 6f62fc0..095cef9 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -51,13 +51,12 @@ public abstract class GhCliAuthBase { return@getOrPut credentialsFromEnv } else { logger.debug("Falling back to gh CLI for GitHub credentials.") - - val authStatusProvider = this - .of(GhCliAuthProcessor::class.java) {} - - // We collect (i.e., `.get()`) the value before validation to ensure - // the final side effects of the provider are executed - return@getOrPut GhCliAuthParser.parse(authStatusProvider.get()) + return@getOrPut GhCliAuthProcessor + .create(this) + // We collect (i.e., `.get()`) the value before validation to ensure + // the final side effects of the provider are executed + .get() + .run(GhCliAuthParser::parse) } } @@ -77,7 +76,7 @@ public abstract class GhCliAuthBase { } protected fun RepositoryHandler.addUserConfiguredOrgGhPackagesRepository(providers: ProviderFactory) { - logger.info("Registering GitHub Packages repo for org: ${providers.githubOrg}") + logger.info("Registering GitHub Packages maven repository for organization: ${providers.githubOrg}") maven { name = providers.githubOrg url = URI("https://maven.pkg.github.com/${providers.githubOrg}/*") From 1c77bc50db29b81961ae9fbdb5881dd754eb1a49 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 16:48:23 +0000 Subject: [PATCH 15/22] feat: add fake GitHub CLI authentication for functional tests to finally make testing with GH CLI plausible --- gradle/libs.versions.toml | 4 +- plugin/build.gradle.kts | 3 ++ .../gradle/plugins/support/GhCliAuthFake.kt | 48 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFake.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4af140..69e1297 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,4 +4,6 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.0.21" } gradle-plugin-publish = { id = "com.gradle.plugin-publish", version = "1.3.1" } [libraries] -mockk = { group = "io.mockk", name = "mockk", version = "1.14.5" } +kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version = "5.9.1" } +kotest-extensions-jvm = { module = "io.kotest:kotest-extensions-jvm", version = "5.9.1" } +mockk = { module = "io.mockk:mockk", version = "1.14.5" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index 82c9f9f..0eed7d1 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -12,6 +12,8 @@ version = System dependencies { testImplementation(libs.mockk) + testImplementation(libs.kotest.extensions.jvm) + testImplementation(libs.kotest.assertions.core) testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -27,6 +29,7 @@ testing.suites.register("functionalTest").configure { configurations["functionalTestImplementation"].extendsFrom(configurations["testImplementation"]) configurations["functionalTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"]) tasks.named("check") { dependsOn(this@configure) } + kotlin.target.compilations { named { it == this@configure.name }.configureEach { associateWith(getByName("main")) } } } /** diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFake.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFake.kt new file mode 100644 index 0000000..7abab5a --- /dev/null +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFake.kt @@ -0,0 +1,48 @@ +package io.github.adelinosousa.gradle.plugins.support + +import java.io.File + +/** + * Fake GitHub CLI authentication script for functional tests. + * + * This class creates a fake `gh` CLI script that simulates authentication + * by outputting a predefined token and scopes. + */ +class GhCliAuthFake( + private val projectDir: File +) { + companion object { + internal const val DEFAULT_USER_VALUE: String = "testuser" + internal const val DEFAULT_TOKEN_VALUE: String = "ghp_mocked_token_1234567890" + internal val DEFAULT_VALID_SCOPES: List = listOf("read:packages", "read:org", "repo") + } + + internal lateinit var fakeGhScript: File + private set + + internal fun execute( + token: String = DEFAULT_TOKEN_VALUE, + validScopes: List = DEFAULT_VALID_SCOPES, + username: String = DEFAULT_USER_VALUE + ) { + fakeGhScript = projectDir + .resolve("bin") + .apply { mkdirs() } + .resolve("gh") + .apply { + writeText( + """ + #!/bin/bash + echo "Logged in to github.com account $username (keyring)" + echo "Token: $token" + echo "Token scopes: ${validScopes.joinToString(", ")}" + exit 0 + """ + ) + } + + ProcessBuilder("chmod", "+x", fakeGhScript.absolutePath) + .start() + .waitFor() + } +} \ No newline at end of file From 992cba08609a2763f38c7d41bc0aab7a8c7b7d64 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 16:49:06 +0000 Subject: [PATCH 16/22] refactor: implement functional test setup abstract to greatly reduce duplication, and notable improve acceptance test coverage at the integration level! --- .../GhCliAuthProjectPluginFunctionalTest.kt | 145 +++++--- .../GhCliAuthSettingsPluginFunctionalTest.kt | 323 ++++++++++-------- .../support/GhCliAuthFunctionalTestSetup.kt | 54 +++ .../gradle/plugins/GhCliAuthBase.kt | 2 +- .../gradle/github/GhCliAuthParserTest.kt | 128 +++++++ .../gradle/github/GhCliAuthProcessorTest.kt | 152 +++++++++ .../plugins/GhCliAuthProjectPluginTest.kt | 162 --------- .../plugins/GhCliAuthSettingsPluginTest.kt | 138 -------- .../gradle/plugins/GhCliAuthTest.kt | 110 ------ 9 files changed, 622 insertions(+), 592 deletions(-) create mode 100644 plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFunctionalTestSetup.kt create mode 100644 plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParserTest.kt create mode 100644 plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessorTest.kt delete mode 100644 plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt delete mode 100644 plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt delete mode 100644 plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt index 610d74d..fcb8948 100644 --- a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt @@ -1,61 +1,112 @@ package io.github.adelinosousa.gradle.plugins -import java.io.File -import kotlin.test.assertTrue +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.DEFAULT_TOKEN_ENV_KEY +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.DEFAULT_TOKEN_USERNAME +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_CLI_EXTENSION_NAME +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_ORG_SETTER_PROPERTY +import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFake +import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFunctionalTestSetup +import io.kotest.matchers.string.shouldContain import kotlin.test.Test -import org.gradle.testkit.runner.GradleRunner -import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.api.BeforeEach -class GhCliAuthProjectPluginFunctionalTest { - - @field:TempDir - lateinit var projectDir: File +class GhCliAuthProjectPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { + @BeforeEach + fun setup() { + buildFile.appendText( + """ + plugins { id("io.github.adelinosousa.gradle.plugins.project.gh-cli-auth") } + + // print ghCliAuth extension token value for verification + tasks.register("printGhCliAuthToken") { + doLast { + println("GhCliAuth Extension Token: " + project.extensions.getByName("$GH_CLI_EXTENSION_NAME").let { ext -> + ext as io.github.adelinosousa.gradle.extensions.GhCliAuthExtension + ext.token.get() + }) + } + } + + // print the org user name and token for maven repo for verification + tasks.register("printGhPackagesRepoConfig") { + doLast { + val repo = project.repositories.findByName("$GIVEN_ORG_VALUE") + if (repo is MavenArtifactRepository) { + println("Maven Repo URL: " + repo.url) + val credentials = repo.credentials + println("Maven Repo Username: " + credentials.username) + println("Maven Repo Password: " + credentials.password) + } else { + println("Repository '$GIVEN_ORG_VALUE GitHub Packages' not found...") + } + } + } + """.trimIndent() + ) + } - private val buildFile by lazy { projectDir.resolve("build.gradle") } - private val settingsFile by lazy { projectDir.resolve("settings.gradle") } - private val propertiesFile by lazy { projectDir.resolve("gradle.properties") } + @Test + fun `should apply plugin with no issues`() { + val randomTokenValue = "ghp_${System.currentTimeMillis()}" - @Test fun `can run plugin`() { - settingsFile.writeText("") - buildFile.writeText(""" - plugins { - id('io.github.adelinosousa.gradle.plugins.project.gh-cli-auth') - } - """.trimIndent()) - propertiesFile.writeText("gh.cli.auth.github.org=test-org") - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") + project + .withArguments("printGhPackagesRepoConfig", "--info") + .withEnvironment(mapOf(DEFAULT_TOKEN_ENV_KEY to randomTokenValue)) .build() + .output + .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") + .shouldContain("Maven Repo URL: https://maven.pkg.github.com/$GIVEN_ORG_VALUE/*") + .shouldContain("Maven Repo Username: $DEFAULT_TOKEN_USERNAME") + .shouldContain("Maven Repo Password: $randomTokenValue") + } + + @Test + fun `should allow setting a custom environment variable for the token`() { + val customEnvKey = "CUSTOM_ENV_KEY" + val customFakeTokenValue = "ghp_fake_token_value" - assertTrue(result.output.contains("Registering Maven GitHub repository for organization: test-org")) + propertiesFile + .appendText("\ngh.cli.auth.env.name=$customEnvKey") + + project + .withArguments("printGhCliAuthToken", "--debug") + .withEnvironment(mapOf(customEnvKey to customFakeTokenValue)) + .build() + .output + .shouldContain("Attempting to use GitHub credentials from environment variable: $customEnvKey") + .shouldContain("GhCliAuth Extension Token: $customFakeTokenValue") } - @Test fun `runs plugin with custom env variable name`() { - settingsFile.writeText("") - buildFile.writeText(""" - plugins { - id('io.github.adelinosousa.gradle.plugins.project.gh-cli-auth') - } - """.trimIndent()) - propertiesFile.writeText(""" - gh.cli.auth.github.org=test-org - gh.cli.auth.env.name=CUSTOM_ENV_VAR - """.trimIndent()) - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") - .withEnvironment(mapOf("CUSTOM_ENV_VAR" to "ghp_exampletoken1234567890")) + @Test + fun `should fallback to gh CLI auth when environment variable is not found`() { + val badEnvKey = "NON_EXISTENT_ENV_KEY-${System.currentTimeMillis()}" + + propertiesFile + .appendText("\ngh.cli.auth.env.name=$badEnvKey") + + project + .withArguments("printGhCliAuthToken", "--debug") .build() + .output + .shouldContain("Falling back to gh CLI for GitHub credentials.") + .shouldContain("GhCliAuth Extension Token: ${GhCliAuthFake.DEFAULT_TOKEN_VALUE}") + } - assertTrue(result.output.contains("Registering Maven GitHub repository for organization: test-org")) + @Test + fun `should fail when org is not set in gradle properties`() { + project + .apply { + // remove org property from gradle.properties + propertiesFile.writeText( + propertiesFile + .readText() + .lines() + .filterNot { it.startsWith(GH_ORG_SETTER_PROPERTY) } + .joinToString("\n") + ) + } + .buildAndFail() + .output + .shouldContain("Please set '$GH_ORG_SETTER_PROPERTY' in gradle.properties.") } } diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt index 34a7afd..d4225a4 100644 --- a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt @@ -1,171 +1,226 @@ package io.github.adelinosousa.gradle.plugins -import java.io.File -import kotlin.test.assertTrue +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.DEFAULT_TOKEN_ENV_KEY +import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFake +import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFunctionalTestSetup +import io.kotest.matchers.string.shouldContain import kotlin.test.Test -import org.gradle.testkit.runner.GradleRunner -import org.junit.jupiter.api.io.TempDir -class GhCliAuthSettingsPluginFunctionalTest { +class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { + @Test + fun `should apply settings plugin with no issues`() { + val randomTokenValue = "ghp_${System.currentTimeMillis()}" - @field:TempDir - lateinit var projectDir: File + val output = project + .apply { writeSettings() } + .withArguments("help", "--info") + .withEnvironment(mapOf(DEFAULT_TOKEN_ENV_KEY to randomTokenValue)) + .build() + .output - private val buildFile by lazy { projectDir.resolve("build.gradle") } - private val settingsFile by lazy { projectDir.resolve("settings.gradle") } - private val propertiesFile by lazy { projectDir.resolve("gradle.properties") } + output + .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") + .shouldContain("gh-cli-auth-token: $randomTokenValue") + } - @Test fun `can run plugin`() { - settingsFile.writeText(""" - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') - } - """.trimIndent()) - buildFile.writeText("") - propertiesFile.writeText("gh.cli.auth.github.org=test-org") - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") + @Test + fun `should use default environment variable if present`() { + val tokenFromDefaultEnv = "ghp_token_from_default_env_123" + + // Do NOT override env name; provider should try DEFAULT_TOKEN_ENV_KEY first + writeSettings() + + val output = project + .withArguments("help", "--debug") + .withEnvironment(mapOf(DEFAULT_TOKEN_ENV_KEY to tokenFromDefaultEnv)) .build() + .output - assertTrue(result.output.contains("Registering Maven GitHub repository for organization: test-org")) + output + .shouldContain("Attempting to use GitHub credentials from environment variable: $DEFAULT_TOKEN_ENV_KEY") + .shouldContain("gh-cli-auth-token: $tokenFromDefaultEnv") } - @Test fun `correct number of repositories are configured for pluginManagement`() { - settingsFile.writeText(""" - pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } - } - - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') - } - - dependencyResolutionManagement { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } + @Test + fun `should allow setting a custom environment variable for the token`() { + val customKey = "CUSTOM_ENV_VAR" + val customToken = "ghp_exampletoken1234567890" + + val output = project + .apply { + // Let the provider pick up the custom env var + propertiesFile.appendText("\ngh.cli.auth.env.name=$customKey\n") + writeSettings() } - """.trimIndent()) - buildFile.writeText("") - propertiesFile.writeText("gh.cli.auth.github.org=test-org") - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") + .withArguments("help", "--debug") + .withEnvironment(mapOf(customKey to customToken)) .build() + .output - assertTrue(result.output.contains("Adding Google repository")) + output + .shouldContain("Attempting to use GitHub credentials from environment variable: $customKey") + .shouldContain("gh-cli-auth-token: $customToken") } - @Test fun `correct number of repositories are configured for dependencyResolutionManagement`() { - settingsFile.writeText(""" - pluginManagement { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } - } - - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') + @Test + fun `should share token with other settings plugins via gradle extra`() { + val customKey = "ANOTHER_CUSTOM_ENV" + val expectedToken = "ghp_token_shared_via_extra" + + val output = project + .apply { + propertiesFile.appendText("\ngh.cli.auth.env.name=$customKey\n") + writeSettings( + afterEvalExtra = """ + // Simulate another settings plugin reading the shared token + val shared = gradle.extra.get("gh-cli-auth-token") + println("gh-cli-auth-token (read by another settings plugin): ${'$'}shared") + """.trimIndent() + ) } - - dependencyResolutionManagement { - repositories { - google() - mavenCentral() - } - } - """.trimIndent()) - buildFile.writeText("") - propertiesFile.writeText("gh.cli.auth.github.org=test-org") - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") + .withArguments("help", "--info") + .withEnvironment(mapOf(customKey to expectedToken)) .build() + .output - assertTrue(result.output.contains("Adding Gradle Plugin Portal repository")) + output + .shouldContain("gh-cli-auth-token: $expectedToken") + .shouldContain("gh-cli-auth-token (read by another settings plugin): $expectedToken") } - @Test fun `runs plugin with custom env variable name`() { - settingsFile.writeText( - """ - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') + @Test + fun `should fallback to gh CLI auth when environment variable is not found`() { + val missingKey = "NON_EXISTENT_ENV_${System.currentTimeMillis()}" + + val output = project + .apply { + propertiesFile.appendText("\ngh.cli.auth.env.name=$missingKey\n") + writeSettings( + afterEvalExtra = """ + // print maven repo details for verification + val repo = dependencyResolutionManagement + .repositories + .findByName("$GIVEN_ORG_VALUE") + .let { it as org.gradle.api.artifacts.repositories.MavenArtifactRepository } + + println("Maven Repo URL: " + repo.url) + val credentials = repo.credentials + println("Maven Repo Username: " + credentials.username) + println("Maven Repo Password: " + credentials.password) + """.trimIndent() + ) } - """.trimIndent() + .withArguments("help", "--debug") + .build() + .output + + output + .shouldContain("Falling back to gh CLI for GitHub credentials.") + .shouldContain("Maven Repo URL: https://maven.pkg.github.com/$GIVEN_ORG_VALUE/*") + .shouldContain("Maven Repo Username: ${GhCliAuthFake.DEFAULT_USER_VALUE}") + .shouldContain("Maven Repo Password: ${GhCliAuthFake.DEFAULT_TOKEN_VALUE}") + } + + @Test + fun `should configure trusted repositories for pluginManagement`() { + // PM is missing Google; DRM already has all three, so only PM should be updated + writeSettings( + pmReposBlock = """ + gradlePluginPortal() + mavenCentral() + """.trimIndent(), + drmReposBlock = """ + gradlePluginPortal() + google() + mavenCentral() + """.trimIndent() ) - buildFile.writeText("") - propertiesFile.writeText(""" - gh.cli.auth.github.org=test-org - gh.cli.auth.env.name=CUSTOM_ENV_VAR - """.trimIndent()) - - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") - .withEnvironment(mapOf("CUSTOM_ENV_VAR" to "ghp_exampletoken1234567890")) + val output = project + .withArguments("help", "--info") .build() + .output - assertTrue(result.output.contains("Registering Maven GitHub repository for organization: test-org")) + output + .shouldContain("Adding Google repository") + .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") } - @Test fun `plugin shares token with other settings plugins`() { - val tokenName = """gh-cli-auth-token""" - val expectedToken = "ghp_exampletoken1234567890" + @Test + fun `should configure trusted repositories for dependencyResolutionManagement`() { + // DRM is missing Gradle Plugin Portal; PM already has all three + writeSettings( + pmReposBlock = """ + gradlePluginPortal() + google() + mavenCentral() + """.trimIndent(), + drmReposBlock = """ + google() + mavenCentral() + """.trimIndent() + ) - settingsFile.writeText( + val output = project + .withArguments("help", "--info") + .build() + .output + + output + .shouldContain("Adding Gradle Plugin Portal repository") + .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") + } + + private fun writeSettings( + pmReposBlock: String? = null, + drmReposBlock: String? = null, + afterEvalExtra: String = "", + ) { + val pm = pmReposBlock?.let { """ - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') - } - - // a simple log to verify that the token is accessible from other setting plugins - gradle.settingsEvaluated { - def ghToken = gradle.ext.get("$tokenName") - println("$tokenName: ${'$'}ghToken") + pluginManagement { + repositories { + $it + } } - """.trimIndent() - ) + """.trimIndent() + }.orEmpty() - buildFile.writeText("") + val drm = drmReposBlock?.let { + """ + @Suppress("UnstableApiUsage") + dependencyResolutionManagement { + repositories { + $it + } + } + """.trimIndent() + }.orEmpty() - propertiesFile.writeText(""" - gh.cli.auth.github.org=test-org - gh.cli.auth.env.name=CUSTOM_ENV_VAR - """.trimIndent()) + settingsFile.writeText( + """ + // Optional user-provided initial repositories (to test addTrustedRepositoriesIfMissing behavior) + $pm + $drm + + // Apply the gh-cli-auth settings plugin + plugins { + id("io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth") + } - val result = GradleRunner.create() - .forwardOutput() - .withPluginClasspath() - .withProjectDir(projectDir) - .withArguments("--stacktrace", "--info") - .withGradleVersion("8.14.2") - .withEnvironment(mapOf("CUSTOM_ENV_VAR" to expectedToken)) - .build() + // Helper to verify final state *after* settings have been evaluated + fun afterSettingsEvaluate(action: Settings.() -> Unit) { + gradle.settingsEvaluated { action(this) } + } - assertTrue(result.output.contains("$tokenName: $expectedToken")) + // Verify that the plugin shared the token via gradle.extra and allow custom assertions + afterSettingsEvaluate { + val tokenName = "gh-cli-auth-token" + val ghToken = gradle.extra.get(tokenName) + println("${'$'}tokenName: ${'$'}ghToken") + $afterEvalExtra + } + """.trimIndent() + ) } } diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFunctionalTestSetup.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFunctionalTestSetup.kt new file mode 100644 index 0000000..d118a6c --- /dev/null +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/support/GhCliAuthFunctionalTestSetup.kt @@ -0,0 +1,54 @@ +package io.github.adelinosousa.gradle.plugins.support + +import java.io.File +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.io.TempDir + +abstract class GhCliAuthFunctionalTestSetup { + companion object { + const val GIVEN_ORG_VALUE = "test-org" + } + + @field:TempDir + lateinit var projectDir: File + + lateinit var project: GradleRunner + + val buildFile by lazy { + projectDir.resolve("build.gradle.kts") + } + + val settingsFile by lazy { + projectDir.resolve("settings.gradle.kts") + } + + val propertiesFile by lazy { + projectDir.resolve("gradle.properties") + } + + val fakeGhExtension by lazy { + GhCliAuthFake(projectDir).apply { execute() } + } + + @BeforeEach + fun `configure project defaults`() { + propertiesFile.writeText( + """ + gh.cli.auth.github.org=$GIVEN_ORG_VALUE + systemProp.gh.cli.binary.path=${fakeGhExtension.fakeGhScript.absolutePath} + """.trimIndent() + ) + + settingsFile.createNewFile() + buildFile.createNewFile() + + project = GradleRunner.create() + .forwardOutput() + .withPluginClasspath() + .withProjectDir(projectDir) + .withArguments("--info") + .withGradleVersion("8.14.2") + } + +} \ No newline at end of file diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index 095cef9..16c4ad1 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -32,7 +32,7 @@ public abstract class GhCliAuthBase { this .gradleProperty(GH_ORG_SETTER_PROPERTY) .orNull - .let { requireNotNull(it) { "Please set ${GH_ORG_SETTER_PROPERTY}' in gradle.properties." } } + .let { requireNotNull(it) { "Please set '${GH_ORG_SETTER_PROPERTY}' in gradle.properties." } } .also { require(it.isNotBlank()) { "Property '${GH_ORG_SETTER_PROPERTY}' MUST not be blank." } } } diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParserTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParserTest.kt new file mode 100644 index 0000000..c01a4c1 --- /dev/null +++ b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParserTest.kt @@ -0,0 +1,128 @@ +package io.github.adelinosousa.gradle.github + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import kotlin.test.Test + +class GhCliAuthParserTest { + + private fun sampleOutput( + user: String? = "octocat", + token: String? = "ghp_exampletoken123", + scopes: String = "'read:packages', 'read:org'" + ): String = buildString { + appendLine("gh auth status") + appendLine("Logged in to github.com") + if (user != null) appendLine("Logged in to github.com account $user (keyring)") + appendLine("Token scopes: $scopes") + if (token != null) appendLine("Token: $token") + appendLine("Config dir: /Users/octo/.config/gh") + } + + @Test + fun `parse extracts username and token when output is valid`() { + val output = sampleOutput( + user = "octocat", + token = "ghp_abcdef1234567890", + scopes = "'workflow', 'read:packages', 'read:org'" + ) + + val creds = GhCliAuthParser.parse(output) + + creds.username shouldBe "octocat" + creds.token shouldBe "ghp_abcdef1234567890" + } + + @Test + fun `parse trims keyring suffix and whitespace`() { + val output = buildString { + appendLine("Some header") + appendLine("Logged in to github.com account octo-user (keyring) ") + appendLine("Token scopes: 'read:packages' , 'read:org' ") + appendLine("Token: ghp_trim_me ") + } + + val creds = GhCliAuthParser.parse(output) + + creds.username shouldBe "octo-user" + creds.token shouldBe "ghp_trim_me" + } + + @Test + fun `parse fails when required scopes are missing`() { + val output = sampleOutput( + user = "octocat", + token = "ghp_no_scopes", + scopes = "'read:packages'" // missing 'read:org' + ) + + val ex = shouldThrow { + GhCliAuthParser.parse(output) + } + + ex.message.shouldContain("GitHub CLI token is missing required scopes") + ex.message.shouldContain("read:packages") + ex.message.shouldContain("read:org") + } + + @Test + fun `parse fails when Token scopes line is absent`() { + val output = buildString { + appendLine("gh auth status") + appendLine("Logged in to github.com account octocat (keyring)") + // No "Token scopes:" line at all + appendLine("Token: ghp_token_but_no_scopes") + } + + val ex = shouldThrow { + GhCliAuthParser.parse(output) + } + + ex.message.shouldContain("GitHub CLI token is missing required scopes") + } + + @Test + fun `parse fails with generic message when username is missing`() { + val output = sampleOutput( + user = null, // remove username line + token = "ghp_missing_user", + scopes = "'read:packages', 'read:org'" + ) + + val ex = shouldThrow { + GhCliAuthParser.parse(output) + } + + ex.message shouldBe "'gh' CLI is authenticated but failed to extract user or token" + } + + @Test + fun `parse fails with generic message when token is missing`() { + val output = sampleOutput( + user = "octocat", + token = null, // remove token line + scopes = "'read:packages', 'read:org'" + ) + + val ex = shouldThrow { + GhCliAuthParser.parse(output) + } + + ex.message shouldBe "'gh' CLI is authenticated but failed to extract user or token" + } + + @Test + fun `parse accepts scopes in any order and with quotes`() { + val output = sampleOutput( + user = "someone", + token = "ghp_ok", + scopes = "'read:org', 'workflow', 'read:packages'" + ) + + val creds = GhCliAuthParser.parse(output) + + creds.username shouldBe "someone" + creds.token shouldBe "ghp_ok" + } +} diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessorTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessorTest.kt new file mode 100644 index 0000000..3004442 --- /dev/null +++ b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessorTest.kt @@ -0,0 +1,152 @@ +package io.github.adelinosousa.gradle.github + +import io.github.adelinosousa.gradle.github.GhCliAuthProcessor.Companion.GH_CLI_BINARY_PATH +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import java.io.OutputStream +import org.gradle.api.Action +import org.gradle.process.ExecOperations +import org.gradle.process.ExecResult +import org.gradle.process.ExecSpec + +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import kotlin.test.Test + +class GhCliAuthProcessorTest { + private val originalOs = System.getProperty("os.name") + private val originalGhPath = System.getProperty(GH_CLI_BINARY_PATH) + + @AfterEach + fun restoreProps() { + if (originalOs == null) System.clearProperty("os.name") else System.setProperty("os.name", originalOs) + if (originalGhPath == null) System.clearProperty(GH_CLI_BINARY_PATH) + else System.setProperty(GH_CLI_BINARY_PATH, originalGhPath) + } + + @Test + fun `obtain returns trimmed stdout on success and uses expected args`() { + System.setProperty("os.name", "Linux") + System.clearProperty(GH_CLI_BINARY_PATH) + + val captured = mutableListOf() + val execOps = mockExec(" some-output\n", captured) + val processor = processorWith(execOps) + + processor.obtain() shouldBe "some-output" + + // Command and args irrespective of which overload Gradle used + captured.first() shouldBe "gh" + captured.drop(1) shouldBe listOf("auth", "status", "--show-token") + + verify { execOps.exec(any()) } // executed once + } + + @Test + fun `respects custom GH_CLI_BINARY_PATH`() { + System.setProperty("os.name", "Linux") + val custom = "/tmp/custom/gh" + System.setProperty(GH_CLI_BINARY_PATH, custom) + + val captured = mutableListOf() + val execOps = mockExec("ok", captured) + val processor = processorWith(execOps) + + processor.obtain() shouldBe "ok" + captured.first() shouldBe custom + } + + @Test + fun `detects Windows exe`() { + System.setProperty("os.name", "Windows 11") + System.clearProperty(GH_CLI_BINARY_PATH) + + val captured = mutableListOf() + val execOps = mockExec("ok", captured) + val processor = processorWith(execOps) + + processor.obtain() shouldBe "ok" + captured.first() shouldBe "gh.exe" + } + + @Test + fun `wraps failures with helpful error if gh fails`() { + System.setProperty("os.name", "Linux") + System.clearProperty(GH_CLI_BINARY_PATH) + + // Simulate non-zero exit via assertNormalExitValue throwing + val execOps = mockExec(stdout = "", captureCmd = mutableListOf(), okExit = false) + val processor = processorWith(execOps) + + val ex = shouldThrow { processor.obtain() } + ex.message.shouldContain("Failed to authenticate:") + ex.cause?.message shouldBe "non-zero exit" + } + + /** + * Creates an ExecOperations mock that returns [stdout] and captures the command line. + * + * @param stdout The standard output to simulate from the command. + * @param captureCmd A mutable list that will be populated with the command line arguments used + * when the exec is invoked. + * @param okExit If true, simulates a successful exit; if false, simulates a non-zero exit. + * @return A mocked ExecOperations instance. + */ + private fun mockExec( + stdout: String = "", + captureCmd: MutableList = mutableListOf(), + okExit: Boolean = true, + ): ExecOperations { + val execOps = mockk() + val execSpec = mockk(relaxed = true) + val result = mockk() + val outSlot = slot() + + // Capture BOTH overloads to be robust to Gradle's call site + every { execSpec.commandLine(*anyVararg()) } answers { + captureCmd.clear() + @Suppress("UNCHECKED_CAST") + val arr = this.args[0] as Array + captureCmd.addAll(arr.toList()) + execSpec + } + every { execSpec.commandLine(any>()) } answers { + captureCmd.clear() + captureCmd.addAll(firstArg()) + execSpec + } + + // Capture setters used by the code under test + every { execSpec.setStandardOutput(capture(outSlot)) } answers { execSpec } + every { execSpec.setIgnoreExitValue(any()) } answers { execSpec } + + if (okExit) { + every { result.assertNormalExitValue() } returns result + } else { + every { result.assertNormalExitValue() } throws RuntimeException("non-zero exit") + } + + // Make exec(Action) run the action against our execSpec and write stdout + every { execOps.exec(any()) } answers { + val action = firstArg>() + action.execute(execSpec) + if (outSlot.isCaptured) { + outSlot.captured.write(stdout.toByteArray()) + outSlot.captured.flush() + } + result + } + + return execOps + } + + /** Convenience to construct a testable processor with our mocked ExecOperations. */ + private fun processorWith(execOps: ExecOperations) = object : GhCliAuthProcessor() { + override val execOperations: ExecOperations = execOps + override fun getParameters() = throw NotImplementedError() // Gradle won't call in these unit tests + } +} diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt deleted file mode 100644 index 690d3a7..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt +++ /dev/null @@ -1,162 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -import io.github.adelinosousa.gradle.extensions.GhCliAuthExtension -import io.github.adelinosousa.gradle.internal.Config -import io.github.adelinosousa.gradle.internal.Environment -import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GitHubCLIProcess -import io.github.adelinosousa.gradle.internal.RepositoryCredentials -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.spyk -import io.mockk.unmockkAll -import org.gradle.api.GradleException -import org.gradle.api.artifacts.repositories.MavenArtifactRepository -import org.gradle.api.internal.provider.Providers -import org.gradle.api.provider.Provider -import org.gradle.api.provider.ProviderFactory -import org.gradle.testfixtures.ProjectBuilder -import org.junit.jupiter.api.assertThrows -import kotlin.IllegalStateException -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull - -class GhCliAuthProjectPluginTest { - val testOrg = "test-org" - val testUsername = "test-user" - val testToken = "test-token" - val providerFactory = mockk(relaxed = true) - val cliProcessProvider = mockk>(relaxed = true) - - @BeforeTest fun setUp() { - mockkObject(GhCliAuth, Environment) - } - - @AfterTest fun tearDown() { - unmockkAll() - } - - @Test fun `plugin applies and creates extension`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } returns Providers.of("") - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(testUsername, testToken) - every { providerFactory.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { providerFactory.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - GhCliAuthProjectPlugin().apply(spyProject) - - assertNotNull(project.extensions.findByName("ghCliAuth")) - } - - @Test fun `configures maven repository with environment variables`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - val customEnvName = "CUSTOM_GITHUB_TOKEN" - - every { spyProject.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { spyProject.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of(customEnvName) - every { Environment.getEnv(customEnvName) } returns testToken - - GhCliAuthProjectPlugin().apply(spyProject) - - val repo = spyProject.repositories.findByName("GitHubPackages") as? MavenArtifactRepository - assertNotNull(repo) - assertEquals("https://maven.pkg.github.com/${testOrg}/*", repo.url.toString()) - assertEquals("", repo.credentials.username) - assertEquals(testToken, repo.credentials.password) - - val extension = spyProject.extensions.getByType(GhCliAuthExtension::class.java) - assertEquals(testToken, extension.token.get()) - } - - @Test fun `configures maven repository with gh CLI credentials`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } returns Providers.of("") - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(testUsername, testToken) - every { providerFactory.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { providerFactory.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - GhCliAuthProjectPlugin().apply(spyProject) - - val repo = spyProject.repositories.findByName("GitHubPackages") as? MavenArtifactRepository - assertNotNull(repo) - assertEquals("https://maven.pkg.github.com/${testOrg}/*", repo.url.toString()) - assertEquals(testUsername, repo.credentials.username) - assertEquals(testToken, repo.credentials.password) - - val extension = spyProject.extensions.getByType(GhCliAuthExtension::class.java) - assertEquals(testToken, extension.token.get()) - } - - @Test fun `throws error when repository is not configured with gh CLI credentials`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } returns Providers.of("") - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(null, null) - every { providerFactory.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { providerFactory.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val exception = assertThrows { - GhCliAuthProjectPlugin().apply(spyProject) - } - - val repo = project.repositories.findByName("GitHubPackages") - assertNull(repo) - assertEquals("Token not found in environment variable '' or 'gh' CLI. Unable to configure GitHub Packages repository.", exception.message) - } - - @Test fun `throws error when gh CLI is not installed`() { - val exceptionMessage = "Failed to authenticate: GitHub CLI is not installed or not found in PATH. Please install it before using this plugin." - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } throws GradleException(exceptionMessage) - every { providerFactory.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { providerFactory.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val exception = assertThrows { - GhCliAuthProjectPlugin().apply(spyProject) - } - - assertEquals(exceptionMessage, exception.message) - } - - @Test fun `does not configure repository when github org property is missing`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(testUsername, testToken) - every { spyProject.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of("") - every { spyProject.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - - val exception = assertThrows { - GhCliAuthProjectPlugin().apply(spyProject) - } - - val repo = project.repositories.findByName("GitHubPackages") - assertNull(repo) - assertEquals("GitHub organization not specified. Please set the '${Config.GITHUB_ORG}' in your gradle.properties file.", exception.message) - } -} diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt deleted file mode 100644 index 96c0e0a..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -import io.github.adelinosousa.gradle.internal.Config -import io.github.adelinosousa.gradle.internal.Environment -import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.RepositoryCredentials -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.unmockkAll -import io.mockk.verify -import org.gradle.api.Action -import org.gradle.api.GradleException -import org.gradle.api.artifacts.repositories.MavenArtifactRepository -import org.gradle.api.artifacts.repositories.PasswordCredentials -import org.gradle.api.initialization.Settings -import org.gradle.api.internal.provider.Providers -import org.junit.jupiter.api.assertThrows -import java.net.URI -import kotlin.IllegalStateException -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class GhCliAuthSettingsPluginTest { - val testOrg = "test-org" - val testUsername = "test-user" - val testToken = "test-token" - val settings = mockk(relaxed = true) - val pluginMavenAction = slot>() - val dependencyResolutionMavenAction = slot>() - val mockRepo = mockk(relaxed = true) - val mockCredentials = mockk(relaxed = true) - - @BeforeTest fun setUp() { - mockkObject(GhCliAuth, Environment) - } - - @AfterTest fun tearDown() { - unmockkAll() - } - - @Test fun `configures maven repository with environment variables`() { - val customEnvName = "CUSTOM_GITHUB_TOKEN" - - every { settings.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { settings.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of(customEnvName) - every { Environment.getEnvCredentials(customEnvName) } returns RepositoryCredentials(username = "", token = testToken) - every { settings.pluginManagement.repositories.findByName(any()) } returns null - every { settings.dependencyResolutionManagement.repositories.findByName(any()) } returns null - every { settings.pluginManagement.repositories.maven(capture(pluginMavenAction)) } returns mockk() - every { settings.dependencyResolutionManagement.repositories.maven(capture(dependencyResolutionMavenAction)) } returns mockk() - every { mockRepo.credentials(any>()) } answers { - val credentialsAction = firstArg>() - credentialsAction.execute(mockCredentials) - } - - GhCliAuthSettingsPlugin().apply(settings) - - pluginMavenAction.captured.execute(mockRepo) - dependencyResolutionMavenAction.captured.execute(mockRepo) - - verify(exactly = 2) { - mockRepo.name = "GitHubPackages" - mockRepo.url = URI("https://maven.pkg.github.com/$testOrg/*") - mockCredentials.username = "" - mockCredentials.password = testToken - } - } - - @Test fun `configures maven repository with gh CLI credentials`() { - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } returns Providers.of("") - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(testUsername, testToken) - every { settings.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { settings.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - every { settings.pluginManagement.repositories.findByName(any()) } returns null - every { settings.dependencyResolutionManagement.repositories.findByName(any()) } returns null - every { settings.pluginManagement.repositories.maven(capture(pluginMavenAction)) } returns mockk() - every { settings.dependencyResolutionManagement.repositories.maven(capture(dependencyResolutionMavenAction)) } returns mockk() - every { mockRepo.credentials(any>()) } answers { - val credentialsAction = firstArg>() - credentialsAction.execute(mockCredentials) - } - - GhCliAuthSettingsPlugin().apply(settings) - - pluginMavenAction.captured.execute(mockRepo) - dependencyResolutionMavenAction.captured.execute(mockRepo) - - verify(exactly = 2) { - mockRepo.name = "GitHubPackages" - mockRepo.url = URI("https://maven.pkg.github.com/$testOrg/*") - mockCredentials.username = testUsername - mockCredentials.password = testToken - } - } - - @Test fun `throws error when repository is not configured with gh CLI credentials`() { - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } returns Providers.of("") - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(null, null) - every { settings.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { settings.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - - val exception = assertThrows { - GhCliAuthSettingsPlugin().apply(settings) - } - - assertEquals("Token not found in environment variable '' or 'gh' CLI. Unable to configure GitHub Packages repository.", exception.message) - } - - @Test fun `throws error when gh CLI is not installed`() { - val exceptionMessage = "Failed to authenticate: GitHub CLI is not installed or not found in PATH. Please install it before using this plugin." - - every { GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(any()) } throws GradleException(exceptionMessage) - every { settings.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of(testOrg) - every { settings.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - - val exception = assertThrows { - GhCliAuthSettingsPlugin().apply(settings) - } - - assertEquals(exceptionMessage, exception.message) - } - - @Test fun `does not configure repository when github org property is missing`() { - every { GhCliAuth.getGitHubCredentials(any()) } returns RepositoryCredentials(testUsername, testToken) - every { settings.providers.gradleProperty(Config.GITHUB_ORG) } returns Providers.of("") - every { settings.providers.gradleProperty(Config.ENV_PROPERTY_NAME) } returns Providers.of("") - - val exception = assertThrows { - GhCliAuthSettingsPlugin().apply(settings) - } - - assertEquals("GitHub organization not specified. Please set the '${Config.GITHUB_ORG}' in your gradle.properties file.", exception.message) - } -} diff --git a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt b/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt deleted file mode 100644 index 2eb49da..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -import io.github.adelinosousa.gradle.internal.GhCliAuth -import io.github.adelinosousa.gradle.internal.GitHubCLIProcess -import io.mockk.every -import io.mockk.mockk -import io.mockk.spyk -import io.mockk.unmockkAll -import org.gradle.api.GradleException -import org.gradle.api.Transformer -import org.gradle.api.provider.Provider -import org.gradle.api.provider.ProviderFactory -import org.gradle.testfixtures.ProjectBuilder -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.assertThrows -import kotlin.test.Test -import kotlin.test.assertEquals - -class GhCliAuthTest { - val providerFactory = mockk(relaxed = true) - val cliProcessProvider = mockk>(relaxed = true) - - @AfterEach fun tearDown() { - unmockkAll() - } - - @Test fun `checkGhCliAuthenticatedWithCorrectScopes throws exception when gh CLI is not installed`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - val exception = GradleException("Failed to authenticate: GitHub CLI is not installed or not found in PATH. Please install it before using this plugin.") - every { cliProcessProvider.get() } throws exception - every { cliProcessProvider.map(any>())} returns cliProcessProvider - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val result = assertThrows { - val authStatusProvider = spyProject.providers.of(GitHubCLIProcess::class.java) {} - GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider).get() - } - - assertEquals("Failed to authenticate: GitHub CLI is not installed or not found in PATH. Please install it before using this plugin.", result.message) - } - - @Test fun `checkGhCliAuthenticatedWithCorrectScopes throws exception when gh CLI is not authenticated`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - val exception = GradleException("Failed to authenticate: GitHub CLI is not authenticated or does not have the required scopes ${GhCliAuth.requiredScopes}") - every { cliProcessProvider.get() } throws exception - every { cliProcessProvider.map(any>())} returns cliProcessProvider - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val result = assertThrows { - val authStatusProvider = spyProject.providers.of(GitHubCLIProcess::class.java) {} - GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider).get() - } - assertEquals("Failed to authenticate: GitHub CLI is not authenticated or does not have the required scopes ${GhCliAuth.requiredScopes}", result.message) - } - - @Test fun `checkGhCliAuthenticatedWithCorrectScopes throws exception when gh CLI does not have required scopes`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - val exception = GradleException("Failed to authenticate: GitHub CLI is not authenticated or does not have the required scopes ${GhCliAuth.requiredScopes}") - - val output = """ - Logged in to github.com account testuser (keyring) - Token: ghs_1234567890abcdef - Token scopes: 'repo', 'user' - """.trimIndent() - every { cliProcessProvider.get() } returns output - every { cliProcessProvider.map(any>())} throws exception - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val result = assertThrows { - val authStatusProvider = spyProject.providers.of(GitHubCLIProcess::class.java) {} - GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider).get() - } - - assertEquals("Failed to authenticate: GitHub CLI is not authenticated or does not have the required scopes ${GhCliAuth.requiredScopes}", result.message) - } - - @Test fun `checkGhCliAuthenticatedWithCorrectScopes returns output when authenticated with required scopes`() { - val project = ProjectBuilder.builder().build() - val spyProject = spyk(project) - - every { spyProject.providers } returns providerFactory - - val expectedOutput = """ - Logged in to github.com account testuser (keyring) - Token: ghs_1234567890abcdef - Token scopes: 'read:packages', 'repo', 'read:org', 'user' - """.trimIndent() - - every { cliProcessProvider.get() } returns expectedOutput - every { cliProcessProvider.map(any>())} returns cliProcessProvider - every { providerFactory.of(eq(GitHubCLIProcess::class.java), any()) } returns cliProcessProvider - - val authStatusProvider = spyProject.providers.of(GitHubCLIProcess::class.java) {} - val result = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider).get() - - assertEquals(expectedOutput, result) - } -} \ No newline at end of file From 5266f0397f75995ade0d331869a0c0256c160550 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 17:58:13 +0000 Subject: [PATCH 17/22] ci: add --info flag to gradlew build command for improved logging during build process --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 23e7748..7f4f2b7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -26,6 +26,6 @@ jobs: - name: Build shell: bash - run: ./gradlew build + run: ./gradlew build --info env: GH_TOKEN: ${{ secrets.TEST_PAT }} From fe6ddd8d0ea933789e08c35d162a7d9b3d10dc22 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 18:06:36 +0000 Subject: [PATCH 18/22] refactor: update token naming convention from 'gh-cli-auth-token' to 'gh.cli.auth.token' for consistency --- README.md | 4 ++-- .../GhCliAuthSettingsPluginFunctionalTest.kt | 16 ++++++++-------- .../adelinosousa/gradle/plugins/GhCliAuthBase.kt | 3 +-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1b97a9a..0abaa13 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ This plugin is split into two: one for `settings` and the other for `project`. D If you need to consume the token in your own settings plugin, you can access it via gradle extra properties: ```shell - if (settings.gradle.extra.has("gh-cli-auth-token")) { - val ghToken = settings.gradle.extra["gh-cli-auth-token"] as String + if (settings.gradle.extra.has("gh.cli.auth.token")) { + val ghToken = settings.gradle.extra["gh.cli.auth.token"] as String } ``` diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt index d4225a4..3dfdf55 100644 --- a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt @@ -20,7 +20,7 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { output .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") - .shouldContain("gh-cli-auth-token: $randomTokenValue") + .shouldContain("gh.cli.auth.token: $randomTokenValue") } @Test @@ -38,7 +38,7 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { output .shouldContain("Attempting to use GitHub credentials from environment variable: $DEFAULT_TOKEN_ENV_KEY") - .shouldContain("gh-cli-auth-token: $tokenFromDefaultEnv") + .shouldContain("gh.cli.auth.token: $tokenFromDefaultEnv") } @Test @@ -59,7 +59,7 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { output .shouldContain("Attempting to use GitHub credentials from environment variable: $customKey") - .shouldContain("gh-cli-auth-token: $customToken") + .shouldContain("gh.cli.auth.token: $customToken") } @Test @@ -73,8 +73,8 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { writeSettings( afterEvalExtra = """ // Simulate another settings plugin reading the shared token - val shared = gradle.extra.get("gh-cli-auth-token") - println("gh-cli-auth-token (read by another settings plugin): ${'$'}shared") + val shared = gradle.extra.get("gh.cli.auth.token") + println("gh.cli.auth.token (read by another settings plugin): ${'$'}shared") """.trimIndent() ) } @@ -84,8 +84,8 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { .output output - .shouldContain("gh-cli-auth-token: $expectedToken") - .shouldContain("gh-cli-auth-token (read by another settings plugin): $expectedToken") + .shouldContain("gh.cli.auth.token: $expectedToken") + .shouldContain("gh.cli.auth.token (read by another settings plugin): $expectedToken") } @Test @@ -215,7 +215,7 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { // Verify that the plugin shared the token via gradle.extra and allow custom assertions afterSettingsEvaluate { - val tokenName = "gh-cli-auth-token" + val tokenName = "gh.cli.auth.token" val ghToken = gradle.extra.get(tokenName) println("${'$'}tokenName: ${'$'}ghToken") $afterEvalExtra diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index 16c4ad1..f0d7808 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -15,8 +15,7 @@ public abstract class GhCliAuthBase { const val GH_CLI_EXTENSION_NAME: String = "ghCliAuth" const val GH_ORG_SETTER_PROPERTY: String = "gh.cli.auth.github.org" const val GH_ENV_KEY_SETTER_PROPERTY: String = "gh.cli.auth.env.name" - // FIXME: This needs to match the same convention (dots vs dashes) + provide a way to override the preferred extra key name - const val GH_EXTRA_TOKEN_KEY: String = "gh-cli-auth-token" + const val GH_EXTRA_TOKEN_KEY: String = "gh.cli.auth.token" const val DEFAULT_TOKEN_ENV_KEY: String = "GITHUB_TOKEN" const val DEFAULT_TOKEN_USERNAME: String = "" From d175bc779dc8b2e03eab0a7107dd30ab9277ed84 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 18:39:05 +0000 Subject: [PATCH 19/22] chore: add '*.salive' to .gitignore to exclude live session files from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5a03bc3..4390218 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ out/ *.iml *.ipr *.iws +*.salive .project .settings .classpath From 8fcf6fe2bb280b43147403a7ab511fb000c78d64 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 18:39:51 +0000 Subject: [PATCH 20/22] feat: add support for allowing custom gradle property keys to collect token values --- .../GhCliAuthProjectPluginFunctionalTest.kt | 9 ++- .../GhCliAuthSettingsPluginFunctionalTest.kt | 65 ++++++++++++++++++- .../gradle/plugins/GhCliAuthBase.kt | 30 +++++++-- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt index fcb8948..ab3e69e 100644 --- a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginFunctionalTest.kt @@ -80,15 +80,20 @@ class GhCliAuthProjectPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { @Test fun `should fallback to gh CLI auth when environment variable is not found`() { val badEnvKey = "NON_EXISTENT_ENV_KEY-${System.currentTimeMillis()}" + val badPropertyKey = "non.existent.property.key.${System.currentTimeMillis()}" propertiesFile - .appendText("\ngh.cli.auth.env.name=$badEnvKey") + .appendText( + "\n" + + "gh.cli.auth.env.name=$badEnvKey\n" + + "gh.cli.auth.property.name=$badPropertyKey\n" + ) project .withArguments("printGhCliAuthToken", "--debug") .build() .output - .shouldContain("Falling back to gh CLI for GitHub credentials.") + .shouldContain("Attempting to use GitHub credentials from gh CLI.") .shouldContain("GhCliAuth Extension Token: ${GhCliAuthFake.DEFAULT_TOKEN_VALUE}") } diff --git a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt index 3dfdf55..3d598d3 100644 --- a/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt +++ b/plugin/src/functionalTest/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginFunctionalTest.kt @@ -1,6 +1,9 @@ package io.github.adelinosousa.gradle.plugins import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.DEFAULT_TOKEN_ENV_KEY +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.DEFAULT_TOKEN_PROPERTY_KEY +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_ENV_KEY_SETTER_PROPERTY +import io.github.adelinosousa.gradle.plugins.GhCliAuthBase.Companion.GH_PROPERTY_KEY_SETTER_PROPERTY import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFake import io.github.adelinosousa.gradle.plugins.support.GhCliAuthFunctionalTestSetup import io.kotest.matchers.string.shouldContain @@ -91,10 +94,16 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { @Test fun `should fallback to gh CLI auth when environment variable is not found`() { val missingKey = "NON_EXISTENT_ENV_${System.currentTimeMillis()}" + val missingPropKey = "non.existent.prop.${System.currentTimeMillis()}" val output = project .apply { - propertiesFile.appendText("\ngh.cli.auth.env.name=$missingKey\n") + propertiesFile + .appendText( + "\n" + + "$GH_ENV_KEY_SETTER_PROPERTY=$missingKey\n" + + "$GH_PROPERTY_KEY_SETTER_PROPERTY=$missingPropKey\n" + ) writeSettings( afterEvalExtra = """ // print maven repo details for verification @@ -115,7 +124,7 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { .output output - .shouldContain("Falling back to gh CLI for GitHub credentials.") + .shouldContain("Attempting to use GitHub credentials from gh CLI.") .shouldContain("Maven Repo URL: https://maven.pkg.github.com/$GIVEN_ORG_VALUE/*") .shouldContain("Maven Repo Username: ${GhCliAuthFake.DEFAULT_USER_VALUE}") .shouldContain("Maven Repo Password: ${GhCliAuthFake.DEFAULT_TOKEN_VALUE}") @@ -171,6 +180,58 @@ class GhCliAuthSettingsPluginFunctionalTest : GhCliAuthFunctionalTestSetup() { .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") } + @Test + fun `should use token from default gradle property when environment variable is not set`() { + val expectedToken = "ghp_property_default_${System.currentTimeMillis()}" + val missingEnvKey = "NON_EXISTENT_ENV_${System.currentTimeMillis()}" + + val output = project + .apply { + propertiesFile + .apply { appendText("\n") } + .appendText( + """ + $GH_ENV_KEY_SETTER_PROPERTY=$missingEnvKey + """.trimIndent() + ) + writeSettings() + } + .withArguments("help", "-P${DEFAULT_TOKEN_PROPERTY_KEY}=$expectedToken", "--debug") + .build() + .output + + output + .shouldContain("Attempting to use GitHub credentials from gradle property: $DEFAULT_TOKEN_PROPERTY_KEY") + .shouldContain("gh.cli.auth.token: $expectedToken") + } + + @Test + fun `should allow setting a custom gradle property key for the token`() { + val customPropKey = "my.custom.token.prop" + val expectedToken = "ghp_property_custom_${System.currentTimeMillis()}" + val missingEnvKey = "NON_EXISTENT_ENV_${System.currentTimeMillis()}" + + val output = project + .apply { + propertiesFile + .apply { appendText("\n") } + .appendText( + """ + $GH_ENV_KEY_SETTER_PROPERTY=$missingEnvKey + $GH_PROPERTY_KEY_SETTER_PROPERTY=$customPropKey + """.trimIndent() + ) + writeSettings() + } + .withArguments("help", "-P$customPropKey=$expectedToken", "--debug") + .build() + .output + + output + .shouldContain("Attempting to use GitHub credentials from gradle property: $customPropKey") + .shouldContain("gh.cli.auth.token: $expectedToken") + } + private fun writeSettings( pmReposBlock: String? = null, drmReposBlock: String? = null, diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt index f0d7808..ea3734f 100644 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -15,9 +15,11 @@ public abstract class GhCliAuthBase { const val GH_CLI_EXTENSION_NAME: String = "ghCliAuth" const val GH_ORG_SETTER_PROPERTY: String = "gh.cli.auth.github.org" const val GH_ENV_KEY_SETTER_PROPERTY: String = "gh.cli.auth.env.name" + const val GH_PROPERTY_KEY_SETTER_PROPERTY: String = "gh.cli.auth.property.name" const val GH_EXTRA_TOKEN_KEY: String = "gh.cli.auth.token" const val DEFAULT_TOKEN_ENV_KEY: String = "GITHUB_TOKEN" + const val DEFAULT_TOKEN_PROPERTY_KEY: String = "gpr.token" const val DEFAULT_TOKEN_USERNAME: String = "" } @@ -49,13 +51,27 @@ public abstract class GhCliAuthBase { logger.debug("Attempting to use GitHub credentials from environment variable: $tokenEnvKey") return@getOrPut credentialsFromEnv } else { - logger.debug("Falling back to gh CLI for GitHub credentials.") - return@getOrPut GhCliAuthProcessor - .create(this) - // We collect (i.e., `.get()`) the value before validation to ensure - // the final side effects of the provider are executed - .get() - .run(GhCliAuthParser::parse) + val tokenPropertyKey = this + .gradleProperty(GH_PROPERTY_KEY_SETTER_PROPERTY) + .orNull ?: DEFAULT_TOKEN_PROPERTY_KEY + + val credentialsFromProperty = this + .gradleProperty(tokenPropertyKey) + .orNull + .let { token -> if (!token.isNullOrBlank()) GhCredentials(DEFAULT_TOKEN_USERNAME, token) else null } + + if (credentialsFromProperty != null) { + logger.debug("Attempting to use GitHub credentials from gradle property: $tokenPropertyKey") + return@getOrPut credentialsFromProperty + } else { + logger.debug("Attempting to use GitHub credentials from gh CLI.") + return@getOrPut GhCliAuthProcessor + .create(this) + // We collect (i.e., `.get()`) the value before validation to ensure + // the final side effects of the provider are executed + .get() + .run(GhCliAuthParser::parse) + } } } From be66c92c4ce98f015e9da74a58118a058ee7239a Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 18:58:33 +0000 Subject: [PATCH 21/22] cleanup: remove unnecessary settings.gradle.kts in test resources --- plugin/src/functionalTest/resources/settings.gradle.kts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 plugin/src/functionalTest/resources/settings.gradle.kts diff --git a/plugin/src/functionalTest/resources/settings.gradle.kts b/plugin/src/functionalTest/resources/settings.gradle.kts deleted file mode 100644 index 8b13789..0000000 --- a/plugin/src/functionalTest/resources/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ - From 788cc1ab663db31ae02aa71261833014094c8a79 Mon Sep 17 00:00:00 2001 From: "uways.e" Date: Thu, 6 Nov 2025 19:18:12 +0000 Subject: [PATCH 22/22] docs: update README to enhance structure and clarify plugin usage --- README.md | 241 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 170 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 0abaa13..763d6e6 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,214 @@ -[![Gradle Plugin Portal](https://img.shields.io/gradle-plugin-portal/v/io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth.svg?label=gh-cli-auth%20Settings%20Plugin)](https://plugins.gradle.org/plugin/io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth) -[![Gradle Plugin Portal](https://img.shields.io/gradle-plugin-portal/v/io.github.adelinosousa.gradle.plugins.project.gh-cli-auth.svg?label=gh-cli-auth%20Project%20Plugin)](https://plugins.gradle.org/plugin/io.github.adelinosousa.gradle.plugins.project.gh-cli-auth) - # GitHub CLI Auth Gradle Plugin -Gradle plugin that automatically configures access to GitHub organization maven plugins and packages. Authenticates using credentials from GitHub CLI and removes the need to store personal access tokens (PATs) in your project, environment or gradle configuration. +[![Kotlin](https://img.shields.io/badge/Kotlin-%237F52FF.svg?logo=kotlin&logoColor=white)](#) +[![Continuous Integration](https://github.com/adelinosousa/gh-cli-auth/actions/workflows/pr-checks.yml/badge.svg)](https://github.com/adelinosousa/gh-cli-auth/actions/workflows/pr-checks.yml) +[![Gradle Plugin Portal](https://img.shields.io/gradle-plugin-portal/v/io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth.svg?label=Settings%20Plugin)](https://plugins.gradle.org/plugin/io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth) +[![Gradle Plugin Portal](https://img.shields.io/gradle-plugin-portal/v/io.github.adelinosousa.gradle.plugins.project.gh-cli-auth.svg?label=Project%20Plugin)](https://plugins.gradle.org/plugin/io.github.adelinosousa.gradle.plugins.project.gh-cli-auth) + +1. [Overview](#overview) +2. [Features](#features) +3. [Installation](#installation) + - [A) Settings Plugin (Recommended)](#a-settings-plugin-recommended) + - [B) Project Plugin (Per Project)](#b-project-plugin-per-project) +4. [Usage](#usage) + - [1) Required: Set Your GitHub Organization](#1-required-tell-the-plugin-which-organization-to-use) + - [2) Choose How to Provide Credentials](#2-choose-how-you-want-to-provide-credentials) +5. [Configuration Options](#configuration-options) + - [Token Resolution Order](#token-resolution-order) + - [GitHub CLI Scopes](#github-cli-scopes-cli-fallback) + - [Repository Configuration](#repository-thats-registered) +6. [CI Tips](#ci-tips) +7. [Troubleshooting](#troubleshooting) +8. [Limitations](#limitations) +9. [Contributing](#contributing) +10. [License](#license) + +## Overview + +**Zero‑boilerplate access to GitHub Packages (Maven) for your organization.** + +This plugin family configures the GitHub Packages Maven repository for your org and provides credentials automatically from one of three sources (in order): + +1. **Environment variable** (name configurable, default `GITHUB_TOKEN`) +2. **Gradle property** (key configurable, default `gpr.token`) +3. **GitHub CLI**: parses `gh auth status --show-token` (requires `read:packages, read:org`) + +> [!NOTE] +> This allows you to onboard this plugin to existing production CI/CD pipelines with minimal changes, while also supporting local development via the GitHub CLI. + +It works as a **settings** plugin (centralized repository management for the whole build) and/or a **project** plugin (per‑project repository + a `ghCliAuth` extension to read the token). + +## Features + +- Registers your authenticated GitHub Packages Maven repository for your organization automatically. +- Complete backwards compatibility with existing environment-based and Gradle property-based token provisioning. +- Ensures common “trusted” repos are present at settings level (added *only if missing*) such as Maven Central and Gradle Plugin Portal. +- Most importantly, No need to rely on hardcoded tokens local configs anymore, just use the GitHub CLI for local dev! -## Prerequisites +## Installation -You need to have [GitHub CLI](https://cli.github.com/) installed on your system and be logged in to your GitHub account: +You can use **either** plugin—or **both** together. -```bash -gh auth login --scopes "read:packages,read:org" +> [!TIP] +> **Recommendation:** In multi‑module builds (or when using `RepositoriesMode.FAIL_ON_PROJECT_REPOS`), prefer the **settings** plugin to centralize repository configuration. The **project** plugin declares repositories at project level and may conflict with `FAIL_ON_PROJECT_REPOS`. + +### A) Settings plugin (recommended) + +**Kotlin DSL – `settings.gradle.kts`** + +```kotlin +plugins { + id("io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth") version "2.0.0" +} +``` + +**Groovy DSL – `settings.gradle`** + +```groovy +plugins { + id 'io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth' version '2.0.0' +} ``` -If you're already logged in but don't have the required scopes, you can refresh your authentication using: +With the settings plugin applied, your build will have: + +- **GitHub Packages** repo for your org in both `pluginManagement` and `dependencyResolutionManagement`. +- **Default** repos added if missing: Gradle Plugin Portal, Google, Maven Central. +- A shared token available at `gradle.extra["gh.cli.auth.token"]`. + +### B) Project plugin (per project) -```bash -gh auth refresh --scopes "read:packages,read:org" +**Kotlin DSL – `build.gradle.kts`** + +```kotlin +plugins { + id("io.github.adelinosousa.gradle.plugins.project.gh-cli-auth") version "2.0.0" +} ``` -To check your current GitHub CLI authentication status, do: +**Groovy DSL – `build.gradle`** -```bash -gh auth status +```groovy +plugins { + id 'io.github.adelinosousa.gradle.plugins.project.gh-cli-auth' version '2.0.0' +} ``` +With the project plugin applied, your project will have: +- **GitHub Packages** repo for your org at `project.repositories`. +- The **`ghCliAuth`** extension exposing the token: + - Kotlin: `val token: String? = extensions.getByName("ghCliAuth") as io.github.adelinosousa.gradle.extensions.GhCliAuthExtension; token.token.get()` + - Groovy: `def token = extensions.getByName("ghCliAuth").token.get()` + ## Usage -This plugin is split into two: one for `settings` and the other for `project`. Depending on your solution repository management, you can choose to use either one or both. +### 1) Required: Tell the plugin which **organization** to use -1. Setup for `settings`: +Add this to your **`gradle.properties`** (root of the build): - In your `settings.gradle` file, add the following: +```properties +gh.cli.auth.github.org= +```` - ```shell - # settings.gradle - - # (optional) ensure that the repositories are resolved from settings.gradle - dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) - } +### 2) Choose how you want to provide credentials - plugins { - id 'io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth' version '1.1.0' - } - ``` - - If you need to consume the token in your own settings plugin, you can access it via gradle extra properties: +You can do nothing (and rely on the GitHub CLI path below), or pick one of these: - ```shell - if (settings.gradle.extra.has("gh.cli.auth.token")) { - val ghToken = settings.gradle.extra["gh.cli.auth.token"] as String - } - ``` +- **Environment variable** (fastest for CI): + - Leave default: export `GITHUB_TOKEN` + - Or choose a name: set `gh.cli.auth.env.name` in `gradle.properties` and export that variable. -2. Setup for `project`: +- **Gradle property** (CLI args or `gradle.properties`): + - Leave default key: `gpr.token` + - Or choose a key: set `gh.cli.auth.property.name` and pass `-P=` (or define it in `gradle.properties`). - In your `build.gradle` file, add the following: +- **GitHub CLI** fallback: + - Make sure `gh` is installed and authenticated with the required scopes: - ```shell - # build.gradle + ```bash + gh auth login --scopes "read:packages,read:org" + # or, if already logged in: + gh auth refresh --scopes "read:packages,read:org" + gh auth status + ``` - plugins { - id 'io.github.adelinosousa.gradle.plugins.project.gh-cli-auth' version '1.1.0' - } - ``` +> [!WARNING] +> If both ENV and Gradle property are absent, the plugin automatically falls back to the GitHub CLI route. - This plugin exposes a `ghCliAuth` extension to access the token, if needed: +## Configuration Options - ```shell - # build.gradle +| Key / Surface | Where to set/read | Default | Purpose | +|-------------------------------------|-------------------------------------|----------------|--------------------------------------------------------------------------------------------------------------------------------| +| `gh.cli.auth.github.org` | `gradle.properties` | **(required)** | GitHub Organization used to build the repo URL and name the repo entry (`https://maven.pkg.github.com//*`). | +| `gh.cli.auth.env.name` | `gradle.properties` | `GITHUB_TOKEN` | Name of the environment variable the plugin checks **first** for the token. | +| `gh.cli.auth.property.name` | `gradle.properties` | `gpr.token` | Name of the Gradle property the plugin checks **second** for the token (e.g., pass `-Pgpr.token=...` or define in properties). | +| `gradle.extra["gh.cli.auth.token"]` | **read** in `settings.gradle(.kts)` | n/a | Token shared by the **settings** plugin for use by other settings logic/plugins. | +| `ghCliAuth.token` | **read** in `build.gradle(.kts)` | n/a | Token exposed by the **project** plugin’s extension. | +| `-Dgh.cli.binary.path=/path/to/gh` | JVM/system property | auto‑detect | Override the `gh` binary path used by the CLI fallback. Useful for custom installs (e.g., Homebrew prefix, Nix). | - val ghToken = ghCliAuth.token.get() - ``` +### Token resolution order -### Important Notes -- When using both plugins, ensure that you **only** apply the plugin version to settings plugin block and not to the project plugin block, as it will lead to a conflict. -- You won't be able to obtain GitHub token from the `ghCliAuth` extension if you're setting `RepositoriesMode` as `FAIL_ON_PROJECT_REPOS`, as it is only _currently_ available in the `project` plugin. -- By default, the settings plugin will configure default repositories for plugins and dependencies (google, mavenCentral, gradlePluginPortal). This is to ensure default repositories are always available. +``` +ENV (name = gh.cli.auth.env.name, default GITHUB_TOKEN) + └── if unset/empty → GRADLE PROPERTY (key = gh.cli.auth.property.name, default gpr.token) + └── if unset/empty → GitHub CLI: gh auth status --show-token +``` -### Configuration +### GitHub CLI scopes (CLI fallback): -Regardless of which one you use, you need to specify your GitHub **organization**, where the plugins or packages are hosted, in the `gradle.properties` file: +Below is the required scopes for the token retrieved via the GitHub CLI: -```properties -# gradle.properties +- `read:packages` +- `read:org` -gh.cli.auth.github.org= -``` +If the token lacks these scopes, the plugin will fail with an error message prompting you to refresh your authentication. -You can also specify custom environment variable name for the GitHub CLI authentication token. Defaults `GITHUB_TOKEN`. +### Repository that’s registered: -```properties -# gradle.properties +`https://maven.pkg.github.com//*` (name = ``), with credentials automatically supplied by the selected token source. -gh.cli.auth.env.name= -``` +> [!NOTE] +> Note on username: when the **CLI** path is used, the plugin extracts your GitHub login and uses it as the repository credential username; when **ENV/Gradle property** is used, the username is left empty. + +## CI tips -**NOTE**: Environment variable takes precedence over the GitHub CLI token mechanism. GitHub CLI is used as a fallback if the environment variable is not set. -This is by design, to ensure that the plugin remains performant and skips unnecessary checks/steps during CI/CD runs. +- **GitHub Actions**: the default `GITHUB_TOKEN` environment variable is already present → no extra config needed; just set `gh.cli.auth.github.org`. +- **Local development**: Rely on the GitHub CLI route (make sure you’ve logged in with the correct scopes). -## Notes +## Troubleshooting -Currently **not** supported: +- **“Please set `gh.cli.auth.github.org` in gradle.properties.”** + Add `gh.cli.auth.github.org=` to `gradle.properties`. -- Dedicated GitHub Enterprise Servers or Hosts -- Profile selection (the plugin uses the default from `gh`) -- Only Maven repositories are supported (no Ivy or other types) +- **“GitHub CLI token is missing required scopes …”** + Run: + + ```bash + gh auth refresh --scopes "read:packages,read:org" + gh auth status + ``` + +- **Custom `gh` install not found** + Point the plugin at your binary: + + ``` + ./gradlew -Dgh.cli.binary.path=/absolute/path/to/gh + ``` + +- **Using `RepositoriesMode.FAIL_ON_PROJECT_REPOS`** + Prefer the **settings** plugin (the project plugin adds repositories at the project level and may conflict with this mode). + +## Limitations + +- Only **Maven** repositories are configured. +- GitHub **Enterprise**/custom hosts and CLI **profile selection** are not supported; the CLI path expects `github.com` default auth. ## Contributing -Contributions are welcome! Please read the contributing [guidelines](CONTRIBUTING.md). + +PRs and issues are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). ## License + This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) for details. + +___ \ No newline at end of file