diff --git a/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 b/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 index fc1aeed1f9..e48f8b3cd9 100644 --- a/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 +++ b/Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1 @@ -430,15 +430,30 @@ function Get-UnmodifiedAppsFromBaselineWorkflowRun { Sort-AppFoldersByDependencies -appFolders $allFolders -baseFolder $baseFolder -skippedApps ([ref] $skipFolders) -unknownDependencies ([ref]$unknownDependencies) -knownApps ([ref] $knownApps) -selectSubordinates $modifiedFolders | Out-Null OutputMessageAndArray -message "Skip folders" -arrayOfStrings $skipFolders - $projectWithSeperator = '' - if ($project) { - $projectWithSeperator = "$project$([System.IO.Path]::DirectorySeparatorChar)" + # Convert a project-relative folder path (from settings.appFolders etc.) to a repo-relative path + # that matches the format used by $skipFolders (produced by GetFoldersFromAllProjects). + # Settings folders may use ../ to reference paths above the project folder, + # so we resolve to absolute first, then strip the base folder prefix to get a clean repo-relative path. + function ConvertTo-RepoRelativePath { + param( + [string] $folder, + [string] $projectPath, + [string] $baseFolder + ) + $fullPath = Join-Path $projectPath $folder -Resolve -ErrorAction SilentlyContinue + if (-not $fullPath) { + return $null + } + $normalizedBase = $baseFolder.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if ($fullPath.StartsWith($normalizedBase, [System.StringComparison]::OrdinalIgnoreCase)) { + return $fullPath.Substring($normalizedBase.Length) + } + return $null } - # AppFolders, TestFolders and BcptTestFolders in settings are always preceded by ./ or .\, so we need to remove that (hence Substring(2)) - $downloadAppFolders = @($settings.appFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) - $downloadTestFolders = @($settings.testFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) - $downloadBcptTestFolders = @($settings.bcptTestFolders | Where-Object { $skipFolders -contains "$projectWithSeperator$($_.SubString(2))" }) + $downloadAppFolders = @($settings.appFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) + $downloadTestFolders = @($settings.testFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) + $downloadBcptTestFolders = @($settings.bcptTestFolders | Where-Object { $skipFolders -contains (ConvertTo-RepoRelativePath -folder $_ -projectPath $projectPath -baseFolder $baseFolder) }) OutputMessageAndArray -message "Download appFolders" -arrayOfStrings $downloadAppFolders OutputMessageAndArray -message "Download testFolders" -arrayOfStrings $downloadTestFolders diff --git a/Tests/DetermineProjectsToBuild.Test.ps1 b/Tests/DetermineProjectsToBuild.Test.ps1 index 2d7ca5baf2..289a1ccf7a 100644 --- a/Tests/DetermineProjectsToBuild.Test.ps1 +++ b/Tests/DetermineProjectsToBuild.Test.ps1 @@ -1214,3 +1214,252 @@ Describe "Get-BuildAllProjects" { Remove-Item $baseFolder -Force -Recurse } } + +Describe "Get-UnmodifiedAppsFromBaselineWorkflowRun" { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath "../Actions/AL-Go-Helper.ps1" -Resolve) + DownloadAndImportBcContainerHelper -baseFolder $([System.IO.Path]::GetTempPath()) + + Import-Module (Join-Path $PSScriptRoot "../Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1" -Resolve) -DisableNameChecking -Force + } + + BeforeEach { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'baseFolder', Justification = 'False positive.')] + $baseFolder = (New-Item -ItemType Directory -Path (Join-Path $([System.IO.Path]::GetTempPath()) $([System.IO.Path]::GetRandomFileName()))).FullName + } + + It 'correctly identifies unmodified apps when appFolders reference paths above the project folder via ../' { + # Repo layout where the project is nested and appFolders reference sources via ../: + # baseFolder/ + # .github/AL-Go-Settings.json + # src/Apps/AppA/App/app.json (modified) + # src/Apps/AppB/App/app.json (unmodified - should be downloaded from baseline) + # src/Apps/AppC/App/app.json (unmodified - should be downloaded from baseline) + # build/projects/MyProject/.AL-Go/settings.json + # appFolders: ["../../../src/Apps/*/App"] + + $project = 'build/projects/MyProject' + $projectPath = Join-Path $baseFolder $project + + # Create repo-level AL-Go settings + $repoSettings = @{ + fullBuildPatterns = @() + projects = @($project) + powerPlatformSolutionFolder = '' + useProjectDependencies = $false + incrementalBuilds = @{ + onPull_Request = $true + mode = 'modifiedApps' + } + } + New-Item -Path "$baseFolder/.github/AL-Go-Settings.json" -Value (ConvertTo-Json $repoSettings -Depth 10) -type File -Force | Out-Null + + # Create project-level settings with appFolders that go above the project + $projectSettingsJson = @{ + appFolders = @("../../../src/Apps/*/App") + testFolders = @() + bcptTestFolders = @() + } + New-Item -Path "$projectPath/.AL-Go/settings.json" -Value (ConvertTo-Json $projectSettingsJson -Depth 10) -type File -Force | Out-Null + + # Create three independent apps + $appA = @{ id = 'aaaaaaaa-0000-0000-0000-000000000001'; name = 'App A'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $appB = @{ id = 'bbbbbbbb-0000-0000-0000-000000000002'; name = 'App B'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $appC = @{ id = 'cccccccc-0000-0000-0000-000000000003'; name = 'App C'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + New-Item -Path "$baseFolder/src/Apps/AppA/App/app.json" -Value (ConvertTo-Json $appA -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/src/Apps/AppB/App/app.json" -Value (ConvertTo-Json $appB -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/src/Apps/AppC/App/app.json" -Value (ConvertTo-Json $appC -Depth 10) -type File -Force | Out-Null + + # Also create a dummy .al file so there's something to be "modified" + New-Item -Path "$baseFolder/src/Apps/AppA/App/MyCodeunit.al" -Value "// modified file" -type File -Force | Out-Null + + # Set env:Settings for helper functions + $env:Settings = ConvertTo-Json $repoSettings -Depth 99 -Compress + + # Resolve appFolders the way ResolveProjectFolders/AnalyzeRepo does: + # Push-Location to project, Resolve-Path -Relative + Push-Location $projectPath + $resolvedAppFolders = @(Resolve-Path "../../../src/Apps/*/App" -Relative -ErrorAction SilentlyContinue | Where-Object { Test-Path (Join-Path $_ 'app.json') }) + Pop-Location + + # Build a settings hashtable with resolved folders (same as what RunPipeline passes in) + $resolvedSettings = @{ + appFolders = $resolvedAppFolders + testFolders = @() + bcptTestFolders = @() + } + + # Build artifact folder + $buildArtifactFolder = Join-Path $projectPath ".buildartifacts" + New-Item -Path $buildArtifactFolder -ItemType Directory -Force | Out-Null + + # Only AppA is modified + $sep = [System.IO.Path]::DirectorySeparatorChar + $modifiedFiles = @("src${sep}Apps${sep}AppA${sep}App${sep}MyCodeunit.al") + + # Mock GitHub API calls since we don't have a real baseline workflow + # Stub functions that are normally provided by GitHub Actions runtime + if (-not (Get-Command 'Trace-Information' -ErrorAction SilentlyContinue)) { + function global:Trace-Information { param([string]$Message, $AdditionalData) } + } + $env:GITHUB_API_URL = 'https://api.github.com' + $env:GITHUB_REPOSITORY = 'test/repo' + Mock InvokeWebRequest { + # Return a mock response object that mimics the GitHub API + $uri = $args[0] + if (-not $uri) { $uri = $Uri } + $content = if ($uri -like '*/actions/runs/*/artifacts*') { + # Artifacts endpoint - return empty list to stop pagination + '{"artifacts":[]}' + } else { + # Workflow run info endpoint + '{"head_branch":"main"}' + } + return [PSCustomObject]@{ Content = $content } + } -ModuleName 'Github-Helper' + + # Capture Write-Host output to verify download folder matching + $script:capturedOutput = [System.Collections.ArrayList]::new() + Mock Write-Host { $null = $script:capturedOutput.Add($Object) } -ModuleName 'DetermineProjectsToBuild' + + Get-UnmodifiedAppsFromBaselineWorkflowRun ` + -token 'fake-token' ` + -settings $resolvedSettings ` + -baseFolder $baseFolder ` + -project $project ` + -baselineWorkflowRunId '12345' ` + -modifiedFiles $modifiedFiles ` + -buildArtifactFolder $buildArtifactFolder ` + -buildMode 'Default' ` + -projectPath $projectPath + + # The output should list the unmodified app folders (AppB and AppC) as download candidates. + # Before the fix: the download list was always empty ("- None") because the path matching + # used SubString(2) which mangled paths starting with ..\ into nonsense like \..\..\src\... + $downloadAppLine = $script:capturedOutput | Where-Object { $_ -eq 'Download appFolders:' } + $downloadAppLine | Should -Not -BeNullOrEmpty -Because "the function should output the 'Download appFolders:' header" + + # Find entries after "Download appFolders:" up to the next section header + $inDownloadSection = $false + $downloadEntries = @() + foreach ($line in $script:capturedOutput) { + if ($line -eq 'Download appFolders:') { + $inDownloadSection = $true + continue + } + if ($inDownloadSection) { + if ($line -match '^Download (test|bcpt)') { break } + $downloadEntries += $line + } + } + + # With the bug: downloadEntries would be @("- None") because the path matching fails + # With the fix: downloadEntries should contain AppB and AppC folders + $downloadEntries | Should -Not -Contain '- None' -Because "unmodified apps should be identified for download from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppB*' }) | Should -Not -BeNullOrEmpty -Because "AppB was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppC*' }) | Should -Not -BeNullOrEmpty -Because "AppC was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*AppA*' }) | Should -BeNullOrEmpty -Because "AppA was modified and should NOT be downloaded from baseline" + } + + It 'also works when appFolders are inside the project folder (standard layout)' { + # Standard layout where apps are inside the project folder: + # baseFolder/ + # .AL-Go/settings.json + # .github/AL-Go-Settings.json + # app1/app.json + # app2/app.json + + $project = '' + $projectPath = $baseFolder + + # Create repo-level AL-Go settings + $repoSettings = @{ + fullBuildPatterns = @() + projects = @() + powerPlatformSolutionFolder = '' + useProjectDependencies = $false + incrementalBuilds = @{ + onPull_Request = $true + mode = 'modifiedApps' + } + } + New-Item -Path "$baseFolder/.github/AL-Go-Settings.json" -Value (ConvertTo-Json $repoSettings -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/.AL-Go/settings.json" -Value (ConvertTo-Json @{} -Depth 10) -type File -Force | Out-Null + + # Create two apps inside the project folder + $app1 = @{ id = '11111111-0000-0000-0000-000000000001'; name = 'App One'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + $app2 = @{ id = '22222222-0000-0000-0000-000000000002'; name = 'App Two'; publisher = 'TestPublisher'; version = '1.0.0.0'; dependencies = @() } + New-Item -Path "$baseFolder/app1/app.json" -Value (ConvertTo-Json $app1 -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/app2/app.json" -Value (ConvertTo-Json $app2 -Depth 10) -type File -Force | Out-Null + New-Item -Path "$baseFolder/app1/MyCodeunit.al" -Value "// modified file" -type File -Force | Out-Null + + $env:Settings = ConvertTo-Json $repoSettings -Depth 99 -Compress + + # Resolve appFolders (standard layout: .\ prefix) + $resolvedSettings = @{ + appFolders = @('.\app1', '.\app2') + testFolders = @() + bcptTestFolders = @() + } + + $buildArtifactFolder = Join-Path $projectPath ".buildartifacts" + New-Item -Path $buildArtifactFolder -ItemType Directory -Force | Out-Null + + # Only app1 is modified + $sep = [System.IO.Path]::DirectorySeparatorChar + $modifiedFiles = @("app1${sep}MyCodeunit.al") + + Mock InvokeWebRequest { + $uri = $args[0] + if (-not $uri) { $uri = $Uri } + $content = if ($uri -like '*/actions/runs/*/artifacts*') { + '{"artifacts":[]}' + } else { + '{"head_branch":"main"}' + } + return [PSCustomObject]@{ Content = $content } + } -ModuleName 'Github-Helper' + # Stub Trace-Information if not already defined (normally loaded by Invoke-AlGoAction.ps1) + if (-not (Get-Command 'Trace-Information' -ErrorAction SilentlyContinue)) { + function global:Trace-Information { param([string]$Message, $AdditionalData) } + } + $env:GITHUB_API_URL = 'https://api.github.com' + $env:GITHUB_REPOSITORY = 'test/repo' + + $script:capturedOutput = [System.Collections.ArrayList]::new() + Mock Write-Host { $null = $script:capturedOutput.Add($Object) } -ModuleName 'DetermineProjectsToBuild' + + Get-UnmodifiedAppsFromBaselineWorkflowRun ` + -token 'fake-token' ` + -settings $resolvedSettings ` + -baseFolder $baseFolder ` + -project $project ` + -baselineWorkflowRunId '12345' ` + -modifiedFiles $modifiedFiles ` + -buildArtifactFolder $buildArtifactFolder ` + -buildMode 'Default' ` + -projectPath $projectPath + + $inDownloadSection = $false + $downloadEntries = @() + foreach ($line in $script:capturedOutput) { + if ($line -eq 'Download appFolders:') { + $inDownloadSection = $true + continue + } + if ($inDownloadSection) { + if ($line -match '^Download (test|bcpt)') { break } + $downloadEntries += $line + } + } + + # app2 should be marked for download (unmodified), app1 should not + $downloadEntries | Should -Not -Contain '- None' -Because "the unmodified app2 should be identified for download" + ($downloadEntries | Where-Object { $_ -like '*app2*' }) | Should -Not -BeNullOrEmpty -Because "app2 was not modified and should be downloaded from baseline" + ($downloadEntries | Where-Object { $_ -like '*app1*' }) | Should -BeNullOrEmpty -Because "app1 was modified and should NOT be downloaded from baseline" + } + + AfterEach { + Remove-Item $baseFolder -Force -Recurse + } +}