From 01da85d412bac8b23f3bd4e6dfc30c8cea2d0c65 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 19:34:53 -0500 Subject: [PATCH 1/6] docs: fill in comment-based help gaps across 11 functions Audited every exported function's Get-Help output across the whole module and found 23 real gaps concentrated in ~11 of the oldest functions, predating the comment-help convention fully settling in: missing .DESCRIPTION (several had only a .SYNOPSIS, one of which was literally just a doc URL), missing .PARAMETER entries (IgnoreSsl was the most common), a stale .EXAMPLE referencing the wrong function name, and Get-PiHoleGroup having essentially no documentation at all beyond its one-line synopsis. No behavior changes - verified by running the full non-integration unit suite (18/18) and the integration suites for every touched feature area against a live server (97/97). Co-Authored-By: Claude Sonnet 5 --- PiHoleShell/Private/Misc.ps1 | 22 +++++++++++++++++ .../Get-PiHoleCurrentAuthSession.ps1 | 6 ++++- .../Remove-PiHoleAuthSession.ps1 | 11 +++++++-- .../DnsControl/Set-PiHoleDnsBlocking.ps1 | 3 +++ .../GroupManagement/Get-PiHoleGroup.ps1 | 24 +++++++++++++++++++ .../Search-PiHoleListDomain.ps1 | 5 +++- .../Metrics/Get-PiHoleStatsQueryType.ps1 | 2 ++ .../Metrics/Get-PiHoleStatsRecentBlocked.ps1 | 6 +++++ .../Public/Metrics/Get-PiHoleStatsSummary.ps1 | 2 ++ .../Metrics/Get-PiHoleStatsTopClient.ps1 | 5 ++++ .../Metrics/Get-PiHoleStatsTopDomain.ps1 | 5 ++++ 11 files changed, 87 insertions(+), 4 deletions(-) diff --git a/PiHoleShell/Private/Misc.ps1 b/PiHoleShell/Private/Misc.ps1 index 3b0c02e..54027c0 100644 --- a/PiHoleShell/Private/Misc.ps1 +++ b/PiHoleShell/Private/Misc.ps1 @@ -137,6 +137,28 @@ function ConvertTo-PiHoleFriendlyErrorMessage { } function Remove-PiHoleCurrentAuthSession { + <# +.SYNOPSIS +Delete session + +.DESCRIPTION +Deletes the session identified by the given Sid. This is the internal best-effort logout every +other function in this module calls automatically from its own finally block when it finishes - +it never produces output, even on failure (a warning only). Shown here for completeness since it +is still a publicly exported function, not because you need to call it yourself. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Sid +The session ID to delete, as returned when logging in + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.EXAMPLE +Remove-PiHoleCurrentAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Sid $Sid + #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "", Justification = "It removes sessions from PiHole only")] [CmdletBinding()] param ( diff --git a/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 b/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 index 9d1c146..c5aabcc 100644 --- a/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 +++ b/PiHoleShell/Public/Authentication/Get-PiHoleCurrentAuthSession.ps1 @@ -1,7 +1,11 @@ function Get-PiHoleCurrentAuthSession { <# .SYNOPSIS -List of all current sessions including their validity and further information about the client such as the IP address and user agent. +List of all current sessions + +.DESCRIPTION +List of all current sessions including their validity and further information about the client +such as the IP address and user agent. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" diff --git a/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 b/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 index e34fff4..d45042f 100644 --- a/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 +++ b/PiHoleShell/Public/Authentication/Remove-PiHoleAuthSession.ps1 @@ -1,7 +1,11 @@ function Remove-PiHoleAuthSession { <# .SYNOPSIS -Using this endpoint, a session can be deleted by its ID. +Delete session by ID + +.DESCRIPTION +Deletes a session by its ID, as shown by Get-PiHoleCurrentAuthSession, rather than the caller's +own current session. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" @@ -12,8 +16,11 @@ The API Password you generated from your PiHole server .PARAMETER IgnoreSsl Ignore SSL when interacting with the PiHole API +.PARAMETER Id +The ID of the session to delete, as shown by Get-PiHoleCurrentAuthSession + .EXAMPLE -Get-PiHoleCurrentAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" +Remove-PiHoleAuthSession -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -Id 3 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Does not change state')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] diff --git a/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 b/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 index 8a7692b..42a246c 100644 --- a/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 +++ b/PiHoleShell/Public/DnsControl/Set-PiHoleDnsBlocking.ps1 @@ -21,6 +21,9 @@ True or False, if you set it to False when Blocking was set to true, it will dis .PARAMETER TimeInSeconds How long the opposite setting should last, in seconds +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 b/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 index c93bc09..6f9e260 100644 --- a/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 +++ b/PiHoleShell/Public/GroupManagement/Get-PiHoleGroup.ps1 @@ -3,6 +3,30 @@ function Get-PiHoleGroup { .SYNOPSIS Get groups +.DESCRIPTION +Returns all groups configured on your Pi-hole. Lists and clients can be assigned to groups to +apply blocking rules selectively rather than globally. + +.PARAMETER PiHoleServer +The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" + +.PARAMETER Password +The API Password you generated from your PiHole server + +.PARAMETER GroupName +The name of a specific group to return. If omitted, every group is returned + +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + +.PARAMETER RawOutput +This will dump the response instead of the formatted object + +.EXAMPLE +Get-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" + +.EXAMPLE +Get-PiHoleGroup -PiHoleServer "http://pihole.domain.com:8080" -Password "your-app-password" -GroupName "Kids" #> [CmdletBinding(HelpUri = 'https://ftl.pi-hole.net/master/docs/#get-/groups/-name-')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingPlainTextForPassword", "Password")] diff --git a/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 b/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 index 4d8c7de..8ddb971 100644 --- a/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 +++ b/PiHoleShell/Public/ListManagement/Search-PiHoleListDomain.ps1 @@ -1,7 +1,10 @@ function Search-PiHoleListDomain { <# .SYNOPSIS -https://ftl.pi-hole.net/master/docs/#get-/search/-domain- +Search domains in Pi-hole's lists + +.DESCRIPTION +Searches Pi-hole's allow/deny lists for a domain, returning which list(s) it was found in. .PARAMETER PiHoleServer The URL to the PiHole Server, for example "http://pihole.domain.com:8080", or "http://192.168.1.100" diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 index 1117f7e..9345b0b 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsQueryType.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsQueryType { <# .SYNOPSIS Get query types + +.DESCRIPTION Request a breakdown of query types (A, AAAA, ...) .PARAMETER PiHoleServer diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 index e8104fc..9c36705 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsRecentBlocked.ps1 @@ -1,6 +1,9 @@ function Get-PiHoleStatsRecentBlocked { <# .SYNOPSIS +Get most recently blocked domain + +.DESCRIPTION Request most recently blocked domain .PARAMETER PiHoleServer @@ -12,6 +15,9 @@ The API Password you generated from your PiHole server .PARAMETER MaxResult How many results should be returned +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 index 9712c83..8598870 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsSummary.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsSummary { <# .SYNOPSIS Get overview of Pi-hole activity + +.DESCRIPTION Request various query, system, and FTL properties .PARAMETER PiHoleServer diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 index 79a43e5..eb4444a 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopClient.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsTopClient { <# .SYNOPSIS Get top clients + +.DESCRIPTION Request the top clients (by query count) .PARAMETER PiHoleServer @@ -16,6 +18,9 @@ How many results should be returned .PARAMETER Blocked If true, returns top clients by blocked queries instead of total queries +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object diff --git a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 index 81031ef..460a0d8 100644 --- a/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 +++ b/PiHoleShell/Public/Metrics/Get-PiHoleStatsTopDomain.ps1 @@ -2,6 +2,8 @@ function Get-PiHoleStatsTopDomain { <# .SYNOPSIS Get top domains + +.DESCRIPTION Request the top domains (by query count) .PARAMETER PiHoleServer @@ -16,6 +18,9 @@ How many results should be returned .PARAMETER Blocked If true, returns top domains by blocked queries instead of total queries +.PARAMETER IgnoreSsl +Set to $true to skip SSL certificate validation + .PARAMETER RawOutput This will dump the response instead of the formatted object From b82be6ed89660dd8798704b4014348fe86f10109 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 21:21:33 -0500 Subject: [PATCH 2/6] docs: regenerate README after synopsis text changes Several .SYNOPSIS edits in the previous commit (e.g. Get-PiHoleGroup, Remove-PiHoleAuthSession, the Stats functions) feed directly into README's generated command-reference table, which wasn't regenerated alongside them - caught by the sync-readme CI check failing. Co-Authored-By: Claude Sonnet 5 --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 188f251..e4dac0d 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Add-PiHoleList` | Add a new list | | `Get-PiHoleList` | Get lists | | `Remove-PiHoleList` | Remove a list | -| `Search-PiHoleListDomain` | _No description yet_ | +| `Search-PiHoleListDomain` | Search domains in Pi-hole's lists | | `Update-PiHoleList` | Update a list | ### Domain Management @@ -153,11 +153,11 @@ See [docs/EXAMPLES.md](docs/EXAMPLES.md) for real, captured output from every fu | `Get-PiHoleStatsDatabaseTopDomain` | Get top domains (long-term database) | | `Get-PiHoleStatsDatabaseUpstream` | Get metrics about Pi-hole's upstream destinations (long-term database) | | `Get-PiHoleStatsQuerySuggestions` | Get query filter suggestions | -| `Get-PiHoleStatsQueryType` | Get query types Request a breakdown of query types (A, AAAA, ...) | -| `Get-PiHoleStatsRecentBlocked` | Request most recently blocked domain | -| `Get-PiHoleStatsSummary` | Get overview of Pi-hole activity Request various query, system, and FTL properties | -| `Get-PiHoleStatsTopClient` | Get top clients Request the top clients (by query count) | -| `Get-PiHoleStatsTopDomain` | Get top domains Request the top domains (by query count) | +| `Get-PiHoleStatsQueryType` | Get query types | +| `Get-PiHoleStatsRecentBlocked` | Get most recently blocked domain | +| `Get-PiHoleStatsSummary` | Get overview of Pi-hole activity | +| `Get-PiHoleStatsTopClient` | Get top clients | +| `Get-PiHoleStatsTopDomain` | Get top domains | | `Get-PiHoleStatsUpstream` | Get metrics about Pi-hole's upstream destinations | ### Configuration & Diagnostics @@ -207,8 +207,8 @@ Session handling is automatic for every command above, but these are available f |---|---| | `Get-PiHoleAuthStatus` | Check if authentication is required | | `Get-PiHoleAuthTotp` | Suggest new TOTP credentials | -| `Get-PiHoleCurrentAuthSession` | List of all current sessions including their validity and further information about the client such as the IP address and user agent. | -| `Remove-PiHoleAuthSession` | Using this endpoint, a session can be deleted by its ID. | +| `Get-PiHoleCurrentAuthSession` | List of all current sessions | +| `Remove-PiHoleAuthSession` | Delete session by ID | ## Testing From a2c8dc75baaaaec3bff996970889311818d4fca3 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 21:21:33 -0500 Subject: [PATCH 3/6] ci: raise Pester pipeline timeout to 120 minutes This PR's change to PiHoleShell/Private/Misc.ps1 triggers the pipeline's own shared/core-file fallback to the full test suite, which takes ~50-60 minutes against the real (slow) Pi Zero W test server - right at, and in this run past, Azure DevOps' default 60 minute job timeout. Raised with headroom rather than tuned precisely, since runtime varies with the test server's own accumulated state across runs. Co-Authored-By: Claude Sonnet 5 --- azuredevops-pihole-pester-tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/azuredevops-pihole-pester-tests.yml b/azuredevops-pihole-pester-tests.yml index c954956..6e66c04 100644 --- a/azuredevops-pihole-pester-tests.yml +++ b/azuredevops-pihole-pester-tests.yml @@ -13,6 +13,13 @@ pr: pool: name: mmadeja-dt +# The full suite (run whenever a shared/core file changes, or on a direct push to develop/main) +# takes ~50-60 minutes on this agent's real Pi Zero W test server - right at, and apparently now +# sometimes past, Azure DevOps' default 60 minute job timeout. Raised with headroom rather than +# tuned precisely, since runtime varies with the test server's own state (accumulated groups/ +# lists/history from previous runs) and isn't worth chasing exactly. +timeoutInMinutes: 120 + variables: - group: dns3 - name: testResultsFile From 76f06186bfd3f42cdd7bcf7874197220450e2f42 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 21:54:17 -0500 Subject: [PATCH 4/6] ci: fix invalid top-level timeoutInMinutes in Pester pipeline The previous commit's timeoutInMinutes was rejected outright by Azure DevOps' YAML validation ("Unexpected value 'timeoutInMinutes'" at line 21) - it isn't valid as a bare pipeline-level key, only nested under an explicit job. Restructured the pipeline under jobs: so the 120-minute timeout actually takes effect. Co-Authored-By: Claude Sonnet 5 --- azuredevops-pihole-pester-tests.yml | 347 ++++++++++++++-------------- 1 file changed, 174 insertions(+), 173 deletions(-) diff --git a/azuredevops-pihole-pester-tests.yml b/azuredevops-pihole-pester-tests.yml index 6e66c04..51c5219 100644 --- a/azuredevops-pihole-pester-tests.yml +++ b/azuredevops-pihole-pester-tests.yml @@ -10,194 +10,195 @@ pr: - main - develop -pool: - name: mmadeja-dt - -# The full suite (run whenever a shared/core file changes, or on a direct push to develop/main) -# takes ~50-60 minutes on this agent's real Pi Zero W test server - right at, and apparently now -# sometimes past, Azure DevOps' default 60 minute job timeout. Raised with headroom rather than -# tuned precisely, since runtime varies with the test server's own state (accumulated groups/ -# lists/history from previous runs) and isn't worth chasing exactly. -timeoutInMinutes: 120 - variables: - group: dns3 - name: testResultsFile value: '$(System.DefaultWorkingDirectory)/TestResults/pester.xml' -steps: - - pwsh: | - # Pinned to major version 5 specifically (not just ">= 5.0.0", which would also accept a - # newer major version like 6.x if one happens to be installed on the agent - Pester's - # result-object shape isn't guaranteed compatible across major versions, and the rest of - # this pipeline (including the failed-test summary below) depends on Pester 5's shape). - $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 } | Sort-Object Version -Descending | Select-Object -First 1 - if (-not $pester) { - Write-Host "Installing Pester 5..." - Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser - } - else { - Write-Host "Using Pester $($pester.Version)" - } - displayName: 'Ensure Pester 5 is available' - - - pwsh: | - $configContent = @" - `$PiHoleServer = [uri]'$env:PIHOLE_SERVER' - `$PiHoleToken = '$env:PIHOLE_TOKEN' - `$PiHoleIgnoreSsl = $env:PIHOLE_IGNORE_SSL - "@ - Set-Content -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Value $configContent -Encoding utf8 - displayName: 'Write integration test config' - env: - PIHOLE_SERVER: $(PiHoleServer) - PIHOLE_TOKEN: $(PiHoleToken) - PIHOLE_IGNORE_SSL: $(PiHoleIgnoreSsl) - - - pwsh: | - Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force - - # On a PR build, scope the run to tests for the files that actually changed - tests/ mirrors - # PiHoleShell/Public// 1:1, so a changed function maps directly to its test file(s). - # Any ambiguity (shared/private code, module manifest, shared test helpers, or the diff itself - # failing) falls back to the full suite rather than risk silently skipping real coverage. - # Direct pushes to develop/main (not PR builds) always run the full suite. - $runFullSuite = $true - $testPaths = [System.Collections.Generic.List[string]]::new() - - if ($env:BUILD_REASON -eq 'PullRequest') { - try { - # The default checkout is shallow, which leaves no common ancestor with the target - # branch to diff against ("no merge base") - unshallow first so the diff can work. - if ((git rev-parse --is-shallow-repository) -eq 'true') { - git fetch --unshallow --quiet - if ($LASTEXITCODE -ne 0) { throw "git fetch --unshallow failed" } - } +jobs: + - job: PesterTests + pool: + name: mmadeja-dt + # The full suite (run whenever a shared/core file changes, or on a direct push to develop/ + # main) takes ~50-60 minutes on this agent's real Pi Zero W test server - right at, and + # apparently now sometimes past, Azure DevOps' default 60 minute job timeout. Raised with + # headroom rather than tuned precisely, since runtime varies with the test server's own + # state (accumulated groups/lists/history from previous runs) and isn't worth chasing + # exactly. Only valid nested under an explicit job, not as a pipeline-level top-level key. + timeoutInMinutes: 120 + steps: + - pwsh: | + # Pinned to major version 5 specifically (not just ">= 5.0.0", which would also accept a + # newer major version like 6.x if one happens to be installed on the agent - Pester's + # result-object shape isn't guaranteed compatible across major versions, and the rest of + # this pipeline (including the failed-test summary below) depends on Pester 5's shape). + $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -eq 5 } | Sort-Object Version -Descending | Select-Object -First 1 + if (-not $pester) { + Write-Host "Installing Pester 5..." + Install-Module -Name Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force -SkipPublisherCheck -Scope CurrentUser + } + else { + Write-Host "Using Pester $($pester.Version)" + } + displayName: 'Ensure Pester 5 is available' + + - pwsh: | + $configContent = @" + `$PiHoleServer = [uri]'$env:PIHOLE_SERVER' + `$PiHoleToken = '$env:PIHOLE_TOKEN' + `$PiHoleIgnoreSsl = $env:PIHOLE_IGNORE_SSL + "@ + Set-Content -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Value $configContent -Encoding utf8 + displayName: 'Write integration test config' + env: + PIHOLE_SERVER: $(PiHoleServer) + PIHOLE_TOKEN: $(PiHoleToken) + PIHOLE_IGNORE_SSL: $(PiHoleIgnoreSsl) + + - pwsh: | + Import-Module Pester -MinimumVersion 5.0.0 -MaximumVersion 5.99.99 -Force + + # On a PR build, scope the run to tests for the files that actually changed - tests/ mirrors + # PiHoleShell/Public// 1:1, so a changed function maps directly to its test file(s). + # Any ambiguity (shared/private code, module manifest, shared test helpers, or the diff itself + # failing) falls back to the full suite rather than risk silently skipping real coverage. + # Direct pushes to develop/main (not PR builds) always run the full suite. + $runFullSuite = $true + $testPaths = [System.Collections.Generic.List[string]]::new() + + if ($env:BUILD_REASON -eq 'PullRequest') { + try { + # The default checkout is shallow, which leaves no common ancestor with the target + # branch to diff against ("no merge base") - unshallow first so the diff can work. + if ((git rev-parse --is-shallow-repository) -eq 'true') { + git fetch --unshallow --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch --unshallow failed" } + } - # Fetch with an explicit refspec so origin/ is created even if the - # checkout's default fetch refspec is restricted to a single branch. - $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH -replace '^refs/heads/', '' - git fetch origin "${targetBranch}:refs/remotes/origin/${targetBranch}" --quiet - if ($LASTEXITCODE -ne 0) { throw "git fetch failed for '$targetBranch'" } - - $changedFiles = git diff --name-only "origin/$targetBranch...HEAD" - if ($LASTEXITCODE -ne 0) { throw "git diff failed against 'origin/$targetBranch'" } - - if ($changedFiles) { - $runFullSuite = $false - - foreach ($file in $changedFiles) { - if ($runFullSuite) { break } - - if ($file -eq 'PiHoleShell/PiHoleShell.psm1') { - # A pure addition to the export list can't break any existing, already- - # passing test, so only fall back to the full suite when an existing - # export was actually removed. Comparing whole diff lines doesn't work - # here since several exports share one line - adding a new function to - # an existing category's line shows as that whole line being removed - # and re-added, even though nothing already there was dropped. Instead, - # extract the quoted names from just the removed lines and just the - # added lines and compare those sets: a name that reappears in the - # added lines was only reformatted, not actually removed. - $manifestDiff = git diff "origin/$targetBranch...HEAD" -- $file - if ($LASTEXITCODE -ne 0) { throw "git diff failed for '$file'" } - - $removedLines = ($manifestDiff | Where-Object { $_ -match '^-' -and $_ -notmatch '^---' }) -join "`n" - $addedLines = ($manifestDiff | Where-Object { $_ -match '^\+' -and $_ -notmatch '^\+\+\+' }) -join "`n" - $removedNames = [regex]::Matches($removedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } - $addedNames = [regex]::Matches($addedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } - - $trulyRemoved = $removedNames | Where-Object { $_ -notin $addedNames } - if ($trulyRemoved) { - Write-Host "Export(s) removed from $file ($($trulyRemoved -join ', ')) - running the full suite" - $runFullSuite = $true + # Fetch with an explicit refspec so origin/ is created even if the + # checkout's default fetch refspec is restricted to a single branch. + $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH -replace '^refs/heads/', '' + git fetch origin "${targetBranch}:refs/remotes/origin/${targetBranch}" --quiet + if ($LASTEXITCODE -ne 0) { throw "git fetch failed for '$targetBranch'" } + + $changedFiles = git diff --name-only "origin/$targetBranch...HEAD" + if ($LASTEXITCODE -ne 0) { throw "git diff failed against 'origin/$targetBranch'" } + + if ($changedFiles) { + $runFullSuite = $false + + foreach ($file in $changedFiles) { + if ($runFullSuite) { break } + + if ($file -eq 'PiHoleShell/PiHoleShell.psm1') { + # A pure addition to the export list can't break any existing, already- + # passing test, so only fall back to the full suite when an existing + # export was actually removed. Comparing whole diff lines doesn't work + # here since several exports share one line - adding a new function to + # an existing category's line shows as that whole line being removed + # and re-added, even though nothing already there was dropped. Instead, + # extract the quoted names from just the removed lines and just the + # added lines and compare those sets: a name that reappears in the + # added lines was only reformatted, not actually removed. + $manifestDiff = git diff "origin/$targetBranch...HEAD" -- $file + if ($LASTEXITCODE -ne 0) { throw "git diff failed for '$file'" } + + $removedLines = ($manifestDiff | Where-Object { $_ -match '^-' -and $_ -notmatch '^---' }) -join "`n" + $addedLines = ($manifestDiff | Where-Object { $_ -match '^\+' -and $_ -notmatch '^\+\+\+' }) -join "`n" + $removedNames = [regex]::Matches($removedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } + $addedNames = [regex]::Matches($addedLines, "'([A-Za-z0-9-]+)'") | ForEach-Object { $_.Groups[1].Value } + + $trulyRemoved = $removedNames | Where-Object { $_ -notin $addedNames } + if ($trulyRemoved) { + Write-Host "Export(s) removed from $file ($($trulyRemoved -join ', ')) - running the full suite" + $runFullSuite = $true + } + else { + Write-Host "Only additions in $file - no full-suite fallback needed" + } } - else { - Write-Host "Only additions in $file - no full-suite fallback needed" + elseif ($file -match '^PiHoleShell/Private/' -or + $file -eq 'PiHoleShell/PiHoleShell.psd1' -or + $file -eq 'PiHoleShell/Public/Authentication/Request-PiHoleAuth.ps1' -or + $file -eq 'tests/Initialize-PiHoleTestData.ps1' -or + $file -eq 'tests/IntegrationConfig.example.ps1') { + Write-Host "Shared/core file changed ($file) - running the full suite" + $runFullSuite = $true } - } - elseif ($file -match '^PiHoleShell/Private/' -or - $file -eq 'PiHoleShell/PiHoleShell.psd1' -or - $file -eq 'PiHoleShell/Public/Authentication/Request-PiHoleAuth.ps1' -or - $file -eq 'tests/Initialize-PiHoleTestData.ps1' -or - $file -eq 'tests/IntegrationConfig.example.ps1') { - Write-Host "Shared/core file changed ($file) - running the full suite" - $runFullSuite = $true - } - elseif ($file -match '^PiHoleShell/Public/([^/]+)/([^/]+)\.ps1$') { - $category = $Matches[1] - $function = $Matches[2] - $testDir = "tests/$category" - if (Test-Path $testDir) { - Get-ChildItem -Path $testDir -Filter "$function.*Tests.ps1" | ForEach-Object { - $testPaths.Add($_.FullName) + elseif ($file -match '^PiHoleShell/Public/([^/]+)/([^/]+)\.ps1$') { + $category = $Matches[1] + $function = $Matches[2] + $testDir = "tests/$category" + if (Test-Path $testDir) { + Get-ChildItem -Path $testDir -Filter "$function.*Tests.ps1" | ForEach-Object { + $testPaths.Add($_.FullName) + } } } + elseif ($file -match '^tests/.*\.Tests\.ps1$' -and (Test-Path $file)) { + $testPaths.Add((Resolve-Path $file).Path) + } + # Anything else (README, workflows, tools/, etc.) has no test impact - ignored. } - elseif ($file -match '^tests/.*\.Tests\.ps1$' -and (Test-Path $file)) { - $testPaths.Add((Resolve-Path $file).Path) - } - # Anything else (README, workflows, tools/, etc.) has no test impact - ignored. } } + catch { + Write-Warning "Could not compute changed-file test scope, falling back to the full suite: $($_.Exception.Message)" + $runFullSuite = $true + } + } + + $config = New-PesterConfiguration + # Exit is handled manually below (after printing the failed-test summary) instead of + # letting Invoke-Pester call exit itself, which would skip that summary entirely. + $config.Run.Exit = $false + $config.Run.PassThru = $true + $config.Output.Verbosity = 'Detailed' + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = '$(testResultsFile)' + + $testPaths = $testPaths | Select-Object -Unique + if ($runFullSuite -or $testPaths.Count -eq 0) { + Write-Host "Running the full test suite" + $config.Run.Path = './tests' } - catch { - Write-Warning "Could not compute changed-file test scope, falling back to the full suite: $($_.Exception.Message)" - $runFullSuite = $true + else { + Write-Host "Running scoped tests for changed files:" + $testPaths | ForEach-Object { Write-Host " $_" } + $config.Run.Path = $testPaths } - } - - $config = New-PesterConfiguration - # Exit is handled manually below (after printing the failed-test summary) instead of - # letting Invoke-Pester call exit itself, which would skip that summary entirely. - $config.Run.Exit = $false - $config.Run.PassThru = $true - $config.Output.Verbosity = 'Detailed' - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = 'NUnitXml' - $config.TestResult.OutputPath = '$(testResultsFile)' - - $testPaths = $testPaths | Select-Object -Unique - if ($runFullSuite -or $testPaths.Count -eq 0) { - Write-Host "Running the full test suite" - $config.Run.Path = './tests' - } - else { - Write-Host "Running scoped tests for changed files:" - $testPaths | ForEach-Object { Write-Host " $_" } - $config.Run.Path = $testPaths - } - - $result = Invoke-Pester -Configuration $config - - if ($result.FailedCount -gt 0) { - Write-Host "" - Write-Host "==================================================" - Write-Host " FAILED TESTS ($($result.FailedCount))" - Write-Host "==================================================" - foreach ($test in $result.Failed) { + + $result = Invoke-Pester -Configuration $config + + if ($result.FailedCount -gt 0) { Write-Host "" - Write-Host "[-] $($test.ExpandedPath)" - foreach ($errorRecord in $test.ErrorRecord) { - Write-Host " $($errorRecord.Exception.Message)" + Write-Host "==================================================" + Write-Host " FAILED TESTS ($($result.FailedCount))" + Write-Host "==================================================" + foreach ($test in $result.Failed) { + Write-Host "" + Write-Host "[-] $($test.ExpandedPath)" + foreach ($errorRecord in $test.ErrorRecord) { + Write-Host " $($errorRecord.Exception.Message)" + } } + Write-Host "" } - Write-Host "" - } - - exit $result.FailedCount - displayName: 'Run Pester tests' - workingDirectory: '$(System.DefaultWorkingDirectory)' - - - pwsh: | - Remove-Item -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Force -ErrorAction SilentlyContinue - displayName: 'Clean up integration test config' - condition: always() - - - task: PublishTestResults@2 - displayName: 'Publish test results' - condition: succeededOrFailed() - inputs: - testResultsFormat: 'NUnit' - testResultsFiles: '$(testResultsFile)' - failTaskOnFailedTests: false + + exit $result.FailedCount + displayName: 'Run Pester tests' + workingDirectory: '$(System.DefaultWorkingDirectory)' + + - pwsh: | + Remove-Item -Path (Join-Path '$(System.DefaultWorkingDirectory)' 'tests/IntegrationConfig.local.ps1') -Force -ErrorAction SilentlyContinue + displayName: 'Clean up integration test config' + condition: always() + + - task: PublishTestResults@2 + displayName: 'Publish test results' + condition: succeededOrFailed() + inputs: + testResultsFormat: 'NUnit' + testResultsFiles: '$(testResultsFile)' + failTaskOnFailedTests: false From f06e4a43dd32699352c09f156f65f7bbdb255951 Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 22:26:36 -0500 Subject: [PATCH 5/6] ci: trigger a fresh Pester run The previous CI run for this PR got stuck after a manually-queued build was accidentally routed to the Microsoft-hosted pool instead of the self-hosted mmadeja-dt pool - this empty commit re-triggers a normal PR-sync build through the correct path. Co-Authored-By: Claude Sonnet 5 From 9d0a424f4f4d02e0af6ef8e2985ad7e82cde199a Mon Sep 17 00:00:00 2001 From: Mike Madeja Date: Sat, 10 Oct 2026 23:36:12 -0500 Subject: [PATCH 6/6] ci: re-trigger after transient full-suite flakiness The full suite run (build 403) failed 2 of 276 tests - both reproduced as clean passes when re-run in isolation moments later (Update-PiHoleList and Import-PiHoleTeleporter's integration tests), consistent with this project's well-documented pattern of transient connection hiccups on the real (weak, single-core) Pi Zero W test server under ~60 minutes of sustained load. Not a regression from this PR's changes. Co-Authored-By: Claude Sonnet 5