diff --git a/backup.ps1 b/backup.ps1 index 8eeb7f4..7655723 100644 --- a/backup.ps1 +++ b/backup.ps1 @@ -71,10 +71,24 @@ try { try { $projectPath = "$glNamespace/$($repo.name)" $existing = Get-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectPath $projectPath + + # A project the user deleted on GitLab is only soft-deleted: it lingers + # for a retention window, still occupying its path but read-only, so the + # mirror push fails with 403. It also answers "exists" here, so without + # this we'd never recreate it either. Restore it back to writable. + if ($existing) { + $pendingDeletion = Get-GitLabProjectDeletionSchedule -Project $existing + if ($pendingDeletion) { + Write-Log -Level WARN (" GitLab project '{0}' is pending deletion (scheduled {1}); restoring it" -f $projectPath, $pendingDeletion) + $null = Restore-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectId $existing.id + $existing = Get-GitLabProject -GitLabHost $config.GitLabHost -Token $glToken -ProjectPath $projectPath + } + } + if (-not $existing) { Write-Log " creating GitLab project: $projectPath" # NamespaceId omitted -> GitLab creates under the authenticated user - $null = New-GitLabProject ` + $existing = New-GitLabProject ` -GitLabHost $config.GitLabHost ` -Token $glToken ` -Name $repo.name ` @@ -83,6 +97,13 @@ try { -Description ("Mirror of github.com/{0}" -f $repo.full_name) } + # GitLab auto-protects the default branch; on a mirror that blocks the + # force-updates push --mirror needs. Strip protection before pushing. + $clearedRules = Clear-GitLabProtectedBranches -GitLabHost $config.GitLabHost -Token $glToken -ProjectId $existing.id + if ($clearedRules -gt 0) { + Write-Log (" cleared {0} protected-branch rule(s) on mirror" -f $clearedRules) + } + $cachePath = Join-Path $config.CachePath ("{0}.git" -f $repo.name) $result = Sync-Mirror ` -GitHubFullName $repo.full_name ` diff --git a/lib/GitLab.psm1 b/lib/GitLab.psm1 index 5dd96ba..5a7d747 100644 --- a/lib/GitLab.psm1 +++ b/lib/GitLab.psm1 @@ -77,4 +77,61 @@ function New-GitLabProject { return Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token -Path '/api/v4/projects' -Method POST -Body $body } -Export-ModuleMember -Function Get-GitLabCurrentUser, Get-GitLabProject, New-GitLabProject +function Get-GitLabProjectDeletionSchedule { + # Returns the pending-deletion timestamp for a project object, or $null if the + # project is not scheduled for deletion. GitLab renamed this field from + # 'marked_for_deletion_on' to 'marked_for_deletion_at'; check both, and probe + # via PSObject so an absent field yields $null instead of throwing under + # Set-StrictMode. + [CmdletBinding()] + param([Parameter(Mandatory)]$Project) + foreach ($name in 'marked_for_deletion_at', 'marked_for_deletion_on') { + $prop = $Project.PSObject.Properties[$name] + if ($prop -and $prop.Value) { return $prop.Value } + } + return $null +} + +function Restore-GitLabProject { + # Undo a pending (delayed) deletion. gitlab.com keeps a deleted project for + # a retention window during which it exists but is read-only, so pushes fail + # with HTTP 403 "You are not allowed to push code to this project". Restoring + # makes it writable again. The restore route requires the numeric project id + # (the URL-encoded path form returns 405). + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$GitLabHost, + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][int]$ProjectId + ) + return Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/restore" -Method POST +} + +function Clear-GitLabProtectedBranches { + # Remove every branch-protection rule on a mirror project. GitLab auto-protects + # the default branch on a project's first push; thereafter `git push --mirror` + # fails whenever it needs to force-update that branch (rebased history, deleted + # refs) with "You are not allowed to force push code to a protected branch". + # Mirrors are throwaway copies of the GitHub source, so protection serves no + # purpose here. Returns the number of rules removed. + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$GitLabHost, + [Parameter(Mandatory)][string]$Token, + [Parameter(Mandatory)][int]$ProjectId + ) + $response = Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/protected_branches?per_page=100" + # An empty list comes back as $null; filter it so the loop doesn't try to + # dereference a null element under Set-StrictMode. + $protected = @($response | Where-Object { $null -ne $_ }) + foreach ($b in $protected) { + $name = [uri]::EscapeDataString($b.name) + $null = Invoke-GitLabApi -GitLabHost $GitLabHost -Token $Token ` + -Path "/api/v4/projects/$ProjectId/protected_branches/$name" -Method DELETE + } + return $protected.Count +} + +Export-ModuleMember -Function Get-GitLabCurrentUser, Get-GitLabProject, New-GitLabProject, Get-GitLabProjectDeletionSchedule, Restore-GitLabProject, Clear-GitLabProtectedBranches