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 ---"
    
    $filesChanged = (git diff --name-only HEAD origin/$baseBranch 2>$null | Measure-Object).Count
    $linesAdded = (git diff --numstat HEAD origin/$baseBranch 2>$null | ForEach-Object { ($_ -split '\s+')[0] } | Where-Object { $_ -match '^\d+$' } | Measure-Object -Sum).Sum
    $linesRemoved = (git diff --numstat HEAD origin/$baseBranch 2>$null | ForEach-Object { ($_ -split '\s+')[1] } | Where-Object { $_ -match '^\d+$' } | Measure-Object -Sum).Sum
    $alFiles = (git diff --name-only HEAD origin/$baseBranch 2>$null | Where-Object { $_ -match '\.al$' } | Measure-Object).Count
    
    # Count AL object types changed
    $alObjectTypes = @{}
    git diff --name-only HEAD origin/$baseBranch 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" }
    
    Write-Host "Files changed: $filesChanged"
    Write-Host "Lines: +$linesAdded -$linesRemoved"
    Write-Host "AL files: $alFiles"
    Write-Host "Objects: $objectsSummary"
    
    # Extract guidelines from rules file
    $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
        $ruleHeaders = [regex]::Matches($rulesContent, '(?m)^#{2,3}\s+(.+)$')
        $guidelines = $ruleHeaders | ForEach-Object { $_.Groups[1].Value.Trim() } | Select-Object -First 10
    }
    
    $guidelinesText = if ($guidelines.Count -gt 0) {
        ($guidelines | ForEach-Object { "✓ $_" }) -join "`n"
    } else {
        "✓ Standard AL coding guidelines`n✓ iFacto company rules"
    }
    
    Write-Host "`nGuidelines used:"
    $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
    }
}