Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions Actions/DetermineProjectsToBuild/DetermineProjectsToBuild.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
249 changes: 249 additions & 0 deletions Tests/DetermineProjectsToBuild.Test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading