Public/Get-SemaphoreProjectTaskOutput.ps1

function Get-SemaphoreProjectTaskOutput
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $ProjectId,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $Id,

        [Parameter(Mandatory = $false)]
        [ValidateSet('json', 'text')]
        [string]
        $ParseType = 'json'
    )

    begin
    {
        Write-Verbose -Message "Calling function $($MyInvocation.MyCommand)"
        if(!$Script:Session)
        {
            throw "Please run Connect-Semaphore first"
        }
    }
    process
    {
        try
        {
            $Data = Invoke-RestMethod -Uri "$($Script:Config.url)/project/$ProjectId/tasks/$Id/output" -Method Get -ContentType 'application/json' -WebSession $Script:Session
            if(!$Data)
            {
                return $Null
            }

            # Write all data to the verbose stream so we can see it if we want to:
            Write-Verbose -Message $($Global:Data | Out-String)
        }
        catch
        {
            throw $_
        }

        if($ParseType -eq 'json')
        {
            # The output, when ansible.cfg is set to return JSON is as followed:
            <#
                task_id task time output
                ------- ---- ---- ------
                    25 17/10/2023 13:47:53 Task 25 added to queue
                    25 17/10/2023 13:47:58 Started: 25
                    25 17/10/2023 13:47:58 Run TaskRunner with template: Test Install Via Choco on TESTHOST
                    25 17/10/2023 13:47:58 Preparing: 25
                    25 17/10/2023 13:47:58 Updating Repository https://github.com/temp/ansibleplaybooks.git
                    25 17/10/2023 13:47:58 From https://github.com/temp/ansibleplaybooks
                    25 17/10/2023 13:47:58 * branch main -> FETCH_HEAD
                    25 17/10/2023 13:47:58 Already up to date.
                    25 17/10/2023 13:47:58 No collections/requirements.yml file found. Skip galaxy install process.
                    25 17/10/2023 13:47:58 No roles/requirements.yml file found. Skip galaxy install process.
                    25 17/10/2023 13:48:45 {
                    25 17/10/2023 13:48:45 "custom_stats": {},
                    25 17/10/2023 13:48:45 "global_custom_stats": {},
                    25 17/10/2023 13:48:45 "plays": [
                    25 17/10/2023 13:48:45 {
                            ..................................... SNIP .....................................
                    25 17/10/2023 13:48:45 }
                    25 17/10/2023 13:48:45 ],
                    25 17/10/2023 13:48:45 "stats": {
                    25 17/10/2023 13:48:45 "testhost.domain.com": {
                    25 17/10/2023 13:48:45 "changed": 1,
                    25 17/10/2023 13:48:45 "failures": 0,
                    25 17/10/2023 13:48:45 "ignored": 0,
                    25 17/10/2023 13:48:45 "ok": 6,
                    25 17/10/2023 13:48:45 "rescued": 0,
                    25 17/10/2023 13:48:45 "skipped": 0,
                    25 17/10/2023 13:48:45 "unreachable": 0
                    25 17/10/2023 13:48:45 }
                    25 17/10/2023 13:48:45 }
                    25 17/10/2023 13:48:45 }
            #>


            #Region Find Start and End of JSON
            try
            {
                # Find the array number where .output equals exactly "{" as this is the start of the JSON data:
                $JSONStart = $Data.Output.IndexOf('{')
                # If -1 then we have no result data yet.
                if($JSONStart -eq -1)
                {
                    return $Null
                }

                # Not sure why but LastIndexOf('}') returns an array. Let's use IndexOf('}') instead. This works as the JSON is returned 'pretty' with indentation so the final line is just }.

                $JSONEnd = $Data.Output.IndexOf('}')

                # If this function is called at exactly the right (wrong) time, it can be possible that the { is found but the } is not. This is because the task data is one line per record
                # and presumably behind the scenes, data is still being written to disk and thus we end up with a partial result.

                # To cater to this, if we've found '{' but '}' isn't a value above 0, use a small retry loop making additional queries for the task data.

                $RetryCount = 0
                while(($JSONEnd -lt 0) -and $RetryCount -lt 20)
                {
                    $JSONEnd = $Data.Output.IndexOf('}')
                    $Data = Invoke-RestMethod -Uri "$($Script:Config.url)/project/$ProjectId/tasks/$Id/output" -Method Get -ContentType 'application/json' -WebSession $Script:Session -Verbose:$False
                    $RetryCount++
                    Start-Sleep -Seconds 2
                }

                if($JSONEnd -eq -1)
                {
                    throw "Unable to find end of JSON data."
                }

                # We are assuming these are integers here...
                if($JSONStart -and $JSONEnd)
                {
                    # Add all items in the array between the start and end to a new array:
                    $Global:JSON = $Data.Output[$JSONStart..$JSONEnd]
                }
                else
                {
                    return $Null
                }
            }
            catch
            {
                throw $_
            }
            #EndRegion

            #Region Convert to a PowerShell object and hope it doesn't break:
            try
            {
                $Converted = $JSON | ConvertFrom-Json -ErrorAction Stop
            }
            catch
            {
                throw $_
            }
            #EndRegion

            #Region Manipulate data to make it more useful:
            # Unfortunately, .stats is converted to singular PSCustomObject (count = 1!) with the host names as NoteProperties and their value is another PSCustomObject
            # with multiple properties (failures, changed, ok ,etc). This means you can't iterate over it to use with Where/Select statements. So, convert it into a useful array of objects:
            return $Converted.stats | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name | ForEach-Object { [pscustomobject]@{'Name' = $_; 'Results' = $Converted.stats.$_ } }
            #EndRegion
        }
        elseif($ParseType -eq 'text')
        {
            return $Data
        }
        else
        {
        }
    }
    end
    {
    }
}