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 }} 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 diff --git a/README.md b/README.md index 1b97a9a..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 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97fb257..69e1297 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,9 @@ [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] -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 a0c7cec..0eed7d1 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -1,71 +1,74 @@ -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" -version = System.getenv("GRADLE_PUBLISH_VERSION") ?: project.findProperty("gradle.publish.version") ?: "1.0.1" -kotlin { - explicitApi = ExplicitApiMode.Strict +version = System + .getenv("GRADLE_PUBLISH_VERSION") + ?: requireNotNull(project.property("gradle.publish.version")) + +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") } 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) } + kotlin.target.compilations { named { it == this@configure.name }.configureEach { associateWith(getByName("main")) } } +} + +/** + * 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 { 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" - 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")) } - 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" - 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() -} 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..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 @@ -1,61 +1,117 @@ 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()}" + val badPropertyKey = "non.existent.property.key.${System.currentTimeMillis()}" + + propertiesFile + .appendText( + "\n" + + "gh.cli.auth.env.name=$badEnvKey\n" + + "gh.cli.auth.property.name=$badPropertyKey\n" + ) + + project + .withArguments("printGhCliAuthToken", "--debug") .build() + .output + .shouldContain("Attempting to use GitHub credentials from gh CLI.") + .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..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,171 +1,287 @@ 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_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 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') + @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 + + output + .shouldContain("Attempting to use GitHub credentials from environment variable: $DEFAULT_TOKEN_ENV_KEY") + .shouldContain("gh.cli.auth.token: $tokenFromDefaultEnv") + } + + @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("Registering Maven GitHub repository for organization: test-org")) + 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 pluginManagement`() { - settingsFile.writeText(""" - pluginManagement { - repositories { - gradlePluginPortal() - mavenCentral() - } + @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() + ) } - - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') + .withArguments("help", "--info") + .withEnvironment(mapOf(customKey to expectedToken)) + .build() + .output + + output + .shouldContain("gh.cli.auth.token: $expectedToken") + .shouldContain("gh.cli.auth.token (read by another settings plugin): $expectedToken") + } + + @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( + "\n" + + "$GH_ENV_KEY_SETTER_PROPERTY=$missingKey\n" + + "$GH_PROPERTY_KEY_SETTER_PROPERTY=$missingPropKey\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() + ) } - - dependencyResolutionManagement { - repositories { - gradlePluginPortal() - google() - mavenCentral() - } + .withArguments("help", "--debug") + .build() + .output + + output + .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}") + } + + @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() + ) + + val output = project + .withArguments("help", "--info") + .build() + .output + + output + .shouldContain("Adding Google repository") + .shouldContain("Registering GitHub Packages maven repository for organization: $GIVEN_ORG_VALUE") + } + + @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() + ) + + 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") + } + + @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() } - """.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", "-P${DEFAULT_TOKEN_PROPERTY_KEY}=$expectedToken", "--debug") .build() + .output - assertTrue(result.output.contains("Adding Google repository")) + output + .shouldContain("Attempting to use GitHub credentials from gradle property: $DEFAULT_TOKEN_PROPERTY_KEY") + .shouldContain("gh.cli.auth.token: $expectedToken") } - @Test fun `correct number of repositories are configured for dependencyResolutionManagement`() { - settingsFile.writeText(""" + @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, + afterEvalExtra: String = "", + ) { + val pm = pmReposBlock?.let { + """ pluginManagement { repositories { - gradlePluginPortal() - google() - mavenCentral() + $it } } - - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') - } - + """.trimIndent() + }.orEmpty() + + val drm = drmReposBlock?.let { + """ + @Suppress("UnstableApiUsage") dependencyResolutionManagement { repositories { - google() - mavenCentral() + $it } } - """.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") - .build() + """.trimIndent() + }.orEmpty() - assertTrue(result.output.contains("Adding Gradle Plugin Portal repository")) - } - - @Test fun `runs plugin with custom env variable name`() { 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') + id("io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth") } - """.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")) - .build() - - assertTrue(result.output.contains("Registering Maven GitHub repository for organization: test-org")) - } - - @Test fun `plugin shares token with other settings plugins`() { - val tokenName = """gh-cli-auth-token""" - val expectedToken = "ghp_exampletoken1234567890" - settingsFile.writeText( - """ - plugins { - id('io.github.adelinosousa.gradle.plugins.settings.gh-cli-auth') + // Helper to verify final state *after* settings have been evaluated + fun afterSettingsEvaluate(action: Settings.() -> Unit) { + gradle.settingsEvaluated { action(this) } } - - // 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") + + // 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() + """.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 expectedToken)) - .build() - - assertTrue(result.output.contains("$tokenName: $expectedToken")) } } 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 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/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 @@ - 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/github/GhCliAuthParser.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt new file mode 100644 index 0000000..cd82163 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthParser.kt @@ -0,0 +1,47 @@ +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(GH_CLI_EXTENSION_NAME) + 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/github/GhCliAuthProcessor.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt new file mode 100644 index 0000000..2518730 --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/github/GhCliAuthProcessor.kt @@ -0,0 +1,76 @@ +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 + + 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 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" + } + } + } +} \ No newline at end of file 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/plugins/Config.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Config.kt deleted file mode 100644 index 7f70f36..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Config.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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/plugins/Environment.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Environment.kt deleted file mode 100644 index 44ee680..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/Environment.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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/plugins/GhCliAuth.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuth.kt deleted file mode 100644 index 0dce650..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuth.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -import org.gradle.api.GradleException -import org.gradle.api.provider.Provider - -internal object GhCliAuth { - 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) { - println("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/plugins/GhCliAuthBase.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt new file mode 100644 index 0000000..ea3734f --- /dev/null +++ b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthBase.kt @@ -0,0 +1,104 @@ +package io.github.adelinosousa.gradle.plugins + +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 +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" + 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 = "" + } + + 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 { + 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) + } + } + } + + 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 maven repository for organization: ${providers.githubOrg}") + maven { + name = providers.githubOrg + 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 77df259..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,54 +1,23 @@ package io.github.adelinosousa.gradle.plugins -import org.gradle.api.Project +import io.github.adelinosousa.gradle.extensions.GhCliAuthExtension import org.gradle.api.Plugin -import org.gradle.api.logging.Logging -import org.gradle.api.provider.Property - -internal 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 githubOrg = getGradleProperty(project, Config.GITHUB_ORG) - val gitEnvTokenName = getGradleProperty(project, Config.ENV_PROPERTY_NAME) ?: "GITHUB_TOKEN" + val provider = project.providers - if (githubOrg.isNullOrEmpty()) { - throw IllegalStateException("GitHub organization not specified. Please set the '${Config.GITHUB_ORG}' in your gradle.properties file.") - } + val extension = (project + .extensions.findByType(GhCliAuthExtension::class.java) + ?: project.extensions.create(GH_CLI_EXTENSION_NAME, GhCliAuthExtension::class.java)) - 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(GitHubCLIProcess::class.java) {} - val output = GhCliAuth.checkGhCliAuthenticatedWithCorrectScopes(authStatusProvider) - return GhCliAuth.getGitHubCredentials(output.get()) - } + extension + .token + .set(provider.credentials.token) - private fun getGradleProperty(project: Project, propertyName: String): String? { - return project.providers.gradleProperty(propertyName).orNull + project + .repositories + .addUserConfiguredOrgGhPackagesRepository(provider) } } - -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..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,70 +1,27 @@ package io.github.adelinosousa.gradle.plugins 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 - -internal 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) - 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(GitHubCLIProcess::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/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt deleted file mode 100644 index 0963c8a..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/GitHubCLIProcess.kt +++ /dev/null @@ -1,45 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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 - -internal abstract class GitHubCLIProcess : 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" - } -} - diff --git a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/RepositoryCredentials.kt b/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/RepositoryCredentials.kt deleted file mode 100644 index 3695829..0000000 --- a/plugin/src/main/kotlin/io/github/adelinosousa/gradle/plugins/RepositoryCredentials.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -internal 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/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/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 0f15a8c..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthProjectPluginTest.kt +++ /dev/null @@ -1,156 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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 1018caa..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthSettingsPluginTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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 2f26995..0000000 --- a/plugin/src/test/kotlin/io/github/adelinosousa/gradle/plugins/GhCliAuthTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package io.github.adelinosousa.gradle.plugins - -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 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" } + } +} 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")