LibreDevOpsHelpers.GitLab/LibreDevOpsHelpers.GitLab.psm1

Set-StrictMode -Version Latest

function Invoke-LdoGlabCommand {
    # Internal. Asserts glab is present, runs it with the supplied arguments, and throws a
    # descriptive error on a non-zero exit. Centralises the shell-out so every public glab
    # function logs and error-checks identically.
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [string[]]$ArgumentList,

        [string]$Operation
    )

    Assert-LdoCommand -Name 'glab'

    if (-not $Operation) {
        $Operation = "glab $($ArgumentList -join ' ')"
    }

    Write-LdoLog -Level INFO -Message "Running: glab $($ArgumentList -join ' ')"
    & glab @ArgumentList
    Assert-LdoLastExitCode -Operation $Operation
}

function Install-LdoGlab {
    <#
    .SYNOPSIS
        Installs the GitLab CLI (glab) if it is not already present.

    .DESCRIPTION
        Installs glab via Chocolatey on Windows or Homebrew on other platforms when the glab
        command is not found on PATH. Does nothing when glab is already installed.

    .EXAMPLE
        Install-LdoGlab

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param()

    if (Get-Command glab -ErrorAction SilentlyContinue) {
        Write-LdoLog -Level INFO -Message 'glab already installed.'
        return
    }

    $os = Get-LdoOperatingSystem
    if ($os -eq 'Windows') {
        Assert-LdoChocoPath
        Write-LdoLog -Level INFO -Message 'Installing glab via Chocolatey.'
        choco install glab -y
        Assert-LdoLastExitCode -Operation 'choco install glab'
    }
    else {
        Assert-LdoHomebrewPath
        Write-LdoLog -Level INFO -Message 'Installing glab via Homebrew.'
        brew install glab
        Assert-LdoLastExitCode -Operation 'brew install glab'
    }

    Write-LdoLog -Level SUCCESS -Message 'glab installed.'
}

function Test-LdoGlab {
    <#
    .SYNOPSIS
        Tests whether glab is available on PATH.

    .DESCRIPTION
        Returns $true when the glab command is found, otherwise $false.

    .EXAMPLE
        if (-not (Test-LdoGlab)) { Install-LdoGlab }

    .OUTPUTS
        System.Boolean
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    $glabPath = Get-Command glab -ErrorAction SilentlyContinue
    if ($glabPath) {
        Write-LdoLog -Level INFO -Message "glab found at: $($glabPath.Source)"
        return $true
    }

    Write-LdoLog -Level WARN -Message 'glab is not installed or not in PATH.'
    return $false
}

function Connect-LdoGlab {
    <#
    .SYNOPSIS
        Authenticates glab against a GitLab instance using a token.

    .DESCRIPTION
        Logs glab in non-interactively by piping the token to 'glab auth login --stdin'. Supports
        self-managed GitLab via -Hostname. The token is supplied as a SecureString and is never
        written to the command line or logs.

    .PARAMETER Token
        A GitLab personal or project access token as a SecureString.

    .PARAMETER Hostname
        The GitLab host to authenticate against. Defaults to gitlab.com.

    .EXAMPLE
        Connect-LdoGlab -Token $secureToken -Hostname gitlab.mycorp.com

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [securestring]$Token,

        [ValidateNotNullOrEmpty()]
        [string]$Hostname = 'gitlab.com'
    )

    Assert-LdoCommand -Name 'glab'

    $plain = [System.Net.NetworkCredential]::new('', $Token).Password
    if ([string]::IsNullOrWhiteSpace($plain)) {
        throw 'The supplied GitLab token is empty.'
    }

    Write-LdoLog -Level INFO -Message "Authenticating glab against $Hostname."
    $plain | & glab auth login --hostname $Hostname --stdin
    Assert-LdoLastExitCode -Operation "glab auth login ($Hostname)"

    Write-LdoLog -Level SUCCESS -Message "glab authenticated against $Hostname."
}

function Invoke-LdoGlabPipeline {
    <#
    .SYNOPSIS
        Triggers a new CI/CD pipeline with glab.

    .DESCRIPTION
        Runs 'glab ci run' to create a pipeline on a branch, optionally passing CI/CD variables.
        Throws on failure.

    .PARAMETER Ref
        Branch (or tag) to run the pipeline on. Defaults to the current branch when omitted.

    .PARAMETER Variables
        Hashtable of pipeline variables to pass (key/value).

    .PARAMETER Repo
        Target project as OWNER/REPO or a full URL (passed as --repo). Optional.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to glab ci run.

    .EXAMPLE
        Invoke-LdoGlabPipeline -Ref main -Variables @{ ENVIRONMENT = 'prod' }

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [string]$Ref,
        [hashtable]$Variables = @{},
        [string]$Repo,
        [string[]]$AdditionalArgs = @()
    )

    $glabArgs = @('ci', 'run')
    if ($Ref) {
        $glabArgs += @('--branch', $Ref)
    }
    foreach ($key in $Variables.Keys) {
        $glabArgs += @('--variables', "$key`:$($Variables[$key])")
    }
    if ($Repo) {
        $glabArgs += @('--repo', $Repo)
    }
    if ($AdditionalArgs) {
        $glabArgs += $AdditionalArgs
    }

    Invoke-LdoGlabCommand -ArgumentList $glabArgs -Operation 'glab ci run'
    Write-LdoLog -Level SUCCESS -Message 'Pipeline triggered.'
}

function Get-LdoGlabPipeline {
    <#
    .SYNOPSIS
        Returns a pipeline's details from the GitLab API via glab.

    .DESCRIPTION
        Calls 'glab api projects/<ProjectId>/pipelines/<Id>' and returns the parsed JSON object,
        including the current status. ProjectId defaults to the CI_PROJECT_ID environment variable
        when running inside a pipeline.

    .PARAMETER Id
        The pipeline ID.

    .PARAMETER ProjectId
        Numeric project ID or URL-encoded path. Defaults to $env:CI_PROJECT_ID.

    .EXAMPLE
        Get-LdoGlabPipeline -Id 123

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [string]$ProjectId = $env:CI_PROJECT_ID
    )

    Assert-LdoCommand -Name 'glab'
    if ([string]::IsNullOrWhiteSpace($ProjectId)) {
        throw 'No project specified. Pass -ProjectId or run where CI_PROJECT_ID is set.'
    }

    $endpoint = "projects/$ProjectId/pipelines/$Id"
    Write-LdoLog -Level INFO -Message "Running: glab api $endpoint"
    $json = & glab api $endpoint
    Assert-LdoLastExitCode -Operation "glab api $endpoint"

    return ($json | ConvertFrom-Json)
}

function Wait-LdoGlabPipeline {
    <#
    .SYNOPSIS
        Waits for a GitLab pipeline to finish, throwing if it does not succeed.

    .DESCRIPTION
        Polls the pipeline status until it reaches a terminal state. Returns the final pipeline
        object on success and throws when the pipeline fails or is canceled, or when the timeout
        is exceeded.

    .PARAMETER Id
        The pipeline ID.

    .PARAMETER ProjectId
        Numeric project ID or URL-encoded path. Defaults to $env:CI_PROJECT_ID.

    .PARAMETER PollSeconds
        Seconds to wait between status checks. Defaults to 10.

    .PARAMETER TimeoutSeconds
        Maximum seconds to wait before throwing. Defaults to 1800 (30 minutes).

    .EXAMPLE
        Wait-LdoGlabPipeline -Id 123 -TimeoutSeconds 600

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Id,

        [string]$ProjectId = $env:CI_PROJECT_ID,

        [ValidateRange(1, 3600)]
        [int]$PollSeconds = 10,

        [ValidateRange(1, 86400)]
        [int]$TimeoutSeconds = 1800
    )

    $successStates = @('success')
    $failureStates = @('failed', 'canceled', 'skipped')
    $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()

    while ($true) {
        $pipeline = Get-LdoGlabPipeline -Id $Id -ProjectId $ProjectId
        $status = $pipeline.status
        Write-LdoLog -Level INFO -Message "Pipeline $Id status: $status"

        if ($status -in $successStates) {
            Write-LdoLog -Level SUCCESS -Message "Pipeline $Id succeeded."
            return $pipeline
        }
        if ($status -in $failureStates) {
            throw "Pipeline $Id ended with status '$status'."
        }
        if ($stopwatch.Elapsed.TotalSeconds -ge $TimeoutSeconds) {
            throw "Timed out after ${TimeoutSeconds}s waiting for pipeline $Id (last status '$status')."
        }

        Start-Sleep -Seconds $PollSeconds
    }
}

function New-LdoGlabMergeRequest {
    <#
    .SYNOPSIS
        Creates a merge request with glab.

    .DESCRIPTION
        Runs 'glab mr create' non-interactively. Either supply -Title (with optional -Description)
        or use -Fill to derive the title and description from the commits. Throws on failure.

    .PARAMETER Source
        Source branch.

    .PARAMETER Target
        Target branch.

    .PARAMETER Title
        Merge request title. Required unless -Fill is set.

    .PARAMETER Description
        Merge request description.

    .PARAMETER Fill
        When set, passes --fill to populate the title and description from the commits.

    .PARAMETER RemoveSourceBranch
        When set, passes --remove-source-branch.

    .PARAMETER Squash
        When set, passes --squash-before-merge.

    .PARAMETER Repo
        Target project as OWNER/REPO or a full URL (passed as --repo). Optional.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to glab mr create.

    .EXAMPLE
        New-LdoGlabMergeRequest -Source feature/x -Target main -Title 'Add x' -RemoveSourceBranch

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Source,
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Target,
        [string]$Title,
        [string]$Description,
        [switch]$Fill,
        [switch]$RemoveSourceBranch,
        [switch]$Squash,
        [string]$Repo,
        [string[]]$AdditionalArgs = @()
    )

    if (-not $Title -and -not $Fill) {
        throw 'Specify -Title, or use -Fill to derive it from the commits.'
    }

    $glabArgs = @('mr', 'create', '--source-branch', $Source, '--target-branch', $Target, '--yes')
    if ($Fill) {
        $glabArgs += '--fill'
    }
    if ($Title) {
        $glabArgs += @('--title', $Title)
    }
    if ($Description) {
        $glabArgs += @('--description', $Description)
    }
    if ($RemoveSourceBranch) {
        $glabArgs += '--remove-source-branch'
    }
    if ($Squash) {
        $glabArgs += '--squash-before-merge'
    }
    if ($Repo) {
        $glabArgs += @('--repo', $Repo)
    }
    if ($AdditionalArgs) {
        $glabArgs += $AdditionalArgs
    }

    Invoke-LdoGlabCommand -ArgumentList $glabArgs -Operation 'glab mr create'
    Write-LdoLog -Level SUCCESS -Message "Merge request created ($Source -> $Target)."
}

function New-LdoGlabRelease {
    <#
    .SYNOPSIS
        Creates a release with glab.

    .DESCRIPTION
        Runs 'glab release create' for a tag, optionally attaching notes and asset files. Throws
        on failure.

    .PARAMETER Tag
        The release tag (for example v1.2.0).

    .PARAMETER Name
        Release name. Defaults to the tag when omitted.

    .PARAMETER Notes
        Release notes text. Mutually exclusive with -NotesFile.

    .PARAMETER NotesFile
        Path to a file containing the release notes.

    .PARAMETER Ref
        Commit SHA or branch the tag should be created from, if it does not already exist.

    .PARAMETER AssetFiles
        One or more files to attach to the release.

    .PARAMETER Repo
        Target project as OWNER/REPO or a full URL (passed as --repo). Optional.

    .PARAMETER AdditionalArgs
        Additional arguments passed through to glab release create.

    .EXAMPLE
        New-LdoGlabRelease -Tag v1.2.0 -Name 'v1.2.0' -NotesFile CHANGELOG.md

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Tag,
        [string]$Name,
        [string]$Notes,
        [string]$NotesFile,
        [string]$Ref,
        [string[]]$AssetFiles = @(),
        [string]$Repo,
        [string[]]$AdditionalArgs = @()
    )

    if ($Notes -and $NotesFile) {
        throw 'Specify only one of -Notes or -NotesFile.'
    }
    if ($NotesFile -and -not (Test-Path $NotesFile)) {
        throw "Notes file not found: $NotesFile"
    }

    $glabArgs = @('release', 'create', $Tag)
    if ($Name) {
        $glabArgs += @('--name', $Name)
    }
    if ($Notes) {
        $glabArgs += @('--notes', $Notes)
    }
    if ($NotesFile) {
        $glabArgs += @('--notes-file', $NotesFile)
    }
    if ($Ref) {
        $glabArgs += @('--ref', $Ref)
    }
    foreach ($asset in $AssetFiles) {
        if (-not (Test-Path $asset)) {
            throw "Asset file not found: $asset"
        }
        $glabArgs += $asset
    }
    if ($Repo) {
        $glabArgs += @('--repo', $Repo)
    }
    if ($AdditionalArgs) {
        $glabArgs += $AdditionalArgs
    }

    Invoke-LdoGlabCommand -ArgumentList $glabArgs -Operation "glab release create $Tag"
    Write-LdoLog -Level SUCCESS -Message "Release $Tag created."
}

function Set-LdoGlabCiVariable {
    <#
    .SYNOPSIS
        Creates or updates a project CI/CD variable with glab.

    .DESCRIPTION
        Runs 'glab variable set' to create or update a CI/CD variable. The value is piped via
        stdin so it never appears on the command line or in logs. Throws on failure.

    .PARAMETER Key
        The variable key.

    .PARAMETER Value
        The variable value. Piped to glab via stdin.

    .PARAMETER Masked
        When set, passes --masked so the value is masked in job logs.

    .PARAMETER Protected
        When set, passes --protected so the variable is only exposed to protected refs.

    .PARAMETER Scope
        Environment scope for the variable (passed as --scope). Optional.

    .PARAMETER Group
        Set the variable at group level instead of project level (passed as --group).

    .PARAMETER Repo
        Target project as OWNER/REPO or a full URL (passed as --repo). Optional.

    .EXAMPLE
        Set-LdoGlabCiVariable -Key DEPLOY_TOKEN -Value $token -Masked -Protected

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Key,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Value,
        [switch]$Masked,
        [switch]$Protected,
        [string]$Scope,
        [string]$Group,
        [string]$Repo
    )

    Assert-LdoCommand -Name 'glab'

    $glabArgs = @('variable', 'set', $Key)
    if ($Masked) {
        $glabArgs += '--masked'
    }
    if ($Protected) {
        $glabArgs += '--protected'
    }
    if ($Scope) {
        $glabArgs += @('--scope', $Scope)
    }
    if ($Group) {
        $glabArgs += @('--group', $Group)
    }
    if ($Repo) {
        $glabArgs += @('--repo', $Repo)
    }

    # Pipe the value via stdin so it is never exposed on the command line or in logs.
    Write-LdoLog -Level INFO -Message "Setting CI/CD variable '$Key'."
    $Value | & glab @glabArgs
    Assert-LdoLastExitCode -Operation "glab variable set $Key"

    Write-LdoLog -Level SUCCESS -Message "CI/CD variable '$Key' set."
}

function Get-LdoGlabCiVariable {
    <#
    .SYNOPSIS
        Returns the value of a project CI/CD variable via glab.

    .DESCRIPTION
        Runs 'glab variable get' and returns the variable's value. Throws on failure.

    .PARAMETER Key
        The variable key.

    .PARAMETER Scope
        Environment scope to read (passed as --scope). Optional.

    .PARAMETER Group
        Read the variable at group level instead of project level (passed as --group).

    .PARAMETER Repo
        Target project as OWNER/REPO or a full URL (passed as --repo). Optional.

    .EXAMPLE
        Get-LdoGlabCiVariable -Key DEPLOY_TOKEN

    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Key,
        [string]$Scope,
        [string]$Group,
        [string]$Repo
    )

    Assert-LdoCommand -Name 'glab'

    $glabArgs = @('variable', 'get', $Key)
    if ($Scope) {
        $glabArgs += @('--scope', $Scope)
    }
    if ($Group) {
        $glabArgs += @('--group', $Group)
    }
    if ($Repo) {
        $glabArgs += @('--repo', $Repo)
    }

    Write-LdoLog -Level INFO -Message "Getting CI/CD variable '$Key'."
    $value = & glab @glabArgs
    Assert-LdoLastExitCode -Operation "glab variable get $Key"

    return $value
}

function Get-LdoGitLabCiVariable {
    <#
    .SYNOPSIS
        Reads a GitLab CI/CD variable from the environment.

    .DESCRIPTION
        Returns the value of a GitLab CI variable (a predefined variable such as
        CI_COMMIT_REF_NAME, or any variable exposed to the job) from the environment. Returns the
        default value when the variable is unset or empty. Intended for PowerShell running inside a
        GitLab pipeline.

    .PARAMETER Name
        The CI variable name, for example CI_COMMIT_REF_NAME.

    .PARAMETER Default
        Value to return when the variable is not set. Defaults to $null.

    .EXAMPLE
        Get-LdoGitLabCiVariable -Name CI_COMMIT_REF_NAME -Default main

    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Name,
        $Default = $null
    )

    $value = [System.Environment]::GetEnvironmentVariable($Name)
    if (-not [string]::IsNullOrEmpty($value)) {
        return $value
    }

    return $Default
}

function Set-LdoGitLabCiOutput {
    <#
    .SYNOPSIS
        Writes a variable to a dotenv file for passing values to later GitLab CI jobs.

    .DESCRIPTION
        Appends a KEY=value line to a dotenv file. Expose it to downstream jobs by declaring the
        file as a dotenv report artifact in .gitlab-ci.yml:

            artifacts:
              reports:
                dotenv: build.env

        Subsequent jobs then receive the value as an environment variable.

    .PARAMETER Name
        The variable name. Must be a valid environment variable identifier.

    .PARAMETER Value
        The variable value.

    .PARAMETER Path
        The dotenv file to append to. Defaults to build.env.

    .EXAMPLE
        Set-LdoGitLabCiOutput -Name imageTag -Value $tag

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z_][A-Za-z0-9_]*$')]
        [string]$Name,

        [Parameter(Mandatory)][AllowEmptyString()][string]$Value,

        [ValidateNotNullOrEmpty()]
        [string]$Path = 'build.env'
    )

    Add-Content -Path $Path -Value "$Name=$Value"
    Write-LdoLog -Level INFO -Message "Wrote '$Name' to dotenv file '$Path' (declare it as artifacts:reports:dotenv to pass downstream)."
}

function Write-LdoGitLabCiSection {
    <#
    .SYNOPSIS
        Runs a script block inside a collapsible, timed GitLab CI log section.

    .DESCRIPTION
        Emits the GitLab CI section_start/section_end markers around the supplied script block so
        its output is grouped (and optionally collapsed) in the job log, with a duration shown. The
        section is always closed, even when the script block throws.

    .PARAMETER Name
        Section identifier. Letters, numbers and underscores only.

    .PARAMETER ScriptBlock
        The script block to run inside the section.

    .PARAMETER Header
        Human-readable header shown on the section. Defaults to the section name.

    .PARAMETER Collapsed
        When set, the section is collapsed by default in the job log.

    .EXAMPLE
        Write-LdoGitLabCiSection -Name build -Header 'Building image' -ScriptBlock { docker build . }

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)]
        [ValidatePattern('^[A-Za-z0-9_]+$')]
        [string]$Name,

        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [string]$Header,

        [switch]$Collapsed
    )

    if (-not $Header) {
        $Header = $Name
    }

    $esc = [char]27
    $collapsedTag = if ($Collapsed) { '[collapsed=true]' } else { '' }

    $start = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
    Write-Host ("{0}[0Ksection_start:{1}:{2}{3}`r{0}[0K{4}" -f $esc, $start, $Name, $collapsedTag, $Header)
    try {
        & $ScriptBlock
    }
    finally {
        $end = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
        Write-Host ("{0}[0Ksection_end:{1}:{2}`r{0}[0K" -f $esc, $end, $Name)
    }
}

Export-ModuleMember -Function `
    Install-LdoGlab, `
    Test-LdoGlab, `
    Connect-LdoGlab, `
    Invoke-LdoGlabPipeline, `
    Get-LdoGlabPipeline, `
    Wait-LdoGlabPipeline, `
    New-LdoGlabMergeRequest, `
    New-LdoGlabRelease, `
    Set-LdoGlabCiVariable, `
    Get-LdoGlabCiVariable, `
    Get-LdoGitLabCiVariable, `
    Set-LdoGitLabCiOutput, `
    Write-LdoGitLabCiSection