Projects.psm1

<#
.SYNOPSIS
Get one or more Gitlab projects

.DESCRIPTION
Lookup metadata about Gitlab projects by one or more identifiers

.PARAMETER ProjectId
Project id - can be an integer, or a full path

.PARAMETER GroupId
Group id - can be an integer, or a full path

.PARAMETER Recurse
Whether or not to recurse specified group (default: false)
Alias: -r

.PARAMETER Url
Get a project by URL

.PARAMETER IncludeArchived
Whether or not to return archived projects (default: false)

.PARAMETER MaxPages
Maximum pages to return (default: 10)

.PARAMETER SiteUrl
Which Gitlab instance to query. This is optional, if not provided, will
first attempt to use the remote associated with the local git context.
If there is no established context (or no matching configuration), the default
site is used.

.PARAMETER WhatIf
Preview Gitlab API requests

.EXAMPLE
Get-GitlabProject

Get a project from local git context

.EXAMPLE
Get-GitlabProject -ProjectId 'mygroup/myproject'
OR
PS > Get-GitlabProject 'mygroup/myproject'
OR
PS > Get-GitlabProject 42

Get a single project by id

.EXAMPLE
Get-GitlabProject -GroupId 'mygroup' [-Recurse]

Get multiple projects by containing group

.EXAMPLE
Get-GitlabGroup 'mygroup' | Get-GitlabProject

Enumerate projects within a group

.LINK
https://github.com/chris-peterson/pwsh-gitlab#projects

.LINK
https://docs.gitlab.com/ee/api/projects.html
#>

function Get-GitlabProject {

    [CmdletBinding(DefaultParameterSetName='ById')]
    param (
        [Parameter(Position=0, ParameterSetName='ById', ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

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

        [Parameter(ParameterSetName='ByUser')]
        [string]
        $UserId,

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

        [Parameter(Position=0, Mandatory, ParameterSetName='ByTopics')]
        [string []]
        $Topics,

        [Parameter(ParameterSetName='ByGroup')]
        [Alias('r')]
        [switch]
        $Recurse,

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

        [Parameter()]
        [string]
        $Select,

        [switch]
        [Parameter(ParameterSetName='ByGroup')]
        $IncludeArchived = $false,

        [Parameter()]
        [uint]
        $MaxPages,

        [switch]
        [Parameter()]
        $All,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $ProjectId = $ProjectId.TrimEnd('/')

    $MaxPages = Get-GitlabMaxPages -MaxPages $MaxPages -All:$All
    $Projects = @()
    switch ($PSCmdlet.ParameterSetName) {
        ById {
            if ($ProjectId -eq '.') {
                $ProjectId = $(Get-LocalGitContext).Project
                if (-not $ProjectId) {
                    throw "Could not infer project based on current directory ($(Get-Location))"
                }
            }
            $Projects = Invoke-GitlabApi GET "projects/$($ProjectId | ConvertTo-UrlEncoded)" -SiteUrl $SiteUrl
        }
        ByGroup {
            $Group = Get-GitlabGroup $GroupId
            $Query = @{
                'include_subgroups' = $Recurse ? 'true' : 'false'
            }
            if (-not $IncludeArchived) {
                $Query['archived'] = 'false'
            }
            $Projects = Invoke-GitlabApi GET "groups/$($Group.Id)/projects" $Query -MaxPages $MaxPages -SiteUrl $SiteUrl |
                Where-Object { $($_.path_with_namespace).StartsWith($Group.FullPath) } |
                Sort-Object -Property 'Name'
        }
        ByUser {
            if ($Mine) {
                if ($UserId) {
                    Write-Warning "Ignoring '-UserId $UserId' parameter since -Mine was also provided"
                }
                $UserId = Get-GitlabUser -Me | Select-Object -ExpandProperty Username
            }
            # https://docs.gitlab.com/ee/api/projects.html#list-user-projects
            $Projects = Invoke-GitlabApi GET "users/$UserId/projects"
        }
        ByTopics {
            $Projects = Invoke-GitlabApi GET "projects" -Query @{
                topic = $Topics -join ','
            } -MaxPages $MaxPages -SiteUrl $SiteUrl
        }
        ByUrl {
            $Url -match "$($(Get-DefaultGitlabSite).Url)/?(?<ProjectId>.*)" | Out-Null
            if ($Matches) {
                $ProjectId = $Matches.ProjectId
                Get-GitlabProject -ProjectId $ProjectId
            } else {
                throw "Url didn't match expected format"
            }
        }
    }

    $Projects |
        New-WrapperObject 'Gitlab.Project' |
        Get-FilteredObject $Select
}

function ConvertTo-GitlabTriggerYaml {
    param (
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        $InputObject,

        [Parameter(Mandatory=$false)]
        [ValidateSet($null, 'depend')]
        [string]
        $Strategy = $null,

        [Parameter(Mandatory=$false)]
        [string]
        $StageName = 'trigger'
    )
    Begin {
        $Yaml = @"
stages:
  - $StageName
"@

        $Projects = @()
        if ([string]::IsNullOrWhiteSpace($Strategy)) {
            $StrategyYaml = ''
        } else {
            $StrategyYaml = "`n strategy: $Strategy"
        }
    }
    Process {
        foreach ($Object in $InputObject) {
            if ($Projects.Contains($Object.ProjectId)) {
                continue
            }
            $Projects += $Object.ProjectId
            $Yaml += @"


$($Object.Name):
  stage: $StageName
  trigger:
    project: $($Object.PathWithNamespace)$StrategyYaml
"@

        }
    }
    End {
        $Yaml
    }
}

# https://docs.gitlab.com/ee/api/projects.html#transfer-a-project-to-a-new-namespace
function Move-GitlabProject {
    [Alias("Transfer-GitlabProject")]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Mandatory)]
        [string]
        $DestinationGroup,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $SourceProject = Get-GitlabProject -ProjectId $ProjectId
    $Group = Get-GitlabGroup -GroupId $DestinationGroup

    if ($PSCmdlet.ShouldProcess("group $($Group.FullName)", "transfer '$($SourceProject.PathWithNamespace)'")) {
        Invoke-GitlabApi PUT "projects/$($SourceProject.Id)/transfer" @{
            namespace = $Group.Id
        } -SiteUrl $SiteUrl -WhatIf:$WhatIfPreference | New-WrapperObject 'Gitlab.Project'
    }
}

function Rename-GitlabProject {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=0, Mandatory=$true)]
        [string]
        $NewName,

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

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

    Update-GitlabProject -ProjectId $ProjectId -Name $NewName -Path $NewName -SiteUrl $SiteUrl -WhatIf:$WhatIf
}

# https://docs.gitlab.com/ee/api/projects.html#fork-project
function Copy-GitlabProject {
    [Alias("Fork-GitlabProject")]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory=$false)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=0, Mandatory=$true)]
        [string]
        $DestinationGroup,

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


        # https://docs.gitlab.com/ee/api/projects.html#delete-an-existing-forked-from-relationship
        [bool]
        [Parameter(Mandatory=$false)]
        $PreserveForkRelationship = $true,

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

    $SourceProject = Get-GitlabProject -ProjectId $ProjectId
    $Group = Get-GitlabGroup -GroupId $DestinationGroup

    if ($PSCmdlet.ShouldProcess("$($Group.FullPath)", "$($PreserveForkRelationship ? "fork" : "copy") $($SourceProject.Path)")) {
        $NewProject = Invoke-GitlabApi POST "projects/$($SourceProject.Id)/fork" @{
            namespace_id           = $Group.Id
            name                   = $DestinationProjectName ?? $SourceProject.Name
            path                   = $DestinationProjectName ?? $SourceProject.Name
            mr_default_target_self = 'true'
        } -SiteUrl $SiteUrl -WhatIf:$WhatIfPreference

        if (-not $PreserveForkRelationship) {
            Invoke-GitlabApi DELETE "projects/$($NewProject.id)/fork" -SiteUrl $SiteUrl -WhatIf:$WhatIfPreference | Out-Null
            Write-Host "Removed fork relationship between $($SourceProject.Name) and $($NewProject.PathWithNamespace)"
        }
    }
}

function New-GitlabProject {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position=0, Mandatory)]
        [Alias('Name')]
        [string]
        $ProjectName,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName='Group')]
        [Alias('Group')]
        [string]
        $DestinationGroup,

        [Parameter(ParameterSetName = 'Personal')]
        [switch]
        $Personal,

        [Parameter()]
        [ValidateSet('private', 'internal', 'public')]
        [string]
        $Visibility = 'internal',

        [Parameter()]
        [uint]
        $BuildTimeout = 0,

        [switch]
        [Parameter()]
        $CloneNow,

        [Parameter()]
        [string]
        $SiteUrl
    )

    if ($DestinationGroup) {
        $Group = Get-GitlabGroup -GroupId $DestinationGroup
        if(-not $Group) {
            throw "DestinationGroup '$DestinationGroup' not found"
        }
        $NamespaceId = $Group.Id
    }
    if ($Personal) {
        $NamespaceId = $null # defaults to current user
    }

    $Request = @{
        name = $ProjectName
        namespace_id = $NamespaceId
        visibility = $Visibility
    }
    if ($BuildTimeout -gt 0) {
        $Request.build_timeout = $BuildTimeout
    }

    if ($PSCmdlet.ShouldProcess($NamespaceId, "create new project '$ProjectName' $($Request | ConvertTo-Json)" )) {
        # https://docs.gitlab.com/ee/api/projects.html#create-project
        $Project = Invoke-GitlabApi POST "projects" -Body $Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.Project'
    
        if ($CloneNow) {
            git clone $Project.SshUrlToRepo
            Set-Location $ProjectName
        } else {
            $Project
        }
    }
}

# https://docs.gitlab.com/ee/api/projects.html#edit-project
function Update-GitlabProject {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter()]
        [ValidateSet('private', 'internal', 'public')]
        [string]
        $Visibility,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Path,

        [Parameter()]
        [string]
        $DefaultBranch,

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

        [Parameter()]
        [ValidateSet('fetch', 'clone')]
        [string]
        $BuildGitStrategy,

        [Parameter()]
        [uint]
        $CiDefaultGitDepth,

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

        [Parameter()]
        [uint]
        $BuildTimeout = 0,

        [Parameter()]
        [ValidateSet('disabled', 'private', 'enabled')]
        [string]
        $RepositoryAccessLevel,

        [Parameter()]
        [ValidateSet('disabled', 'private', 'enabled')]
        [string]
        $BuildsAccessLevel,

        [Parameter()]
        [ValidateSet($null, 'true', 'false')]
        [object]
        $OnlyAllowMergeIfAllDiscussionsAreResolved,
        
        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    $Request = @{}

    if ($BuildsAccessLevel) {
        $Request.builds_access_level = $BuildsAccessLevel
    }
    if ($BuildGitStrategy) {
        $Request.build_git_strategy = $BuildGitStrategy
    }
    if ($BuildTimeout -gt 0) {
        $Request.build_timeout = $BuildTimeout
    }
    if ($CiDefaultGitDepth) {
        $Request.ci_default_git_depth = $CiDefaultGitDepth
    }
    if ($CiForwardDeployment){
        $Request.ci_forward_deployment_enabled = $CiForwardDeployment
    }
    if ($DefaultBranch) {
        $Request.default_branch = $DefaultBranch
    }
    if ($Name) {
        $Request.name = $Name
    }
    if ($OnlyAllowMergeIfAllDiscussionsAreResolved) {
        $Request.only_allow_merge_if_all_discussions_are_resolved = $OnlyAllowMergeIfAllDiscussionsAreResolved
    }
    if ($Path) {
        $Request.path = $Path
    }
    if ($RepositoryAccessLevel) {
        $Request.repository_access_level = $RepositoryAccessLevel
    }
    if ($Topics) {
        $Request.topics = $Topics -join ','
    }
    if ($Visibility) {
        $Request.visibility = $Visibility
    }

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "update project ($($Request | ConvertTo-Json))")) {
        Invoke-GitlabApi PUT "projects/$($Project.Id)" -Body $Request -SiteUrl $SiteUrl |
            New-WrapperObject 'Gitlab.Project'
    }
}

function Invoke-GitlabProjectArchival {
    [Alias('Archive-GitlabProject')]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',
        
        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "archive")) {
        # https://docs.gitlab.com/ee/api/projects.html#archive-a-project
        Invoke-GitlabApi POST "projects/$($Project.Id)/archive" -SiteUrl $SiteUrl |
            New-WrapperObject 'Gitlab.Project'
    }
}

function Invoke-GitlabProjectUnarchival {
    [Alias('Unarchive-GitlabProject')]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',
        
        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "unarchive")) {
        # https://docs.gitlab.com/ee/api/projects.html#unarchive-a-project
        Invoke-GitlabApi POST "projects/$($Project.Id)/unarchive" -SiteUrl $SiteUrl |
            New-WrapperObject 'Gitlab.Project'
    }
}

function Get-GitlabProjectVariable {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipelineByPropertyName=$true)]
        [string]
        $ProjectId = '.',

        [Parameter(Position=0)]
        [string]
        $Key,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject $ProjectId

    if ($Key) {
        # https://docs.gitlab.com/ee/api/project_level_variables.html#get-a-single-variable
        Invoke-GitlabApi GET "projects/$($Project.Id)/variables/$Key" -SiteUrl $SiteUrl -WhatIf:$WhatIf | New-WrapperObject 'Gitlab.Variable'
    }
    else {
        # https://docs.gitlab.com/ee/api/project_level_variables.html#list-project-variables
        Invoke-GitlabApi GET "projects/$($Project.Id)/variables" -SiteUrl $SiteUrl -WhatIf:$WhatIf | New-WrapperObject 'Gitlab.Variable'
    }
}

function Set-GitlabProjectVariable {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(Mandatory, Position=0, ValueFromPipelineByPropertyName)]
        [string]
        $Key,

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

        [switch]
        [Parameter(ValueFromPipelineByPropertyName)]
        $Protect,

        [switch]
        [Parameter(ValueFromPipelineByPropertyName)]
        $Mask,

        [ValidateSet($null, 'true', 'false')]
        [Parameter(ValueFromPipelineByPropertyName)]
        $ExpandVariables = 'true',

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Request = @{
        value = $Value
        raw   = $ExpandVariables -eq 'true' ? 'false' : 'true'
    }
    if ($Protect) {
        $Request.protected = 'true'
    }
    if ($Mask) {
        $Request.masked = 'true'
    }

    $Project = Get-GitlabProject $ProjectId

    try {
        Get-GitlabProjectVariable -ProjectId $($Project.Id) -Key $Key | Out-Null
        $IsExistingVariable = $true
    }
    catch {
        $IsExistingVariable = $false
    }

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "set $($IsExistingVariable ? 'existing' : 'new') project variable ($Key) $(($Request | ConvertTo-Json))")) {
        if ($IsExistingVariable) {
            # https://docs.gitlab.com/ee/api/project_level_variables.html#update-variable
            Invoke-GitlabApi PUT "projects/$($Project.Id)/variables/$Key" -Body $Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.Variable'
        }
        else {
            $Request.key = $Key
            # https://docs.gitlab.com/ee/api/project_level_variables.html#create-a-variable
            Invoke-GitlabApi POST "projects/$($Project.Id)/variables" -Body $Request -SiteUrl $SiteUrl | New-WrapperObject 'Gitlab.Variable'
        }
    }
}

# https://docs.gitlab.com/ee/api/project_level_variables.html#list-project-variables
function Remove-GitlabProjectVariable {

    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$false)]
        [string]
        $ProjectId = '.',

        [Parameter(Mandatory=$true, Position=0)]
        [string]
        $Key,

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

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

    $Project = Get-GitlabProject $ProjectId

    Invoke-GitlabApi DELETE "projects/$($Project.Id)/variables/$Key" -SiteUrl $SiteUrl -WhatIf:$WhatIf | Out-Null
}


function Rename-GitlabProjectDefaultBranch {
    param (
        [Parameter(Position=0, Mandatory=$true)]
        [string]
        $NewDefaultBranch,

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

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

    $Project = Get-GitlabProject -ProjectId '.'
    if (-not $Project) {
        throw "This cmdlet requires being run from within a gitlab project"
    }

    if ($Project.DefaultBranch -ieq $NewDefaultBranch) {
        throw "Default branch for $($Project.Name) is already $($Project.DefaultBranch)"
    }
    $OldDefaultBranch = $Project.DefaultBranch

    if ($WhatIf) {
        Write-Host "WhatIf: would change default branch for $($Project.PathWithNamespace) from $OldDefaultBranch to $NewDefaultBranch"
        return
    }

    git checkout $OldDefaultBranch | Out-Null
    git pull -p | Out-Null
    git branch -m $OldDefaultBranch $NewDefaultBranch | Out-Null
    git push -u origin $NewDefaultBranch -o ci.skip | Out-Null
    Update-GitlabProject -DefaultBranch $NewDefaultBranch -SiteUrl $SiteUrl -WhatIf:$WhatIf | Out-Null
    try {
        UnProtect-GitlabBranch -Name $OldDefaultBranch -SiteUrl $SiteUrl -WhatIf:$WhatIf | Out-Null
    }
    catch {}
    Protect-GitlabBranch -Name $NewDefaultBranch -SiteUrl $SiteUrl -WhatIf:$WhatIf | Out-Null
    git push --delete origin $OldDefaultBranch | Out-Null
    git remote set-head origin -a | Out-Null

    Get-GitlabProject -ProjectId $Project.Id
}

function Get-GitlabProjectEvent {

    [CmdletBinding()]
    param (
        [Parameter(Position=0, Mandatory=$false)]
        [string]
        $ProjectId = '.',

        [Parameter(Mandatory=$False)]
        [ValidateScript({ValidateGitlabDateFormat $_})]
        [string]
        $Before,

        [Parameter(Mandatory=$False)]
        [ValidateScript({ValidateGitlabDateFormat $_})]
        [string]
        $After,

        [Parameter(Mandatory=$False)]
        [ValidateSet("asc","desc")]
        [string]
        $Sort,

        [Parameter(Mandatory=$false)]
        [int]
        $MaxPages = 1,

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

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

    $Project = Get-GitLabProject $ProjectId

    $Query = @{}
    if($Before) {
        $Query.before = $Before
    }
    if($After) {
        $Query.after = $After
    }
    if($Sort) {
        $Query.sort = $Sort
    }

    Invoke-GitlabApi GET "projects/$($Project.Id)/events" `
        -Query $Query -MaxPages $MaxPages -SiteUrl $SiteUrl -WhatIf:$WhatIf | 
        New-WrapperObject 'Gitlab.Event'
}

function Add-GitlabGroupToProject {
    [CmdletBinding(SupportsShouldProcess)]
    [Alias('Share-GitlabProjectWithGroup')]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId = '.',

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $GroupId,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Access')]
        [string]
        [ValidateSet('guest', 'reporter', 'developer', 'maintainer', 'owner')]
        $GroupAccess
    )

    $AccessLiteral = Get-GitlabMemberAccessLevel $GroupAccess
    $Project = Get-GitlabProject $ProjectId
    $Group = Get-GitlabGroup $GroupId

    # https://docs.gitlab.com/ee/api/projects.html#share-project-with-group
    $Request = @{
        Method = 'POST'
        Path = "projects/$($Project.Id)/share"
        Body = @{
            group_id     = $Group.Id
            group_access = $AccessLiteral
        }
    }

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "share project with group ($($Request | ConvertTo-Json))")) {
        if (Invoke-GitlabApi @Request | Out-Null) {
            Write-Host "Successfully shared $($Project.PathWithNamespace) with $($Group.FullPath)"
        }
    }
}

function Remove-GitlabProject {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
    param (
        [Parameter(Position=0, Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $ProjectId,

        [Parameter()]
        [string]
        $SiteUrl
    )

    $Project = Get-GitlabProject -ProjectId $ProjectId

    if ($PSCmdlet.ShouldProcess("$($Project.PathWithNamespace)", "remove project")) {
        # https://docs.gitlab.com/ee/api/projects.html#delete-project
        Invoke-GitlabApi DELETE "projects/$($ProjectId)" -SiteUrl $SiteUrl | Out-Null
        Write-Host "Removed $($Project.PathWithNamespace)"
    }
}