Public/Publish-PRComments.ps1

<#
.SYNOPSIS
Post AI code review violations as PR thread comments using Azure DevOps REST API
 
.DESCRIPTION
Creates inline comments on pull request files at the specific line numbers where violations were detected.
Uses Azure DevOps REST API to post comments. Requires System.AccessToken with vso.code_write permissions.
 
.PARAMETER Violations
Array of violation objects from Invoke-AICodeReview (must have file, line, severity, message properties)
 
.PARAMETER AccessToken
Azure DevOps OAuth token (typically System.AccessToken from pipeline)
 
.PARAMETER OrganizationUrl
Azure DevOps organization URL (e.g., https://dev.azure.com/iFacto/)
Default: Uses System.TeamFoundationCollectionUri environment variable
 
.PARAMETER ProjectName
Project name containing the repository
Default: Uses System.TeamProject environment variable
 
.PARAMETER RepositoryId
Repository GUID
Default: Uses Build.Repository.ID environment variable
 
.PARAMETER PullRequestId
Pull request ID number
Default: Uses System.PullRequest.PullRequestId environment variable
 
.PARAMETER SkipDuplicates
Skip posting if a comment already exists at the same file:line location
 
.EXAMPLE
$violations = Invoke-AICodeReview -ReviewContext $context -Rules $rules
Publish-PRComments -Violations $violations -AccessToken $env:SYSTEM_ACCESSTOKEN
 
.NOTES
- Requires "Allow scripts to access OAuth token" enabled in pipeline settings
- File paths must use forward slashes (/) and start with /
- Handles API failures gracefully without throwing exceptions
#>

function Publish-PRComments {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$Violations,
        
        [Parameter(Mandatory)]
        [string]$AccessToken,
        
        [string]$OrganizationUrl = $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI,
        
        [string]$ProjectName = $env:SYSTEM_TEAMPROJECT,
        
        [string]$RepositoryId = $env:BUILD_REPOSITORY_ID,
        
        [string]$PullRequestId = $env:SYSTEM_PULLREQUEST_PULLREQUESTID,
        
        [switch]$SkipDuplicates
    )
    
    Write-Host "`n========================================="
    Write-Host "Publishing PR Comments via REST API"
    Write-Host "=========================================`n"
    
    # Validate required context
    if ([string]::IsNullOrWhiteSpace($OrganizationUrl)) {
        throw "OrganizationUrl is required (System.TeamFoundationCollectionUri not found)"
    }
    
    if ([string]::IsNullOrWhiteSpace($ProjectName)) {
        throw "ProjectName is required (System.TeamProject not found)"
    }
    
    if ([string]::IsNullOrWhiteSpace($RepositoryId)) {
        throw "RepositoryId is required (Build.Repository.ID not found)"
    }
    
    if ([string]::IsNullOrWhiteSpace($PullRequestId)) {
        throw "PullRequestId is required (System.PullRequest.PullRequestId not found). Are you running in a PR build?"
    }
    
    Write-Host "Organization: $OrganizationUrl"
    Write-Host "Project: $ProjectName"
    Write-Host "Repository: $RepositoryId"
    Write-Host "Pull Request: $PullRequestId"
    Write-Host "Violations to post: $($Violations.Count)"
    Write-Host ""
    
    # Prepare API client
    $headers = @{
        "Authorization" = "Bearer $AccessToken"
        "Content-Type" = "application/json"
    }
    
    $baseUri = "$OrganizationUrl$ProjectName/_apis/git/repositories/$RepositoryId/pullRequests/$PullRequestId"
    
    # Get existing threads if deduplication is enabled
    $existingThreads = @()
    if ($SkipDuplicates) {
        try {
            Write-Host "Fetching existing PR threads for deduplication..."
            $response = Invoke-RestMethod -Uri "$baseUri/threads?api-version=7.1" -Headers $headers -Method Get
            $existingThreads = $response.value
            Write-Host "Found $($existingThreads.Count) existing thread(s)`n"
        } catch {
            Write-Warning "Failed to fetch existing threads: $($_.Exception.Message)"
            Write-Warning "Proceeding without deduplication"
        }
    }
    
    # Post comments
    $successCount = 0
    $skippedCount = 0
    $failedCount = 0
    
    foreach ($violation in $Violations) {
        # Normalize file path (must start with / and use forward slashes)
        $filePath = $violation.file -replace '\\', '/'
        if (-not $filePath.StartsWith('/')) {
            $filePath = "/$filePath"
        }
        
        Write-Verbose "Processing: ${filePath}:$($violation.line) - $($violation.severity)"
        
        # Check for duplicates
        if ($SkipDuplicates) {
            $duplicate = $existingThreads | Where-Object {
                $_.threadContext -and
                $_.threadContext.filePath -eq $filePath -and
                $_.threadContext.rightFileStart -and
                $_.threadContext.rightFileStart.line -eq $violation.line
            }
            
            if ($duplicate) {
                Write-Host " ⏭️ Skipped (duplicate): ${filePath}:$($violation.line)"
                $skippedCount++
                continue
            }
        }
        
        # Build comment content
        $emoji = switch ($violation.severity) {
            'error' { '❌' }
            'warning' { '⚠️' }
            'info' { 'ℹ️' }
            default { '💡' }
        }
        
        $commentContent = "**$emoji $($violation.severity.ToUpper())**: $($violation.message)"
        
        if ($violation.suggestion) {
            $commentContent += "`n`n**Suggested Fix:**`n``````al`n$($violation.suggestion)`n``````"
        }
        
        $commentContent += "`n---`n*Generated by AI Code Review*"
        
        # Prepare thread payload
        $threadPayload = @{
            comments = @(@{
                parentCommentId = 0
                content = $commentContent
                commentType = 1  # Text
            })
            status = if ($violation.severity -eq 'error') { 1 } else { 2 }  # Active (1) or Pending (2)
            threadContext = @{
                filePath = $filePath
                rightFileStart = @{
                    line = $violation.line
                    offset = 1
                }
                rightFileEnd = @{
                    line = $violation.line
                    offset = 999
                }
            }
        } | ConvertTo-Json -Depth 10
        
        # Post thread
        try {
            $null = Invoke-RestMethod -Uri "$baseUri/threads?api-version=7.1" `
                -Headers $headers `
                -Method Post `
                -Body $threadPayload `
                -TimeoutSec 30
            
            Write-Host " ✅ Posted: ${filePath}:$($violation.line)"
            $successCount++
        } catch {
            Write-Warning "Failed to post comment to ${filePath}:$($violation.line): $($_.Exception.Message)"
            $failedCount++
            
            # Log detailed error for debugging
            if ($_.ErrorDetails.Message) {
                Write-Verbose "API Error Details: $($_.ErrorDetails.Message)"
            }
        }
    }
    
    # Summary
    Write-Host "`n========================================="
    Write-Host "PR Comment Summary"
    Write-Host "=========================================`n"
    Write-Host "✅ Posted: $successCount"
    
    if ($skippedCount -gt 0) {
        Write-Host "⏭️ Skipped: $skippedCount (duplicates)"
    }
    
    if ($failedCount -gt 0) {
        Write-Host "❌ Failed: $failedCount"
    }
    
    Write-Host "`n========================================="
    
    return @{
        Success = $successCount
        Skipped = $skippedCount
        Failed = $failedCount
    }
}

Export-ModuleMember -Function Publish-PRComments