functions/DailyBuild/Merge-DailyBuildBranch.ps1
|
<# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. #> [CmdletBinding()] param( [string]$OrganizationUri = $env:SYSTEM_COLLECTIONURI, [string]$Project = $env:SYSTEM_TEAMPROJECTID, [string]$RepositoryName = $env:BUILD_REPOSITORY_NAME, [string]$DailyBuildBranch = 'daily-build', [ValidateSet('merge', 'squash')] [string]$MergeStrategy = 'merge', [int]$DefaultPriority = 100, [string]$Pat = $env:DEVOPS_PAT, [switch]$SkipUnchangedPush ) if ($MyInvocation.InvocationName -eq '.') { return } $gitArgs = @( '-c', "http.extraheader=AUTHORIZATION: Basic $([System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("PAT:$Pat")))" ) function Resolve-PullRequestPriority { param( [Parameter(Mandatory = $true)] $Labels ) $resolved = $DefaultPriority foreach ($label in @($Labels | ForEach-Object { $_.name })) { if ($label -match '^priority:(\d+)$') { $val = [int]$Matches[1] if ($val -lt $resolved) { $resolved = $val } } } return $resolved } function Invoke-Git { param( [Parameter(Mandatory = $true)] [string[]]$Args ) & git @gitArgs @Args if ($LASTEXITCODE -ne 0) { throw "git $($Args -join ' ') failed with exit code $LASTEXITCODE." } } function Get-CommitIdentity { param( [Parameter(Mandatory = $true)] [string]$Ref ) $identity = & git @gitArgs 'show' '--no-patch' "--format=%an`n%ae" $Ref if ($LASTEXITCODE -ne 0) { throw "Failed to resolve commit identity for $Ref." } $identityLines = @($identity | Where-Object { $_ -ne $null }) if ($identityLines.Count -lt 2) { throw "Commit identity for $Ref is incomplete." } return @{ Name = $identityLines[0] Email = $identityLines[1] } } function Invoke-GitWithCommitIdentity { param( [Parameter(Mandatory = $true)] [hashtable]$Identity, [Parameter(Mandatory = $true)] [string[]]$Args ) & git @gitArgs '-c' "user.name=$($Identity.Name)" '-c' "user.email=$($Identity.Email)" @Args if ($LASTEXITCODE -ne 0) { throw "git $($Args -join ' ') failed with exit code $LASTEXITCODE." } } function Get-BuildResultsUrl { $collectionUri = if ($env:SYSTEM_COLLECTIONURI) { $env:SYSTEM_COLLECTIONURI } else { $OrganizationUri } if ([string]::IsNullOrWhiteSpace($collectionUri)) { return '' } $buildId = $env:BUILD_BUILDID if ([string]::IsNullOrWhiteSpace($buildId)) { return '' } $projectName = if ($env:SYSTEM_TEAMPROJECT) { $env:SYSTEM_TEAMPROJECT } else { $Project } $baseUri = $collectionUri.TrimEnd('/') $queryParts = @{ "buildId" = $buildId 'view' = 'logs' "j" = $env:SYSTEM_JOBID "t" = $env:SYSTEM_TASKINSTANCEID "s" = $env:SYSTEM_STAGEID } $queryString = ($queryParts.GetEnumerator() | ForEach-Object { "$($_.Key)=$([uri]::EscapeDataString($_.Value))" }) -join '&' return "$baseUri/$([Uri]::EscapeDataString($projectName))/_build/results?$queryString" } function Set-PullRequestStatus { param( [Parameter(Mandatory = $true)] [int]$PullRequestId, [Parameter(Mandatory = $true)] [int]$IterationId, [Parameter(Mandatory = $true)] [ValidateSet('succeeded', 'failed', 'pending', 'notSet', 'error')] [string]$State, [Parameter(Mandatory = $true)] [string]$Description ) $authHeader = "Basic $([System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("PAT:$Pat")))" $targetUrl = Get-BuildResultsUrl $body = @{ state = $State description = $Description iterationId = $IterationId context = @{ name = 'daily-build' genre = 'daily-build' } targetUrl = $targetUrl } | ConvertTo-Json $uri = "$OrganizationUri/$([Uri]::EscapeDataString($Project))/_apis/git/repositories/$repositoryId/pullRequests/$PullRequestId/statuses?api-version=7.1" try { Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType 'application/json' -Headers @{ Authorization = $authHeader } | Out-Null } catch { Write-Warning "Failed to post status to PR !$PullRequestId : $_" } } function Get-LatestPullRequestIteration { param( [Parameter(Mandatory = $true)] [int]$PullRequestId ) $authHeader = "Basic $([System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("PAT:$Pat")))" $uri = "$OrganizationUri/$([Uri]::EscapeDataString($Project))/_apis/git/repositories/$repositoryId/pullRequests/$PullRequestId/iterations?includeCommits=false&api-version=7.1" $response = Invoke-RestMethod -Uri $uri -Method Get -Headers @{ Authorization = $authHeader } if (-not $response -or -not $response.value -or $response.value.Count -eq 0) { throw "No iterations found for PR !$PullRequestId." } # The API returns iterations in ascending order; the last one is the current iteration. return $response.value[-1] } function Resolve-IterationIdForStatus { param( [Parameter(Mandatory = $true)] $PullRequestItem ) if ($PullRequestItem.PSObject.Properties.Name -contains 'Iteration' -and $PullRequestItem.Iteration -and $PullRequestItem.Iteration.Id) { return [int]$PullRequestItem.Iteration.Id } return 1 } az devops configure --defaults organization=$OrganizationUri project=$Project # future idea - dependency-aware ordering (depends-on PR list) $prResponse = az repos pr list --repository "$RepositoryName" --target-branch main --status active --query '[?!isDraft]' --output json $pr = ($prResponse | ConvertFrom-Json) $pr = $pr | Where-Object { if (!$_.labels) { $_.labels = @() } $labelNames = @($_.labels | ForEach-Object { $_.name }) $labelNames -notcontains 'AutoMergeIgnore' } $pr = $pr | Sort-Object -Property @( @{ Expression = { Resolve-PullRequestPriority $_.labels }; Ascending = $true }, @{ Expression = { $_.pullRequestId }; Ascending = $true } ) $repositoryId = (az repos show --repository "$RepositoryName" --query id --output tsv).Trim() Invoke-Git @('fetch', 'origin', 'main') Invoke-Git @('checkout', '-B', $DailyBuildBranch, 'origin/main') $mergeLabelScriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'MergeDrivers/Merge-D365LabelFile.ps1' if (Test-Path -Path $mergeLabelScriptPath) { Invoke-Git @('config', 'merge.d365fo-label.driver', "pwsh -File `"$mergeLabelScriptPath`" -Base %O -Ours %A -Theirs %B -MarkerSize %L -FilePath %P") } else { Write-Verbose -Verbose "Merge label driver script not found at $mergeLabelScriptPath. Skipping merge.d365fo-label.driver configuration." } $mergedPRs = @() $skippedPRs = @() foreach ($item in $pr) { if (-not $item.sourceRefName) { Write-Warning "Skipping PR $($item.pullRequestId): missing sourceRefName." continue } if ($item.supportsIterations) { $iteration = Get-LatestPullRequestIteration -PullRequestId $item.pullRequestId $item | Add-Member -MemberType NoteProperty -Name Iteration -Value $iteration if ($item.lastMergeSourceCommit.commitId -ne $item.Iteration.sourceRefCommit.commitId) { Write-Warning "Skipping PR $($item.pullRequestId): source branch has new commits since last iteration." $skippedPRs += $item continue } } $sourceBranch = $item.sourceRefName -replace '^refs/heads/', '' $resolvedPriority = Resolve-PullRequestPriority $item.labels $sourceIdentity = Get-CommitIdentity "origin/$sourceBranch" Write-Host "Merging PR $($item.pullRequestId) from $sourceBranch (priority: $resolvedPriority)" try { switch ($MergeStrategy) { 'squash' { Invoke-Git @('merge', '--squash', "origin/$sourceBranch") Invoke-GitWithCommitIdentity $sourceIdentity @('commit', '-m', "Squash PR !$($item.pullRequestId)") } default { Invoke-GitWithCommitIdentity $sourceIdentity @('merge', '--no-ff', "origin/$sourceBranch", '-m', "Merge PR !$($item.pullRequestId)") } } $mergedPRs += $item } catch { Write-Warning "Merge failed for PR $($item.pullRequestId) ($sourceBranch). Skipping." & git merge --abort | Out-Null & git reset --hard HEAD | Out-Null $skippedPRs += $item continue } } $shouldPush = $true if ($SkipUnchangedPush) { # Silently try to fetch the remote daily-build branch (may not exist yet) & git @gitArgs fetch origin $DailyBuildBranch 2>$null | Out-Null # Check if the remote ref exists & git rev-parse --verify "origin/$DailyBuildBranch" 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { # Compare trees; exit code 0 means no difference & git diff --quiet "origin/$DailyBuildBranch" HEAD if ($LASTEXITCODE -eq 0) { Write-Host "No content changes vs 'origin/$DailyBuildBranch' - skipping push." $shouldPush = $false } } } if ($shouldPush) { Invoke-Git @('push', '--force', 'origin', $DailyBuildBranch) } foreach ($item in $mergedPRs) { try { $iterationId = Resolve-IterationIdForStatus -PullRequestItem $item Set-PullRequestStatus -PullRequestId $item.pullRequestId -IterationId $iterationId -State 'succeeded' -Description "Merged into daily-build (iteration: $iterationId) $($item.lastMergeSourceCommit.commitId)" } catch { Write-Warning "Failed to post status for PR $($item.pullRequestId): $_" } } foreach ($item in $skippedPRs) { try { $iterationId = Resolve-IterationIdForStatus -PullRequestItem $item Set-PullRequestStatus -PullRequestId $item.pullRequestId -IterationId $iterationId -State 'failed' -Description "Merge conflict - skipped (iteration: $iterationId) $($item.lastMergeSourceCommit.commitId)" } catch { Write-Warning "Failed to post status for PR $($item.pullRequestId): $_" } } Write-Host '' Write-Host '=== Daily Build Summary ===' if ($mergedPRs.Count -gt 0) { $mergedList = ($mergedPRs | ForEach-Object { "!$($_.pullRequestId)" }) -join ', ' Write-Host "Merged: $mergedList" } else { Write-Host 'Merged: (none)' } if ($skippedPRs.Count -gt 0) { $skippedList = ($skippedPRs | ForEach-Object { "!$($_.pullRequestId) (merge conflict)" }) -join ', ' Write-Host "Skipped: $skippedList" } else { Write-Host 'Skipped: (none)' } |