Repository: typesafegithub/github-workflows-kt
Pull request #1083 (2023-11-09 06:31:58)

[#1069] feat(abg): cover edge cases in extractUsedActionsFromWorkflow


Patch:
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ActionCoords.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ActionCoords.kt
index b98371acc..511f01ecd 100644
--- a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ActionCoords.kt
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ActionCoords.kt
@@ -14,3 +14,13 @@ public data class ActionCoords(
 public val ActionCoords.isTopLevel: Boolean get() = "/" !in name
 
 public val ActionCoords.prettyPrint: String get() = "$owner/$name@$version"
+
+internal fun String.toActionCoords(): ActionCoords {
+    val (ownerAndName, version) = this.split('@')
+    val (owner, name) = ownerAndName.split('/', limit = 2)
+    return ActionCoords(
+        owner = owner,
+        name = name,
+        version = version,
+    )
+}
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflow.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflow.kt
index e39e4b189..88e4acdcf 100644
--- a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflow.kt
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflow.kt
@@ -1,34 +1,37 @@
 package io.github.typesafegithub.workflows.actionbindinggenerator
 
-import com.charleskorn.kaml.Yaml
 import kotlinx.serialization.decodeFromString
 
-// TODO: cover edge cases in scope of https://github.com/typesafegithub/github-workflows-kt/issues/941
-//  See TODOs in unit tests.
 public fun extractUsedActionsFromWorkflow(manifest: String): List<ActionCoords> {
-    val myYaml =
-        Yaml(
-            configuration =
-                Yaml.default.configuration.copy(
-                    strictMode = false,
-                ),
-        )
-    val parsedWorkflow = myYaml.decodeFromString<Workflow>(manifest)
-    val usesStrings =
-        parsedWorkflow.jobs.flatMap {
-            it.value.steps.mapNotNull { step ->
-                step.uses
-            }
+    val parsedWorkflow = parseWorkflow(manifest)
+    val usesStrings = extractUsesStrings(parsedWorkflow)
+    return usesStrings
+        .map { it.toActionCoords() }
+        .also { assertSingleVersionForEachAction(it) }
+}
+
+private fun extractUsesStrings(parsedWorkflow: Workflow) =
+    parsedWorkflow.jobs?.flatMap {
+        it.value.steps.mapNotNull { step ->
+            step.uses
         }
+    } ?: emptyList()
 
-    return usesStrings.map { it.toActionCoords() }
-}
+private fun parseWorkflow(manifest: String) =
+    try {
+        myYaml.decodeFromString<Workflow>(manifest)
+    } catch (e: Throwable) {
+        throw IllegalArgumentException("The YAML is invalid: ${e.message}")
+    }
 
-private fun String.toActionCoords(): ActionCoords {
-    val (owner, name, version) = this.split('/', '@')
-    return ActionCoords(
-        owner = owner,
-        name = name,
-        version = version,
-    )
+private fun assertSingleVersionForEachAction(actionCoords: List<ActionCoords>) {
+    val actionsWithMultipleVersions =
+        actionCoords
+            .groupingBy { "${it.owner}/${it.name}" }
+            .eachCount()
+            .filterValues { it > 1 }
+            .keys
+    if (actionsWithMultipleVersions.isNotEmpty()) {
+        throw IllegalArgumentException("Multiple versions defined for actions: $actionsWithMultipleVersions")
+    }
 }
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/GitHubWorkflowModel.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/GitHubWorkflowModel.kt
index a33f9a134..a8d9d67ae 100644
--- a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/GitHubWorkflowModel.kt
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/GitHubWorkflowModel.kt
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
 
 @Serializable
 internal data class Workflow(
-    val jobs: Map<String, Job>,
+    val jobs: Map<String, Job>? = emptyMap(),
 )
 
 @Serializable
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/MetadataReading.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/MetadataReading.kt
index 613cb352d..0fb9b2d17 100644
--- a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/MetadataReading.kt
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/MetadataReading.kt
@@ -1,6 +1,5 @@
 package io.github.typesafegithub.workflows.actionbindinggenerator
 
-import com.charleskorn.kaml.Yaml
 import kotlinx.serialization.Serializable
 import kotlinx.serialization.decodeFromString
 import java.io.File
@@ -94,14 +93,6 @@ private fun ActionCoords.getCommitHashFromFileSystem(): String =
 
 internal fun fetchUri(uri: URI): String = uri.toURL().readText()
 
-private val myYaml =
-    Yaml(
-        configuration =
-            Yaml.default.configuration.copy(
-                strictMode = false,
-            ),
-    )
-
 private val actionYamlDir: File = File("build/action-yaml")
 
 public fun deleteActionYamlCacheIfObsolete() {
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
index 6f03519a1..923d339ef 100644
--- a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/TypesProviding.kt
@@ -144,11 +144,3 @@ private inline fun <reified T> Yaml.decodeFromStringOrDefaultIfEmpty(
     } else {
         default
     }
-
-private val myYaml =
-    Yaml(
-        configuration =
-            Yaml.default.configuration.copy(
-                strictMode = false,
-            ),
-    )
diff --git a/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/YamlParsingConfig.kt b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/YamlParsingConfig.kt
new file mode 100644
index 000000000..d970e9629
--- /dev/null
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/YamlParsingConfig.kt
@@ -0,0 +1,11 @@
+package io.github.typesafegithub.workflows.actionbindinggenerator
+
+import com.charleskorn.kaml.Yaml
+
+internal val myYaml =
+    Yaml(
+        configuration =
+            Yaml.default.configuration.copy(
+                strictMode = false,
+            ),
+    )


Test patch:
diff --git a/automation/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowTest.kt b/automation/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowTest.kt
index e111d186b..afb2de37f 100644
--- a/automation/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowTest.kt
+++ b/automation/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowTest.kt
@@ -1,7 +1,11 @@
 package io.github.typesafegithub.workflows.actionbindinggenerator
 
+import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.should
 import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.startWith
+import java.lang.IllegalArgumentException
 
 class ExtractUsedActionsFromWorkflowTest : FunSpec({
     test("parses valid manifest just with 'uses' steps") {
@@ -33,7 +37,33 @@ class ExtractUsedActionsFromWorkflowTest : FunSpec({
             )
     }
 
-    test("workflow with not only with 'uses' steps") {
+    test("nested actions") {
+        // Given
+        val manifest =
+            """
+            on:
+              push:
+
+            jobs:
+              some-job:
+                runs-on: ubuntu-latest
+                steps:
+                  - uses: actions/checkout@v3
+                  - uses: actions/cache/restore@v5
+            """.trimIndent()
+
+        // When
+        val actionCoords = extractUsedActionsFromWorkflow(manifest)
+
+        // Then
+        actionCoords shouldBe
+            listOf(
+                ActionCoords(owner = "actions", name = "checkout", version = "v3"),
+                ActionCoords(owner = "actions", name = "cache/restore", version = "v5"),
+            )
+    }
+
+    test("workflow with not only 'uses' steps") {
         // Given
         val manifest =
             """
@@ -60,13 +90,77 @@ class ExtractUsedActionsFromWorkflowTest : FunSpec({
             )
     }
 
-    // TODO nested actions, i.e. where names have some "/"
+    test("multiple versions of the same action") {
+        // Given
+        val manifest =
+            """
+            on:
+              push:
 
-    // TODO: steps using other kinds of actions, like Docker-based or local ones
+            jobs:
+              some-job:
+                runs-on: ubuntu-latest
+                steps:
+                  - uses: actions/checkout@v3
+                  - uses: actions/setup-java@v4
+                  - uses: actions/checkout@v5
+            """.trimIndent()
 
-    // TODO: malformed YAML
+        // Then
+        shouldThrow<IllegalArgumentException> {
+            // When
+            extractUsedActionsFromWorkflow(manifest)
+        }.also {
+            it.message should startWith("Multiple versions defined for actions: [actions/checkout]")
+        }
+    }
 
-    // TODO: no jobs
+    test("workflow with zero jobs") {
+        // Given
+        val manifest =
+            """
+            on:
+              push:
+
+            jobs:
+            """.trimIndent()
+
+        // When
+        val actionCoords = extractUsedActionsFromWorkflow(manifest)
+
+        // Then
+        actionCoords shouldBe emptyList()
+    }
 
-    // TODO: multiple versions of the same action
+    test("workflow with no 'jobs' key") {
+        // Given
+        val manifest =
+            """
+            on:
+              push:
+            """.trimIndent()
+
+        // When
+        val actionCoords = extractUsedActionsFromWorkflow(manifest)
+
+        // Then
+        actionCoords shouldBe emptyList()
+    }
+
+    test("malformed YAML") {
+        // Given
+        val manifest =
+            """
+            on:--
+              push:
+            """.trimIndent()
+
+        // Then
+        shouldThrow<IllegalArgumentException> {
+            // When
+            extractUsedActionsFromWorkflow(manifest)
+        }.also {
+            it.message should startWith("The YAML is invalid:")
+        }
+    }
 })