Modules/businessdev.ALbuild.Core/Public/Test-BcChangeSet.ps1
|
function Test-BcChangeSet { <# .SYNOPSIS Gates a pull request on what its diff contains - tests for changed feature code, no disabled tests. .DESCRIPTION Static gate that runs on the diff alone, before any container is built, so a pull request that cannot pass fails in seconds instead of after a 40-minute build. It compares HEAD against the merge base with the target branch and applies the rules given in -Rule: TestsForChanges Changed feature AL code requires changed test AL code. A change to logic that no test touches is either untested or the test was not updated. DisabledTests A pull request must not add or change a disabledTests.json: disabling a test is a decision for the team, not a way to make a red build green. A file counts as test code when its content declares AL tests ('Subtype = Test' or a [Test] attribute) and, failing that, when its path matches -TestPathPattern. Content first, because the path heuristic alone both over- and under-reports: a 'Tests' folder may hold library helpers with no [Test] at all, and a substring match would take 'LatestEntry.Codeunit.al' for test code and wave the change through. The default pattern therefore matches 'test'/'tests' only as a whole word in the path. Returns a verdict object; with -ThrowOnFailure it throws so a pipeline step fails. When the diff is empty (nothing to compare against, e.g. a manual run on the target branch itself) the gate passes and says so - it never fails for lack of information. .PARAMETER RepositoryRoot The git working tree to inspect. Default: the current location. .PARAMETER TargetBranch Branch the pull request targets, used to compute the merge base. Default: 'dev'. .PARAMETER BaseRef Explicit comparison base (commit, tag or ref). Skips the merge-base resolution against -TargetBranch. .PARAMETER Fetch Fetch -TargetBranch from 'origin' first. Needed on a build agent with a shallow or single-branch clone, where the target branch is not present locally. .PARAMETER Rule Rules to apply: TestsForChanges, DisabledTests. Default: both. .PARAMETER TestPathPattern Regex marking a path as test code when its content is inconclusive. Default: 'test' or 'tests' as a whole word anywhere in the path (so 'test/', 'Test Library/' and 'Poster.Test.al' count, 'LatestEntry.Codeunit.al' does not). .PARAMETER ThrowOnFailure Throw a terminating error when a rule is violated (for use as a pipeline gate). .PARAMETER Quiet Suppress the gate's own verdict and violation lines (the changed-file list still logs, it is the evidence). For callers that report the verdict themselves -- the Azure DevOps task renders it as a pipeline issue, as a warning or an error depending on failOnIssue -- so nothing is logged twice. .EXAMPLE Test-BcChangeSet -TargetBranch dev -Fetch -ThrowOnFailure The PR gate: fail unless the diff carries test changes and disables no tests. .EXAMPLE Test-BcChangeSet -BaseRef HEAD~1 -Rule TestsForChanges Check the last commit only, for the "tests present" rule. .OUTPUTS PSCustomObject verdict: passed, baseRef, changedFiles[], featureFiles[], testFiles[], issues[]. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string] $RepositoryRoot = (Get-Location).Path, [string] $TargetBranch = 'dev', [string] $BaseRef, [switch] $Fetch, [ValidateSet('TestsForChanges', 'DisabledTests')] [string[]] $Rule = @('TestsForChanges', 'DisabledTests'), [string] $TestPathPattern = '(?i)(^|[^A-Za-z0-9])tests?([^A-Za-z0-9]|$)', [switch] $ThrowOnFailure, [switch] $Quiet ) if (-not (Test-Path -LiteralPath $RepositoryRoot)) { throw "Repository root '$RepositoryRoot' does not exist." } $root = (Resolve-Path -LiteralPath $RepositoryRoot).Path # ----- comparison base --------------------------------------------------------------------------- $base = $BaseRef if (-not $base) { if ($Fetch) { $refSpec = '+refs/heads/{0}:refs/remotes/origin/{0}' -f $TargetBranch $fetched = Invoke-BcGit -RepositoryRoot $root -Arguments @('fetch', '--no-tags', 'origin', $refSpec) -AllowFailure if (-not $fetched.Success) { Write-ALbuildLog -Level Verbose "Could not fetch '$TargetBranch' from origin: $("$($fetched.StdErr)".Trim())" } } foreach ($candidate in @("origin/$TargetBranch", $TargetBranch)) { $mb = Invoke-BcGit -RepositoryRoot $root -Arguments @('merge-base', 'HEAD', $candidate) -AllowFailure if ($mb.Success -and -not [string]::IsNullOrWhiteSpace($mb.StdOut)) { $base = $mb.StdOut.Trim(); break } } if (-not $base) { Write-ALbuildLog -Level Warning "No merge base with '$TargetBranch' found (shallow clone or unknown branch?) - comparing against 'origin/$TargetBranch' directly." $base = "origin/$TargetBranch" } } Write-ALbuildLog "Change-set gate: comparing HEAD against '$base' (target branch '$TargetBranch')." # ----- changed files (added/copied/modified/renamed; deletions cannot violate a rule) ------------- $diff = Invoke-BcGit -RepositoryRoot $root -Arguments @('diff', '--name-only', '--diff-filter=ACMR', $base, 'HEAD') -AllowFailure if (-not $diff.Success) { $detail = @("$($diff.StdErr)", "$($diff.StdOut)" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1) throw "Could not compute the diff against '$base': $("$detail".Trim())" } $changed = @("$($diff.StdOut)" -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $issues = [System.Collections.Generic.List[object]]::new() $featureFiles = [System.Collections.Generic.List[string]]::new() $testFiles = [System.Collections.Generic.List[string]]::new() if ($changed.Count -eq 0) { if (-not $Quiet) { Write-ALbuildLog -Level Success "Change-set gate PASSED: the diff against '$base' is empty - nothing to gate." } return [PSCustomObject]@{ passed = $true baseRef = $base targetBranch = $TargetBranch rules = @($Rule) changedFileCount = 0 changedFiles = @() featureFiles = @() testFiles = @() issues = @() } } Write-ALbuildLog "Changed file(s): $($changed.Count)" foreach ($f in $changed) { Write-ALbuildLog " $f" } # ----- (1) no disabled tests --------------------------------------------------------------------- if ($Rule -contains 'DisabledTests') { $disabled = @($changed | Where-Object { [System.IO.Path]::GetFileName($_) -ieq 'disabledTests.json' }) if ($disabled.Count -gt 0) { $issues.Add([PSCustomObject]@{ rule = 'DisabledTests' message = "The change set adds or changes disabledTests.json ($($disabled -join ', ')) - tests must not be disabled to make a build green." files = @($disabled) }) } } # ----- (2) feature code changed -> test code changed --------------------------------------------- if ($Rule -contains 'TestsForChanges') { foreach ($f in @($changed | Where-Object { $_ -like '*.al' })) { $full = Join-Path $root $f $isTest = $false if (Test-Path -LiteralPath $full) { $content = Get-Content -LiteralPath $full -Raw -ErrorAction SilentlyContinue if ($content -and ($content -match '(?i)Subtype\s*=\s*Test' -or $content -match '(?i)\[Test\b')) { $isTest = $true } } if (-not $isTest -and $f -match $TestPathPattern) { $isTest = $true } if ($isTest) { [void]$testFiles.Add($f) } else { [void]$featureFiles.Add($f) } } Write-ALbuildLog "Changed AL code: $($featureFiles.Count) feature file(s), $($testFiles.Count) test file(s)." if ($featureFiles.Count -gt 0 -and $testFiles.Count -eq 0) { $issues.Add([PSCustomObject]@{ rule = 'TestsForChanges' message = "$($featureFiles.Count) feature file(s) changed without any test change - new or changed logic needs tests." files = @($featureFiles) }) } } $verdict = [PSCustomObject]@{ passed = ($issues.Count -eq 0) baseRef = $base targetBranch = $TargetBranch rules = @($Rule) changedFileCount = $changed.Count changedFiles = @($changed) featureFiles = @($featureFiles) testFiles = @($testFiles) issues = @($issues) } if ($verdict.passed) { if (-not $Quiet) { Write-ALbuildLog -Level Success "Change-set gate PASSED: $($changed.Count) changed file(s), no rule violated." } } else { if (-not $Quiet) { foreach ($i in $issues) { Write-ALbuildLog -Level Warning "($($i.rule)) $($i.message)" foreach ($f in $i.files) { Write-ALbuildLog " $f" } } } if ($ThrowOnFailure) { throw "Change-set gate failed: $(@($issues | ForEach-Object { $_.rule }) -join ', ')." } } return $verdict } |