Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,14 @@ jobs:
- name: Validate control-plane contracts
shell: pwsh
run: ./tests/contract-tests.ps1

- name: Run Pester unit tests
shell: pwsh
run: |
Set-PSRepository PSGallery -InstallationPolicy Trusted
Install-Module Pester -MinimumVersion 5.5.0 -Scope CurrentUser -Force -SkipPublisherCheck
$config = New-PesterConfiguration
$config.Run.Path = './tests'
$config.Run.Exit = $true
$config.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $config
26 changes: 13 additions & 13 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: "CodeQL"
on:
push:
branches: [ "main", "master" ]
branches: ["main", "master"]
pull_request:
branches: [ "main", "master" ]
branches: ["main", "master"]
jobs:
analyze:
name: Analyze
Expand All @@ -15,15 +15,15 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
language: ['python']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: 3.x
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,25 @@ flowchart LR
- Minimal diffs only - no secrets, no destructive operations.
- Required supported verification before PR creation, with explicit approved exceptions only.

## Testing

Run the full suite (workflow YAML validation, control-plane contract tests, and
Pester unit tests) with a single command:

```powershell
pwsh ./tests/run-tests.ps1
```

Unit tests (Pester 5) cover the safety- and payload-critical logic:
`Assert-SafeChangeSet` (sensitive-path and diff-budget guards), `Get-ChangedFile`
(porcelain parsing), `Search-Issue` (GraphQL request construction), and the
`Autopilot.Common` helpers (`Get-RepoName`, `Invoke-GhJson`, `Get-LogTail`).

## Workflows

| Workflow | Trigger | Purpose |
|----------|---------|---------|
| `ci.yml` | push/PR to main | Portfolio CI - YAML validation (ubuntu-latest) |
| `ci.yml` | push/PR to main | Portfolio CI - YAML validation + Pester unit tests (ubuntu-latest) |
| `autopilot-operator.yml` | schedule + dispatch | Core operator - scan issues, run Codex, open PRs |
| `autopilot-org-installer.yml` | hourly + dispatch | Install intake workflow into opted-in repos |
| `autopilot-create-issue.yml` | workflow_run failure | Create intake issue when monitored workflow fails |
Expand Down
21 changes: 11 additions & 10 deletions scripts/autopilot-operator.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ Initialize-Log
Assert-Env -Name "ORG"
$org = $env:ORG

$maxIssues = [int]($env:MAX_ISSUES ?? 5)
$dryRun = ($env:DRY_RUN ?? "false") -eq "true"
$allowUnverified = ($env:ALLOW_UNVERIFIED ?? "false") -eq "true"
$maxIssues = if ($env:MAX_ISSUES) { [int]$env:MAX_ISSUES } else { 5 }
$dryRun = if ($env:DRY_RUN) { $env:DRY_RUN -eq "true" } else { $false }
$allowUnverified = if ($env:ALLOW_UNVERIFIED) { $env:ALLOW_UNVERIFIED -eq "true" } else { $false }
$allowlist = @()
if ($env:REPO_ALLOWLIST) {
$allowlist = $env:REPO_ALLOWLIST.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ }
Expand All @@ -23,7 +23,7 @@ Test-Tool -Name "codex"
Write-Log "Autopilot operator starting for org: $org"
Write-Log "Max issues: $maxIssues Dry run: $dryRun"

function Get-ChangedFiles {
function Get-ChangedFile {
$paths = @()
foreach ($line in @(git status --porcelain)) {
if (-not $line -or $line.Length -lt 4) { continue }
Expand Down Expand Up @@ -56,7 +56,7 @@ function Assert-SafeChangeSet {
if ($changedLines -gt $MaxLines) { throw "Change set has $changedLines changed lines; limit is $MaxLines." }
}

function Search-Issues {
function Search-Issue {
param([string]$SearchQuery, [int]$First)
$gql = @'
query($q:String!, $first:Int!) {
Expand All @@ -83,7 +83,7 @@ query($q:String!, $first:Int!) {

$issues = @()
$query = "org:$org is:issue label:autofix label:queued -label:blocked -label:risky -label:needs-design -label:try-3"
$issues += Search-Issues -SearchQuery $query -First $maxIssues
$issues += Search-Issue -SearchQuery $query -First $maxIssues

if (-not $issues -or $issues.Count -eq 0) {
Write-Log "No issues found."
Expand Down Expand Up @@ -117,7 +117,8 @@ foreach ($issue in $issues) {
continue
}

$attempt = 1 if ($existingLabels -contains "try-2") { $attempt = 3 }
$attempt = 1
if ($existingLabels -contains "try-2") { $attempt = 3 }
elseif ($existingLabels -contains "try-1") { $attempt = 2 }
$attemptLabel = $attemptLabels[$attempt - 1]

Expand Down Expand Up @@ -234,9 +235,9 @@ foreach ($issue in $issues) {
continue
}

$filesChanged = @(Get-ChangedFiles)
$maxChangedFiles = [int]($env:MAX_CHANGED_FILES ?? 20)
$maxChangedLines = [int]($env:MAX_CHANGED_LINES ?? 1000)
$filesChanged = @(Get-ChangedFile)
$maxChangedFiles = if ($env:MAX_CHANGED_FILES) { [int]$env:MAX_CHANGED_FILES } else { 20 }
$maxChangedLines = if ($env:MAX_CHANGED_LINES) { [int]$env:MAX_CHANGED_LINES } else { 1000 }
Assert-SafeChangeSet -Paths $filesChanged -MaxFiles $maxChangedFiles -MaxLines $maxChangedLines

$verification = "skipped"
Expand Down
24 changes: 12 additions & 12 deletions scripts/lib/Autopilot.Common.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ssZ")
$line = "[$timestamp] [$Level] $Message"
Write-Host $line
Write-Output $line
if ($script:LogFile) {
Add-Content -Path $script:LogFile -Value $line
}
Expand All @@ -24,8 +24,8 @@ function Assert-Env {
}

function Invoke-Gh {
param([string[]]$Args)
$cmd = @("gh") + $Args
param([string[]]$Arguments)
$cmd = @("gh") + $Arguments
Write-Log "Running: $($cmd -join ' ')"
& $cmd
if ($LASTEXITCODE -ne 0) {
Expand All @@ -34,8 +34,8 @@ function Invoke-Gh {
}

function Invoke-GhJson {
param([string[]]$Args)
$json = gh @Args 2>$null
param([string[]]$Arguments)
$json = gh @Arguments 2>$null
if (-not $json) { return $null }
$trimmed = $json.Trim()
$objIndex = $trimmed.IndexOf('{')
Expand All @@ -62,21 +62,21 @@ function Test-Tool {
}

function Invoke-Checked {
param([string]$Command, [string[]]$Args = @())
Write-Log "Running: $Command $($Args -join ' ')"
& $Command @Args
param([string]$Command, [string[]]$Arguments = @())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the wrapper parameter name used by callers

The operator still calls these helpers with -Args (for example Invoke-Checked -Command "codex" -Args ...), but this rename removes the Args parameter; -Args is not a valid abbreviation of -Arguments, so non-dry-run processing fails with “parameter cannot be found” before Codex or verification can run. Either keep an Args parameter/alias or update every call site to use -Arguments.

Useful? React with 👍 / 👎.

Write-Log "Running: $Command $($Arguments -join ' ')"
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "Command failed: $Command (exit $LASTEXITCODE)"
}
}

function Invoke-CheckedLogged {
param([string]$Command, [string[]]$Args = @(), [string]$LogPath)
Write-Log "Running: $Command $($Args -join ' ')"
param([string]$Command, [string[]]$Arguments = @(), [string]$LogPath)
Write-Log "Running: $Command $($Arguments -join ' ')"
if ($LogPath) {
& $Command @Args 2>&1 | Tee-Object -FilePath $LogPath -Append
& $Command @Arguments 2>&1 | Tee-Object -FilePath $LogPath -Append
} else {
& $Command @Args
& $Command @Arguments
}
if ($LASTEXITCODE -ne 0) {
throw "Command failed: $Command (exit $LASTEXITCODE)"
Expand Down
82 changes: 82 additions & 0 deletions tests/Autopilot.Common.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#requires -Modules Pester

# Unit tests for the pure/deterministic helper functions in
# scripts/lib/Autopilot.Common.psm1. These functions build gh payloads,
# normalise repo names, and parse tool output — the highest-value logic to
# pin down because the operator mutates OTHER repos based on their results.

BeforeAll {
$script:RepoRoot = Split-Path -Parent $PSScriptRoot
$script:ModulePath = Join-Path $RepoRoot "scripts/lib/Autopilot.Common.psm1"
Import-Module $ModulePath -Force
}

AfterAll {
Remove-Module Autopilot.Common -Force -ErrorAction SilentlyContinue
}

Describe "Get-RepoName" {
It "extracts owner/repo from an https clone URL" {
Get-RepoName -RepoUrl "https://github.com/acme/widgets" | Should -Be "acme/widgets"
}

It "tolerates a trailing slash" {
Get-RepoName -RepoUrl "https://github.com/acme/widgets/" | Should -Be "acme/widgets"
}

It "handles an ssh-style URL by taking the final two path segments" {
Get-RepoName -RepoUrl "https://github.com/acme/deep/nested/widgets" | Should -Be "nested/widgets"
}
}

Describe "Invoke-GhJson" {
Context "when gh returns clean JSON" {
BeforeEach {
Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { '{"login":"octocat","type":"User"}' }
}

It "parses a JSON object into a PSCustomObject" {
$result = Invoke-GhJson -Arguments @("api", "user")
$result.login | Should -Be "octocat"
$result.type | Should -Be "User"
}
}

Context "when gh emits a leading warning line before the JSON" {
BeforeEach {
# gh sometimes prints noise on stdout before the payload; the
# function must locate the first { or [ and parse from there.
Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { "Warning: something`n[{""number"":7}]" }
}

It "strips the prefix and parses the array" {
$result = Invoke-GhJson -Arguments @("api", "issues")
$result[0].number | Should -Be 7
}
}

Context "when gh returns nothing" {
BeforeEach {
Mock -CommandName gh -ModuleName Autopilot.Common -MockWith { $null }
}

It "returns null without throwing" {
Invoke-GhJson -Arguments @("api", "nothing") | Should -BeNullOrEmpty
}
}
}

Describe "Get-LogTail" {
It "returns null when the log file does not exist" {
Get-LogTail -LogPath (Join-Path $TestDrive "missing.log") -Lines 5 | Should -BeNullOrEmpty
}

It "returns only the last N lines of an existing log" {
$log = Join-Path $TestDrive "sample.log"
1..10 | ForEach-Object { "line $_" } | Set-Content -Path $log
$tail = Get-LogTail -LogPath $log -Lines 3
$tail | Should -HaveCount 3
$tail[-1] | Should -Be "line 10"
$tail[0] | Should -Be "line 8"
}
}
Loading
Loading