Convert-BuildStep.ps1

function Convert-BuildStep
{
    <#
    .Synopsis
        Converts Build Steps into build system input
    .Description
        Converts Build Steps defined in a PowerShell script into build steps in a build system
    .Example
        Get-Command Convert-BuildStep | Convert-BuildStep
    .Link
        Import-BuildStep
    .Link
        Expand-BuildStep
    #>

    [CmdletBinding(DefaultParameterSetName='ScriptBlock')]
    [OutputType([Collections.IDictionary])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseOutputTypeCorrectly", "",
        Justification="ScriptAnalyzer False Positive")]
    param(
    # The name of the build step
    [Parameter(Mandatory,ValueFromPipelineByPropertyName)]
    [string]
    $Name,

    # The Script Block that will be converted into a build step
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='ScriptBlock',ValueFromPipeline)]
    [ScriptBlock]
    $ScriptBlock,

    # The module that -ScriptBlock is declared in. If piping in a command, this will be bound automatically
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='ScriptBlock')]
    [Management.Automation.PSModuleInfo]
    $Module,

    # The path to the file
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='ScriptBlock')]
    [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='PathAndExtension')]
    [Alias('Fullname')]
    [string]
    $Path,

    # The extension of the file
    [Parameter(Mandatory,ValueFromPipelineByPropertyName,ParameterSetName='PathAndExtension')]
    [string]
    $Extension,

    # The name of parameters that should be supplied from event input.
    # Wildcards accepted.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('InputParameters')]
    [Collections.IDictionary]
    $InputParameter,

    # The name of parameters that should be supplied from build variables.
    # Wildcards accepted.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('VariableParameters')]
    [string[]]
    $VariableParameter,

    # The name of parameters that should be supplied from the environment.
    # Wildcards accepted.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('EnvironmentParameters')]
    [string[]]
    $EnvironmentParameter,

    # The name of parameters that should be referred to uniquely.
    # For instance, if converting function foo($bar) {} and -UniqueParameter is 'bar'
    # The build parameter would be foo_bar.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('UniqueParameters')]
    [string[]]
    $UniqueParameter,

    # The name of parameters that should be excluded.
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('ExcludeParameters')]
    [string[]]
    $ExcludeParameter,

    # Default parameters for a build step
    [Parameter(ValueFromPipelineByPropertyName)]
    [Collections.IDictionary]
    $DefaultParameter = @{},

    # The build system. Currently supported options, ADO and GitHub. Defaulting to ADO.
    [ValidateSet('ADOPipeline', 'ADOExtension','GitHubWorkflow','GitHubAction')]
    [string]
    $BuildSystem = 'ADOPipeline',

    # Options for the build system. The can contain any additional parameters passed to the build system.
    [PSObject]
    $BuildOption
    )

    begin {
        $MatchesAnyWildcard = {
            param([string[]]$text, [string[]]$Wildcard)
            foreach ($t in $text) {
                foreach ($wc in $Wildcard) {
                    if ($t -like $wc) {return $t }
                }
            }
            return $false
        }
    }

    process {
        # If we have been given a path and an extension,
        if ($PSCmdlet.ParameterSetName -eq 'PathAndExtension') {
            if ($Extension -eq '.ps1') # and that extension is .ps1
            {
                $splatMe=  @{} + $PSBoundParameters # then we recursively call ourselves.
                $splatMe.Remove('Path') # Before we do, take out the -Path and
                $splatMe.Remove('Extension') # -Extension parameters.
                Get-Item -LiteralPath $path |# Get the script file,
                    Get-Command { $_.FullName } | # resolve it to a command
                    Convert-BuildStep @splatMe    # pipe that input to ourselves.
            }
            elseif ($Extension -eq '.sh') # The other extension we know how to deal with is .sh
            {
                $shellScript = Get-Content -LiteralPath $path -Raw
                if ($BuildSystem -eq 'ADOPipeline') { # If the buildsystem is Azure DevOps
                    [Ordered]@{
                        bash= $shellScript
                        displayName=$Name
                    } # echo out a bash: step.
                } elseif ($BuildSystem -in 'GitHubWorkflow', 'GitHubAction') {
                    [Ordered]@{
                        name=$Name
                        run=$shellScript
                        shell='bash'
                    }
                }

            }
            elseif ($Extension -eq '.py') {
                $pythonScript = Get-Content -LiteralPath $path -Raw
                if ($BuildSystem -eq 'ADOPipeline') {
                    [Ordered]@{
                        task = 'PythonScript@0'
                        inputs = [Ordered]@{
                            scriptSource = 'inline'
                            script = $pythonScript
                        }
                    }
                }
                elseif ($BuildSystem -in 'GitHubWorkflow','GitHubAction') {
                    [Ordered]@{
                        name = $Name
                        run = $pythonScript
                        shell = 'python'
                    }
                }
            }
            return
        }
        $innerScript = "$ScriptBlock"

        $sbParams = # Determine if script block had parameters, by examining AST.
            if ($ScriptBlock.Ast.ParamBlock) {
                $ScriptBlock.Ast.ParamBlock
            } elseif ($ScriptBlock.ast.Body.ParamBlock) {
                $ScriptBlock.Ast.Body.ParamBlock
            }
        $definedParameters = @()
        $eventParameters   = @{}
        if ($sbParams) { # If it had parameters,
            $executionContext.SessionState.PSVariable.set('function:_TempFunction', $ScriptBlock) # create a temporary function
            $tempCmd =
                $ExecutionContext.SessionState.InvokeCommand.GetCommand("_TempFunction",'Function')
            $tempCmdMd = [Management.Automation.CommandMetadata]$tempCmd # and get it's command metadata

            #region Accumulate Parameter Script
            $collectParameters = @(
                '$Parameters = @{}' # First, we'll create a hashtable to store the parameters.

                foreach ($parameterName in $tempCmdMd.Parameters.Keys) { # Then we'll walk thru each parameter,
                    $parameterAttributes = $tempCmdMd.Parameters[$parameterName].Attributes
                    $isMandatory = # determine if it is mandatory
                        foreach ($attr in $parameterAttributes) {
                            if ($attr.IsMandatory) { $true; break }
                        }

                    # and create a 'disambiguated' parameter name
                    $disambiguatedParameter = $Name + '_' + $parameterName # (e.g. Get-Command_Syntax).
                    $shouldExclude = # Next, we see if it should be excluded
                        & $MatchesAnyWildCard $disambiguatedParameter, $parameterName $ExcludeParameter
                    if ($shouldExclude) { continue } # if so continue.


                    $defaultValue =
                        # If we provided a default value for the disambiguated parameter,
                        if ($DefaultParameter[$disambiguatedParameter])
                        {
                            $DefaultParameter[$disambiguatedParameter]  # use that as the default value.
                        }
                        # Otherwise, if we have provided a default by name,
                        elseif ($DefaultParameter[$parameterName])
                        {
                            $DefaultParameter[$parameterName]           # use that as the default.
                        }
                        # Otherwise, the default value can be found with the AST.
                        else
                        {
                            foreach ($param in $sbParams.Parameters) {
                                if ($parameterName -eq $param.Name.VariablePath) {
                                    if ($param.DefaultValue.SubExpression) { # If the default value was a subexpression
                                        break # then break, which will actually have a blank default.
                                        # This is desirable, because otherwise, we have to allow string expansion on _any_ incoming parameter
                                        # Doing that would allow generic code injection into a pipeline, which we do not want.
                                    }
                                    "$($param.DefaultValue)"
                                    break
                                }
                            }
                        }

                    # No matter the build system, we'll probably want to know a few things about the parameter.
                    $paramType = $tempCmdMd.Parameters[$parameterName].ParameterType


                    # Determine if it needs to be made unique.
                    $makeUnique = & $MatchesAnyWildcard $parameterName,$disambiguatedParameter $UniqueParameter

                    # What the step parameter name would be (which depends on -MakeUnique).
                    $stepParamName = if ($makeUnique) { $disambiguatedParameter } else {$ParameterName}


                    # In Azure DevOps pipelines, we can pass parameters as a variable.
                    # In GitHub Workflows, variables can come from secrets.
                    $VariableName =
                        & $MatchesAnyWildcard $disambiguatedParameter, $parameterName $VariableParameter


                    if ($BuildSystem -eq 'ADOPipeline') {

                        # In Azure DevOps pipelines, we can also get parameters from the environment.
                        $EnvVariableName =
                            & $MatchesAnyWildcard $disambiguatedParameter, $parameterName $EnvironmentParameter


                        # If we wanted to pass this parameter as a variable,
                        if ($variableName)
                        {
                            # The syntax is like PowerShell string expansion, so put it in single quotes to be safe.
                            "`$Parameters.$ParameterName = '`$($stepParamName)'"
                        }
                        # If wanted to pass this parameter as an environment variable
                        elseif ($envVariableName)
                        {
                            "`$Parameters.$ParameterName = `${env:$($stepParamName)}" # just use the environment provider.
                        }
                        # If we wanted this parameter to become a parameter for the pipeline
                        else
                        {
                            # We have to create it.
                            $thisParameter = [Ordered]@{
                                name = $stepParamName # The name we already know
                                type = # how it maps to Azure DevOps' parameter types gets tedious:
                                    $(
                                    # If it was a [switch] or a [bool],
                                    if ([switch], [bool] -contains $paramType)
                                    {
                                        'boolean' # in Azure DevOps, it's a boolean
                                    }
                                    # [int]s, [float]s, [double]s, [uint32]s, [byte], and [long]s become
                                    elseif ([int],[float],[double],[uint32],[byte], [long] -contains $paramType)
                                    {
                                        'number' # numbers in Azure DevOps.
                                    }
                                    elseif ([string], # any number of other safe types
                                        [Version],
                                        [DateTime],
                                        [TimeSpan],
                                        [DateTime[]],
                                        [ScriptBlock],[ScriptBlock[]],
                                        [string[]],
                                        [int[]],
                                        [float[]] -contains $paramType -or
                                        $paramType.IsSubclassOf([Enum])) {
                                        'string' # will be considered a string
                                    }
                                    # otherwise, we'll treat it as an object
                                    else
                                    {
                                        'object'
                                        # (though passing it down is currently so simple).
                                    }
                                    )
                            }
                            if ($paramType.IsSubclassOf([Enum])) { # If the parameter is an enum,
                                $thisParameter.values = [Enum]::GetValues($paramType)
                            } else {
                                foreach ($attr in $parameterAttributes) { # or if the parameter has a ValidateSet
                                    if ($attr -is [Management.Automation.ValidateSetAttribute]) {
                                        $thisParameter.values = $attr.ValidValues # we know a list of valid values
                                        break
                                    }
                                }
                            }

                            if (-not $isMandatory) { # If the parameter was not mandatory
                                $thisParameter.default = if ($paramType -eq [switch]) { $false} else {''} # default to blank
                                if ($thisParameter.Contains('values')) { # if it had valid values
                                    $thisParameter.values = @('') + $thisParameter.values # default those values to blank.
                                }
                            }

                            if (-not [String]::IsNullOrEmpty($defaultValue)) { # If we have a default,
                                $thisParameter.default = $defaultValue # set it on the object
                            }

                            $definedParameters += $thisParameter # keep track of which parameters we define
                            "`$Parameters.$ParameterName = @'
`${{parameters.$stepParamName}}
'@"
                         # and output the text to bind to this parameter.
                        }
                    }

                    if ($BuildSystem -in 'GitHubWorkflow','GitHubAction') {
                        # In GitHub Workflows, variables can come from an event.
                        $eventName =
                            & $MatchesAnyWildcard $disambiguatedParameter, $parameterName $InputParameter.Keys

                        if ($variableName) {
                            $eventParameters[$stepParamName] = "`${{secrets.$stepParamName}}"
                        }
                        if ($eventName) {
                            foreach ($evt in $eventName) {


                                if ($evt -match '\.(?:\*)?$') {
                                    $evt = ($evt -replace '\.(?:\*)?$') + '.' + $stepParamName
                                }
                                if ($evt -notlike '${{*' -and $evt -notlike '*.*') {
                                    $evt = 
                                        $(if ($BuildSystem -eq 'GitHubWorkflow') {
                                            '${{github.events.inputs.'
                                        } else {
                                            '${{inputs.'
                                        }) + $stepParamName + '}}'
                                }
                                if ($evt -notlike '${{*' -and $evt -notlike 'github.*') {
                                    $evt = "github." + '.' + $stepParamName
                                }
                                if ($evt -like '${{*') {
                                    $eventParameters[$stepParamName] = $evt
                                }
                                else {
                                    $eventParameters[$stepParamName] = "`${{$evt}}"
                                }

                            }

                            # Event parameters might come from workflow_dispatch, so we have to prepare the parameter information.

                            $thisParameter = [Ordered]@{
                                $stepParamName = [Ordered]@{
                                    required = $isMandatory -as [bool]
                                }
                            }

                            if (-not [String]::IsNullOrEmpty($defaultValue)) { # If we have a default,
                                $thisParameter.$stepParamName.default = $defaultValue # set it on the object
                            }

                            $parameterHelp = Get-Command _tempFunction -ErrorAction SilentlyContinue |
                                Get-Help -Parameter $parameterName -ErrorAction SilentlyContinue

                            if ($parameterHelp.description.text) {
                                $thisParameter.$stepParamName.description = ($parameterHelp.description.text | Out-String -Width 1kb).Trim()
                            }

                            $definedParameters += $thisParameter
                        }
                        "`$Parameters.$ParameterName = `${env:$($stepParamName)}"
                    }
                     # If the parameter type was and [int[]], [string[]], or [float[]],
                    if ([int[]], [string[]],[float[]] -contains $paramType) {
                        # it can be split by semicolons.
                        "`$Parameters.$ParameterName = `$parameters.$ParameterName -split ';' -replace '^[''`"]' -replace '[''`"]$'"
                    }
                    if ([switch], [bool] -contains $paramType) {
                        "`$Parameters.$ParameterName = `$parameters.$ParameterName -match 'true';"
                    }
                    # If the parameter type was a scriptblock
                    if ([ScriptBlock], [ScriptBlock[]] -contains $paramType) {
                        "`$Parameters.$ParameterName = foreach (`$p in `$parameters.$ParameterName){ [ScriptBlock]::Create(`$p) }"
                    }
                }

                if ($tempCmdmd.SupportsShouldProcess) {
                    '$Parameters.Confirm = $false'
                }
                @'
foreach ($k in @($parameters.Keys)) {
    if ([String]::IsNullOrEmpty($parameters[$k])) {
        $parameters.Remove($k)
    }
}
'@

            )
            $collectParameters =
                    $collectParameters -join [Environment]::NewLine -replace '\$\{','`${'
            $logParameters =
                @(
                if ($BuildSystem -eq 'ADOPipeline') {
                    'Write-Host "##[command]'
                } elseif ($BuildSystem -in'GitHubWorkflow','GitHubAction') {
                    'Write-Host "::debug::'
                }
                if ($name) { $name}
                '$(@(foreach ($p in $Parameters.GetEnumerator()) {''-'' + $p.Key + '' '' + $p.Value}) -join '' '')"'
                ) -join ' '
            #endregion Accumulate Parameter Script
            if ($Name -and $Module) { # if the command we're converting came from a module
                $modulePathVariable = "${Module}Path"
                $sb = [ScriptBlock]::Create(@"
$collectParameters
Import-Module `$($modulePathVariable) -Force -PassThru
$logParameters
$Name `@Parameters
"@
)
                $innerScript = $sb
            } elseif ($BuildOption.RootDirectory -and $ScriptBlock.File -and 
                $ScriptBlock.File -like "$($BuildOption.RootDirectory)*") {
                $relativeScriptPath = 
                    $ScriptBlock.File.Substring(
                        $BuildOption.RootDirectory.Length
                    ).TrimStart(
                        [IO.Path]::DirectorySeparatorChar
                    ).Replace('\','/')
                $sb = [ScriptBlock]::Create(@"
$CollectParameters
$logParameters
& './$relativeScriptPath' `@Parameters
"@
)
                $innerScript = $sb
            } else {
                $sb = [scriptBlock]::Create(@"
$CollectParameters
$logParameters
& {$ScriptBlock} `@Parameters
"@
)
                $innerScript = $sb
            }
            Remove-Item -Force function:_TempFunction
        }
        $out = [Ordered]@{}
        if ($BuildSystem -eq 'ADOPipeline') {
            if ($DebugPreference -ne 'silentlycontinue') {
                $innerScript = @"
try {
    $innerScript
} catch {
    `$err = `$_;
    `"##[error]`$(`$_ | Out-String)`";
    `$_| Write-Error
}
"@

            }
            if (
                ($outObject.pool -and $outObject.pool.vmimage -notlike '*win*' -and
                    (-not $BuildOption.WindowsPowerShell)
                ) -or $BuildOption.PowerShellCore
            ) {
                $out.pwsh = "$innerScript" -replace '`\$\{','${'
            } else {
                $out.powershell = "$innerScript" -replace '`\$\{','${'
            }
            $out.name = $Name
            $out.displayName = $Name
            if ($definedParameters) {
                $out.parameters = $definedParameters
            }
            if ($BuildOption.UseSystemAccessToken) {
                if (-not $out.env) { $out.env = @{}}
                $out.env."SYSTEM_ACCESSTOKEN"='$(System.AccessToken)'
            }
        } elseif ($BuildSystem -in 'GitHubWorkflow','GitHubAction') {
            if ($DebugPreference -ne 'silentlycontinue') {
                $innerScript = @"
try {
    $innerScript
} catch {
    `$err = `$_;
    `"::error::`$(`$_ | Out-String)`";
    `$_| Write-Error
}
"@

            }
            $out.name = $Name
            $out.id = $Name -replace '\W'
            $out.shell = 'pwsh'
            if ($eventParameters.Count) {
                if (-not $out.env) { $out.env = @{}}
                foreach ($ep in $eventParameters.GetEnumerator()) {
                    $out.env[$ep.Key] = $ep.value
                }
            }
            if ($definedParameters) {
                $out.parameters = $definedParameters
            }
            $out.run = "$innerScript" -replace '`\$\{','${'
        }
        $out
    }
}