Repository: typesafegithub/github-workflows-kt
Pull request #1047 (2023-10-26 18:24:21)

[#941] feat(abg): add util for generating bindings from workflow
Thanks to this, the users will be able to have a YAML file a dummy workflow listing actions that they use in their Kotlin-based workflows, and then use this util function to generate required bindings.

This is not the final API, rather some part of the future plumbing that I'm exposing for now for experimentation.


Patch:
diff --git a/automation/action-binding-generator/api/action-binding-generator.api b/automation/action-binding-generator/api/action-binding-generator.api
index 6b69b3b41..9a052134e 100644
--- a/automation/action-binding-generator/api/action-binding-generator.api
+++ b/automation/action-binding-generator/api/action-binding-generator.api
@@ -49,6 +49,10 @@ public final class io/github/typesafegithub/workflows/actionbindinggenerator/Com
 	public fun toString ()Ljava/lang/String;
 }
 
+public final class io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowKt {
+	public static final fun extractUsedActionsFromWorkflow (Ljava/lang/String;)Ljava/util/List;
+}
+
 public final class io/github/typesafegithub/workflows/actionbindinggenerator/FromLockfile : io/github/typesafegithub/workflows/actionbindinggenerator/MetadataRevision {
 	public static final field INSTANCE Lio/github/typesafegithub/workflows/actionbindinggenerator/FromLockfile;
 	public fun equals (Ljava/lang/Object;)Z
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
new file mode 100644
index 000000000..e39e4b189
--- /dev/null
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflow.kt
@@ -0,0 +1,34 @@
+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
+            }
+        }
+
+    return usesStrings.map { it.toActionCoords() }
+}
+
+private fun String.toActionCoords(): ActionCoords {
+    val (owner, name, version) = this.split('/', '@')
+    return ActionCoords(
+        owner = owner,
+        name = name,
+        version = version,
+    )
+}
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
new file mode 100644
index 000000000..858d4f51c
--- /dev/null
+++ b/automation/action-binding-generator/src/main/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/GitHubWorkflowModel.kt
@@ -0,0 +1,18 @@
+package io.github.typesafegithub.workflows.actionbindinggenerator
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+internal data class Workflow(
+    val jobs: Map<String, Job>,
+)
+
+@Serializable
+internal data class Job(
+    val steps: List<Step>,
+)
+
+@Serializable
+internal data class Step(
+    val uses: String?,
+)
diff --git a/library/api/library.api b/library/api/library.api
index 536a137d1..a4c9ddd20 100644
--- a/library/api/library.api
+++ b/library/api/library.api
@@ -3051,10 +3051,10 @@ public final class io/github/typesafegithub/workflows/yaml/Preamble$WithOriginal
 }
 
 public final class io/github/typesafegithub/workflows/yaml/ToYamlKt {
-	public static final fun toYaml (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;)Ljava/lang/String;
-	public static synthetic fun toYaml$default (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;ILjava/lang/Object;)Ljava/lang/String;
-	public static final fun writeToFile (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;)V
-	public static synthetic fun writeToFile$default (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;ILjava/lang/Object;)V
+	public static final fun toYaml (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;Z)Ljava/lang/String;
+	public static synthetic fun toYaml$default (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;ZILjava/lang/Object;)Ljava/lang/String;
+	public static final fun writeToFile (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;Z)V
+	public static synthetic fun writeToFile$default (Lio/github/typesafegithub/workflows/domain/Workflow;ZLjava/nio/file/Path;Lio/github/typesafegithub/workflows/yaml/Preamble;ZILjava/lang/Object;)V
 }
 
 public final class io/github/typesafegithub/workflows/yaml/TriggersToYamlKt {
diff --git a/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ToYaml.kt b/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ToYaml.kt
index f2f6e3250..7c75fbd84 100644
--- a/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ToYaml.kt
+++ b/library/src/main/kotlin/io/github/typesafegithub/workflows/yaml/ToYaml.kt
@@ -36,12 +36,14 @@ public fun Workflow.toYaml(
     addConsistencyCheck: Boolean = sourceFile != null,
     gitRootDir: Path? = sourceFile?.absolute()?.findGitRoot(),
     preamble: Preamble? = null,
+    generateActionBindings: Boolean = false,
 ): String {
     return generateYaml(
         addConsistencyCheck = addConsistencyCheck,
         useGitDiff = false,
         gitRootDir = gitRootDir,
         preamble,
+        generateActionBindings = generateActionBindings,
     )
 }
 
@@ -63,6 +65,7 @@ public fun Workflow.writeToFile(
     addConsistencyCheck: Boolean = sourceFile != null,
     gitRootDir: Path? = sourceFile?.absolute()?.findGitRoot(),
     preamble: Preamble? = null,
+    generateActionBindings: Boolean = false,
 ) {
     checkNotNull(gitRootDir) {
         "gitRootDir must be specified explicitly when sourceFile is null"
@@ -78,6 +81,7 @@ public fun Workflow.writeToFile(
             useGitDiff = true,
             gitRootDir = gitRootDir,
             preamble,
+            generateActionBindings = generateActionBindings,
         )
 
     gitRootDir.resolve(".github").resolve("workflows").resolve(targetFileName).toFile().let {
@@ -102,6 +106,7 @@ private fun Workflow.generateYaml(
     useGitDiff: Boolean,
     gitRootDir: Path?,
     preamble: Preamble?,
+    generateActionBindings: Boolean,
 ): String {
     val sourceFilePath =
         gitRootDir?.let {
@@ -131,7 +136,9 @@ private fun Workflow.generateYaml(
                 ) {
                     uses(name = "Check out", action = CheckoutV4())
 
-                    if (sourceFile.parent?.resolve(GENERATE_ACTION_BINDINGS_SCRIPT_NAME)?.exists() == true) {
+                    if (generateActionBindings &&
+                        sourceFile.parent?.resolve(GENERATE_ACTION_BINDINGS_SCRIPT_NAME)?.exists() == true
+                    ) {
                         run(
                             name = "Generate action bindings",
                             command = ".github/workflows/$GENERATE_ACTION_BINDINGS_SCRIPT_NAME",


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
new file mode 100644
index 000000000..8d791be80
--- /dev/null
+++ b/automation/action-binding-generator/src/test/kotlin/io/github/typesafegithub/workflows/actionbindinggenerator/ExtractUsedActionsFromWorkflowTest.kt
@@ -0,0 +1,47 @@
+package io.github.typesafegithub.workflows.actionbindinggenerator
+
+import io.kotest.core.spec.style.FunSpec
+import io.kotest.matchers.shouldBe
+
+class ExtractUsedActionsFromWorkflowTest : FunSpec({
+    test("parses valid manifest") {
+        // Given
+        val manifest =
+            """
+            on:
+              push:
+                branches: [this-branch-must-never-be-created]
+
+            jobs:
+              some-job:
+                runs-on: ubuntu-latest
+                steps:
+                  - uses: actions/checkout@v3
+                  - uses: actions/setup-java@main
+                  - uses: typesafegithub/github-actions-typing@d34db33f
+            """.trimIndent()
+
+        // When
+        val actionCoords = extractUsedActionsFromWorkflow(manifest)
+
+        // Then
+        actionCoords shouldBe
+            listOf(
+                ActionCoords(owner = "actions", name = "checkout", version = "v3"),
+                ActionCoords(owner = "actions", name = "setup-java", version = "main"),
+                ActionCoords(owner = "typesafegithub", name = "github-actions-typing", version = "d34db33f"),
+            )
+    }
+
+    // TODO nested actions, i.e. where names have some "/"
+
+    // TODO: steps that don't have "uses"
+
+    // TODO: steps using other kinds of actions, like Docker-based or local ones
+
+    // TODO: malformed YAML
+
+    // TODO: no jobs
+
+    // TODO: multiple versions of the same action
+})