Modules/businessdev.ALbuild.Apps/Public/Get-BcCallGraphBaseline.ps1

function Get-BcCallGraphBaseline {
    <#
    .SYNOPSIS
        Downloads the call graph of the last successful build of a branch, as the baseline to compare with.
 
    .DESCRIPTION
        Looks up the most recent completed build of the pipeline definition on the branch whose result is
        succeeded or partially succeeded, skipping the current build, and downloads its call graph artifact.
        Older builds are tried when the newest one has no such artifact (e.g. it ran before the call graph
        was switched on). Returns $null when there is no baseline, which is normal for a first build.
 
        In a pull request build pass the target branch: the change is then measured against what it will be
        merged into.
 
        With -RepositoryId, a definition that never runs on that branch still finds a baseline: when none of
        its own builds has one, the newest successful build of any pipeline of the same repository on the
        branch is used. A pull request gate (which only ever builds pull requests) then compares with the
        CI build of the target branch.
 
    .PARAMETER CollectionUri
        Organization URL, e.g. https://dev.azure.com/contoso/ (System.CollectionUri).
 
    .PARAMETER Project
        Team project name or id (System.TeamProject).
 
    .PARAMETER DefinitionId
        Pipeline definition id (System.DefinitionId).
 
    .PARAMETER Branch
        Branch to take the baseline from, e.g. refs/heads/master.
 
    .PARAMETER RepositoryId
        Git repository id (Build.Repository.ID). Enables the fallback to other pipelines of the repository.
 
    .PARAMETER CurrentBuildId
        The running build, never used as its own baseline.
 
    .PARAMETER AccessToken
        System.AccessToken (Bearer) or a PAT (with -AuthScheme Basic).
 
    .PARAMETER AuthScheme
        Bearer (default, for System.AccessToken) or Basic (for a PAT).
 
    .PARAMETER ArtifactName
        Name of the build artifact holding the call graph. Default 'callgraph'.
 
    .PARAMETER Destination
        Folder to extract the baseline into.
 
    .PARAMETER MaxBuilds
        How many recent builds to try. Default 5.
 
    .EXAMPLE
        Get-BcCallGraphBaseline -CollectionUri $env:SYSTEM_COLLECTIONURI -Project $env:SYSTEM_TEAMPROJECT `
            -DefinitionId $env:SYSTEM_DEFINITIONID -Branch 'refs/heads/master' -AccessToken $env:SYSTEM_ACCESSTOKEN `
            -Destination "$env:AGENT_TEMPDIRECTORY/callgraph-baseline"
 
    .OUTPUTS
        PSCustomObject: Path (the folder with graph.jsonl), BuildId, BuildNumber, Branch, SourceVersion; or $null.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)] [string] $CollectionUri,
        [Parameter(Mandatory)] [string] $Project,
        [Parameter(Mandatory)] [int] $DefinitionId,
        [Parameter(Mandatory)] [string] $Branch,
        [string] $RepositoryId,
        [int] $CurrentBuildId = 0,
        [Parameter(Mandatory)] [string] $AccessToken,
        [ValidateSet('Bearer', 'Basic')] [string] $AuthScheme = 'Bearer',
        [string] $ArtifactName = 'callgraph',
        [Parameter(Mandatory)] [string] $Destination,
        [ValidateRange(1, 50)] [int] $MaxBuilds = 5
    )

    if (-not $Branch.StartsWith('refs/')) { $Branch = "refs/heads/$Branch" }
    $base = "$($CollectionUri.TrimEnd('/'))/$([uri]::EscapeDataString($Project))/_apis/build"
    $headers = @{
        Authorization = if ($AuthScheme -eq 'Basic') { 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$AccessToken")) } else { "Bearer $AccessToken" }
    }
    $common = "branchName=$([uri]::EscapeDataString($Branch))&statusFilter=completed&resultFilter=succeeded,partiallySucceeded&queryOrder=finishTimeDescending&`$top=$($MaxBuilds + 1)&api-version=7.1"
    # Own definition first; then, with a repository, every pipeline of it (a pull request gate never builds the branch).
    $searches = @(@{ Filter = "definitions=$DefinitionId"; Label = "definition $DefinitionId" })
    if ($RepositoryId) { $searches += @{ Filter = "repositoryId=$([uri]::EscapeDataString($RepositoryId))&repositoryType=TfsGit"; Label = 'any pipeline of the repository' } }

    function Get-BaselineFromBuilds {
        # Downloads the call graph artifact of the newest of these builds that has one; $null when none does.
        param([object[]] $Builds)
        $tried = 0
        foreach ($build in $Builds) {
            if ($build.id -eq $CurrentBuildId) { continue }
            if (++$tried -gt $MaxBuilds) { break }
            $artifact = $null
            try { $artifact = Invoke-RestMethod -Uri "$base/builds/$($build.id)/artifacts?artifactName=$([uri]::EscapeDataString($ArtifactName))&api-version=7.1" -Headers $headers -Method Get -ErrorAction Stop }
            catch { Write-ALbuildLog -Level Verbose "Build $($build.buildNumber) has no '$ArtifactName' artifact ($($_.Exception.Message))."; continue }
            $resource = if ($artifact) { $artifact.PSObject.Properties['resource'] } else { $null }
            $url = if ($resource -and $resource.Value -and $resource.Value.PSObject.Properties['downloadUrl']) { $resource.Value.downloadUrl } else { $null }
            if (-not $url) { continue }

            if (Test-Path -LiteralPath $Destination) { Remove-Item -LiteralPath $Destination -Recurse -Force }
            New-Item -ItemType Directory -Force -Path $Destination | Out-Null
            $zip = Join-Path $Destination 'baseline.zip'
            Invoke-WebRequest -Uri $url -Headers $headers -OutFile $zip -UseBasicParsing -ErrorAction Stop
            Add-Type -AssemblyName System.IO.Compression.FileSystem
            [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $Destination)
            Remove-Item -LiteralPath $zip -Force
            $graph = Get-ChildItem -LiteralPath $Destination -Filter 'graph.jsonl' -File -Recurse | Select-Object -First 1
            if (-not $graph) { Write-ALbuildLog -Level Warning "The '$ArtifactName' artifact of build $($build.buildNumber) has no graph.jsonl."; continue }
            $pipeline = if ($build.PSObject.Properties['definition'] -and $build.definition -and $build.definition.id -ne $DefinitionId) { ", pipeline '$($build.definition.name)'" } else { '' }
            Write-ALbuildLog "Call graph baseline: build $($build.buildNumber) ($($build.sourceBranch), $("$($build.sourceVersion)".Substring(0, [Math]::Min(8, "$($build.sourceVersion)".Length)))$pipeline)."
            return [PSCustomObject]@{
                Path = $graph.DirectoryName; BuildId = $build.id; BuildNumber = $build.buildNumber
                Branch = $build.sourceBranch; SourceVersion = $build.sourceVersion
            }
        }
        return $null
    }

    foreach ($search in $searches) {
        try { $builds = @((Invoke-RestMethod -Uri "$base/builds?$($search.Filter)&$common" -Headers $headers -Method Get -ErrorAction Stop).value) }
        catch { throw "Could not list the builds of $($search.Label) on '$Branch': $($_.Exception.Message)" }
        $found = Get-BaselineFromBuilds -Builds $builds
        if ($found) { return $found }
        Write-ALbuildLog "Call graph baseline: no successful build of $($search.Label) on '$Branch' has a '$ArtifactName' artifact yet."
    }
    return $null
}