Public/Invoke-AICodeReviewPipeline.ps1
|
function Invoke-AICodeReviewPipeline { <# .SYNOPSIS Executes the complete AI Code Review pipeline for Azure DevOps. .DESCRIPTION This cmdlet encapsulates all the logic needed to run AI code review in an Azure DevOps pipeline: - Git repository setup and cloning - Installing and loading the AI Code Review module - Gathering statistics from git diff - Executing the AI review - Creating artifacts and reports - Setting pipeline variables for downstream tasks .PARAMETER BaseBranch The base branch to compare against (e.g., 'origin/main') .PARAMETER Provider The AI provider to use ('github', 'azure', 'openai', 'anthropic') .PARAMETER SeverityFailBuild Severity level that should fail the build ('error', 'warning', 'none') .PARAMETER CustomRulesPath Optional path to custom rules directory .PARAMETER CustomConfigPath Optional path to custom model-config.json .PARAMETER AccessToken System.AccessToken from Azure DevOps for git authentication .PARAMETER RepositoryUri Build.Repository.Uri from Azure DevOps .PARAMETER SourceBranch Build.SourceBranch from Azure DevOps .PARAMETER BuildReason Build.Reason from Azure DevOps (e.g., 'PullRequest') .PARAMETER ArtifactStagingDirectory Build.ArtifactStagingDirectory from Azure DevOps .PARAMETER BuildSourcesDirectory Build.SourcesDirectory from Azure DevOps (where rules are generated) .PARAMETER BuildId Build.BuildId from Azure DevOps (for temp directory naming) .EXAMPLE Invoke-AICodeReviewPipeline -BaseBranch 'origin/main' -Provider 'azure' -SeverityFailBuild 'error' ` -AccessToken $env:SYSTEM_ACCESSTOKEN -RepositoryUri $env:BUILD_REPOSITORY_URI ` -SourceBranch $env:BUILD_SOURCEBRANCH -BuildReason $env:BUILD_REASON #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$BaseBranch, [Parameter(Mandatory)] [ValidateSet('github', 'azure', 'openai', 'anthropic')] [string]$Provider, [Parameter()] [ValidateSet('error', 'warning', 'none')] [string]$SeverityFailBuild = 'error', [Parameter()] [string]$CustomRulesPath = '', [Parameter()] [string]$CustomConfigPath = '', [Parameter(Mandatory)] [string]$AccessToken, [Parameter(Mandatory)] [string]$RepositoryUri, [Parameter(Mandatory)] [string]$SourceBranch, [Parameter(Mandatory)] [string]$BuildReason, [Parameter(Mandatory)] [string]$ArtifactStagingDirectory, [Parameter(Mandatory)] [string]$BuildSourcesDirectory, [Parameter(Mandatory)] [string]$BuildId, [Parameter()] [string]$BuildNumber = '', [Parameter()] [string]$SourceVersion = '' ) $ErrorActionPreference = 'Continue' $global:LASTEXITCODE = 0 Write-Host "##[section]AI Code Review Pipeline" # ========================================= # STEP 1: Ensure git is available # ========================================= Write-Host "`n--- Step 1: Finding git ---" $gitPath = (Get-Command git -ErrorAction SilentlyContinue)?.Source if (-not $gitPath) { Write-Host "Git not in PATH, searching..." $whereResult = where.exe git.exe 2>$null if ($LASTEXITCODE -eq 0 -and $whereResult) { $gitDir = Split-Path $whereResult[0] -Parent $env:PATH = "$gitDir;$env:PATH" Write-Host "✓ Found git via where.exe: $gitDir" } else { # Deep search for git.exe Write-Host "Searching filesystem for git.exe..." $searchPaths = @( "C:\Program Files", "C:\Program Files (x86)", "C:\tools", "C:\Git", "${env:AGENT_HOMEDIRECTORY}", "${env:AGENT_TOOLSDIRECTORY}" ) $gitFound = $false foreach ($searchRoot in $searchPaths) { if (-not (Test-Path $searchRoot)) { continue } Write-Host " Searching: $searchRoot" $found = Get-ChildItem -Path $searchRoot -Filter "git.exe" -Recurse -ErrorAction SilentlyContinue -Depth 4 | Where-Object { $_.FullName -match '\\cmd\\git\.exe$' } | Select-Object -First 1 if ($found) { $gitDir = Split-Path $found.FullName -Parent $env:PATH = "$gitDir;$env:PATH" Write-Host "✓ Found git: $gitDir" $gitFound = $true break } } if (-not $gitFound) { Write-Host "##[warning]Git not found on agent" Write-Host "##[warning]Skipping AI Code Review - git is required but not available" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } } } git --version # ========================================= # STEP 2: Clone repository to temp folder # ========================================= Write-Host "`n--- Step 2: Cloning repository ---" $tempRoot = Join-Path $env:TEMP "ai-code-review-$BuildId" if (Test-Path $tempRoot) { Remove-Item $tempRoot -Recurse -Force } New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null Write-Host "Created temp directory: $tempRoot" $sourceBranch = $SourceBranch -replace '^refs/heads/', '' $baseBranch = $BaseBranch -replace '^origin/', '' # Prepare authenticated URL $cloneUrl = $RepositoryUri if ($AccessToken -and -not $AccessToken.StartsWith('$(')) { $cloneUrl = $RepositoryUri -replace 'https://[^@]+@', 'https://' $cloneUrl = $cloneUrl -replace 'https://', "https://PAT:$AccessToken@" Write-Host "Using authenticated clone" } else { Write-Host "##[warning]System.AccessToken not available" } Write-Host "Repository: $RepositoryUri" Write-Host "Source Branch: $sourceBranch" Write-Host "Base Branch: $baseBranch" Set-Location $tempRoot git config --global credential.useHttpPath true # Clone based on build reason if ($BuildReason -eq "PullRequest") { Write-Host "PR build detected - cloning base branch first" git clone --depth=50 --single-branch --branch $baseBranch $cloneUrl repo 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { Write-Host "##[warning]Failed to clone repository - skipping AI review" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } Set-Location (Join-Path $tempRoot "repo") git fetch origin $SourceBranch --depth=50 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -eq 0) { git checkout FETCH_HEAD 2>&1 | ForEach-Object { Write-Host $_ } Write-Host "✓ PR merge commit checked out" } } else { git clone --depth=50 --single-branch --branch $sourceBranch $cloneUrl repo 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { Write-Host "##[warning]Failed to clone repository - skipping AI review" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } Set-Location (Join-Path $tempRoot "repo") git fetch origin "${baseBranch}:${baseBranch}" --depth=50 2>&1 | ForEach-Object { Write-Host $_ } if ($LASTEXITCODE -ne 0) { git fetch origin 2>&1 | ForEach-Object { Write-Host $_ } } } $repoPath = Get-Location Write-Host "`n✓ Repository ready at: $repoPath" # ========================================= # STEP 3: Gather statistics # ========================================= Write-Host "`n--- Step 3: Gathering Statistics ---" # Determine the correct comparison - HEAD vs base branch # In PR builds, we compare the merge commit (HEAD) against the base branch # The base branch was fetched as origin/$baseBranch, so we use that ref $compareRef = "origin/$baseBranch" # Get file changes (files in HEAD that differ from base) $filesChanged = (git diff --name-only $compareRef...HEAD 2>$null | Measure-Object).Count # Get line statistics (additions/deletions from base to HEAD) $numstatOutput = git diff --numstat $compareRef...HEAD 2>$null $linesAdded = ($numstatOutput | ForEach-Object { $parts = $_ -split '\s+' if ($parts[0] -match '^\d+$') { [int]$parts[0] } else { 0 } } | Measure-Object -Sum).Sum $linesRemoved = ($numstatOutput | ForEach-Object { $parts = $_ -split '\s+' if ($parts[1] -match '^\d+$') { [int]$parts[1] } else { 0 } } | Measure-Object -Sum).Sum # Count AL files changed $alFiles = (git diff --name-only $compareRef...HEAD 2>$null | Where-Object { $_ -match '\.al$' } | Measure-Object).Count # Count AL object types changed $alObjectTypes = @{} git diff --name-only $compareRef...HEAD 2>$null | Where-Object { $_ -match '\.al$' } | ForEach-Object { $content = Get-Content $_ -Raw -ErrorAction SilentlyContinue if ($content -match '^\s*(table|codeunit|page|report|query|xmlport|enum|interface|permissionset|entitlement|profile)\s+\d+') { $type = $matches[1] $alObjectTypes[$type] = ($alObjectTypes[$type] ?? 0) + 1 } } $objectsSummary = if ($alObjectTypes.Count -gt 0) { ($alObjectTypes.GetEnumerator() | ForEach-Object { "$($_.Value) $($_.Key)$(if($_.Value -gt 1){'s'}else{''})" }) -join ', ' } else { "No AL objects" } # Ensure stats have valid values (default to 0 if null) if (-not $filesChanged) { $filesChanged = 0 } if (-not $linesAdded) { $linesAdded = 0 } if (-not $linesRemoved) { $linesRemoved = 0 } if (-not $alFiles) { $alFiles = 0 } Write-Host "Files changed: $filesChanged" Write-Host "Lines: +$linesAdded -$linesRemoved" Write-Host "AL files: $alFiles" Write-Host "Objects: $objectsSummary" # Extract main guidelines from rules file (no AI call needed) $rulesPath = if ($CustomRulesPath -ne '') { $CustomRulesPath } else { "$BuildSourcesDirectory/.devops/code-review/rules" } $guidelines = @() if (Test-Path "$rulesPath/ifacto-company-rules.md") { $rulesContent = Get-Content "$rulesPath/ifacto-company-rules.md" -Raw # Extract only level 2 headers (##) that are in ALL CAPS - these are the main guidelines $matches = [regex]::Matches($rulesContent, '(?m)^##\s+([A-Z][A-Z\s]+[A-Z])\s*$') $guidelines = $matches | ForEach-Object { $_.Groups[1].Value.Trim() } | Where-Object { # Filter out section headers like "SEVERITY" $_ -notmatch '^SEVERITY$' -and $_ -notmatch '^NOTE:' } } $guidelinesText = if ($guidelines.Count -gt 0) { ($guidelines | ForEach-Object { "✓ $_" }) -join "|||" } else { "✓ Standard AL coding guidelines|||✓ iFacto company rules" } Write-Host "`nGuidelines being applied:" $guidelines | ForEach-Object { Write-Host " - $_" } # Store stats as pipeline variables Write-Host "##vso[task.setvariable variable=ReviewStats_FilesChanged]$filesChanged" Write-Host "##vso[task.setvariable variable=ReviewStats_LinesAdded]$linesAdded" Write-Host "##vso[task.setvariable variable=ReviewStats_LinesRemoved]$linesRemoved" Write-Host "##vso[task.setvariable variable=ReviewStats_ALFiles]$alFiles" Write-Host "##vso[task.setvariable variable=ReviewStats_ObjectsSummary]$objectsSummary" Write-Host "##vso[task.setvariable variable=ReviewStats_Guidelines]$guidelinesText" # ========================================= # STEP 4: Execute AI Code Review # ========================================= Write-Host "`n--- Step 4: Running AI Code Review ---" $reviewParams = @{ BaseBranch = $BaseBranch Provider = $Provider SeverityFailBuild = $SeverityFailBuild } if ($CustomRulesPath -ne '') { $reviewParams['RulesPath'] = $CustomRulesPath Write-Host "Using custom rules from: $CustomRulesPath" } else { $reviewParams['RulesPath'] = $rulesPath Write-Host "Using iFacto default rules" } if ($CustomConfigPath -ne '') { $reviewParams['ConfigPath'] = $CustomConfigPath } try { $result = Invoke-AICodeReview @reviewParams # Check for deployment errors if (-not $result -or $result.RawResponse -match 'DeploymentNotFound') { Write-Host "##[warning]AI deployment not available - skipping review" Write-Host "##vso[build.addbuildtag]AI-Review-Skipped" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } Write-Host "`n##[section]Review Complete" Write-Host "`nViolations Summary:" Write-Host " Errors: $($result.ErrorCount)" Write-Host " Warnings: $($result.WarningCount)" Write-Host " Info: $($result.InfoCount)" if ($result.Violations.Count -eq 0) { Write-Host "`n✅ No violations found!" Write-Host "##vso[build.addbuildtag]AI-Review-Passed" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]true" Write-Host "##vso[task.setvariable variable=AI_REVIEW_VIOLATIONS_JSON][]" # Save success report $artifactDir = Join-Path $ArtifactStagingDirectory "AICodeReview" if (-not (Test-Path $artifactDir)) { New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null } $buildDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $successReport = "# ✅ AI Code Review - All Clear!`n`n" $successReport += "**Build:** $BuildNumber`n" $successReport += "**Date:** $buildDate`n" $successReport += "**Branch:** $SourceBranch`n" $successReport += "**Commit:** $SourceVersion`n`n" $successReport += "No violations found! 🎉`n" $mdPath = Join-Path $artifactDir "code-review-report.md" $successReport | Out-File $mdPath -Encoding UTF8 exit 0 } else { # Output violations $adoOutput = $result.Violations | ConvertTo-ADOLogFormat Write-Host $adoOutput # Store violations for PR comment $violationsJson = $result.Violations | ConvertTo-Json -Compress -Depth 10 Write-Host "##vso[task.setvariable variable=AI_REVIEW_VIOLATIONS_JSON]$violationsJson" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]true" # Save artifacts $artifactDir = Join-Path $ArtifactStagingDirectory "AICodeReview" if (-not (Test-Path $artifactDir)) { New-Item -ItemType Directory -Path $artifactDir -Force | Out-Null } $jsonPath = Join-Path $artifactDir "code-review-results.json" $result | ConvertTo-Json -Depth 10 | Out-File $jsonPath -Encoding UTF8 $rawPath = Join-Path $artifactDir "ai-raw-response.txt" $result.RawResponse | Out-File $rawPath -Encoding UTF8 # Create markdown report $mdPath = Join-Path $artifactDir "code-review-report.md" $buildDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $mdReport = "# AI Code Review Report`n`n" $mdReport += "**Build:** $BuildNumber`n" $mdReport += "**Date:** $buildDate`n" $mdReport += "**Branch:** $SourceBranch`n" $mdReport += "**Commit:** $SourceVersion`n`n" $mdReport += "## Summary`n`n" $mdReport += "- **Errors:** $($result.ErrorCount)`n" $mdReport += "- **Warnings:** $($result.WarningCount)`n" $mdReport += "- **Info:** $($result.InfoCount)`n" $mdReport += "- **Total:** $($result.Violations.Count)`n`n" $mdReport += "## Violations`n`n" foreach ($violation in ($result.Violations | Sort-Object -Property severity, file, line)) { $emoji = switch ($violation.severity) { 'error' { '❌' } 'warning' { '⚠️' } 'info' { 'ℹ️' } } $mdReport += "`n### $emoji $($violation.severity.ToUpper()): $($violation.file)`n`n" $mdReport += "**Line:** $($violation.line)`n" $mdReport += "**Message:** $($violation.message)`n`n" if ($violation.suggestion) { $mdReport += "**Suggestion:** $($violation.suggestion)`n`n" } } $mdReport | Out-File $mdPath -Encoding UTF8 # Add build tags if ($result.ErrorCount -gt 0) { Write-Host "##vso[build.addbuildtag]AI-Review-Errors" } if ($result.WarningCount -gt 0) { Write-Host "##vso[build.addbuildtag]AI-Review-Warnings" } # Never fail build Write-Host "`n##[warning]Violations found - build continues (informational only)" Write-Host "##vso[build.addbuildtag]AI-Review-Violations-Found" exit 0 } } catch { $errorMessage = $_.Exception.Message # Check for deployment errors if ($errorMessage -match 'DeploymentNotFound') { Write-Host "##[warning]AI deployment not available - skipping review" Write-Host "##vso[build.addbuildtag]AI-Review-Skipped" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } Write-Host "##[warning]AI Code Review encountered an error" Write-Host "##[warning]Error: $errorMessage" Write-Host "##[warning]Build continues - AI review is optional" Write-Host "##vso[build.addbuildtag]AI-Review-Error" Write-Host "##vso[task.setvariable variable=HasAIReviewResults]false" exit 0 } } |