MergeRequests.psm1

function Get-GitlabMergeRequest {
    [CmdletBinding(DefaultParameterSetName='ByProjectId')]
    [Alias('mrs')]
    param(
        [Parameter(ParameterSetName='ByProjectId', ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=0, ParameterSetName='ByProjectId')]
        [Alias("Id")]
        [string]
        $MergeRequestId,

        [Parameter(Position=0, Mandatory, ParameterSetName='ByGroupId')]
        [string]
        $GroupId,

        [Parameter(Position=0, Mandatory, ParameterSetName='ByUrl')]
        [string]
        $Url,

        [Parameter()]
        [ValidateSet('', 'closed', 'opened', 'merged')]
        [string]
        $State = 'opened',

        [Parameter(ParameterSetName='ByGroupId')]
        [Parameter(ParameterSetName='ByProjectId')]
        [string]
        $CreatedAfter,

        [Parameter(ParameterSetName='ByGroupId')]
        [Parameter(ParameterSetName='ByProjectId')]
        [string]
        $CreatedBefore,

        [Parameter(ParameterSetName='ByGroupId')]
        [Parameter(ParameterSetName='ByProjectId')]
        [ValidateSet($null, $true, $false)]
        [object]
        $IsDraft,

        [Parameter(ParameterSetName='ByGroupId')]
        [Parameter(ParameterSetName='ByProjectId')]
        [string]
        $Branch,

        [Parameter()]
        [Alias('ChangeSummary')]
        [switch]
        $IncludeChangeSummary,

        [Parameter()]
        [Alias('Approvals')]
        [switch]
        $IncludeApprovals,

        [Parameter(Mandatory, ParameterSetName='Mine')]
        [switch]
        $Mine,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Path = $null
    $MaxPages = 1
    $Query = @{}

    if ($Mine) {
        $Path = 'merge_requests'
    }
    else {
        if ($Url) {
            $Resource = $Url | Get-GitlabResourceFromUrl
            $ProjectId = $Resource.ProjectId
            $MergeRequestId = $Resource.ResourceId
        }
        if ($ProjectId) {
            $ProjectId = $(Get-GitlabProject -ProjectId $ProjectId).Id
        }
        if ($GroupId) {
            $GroupId = $(Get-GitlabGroup -GroupId $GroupId).Id
        }

        if ($MergeRequestId) {
            # https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
            $Path = "projects/$ProjectId/merge_requests/$MergeRequestId"
        } elseif ($ProjectId) {
            # https://docs.gitlab.com/ee/api/merge_requests.html#list-project-merge-requests
            $Path = "projects/$ProjectId/merge_requests"
            $MaxPages = 10
        } elseif ($GroupId) {
            # https://docs.gitlab.com/ee/api/merge_requests.html#list-group-merge-requests
            $Path = "groups/$GroupId/merge_requests"
            $MaxPages = 10
        } else {
            throw "Unsupported parameter combination"
        }
    }

    if($State) {
        $Query['state'] = $State
    }

    if ($CreatedBefore) {
        $Query['created_before'] = $CreatedBefore
    }

    if ($CreatedAfter) {
        $Query['created_after'] = $CreatedAfter
    }

    if ($IsDraft) {
        $Query['wip'] = $IsDraft ? 'yes' : 'no'
    }

    if ($Branch) {
        if ($Branch -eq '.') {
            $Branch = Get-LocalGitContext | Select-Object -ExpandProperty Branch
        }
        $Query['source_branch'] = $Branch
    }

    $MergeRequests = Invoke-GitlabApi GET $Path $Query -MaxPages $MaxPages -SiteUrl $SiteUrl |
        Select-Object -Property '*' -ExcludeProperty approvals_before_merge | # https://docs.gitlab.com/ee/api/merge_requests.html#removals-in-api-v5
        New-WrapperObject 'Gitlab.MergeRequest'

    if ($IncludeChangeSummary) {
        $MergeRequests | ForEach-Object {
            $_ | Add-GitlabMergeRequestChangeSummary
        }
    }

    if ($IncludeApprovals) {
        $MergeRequests | ForEach-Object {
            $_ | Add-GitlabMergeRequestApprovals
        }
    }

    $MergeRequests | Sort-Object ProjectPath
}

function Add-GitlabMergeRequestApprovals {
    param(
        [Parameter(Position=0, Mandatory, ValueFromPipeline)]
        $MergeRequest
    )

    $Approval = Invoke-GitlabApi GET "projects/$($MergeRequest.SourceProjectId)/merge_requests/$($MergeRequest.MergeRequestId)/approvals"

    $MergeRequest | Add-Member -NotePropertyMembers @{
        ApprovalsRequired = $Approval.approvals_required
        ApprovalsLeft     = $Approval.approvals_left
        ApprovedBy        = $Approval.approved_by.user.name
    }
}

function Add-GitlabMergeRequestChangeSummary {
    param (
        [Parameter(Position=0, Mandatory, ValueFromPipeline)]
        $MergeRequest
    )

    $Data = Invoke-GitlabGraphQL -Query @"
    {
        project(fullPath: "$($MergeRequest.ProjectPath)") {
            mergeRequest(iid: "$($MergeRequest.MergeRequestId)") {
                diffStatsSummary {
                    additions
                    deletions
                    files: fileCount
                }
                commitsWithoutMergeCommits {
                    nodes {
                      author {
                          username
                      }
                      authoredDate
                    }
                }
                notes {
                    nodes {
                      author {
                          username
                      }
                      body
                      updatedAt
                    }
                }
            }
        }
    }
"@


    $Mr = $Data.Project.mergeRequest
    $Notes = $Mr.notes.nodes | Where-Object body -NotMatch "^assigned to @$($MergeRequest.Author.username)" # filter out self-assignment
    $Summary = [PSCustomObject]@{
        Changes           = $Mr.diffStatsSummary | New-WrapperObject
        Authors           = $Mr.commitsWithoutMergeCommits.nodes.author.username        | Select-Object -Unique | Sort-Object
        FirstCommittedAt  = $Mr.commitsWithoutMergeCommits.nodes.authoredDate           | Sort-Object | Select-Object -First 1
        ReviewRequestedAt = $Notes | Where-Object body -Match '^requested review from @' | Sort-Object updatedAt | Select-Object -First 1 -ExpandProperty updatedAt
        AssignedAt        = $Notes | Where-Object body -Match '^assigned to @' | Sort-Object updatedAt | Select-Object -First 1 -ExpandProperty updatedAt
        MarkedReadyAt     = $Notes | Where-Object body -Match '^marked this merge request as \*\*ready\*\*' | Sort-Object updatedAt | Select-Object -First 1 -ExpandProperty updatedAt
        ApprovedAt        = $Notes | Where-Object body -Match '^approved this merge request' |
                                Sort-Object updatedAt | Select-Object -First 1 -ExpandProperty updatedAt
        TimeToMerge       = '(computed below)'
    }

    $MergedAt = $MergeRequest.MergedAt
    if ($Summary.ReviewRequestedAt) {
        $Summary.TimeToMerge = @{ Duration = $MergedAt - $Summary.ReviewRequestedAt; Measure='FromReviewRequested' }
    } elseif ($Summary.AssignedAt) {
        $Summary.TimeToMerge = @{ Duration = $MergedAt - $Summary.AssignedAt; Measure='FromAssigned' }
    } elseif ($Summary.MarkedReadyAt) {
        $Summary.TimeToMerge = @{ Duration = $MergedAt - $Summary.MarkedReadyAt; Measure='FromMarkedReady' }
    } else {
        $Summary.TimeToMerge = @{ Duration = $MergedAt - $MergeRequest.CreatedAt; Measure='FromCreated'}
    }

    $MergeRequest | Add-Member -NotePropertyMembers @{
        ChangeSummary = $Summary
    }
}

function New-GitlabMergeRequest {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position=0)]
        [string]
        $ProjectId,

        [Parameter(Position=1)]
        [string]
        $SourceBranch,

        [Parameter(Position=2)]
        [string]
        $TargetBranch,

        [Parameter(Position=3)]
        [string]
        $Title,

        [Parameter()]
        [switch]
        $Follow,

        [Parameter()]
        [string]
        $SiteUrl
    )

    if (-not $ProjectId) {
        $ProjectId = '.'
    }

    $Project = Get-GitlabProject -ProjectId $ProjectId

    if (-not $TargetBranch) {
        $TargetBranch = $Project.DefaultBranch
    }
    if (-not $SourceBranch -or $SourceBranch -eq '.') {
        $SourceBranch = $(Get-LocalGitContext).Branch
    }
    if (-not $Title) {
        $Title = $SourceBranch.Replace('-', ' ').Replace('_', ' ')
    }

    $Me = Get-GitlabCurrentUser

    $Request = @{
        Method = 'POST'
        Path   = "projects/$($Project.Id)/merge_requests"
        Body   = @{
            source_branch        = $SourceBranch
            target_branch        = $TargetBranch
            remove_source_branch = 'true'
            assignee_id          = $Me.Id
            title                = $Title
        }
    }

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "create merge request ($($Request | ConvertTo-Json))")) {
        $MergeRequest = Invoke-GitlabApi @Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.MergeRequest'
        if ($Follow) {
            Start-Process $MergeRequest.WebUrl
        }
        $MergeRequest
    }
}


function Merge-GitlabMergeRequest {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=1, Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [string]
        $MergeRequestId,

        [Parameter()]
        [string]
        $MergeCommitMessage,

        [Parameter()]
        [string]
        $SquashCommitMessage,

        [Parameter()]
        [string]
        $ConfirmSha,

        [Parameter()]
        [switch]
        $Squash,

        [Parameter()]
        [switch]
        $KeepSourceBranch,

        [Parameter()]
        [switch]
        $MergeWhenPipelineSucceeds,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject -ProjectId $ProjectId

    # https://docs.gitlab.com/ee/api/merge_requests.html#merge-a-merge-request
    $Request = @{
        Method = 'PUT'
        Path   = "projects/$($Project.Id)/merge_requests/$MergeRequestId/merge"
        Body   = @{}
    }
    if ($MergeCommitMessage) {
        $Request.Body.merge_commit_message = $MergeCommitMessage
    }
    if ($SquashCommitMessage) {
        $Request.Body.squash_commit_message = $SquashCommitMessage
    }
    if ($ConfirmSha) {
        $Request.Body.sha = $Sha
    }
    if ($Squash) {
        $Request.Body.squash = $Squash
    }
    if (-not $KeepSourceBranch) {
        $Request.Body.should_remove_source_branch = 'true'
    }
    if ($MergeWhenPipelineSucceeds) {
        $Request.Body.merge_when_pipeline_succeeds = $MergeWhenPipelineSucceeds
    }
    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "merge ($($Request.Body | ConvertTo-Json))")) {
        Invoke-GitlabApi @Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.MergeRequest'
    }
}

function Set-GitlabMergeRequest {
    [CmdletBinding()]
    [Alias("mr")]

    param (
    )

    $ProjectId = '.'
    $Branch = '.'

    $Existing = Get-GitlabMergeRequest -ProjectId $ProjectId -Branch $Branch -State 'opened'
    if ($Existing) {
        return $Existing
    }

    New-GitlabMergeRequest -ProjectId $ProjectId -SourceBranch $Branch
}

function Update-GitlabMergeRequest {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Position=0, Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId,

        [Parameter(Position=1, Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $MergeRequestId,

        [Parameter()]
        [string]
        $Title,

        [Parameter()]
        [Alias('Wip')]
        [switch]
        $Draft,

        [Parameter()]
        [Alias('RemoveDraft')]
        [Alias('RemoveWip')]
        [switch]
        $MarkReady,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [string []]
        $AssignTo,

        [Parameter()]
        [switch]
        $Unassign,

        [Parameter()]
        [string []]
        $Reviewers,

        [Parameter()]
        [switch]
        $UnsetReviewers,

        [Parameter()]
        [switch]
        $Close,

        [Parameter()]
        [switch]
        $Reopen,

        [Parameter()]
        [string]
        $SiteUrl
    )
    $Project = Get-GitlabProject -ProjectId $ProjectId
    $Request = @{}

    if ($Reopen) {
        $Request.state_event = 'reopen'
    }
    elseif ($Close) {
        $Request.state_event = 'close'
    }
    if ($Title) {
        $Request.title = $Title
    } else {
        $MergeRequest = Get-GitlabMergeRequest -ProjectId $ProjectId -MergeRequestId $MergeRequestId
        if ($Draft -and -not $MergeRequest.Draft) {
            $Request.title = "Draft: $($MergeRequest.Title)"
        } elseif ($MarkReady -and $MergeRequest.Draft) {
            $Request.title = $MergeRequest.Title -replace '^Draft:\s+', ''
        }
    }
    if ($Description) {
        $Request.description = $Description
    }
    if ($AssignTo) {
        $Request.assignee_ids = @($AssignTo | ForEach-Object {
            Get-GitlabUser $_
        } | Select-Object -ExpandProperty Id)
    } elseif ($Unassign) {
        $Request.assignee_ids = @()
    }
    if ($Reviewers) {
        $Request.reviewer_ids = @($Reviewers | ForEach-Object {
            Get-GitlabUser $_
        } | Select-Object -ExpandProperty Id)
    } elseif ($UnsetReviewers) {
        $Request.reviewer_ids = @()
    }

    if ($PSCmdlet.ShouldProcess("MR $MergeRequestId in $($Project.PathWithNamespace)", "update $($Request | ConvertTo-Json)")) {
        # https://docs.gitlab.com/ee/api/merge_requests.html#update-mr
        Invoke-GitlabApi PUT "projects/$ProjectId/merge_requests/$MergeRequestId" -Body $Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.MergeRequest'
    }
}

function Close-GitlabMergeRequest {
    [CmdletBinding()]
    param(
        [Parameter(Position=0, Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
        [string]
        $ProjectId,

        [Parameter(Position=1, Mandatory=$true, ValueFromPipelineByPropertyName=$true)]
        [Alias('Id')]
        [string]
        $MergeRequestId,

        [Parameter(Mandatory=$false)]
        [switch]
        $WhatIf
    )

    $ProjectId = $(Get-GitlabProject -ProjectId $ProjectId).Id

    Update-GitlabMergeRequest -ProjectId $ProjectId -MergeRequestId $MergeRequestId -Close -WhatIf:$WhatIf
}

function Invoke-GitlabMergeRequestReview {
    [CmdletBinding()]
    [Alias('Review-GitlabMergeRequest')]
    param(
        [Parameter(Position=0, Mandatory=$true)]
        [string]
        $MergeRequestId,

        [Parameter(Mandatory=$false)]
        [string]
        $SiteUrl,

        [switch]
        [Parameter(Mandatory=$false)]
        $WhatIf
    )

    $ProjectId = $(Get-GitlabProject -ProjectId '.').Id

    $MergeRequest = Get-GitlabMergeRequest -ProjectId $ProjectId -MergeRequestId $MergeRequestId

    git stash | Out-Null
    git pull -p | Out-Null
    git checkout $MergeRequest.SourceBranch
    git diff "origin/$($MergeRequest.TargetBranch)"
}

function Approve-GitlabMergeRequest {
    [CmdletBinding()]
    param(
        [Parameter(Position=0, Mandatory=$false)]
        [string]
        $MergeRequestId,

        [Parameter(Mandatory=$false)]
        [string]
        $SiteUrl,

        [switch]
        [Parameter(Mandatory=$false)]
        $WhatIf
    )

    $ProjectId = $(Get-GitlabProject -ProjectId '.' -SiteUrl $SiteUrl).Id

    if (-not $MergeRequestId) {
        $MergeRequest = Get-GitlabMergeRequest -ProjectId $ProjectId -Branch '.' -State 'opened' -SiteUrl $SiteUrl
        if ($MergeRequest) {
            $MergeRequestId = $MergeRequest.MergeRequestId
        }
    }

    Invoke-GitlabApi POST "projects/$ProjectId/merge_requests/$MergeRequestId/approve" -SiteUrl $SiteUrl -WhatIf:$WhatIf | Out-Null
    Get-GitlabMergeRequest -ProjectId $ProjectId -MergeRequestId $MergeRequestId -IncludeApprovals -SiteUrl $SiteUrl
}

# https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-configuration
function Get-GitlabMergeRequestApprovalConfiguration {
    [CmdletBinding()]
    param (
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Mandatory=$false)]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    Invoke-GitlabApi GET "projects/$($Project.Id)/approvals" -SiteUrl $SiteUrl | New-WrapperObject
}

# https://docs.gitlab.com/ee/api/merge_request_approvals.html#change-configuration
function Update-GitlabMergeRequestApprovalConfiguration {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $DisableOverridingApproversPerMergeRequest,

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $MergeRequestsAuthorApproval,

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $MergeRequestsDisableCommittersApproval,

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $RequirePasswordToApprove,

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $ResetApprovalsOnPush,

        [Parameter()]
        [ValidateSet($null, $true, $false)]
        [object]
        $SelectiveCodeOwnerRemovals,

        [Parameter(Mandatory=$false)]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    $Request = @{}
    if ($DisableOverridingApproversPerMergeRequest -ne $null) {
        $Request.disable_overriding_approvers_per_merge_request = $DisableOverridingApproversPerMergeRequest.ToLower()
    }
    if ($MergeRequestsAuthorApproval -ne $null) {
        $Request.merge_requests_author_approval = $MergeRequestsAuthorApproval.ToLower()
    }
    if ($MergeRequestsDisableCommittersApproval -ne $null) {
        $Request.merge_requests_disable_committers_approval = $MergeRequestsDisableCommittersApproval.ToLower()
    }
    if ($RequirePasswordToApprove -ne $null) {
        $Request.require_password_to_approve = $RequirePasswordToApprove.ToLower()
    }
    if ($ResetApprovalsOnPush -ne $null) {
        $Request.reset_approvals_on_push = $ResetApprovalsOnPush.ToLower()
    }
    if ($SelectiveCodeOwnerRemovals -ne $null) {
        $Request.selective_code_owner_removals = $SelectiveCodeOwnerRemovals.ToLower()
    }

    if ($PSCmdlet.ShouldProcess($Project.PathWithNamespace, "update merge request approval settings to $($Request | ConvertTo-Json)")) {
        Invoke-GitlabApi POST "projects/$($Project.Id)/approvals" -Body $Request -SiteUrl $SiteUrl | New-WrapperObject
    }
}

function Get-GitlabMergeRequestApprovalRule {
    [CmdletBinding()]
    param (
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=1, ValueFromPipelineByPropertyName)]
        [string]
        $ApprovalRuleId,

        [Parameter(Mandatory=$false)]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    $Resource = "projects/$($Project.Id)/approval_rules"
    if ($ApprovalRuleId) {
        $Resource += "/$ApprovalRuleId"
    }

    # https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-project-level-rules
    # https://docs.gitlab.com/ee/api/merge_request_approvals.html#get-a-single-project-level-rule
    Invoke-GitlabApi GET $Resource -SiteUrl $SiteUrl
        | New-WrapperObject 'Gitlab.MergeRequestApprovalRule'
        | Add-Member -PassThru -NotePropertyMembers @{
            ProjectId = $Project.Id
        }
}

function New-GitlabMergeRequestApprovalRule {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=1, Mandatory)]
        [string]
        $Name,

        [Parameter(Position=2, Mandatory)]
        [uint]
        $ApprovalsRequired,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    $Resource = "projects/$($Project.Id)/approval_rules"
    $Rule = @{
        name                              = $Name
        approvals_required                = $ApprovalsRequired
        applies_to_all_protected_branches = 'true'
    }

    if ($PSCmdlet.ShouldProcess($Project.PathWithNamespace, "create new merge request approval rule $($Rule | ConvertTo-Json)")) {
        # https://docs.gitlab.com/ee/api/merge_request_approvals.html#create-project-level-rule
        Invoke-GitlabApi POST $Resource -Body $Rule -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.MergeRequestApprovalRule'
    }
}

function Remove-GitlabMergeRequestApprovalRule {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=1, Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $MergeRequestApprovalRuleId,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    if ($PSCmdlet.ShouldProcess($Project.PathWithNamespace, "remove merge request approval rule '$MergeRequestApprovalRuleId'")) {
        # https://docs.gitlab.com/ee/api/merge_request_approvals.html#delete-project-level-rule
        Invoke-GitlabApi DELETE "projects/$($Project.Id)/approval_rules/$MergeRequestApprovalRuleId" -SiteUrl $SiteUrl
    }
}