Public/Bitbucket.ps1

# PSSnips — Bitbucket Snippets integration.
function Get-BitbucketSnipList {
    <#
    .SYNOPSIS
        Lists Bitbucket snippets for the authenticated user or a specific workspace.
    .DESCRIPTION
        Calls the Bitbucket Snippets API (GET /2.0/snippets/{workspace} or
        /2.0/snippets) and displays a formatted table of snippets showing Id, Title,
        Created, Updated, and IsPrivate columns.
    .PARAMETER Workspace
        Optional. The Bitbucket workspace slug to list snippets from. Defaults to
        the authenticated user's workspace (their username).
    .PARAMETER Role
        Optional. Filter by role. Accepted values: 'owner', 'contributor', 'member'.
        Maps to the Bitbucket API 'role' query parameter.
    .EXAMPLE
        Get-BitbucketSnipList
    .EXAMPLE
        Get-BitbucketSnipList -Workspace myteam -Role owner
    .INPUTS
        None.
    .OUTPUTS
        System.Management.Automation.PSCustomObject[]
    .NOTES
        Requires BitbucketUsername + BitbucketAppPassword config or
        $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [string]$Workspace = '',
        [ValidateSet('owner','contributor','member','')]
        [string]$Role      = ''
    )
    script:InitEnv
    $p = script:Get-RemoteProvider -Name 'Bitbucket'
    if (-not $p.IsConfigured()) { script:Out-Warn 'Bitbucket credentials not set. Run: Set-SnipConfig -BitbucketUsername <user> -BitbucketAppPassword <app-pwd> (or set $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD)'; return }

    $effectiveWs = if ($Workspace) { $Workspace } else { $null }
    if ($effectiveWs) {
        # Use a workspace-specific provider instance
        $cred = script:GetBitbucketCreds
        if (-not $cred) { return }
        $p = [BitbucketProvider]::new($cred, $effectiveWs)
    }

    try {
        $snips = @($p.ListRemote(''))
    } catch {
        script:Out-Err "Bitbucket API error: $_"
        return
    }
    if ($Role) {
        # Re-query with role filter since the provider uses a simple endpoint
        $cred = script:GetBitbucketCreds
        if (-not $cred) { return }
        $uri  = "https://api.bitbucket.org/2.0/snippets/$(if ($Workspace) { $Workspace } else { $cred.UserName })?role=$Role"
        $rawSnips = [System.Collections.Generic.List[object]]::new()
        try {
            do {
                $page = Invoke-RestMethod -Uri $uri -Method GET -Credential $cred `
                            -Headers @{ 'User-Agent' = 'PSSnips/1.0' } -ErrorAction Stop
                foreach ($v in $page.values) { $rawSnips.Add($v) }
                $uri = if ($page.next) { $page.next } else { $null }
            } while ($uri)
        } catch {
            script:Out-Err "Bitbucket API error: $_"
            return
        }
        $snips = @($rawSnips | ForEach-Object {
            [PSCustomObject]@{
                Id        = $_.id
                Title     = $_.title
                Scm       = if ($_.scm) { $_.scm } else { 'git' }
                IsPrivate = $_.is_private
                CreatedOn = $_.created_on
                UpdatedOn = $_.updated_on
                Links     = $_.links
            }
        })
    }

    if ($snips.Count -eq 0) { script:Out-Info 'No Bitbucket snippets found.'; return }

    Write-Host ''
    Write-Host (" {0,-12} {1,-40} {2,-22} {3,-22} {4}" -f 'ID','TITLE','CREATED','UPDATED','PRIVATE') -ForegroundColor DarkCyan
    Write-Host " $('─' * 105)" -ForegroundColor DarkGray
    foreach ($s in $snips) {
        $created  = if ($s.CreatedOn)  { [datetime]$s.CreatedOn  | Get-Date -Format 'yyyy-MM-dd HH:mm' } else { '' }
        $updated  = if ($s.UpdatedOn)  { [datetime]$s.UpdatedOn  | Get-Date -Format 'yyyy-MM-dd HH:mm' } else { '' }
        $isPriv   = if ($s.IsPrivate)  { 'Yes' } else { 'No' }
        Write-Host (" {0,-12} {1,-40} {2,-22} {3,-22} {4}" -f $s.Id, $s.Title, $created, $updated, $isPriv) -ForegroundColor Gray
    }
    Write-Host ''

    return $snips
}

function Import-BitbucketSnip {
    <#
    .SYNOPSIS
        Downloads a Bitbucket snippet and saves it as one or more local snippets.
    .DESCRIPTION
        Fetches the snippet metadata from GET /2.0/snippets/{workspace}/{encoded_id}
        and then retrieves each file's content via the file self-link. If the snippet
        contains multiple files each is saved as a separate local snippet named
        {Name}-{filename} (or {title}-{filename} when -Name is not provided).
        Each file is registered via New-Snip with -Content and optional -Force.
    .PARAMETER Id
        Mandatory. The short alphanumeric Bitbucket snippet ID (e.g., 'xKjP9').
    .PARAMETER Workspace
        Optional. The Bitbucket workspace slug. Defaults to the authenticated user's
        username.
    .PARAMETER Name
        Optional. Override for the local snippet base name. When the snippet has
        multiple files, each file is saved as {Name}-{filename}.
    .PARAMETER Force
        Optional switch. Overwrites existing local snippets with the same name.
    .EXAMPLE
        Import-BitbucketSnip -Id xKjP9
    .EXAMPLE
        Import-BitbucketSnip -Id xKjP9 -Workspace myteam -Name my-local -Force
    .INPUTS
        None.
    .OUTPUTS
        None.
    .NOTES
        Requires BitbucketUsername + BitbucketAppPassword config or
        $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD.
        The bitbucketId is stored in the index for future sync.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage='Bitbucket snippet ID (e.g. xKjP9)')]
        [ValidateNotNullOrEmpty()]
        [string]$Id,
        [string]$Workspace = '',
        [string]$Name      = '',
        [switch]$Force
    )
    script:InitEnv
    $p    = script:Get-RemoteProvider -Name 'Bitbucket'
    if (-not $p.IsConfigured()) { script:Out-Warn 'Bitbucket credentials not set. Run: Set-SnipConfig -BitbucketUsername <user> -BitbucketAppPassword <app-pwd> (or set $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD)'; return }
    $cred = script:GetBitbucketCreds
    if (-not $cred) { return }

    $bbProvider = if ($Workspace) { [BitbucketProvider]::new($cred, $Workspace) } else { $p }

    try {
        $meta = $bbProvider.GetRemoteById($Id)
    } catch {
        script:Out-Err "Bitbucket API error fetching snippet '$Id': $_"
        return
    }

    $baseName = if ($Name) { $Name } else {
        $meta.title -replace '[^\w\-]', '-' -replace '-{2,}', '-' -replace '^-|-$', ''
    }
    if (-not $baseName) { $baseName = $Id }

    $fileNames = @($meta.files.PSObject.Properties.Name)
    if ($fileNames.Count -eq 0) {
        script:Out-Warn "Snippet '$Id' has no files."
        return
    }

    foreach ($fileName in $fileNames) {
        $fileLink = $meta.files.$fileName.links.self.href
        try {
            $rawContent = Invoke-RestMethod -Uri $fileLink -Method GET -Credential $cred `
                              -Headers @{ 'User-Agent' = 'PSSnips/1.0' } -ErrorAction Stop
        } catch {
            script:Out-Err "Failed to fetch file '$fileName' for snippet '$Id': $_"
            continue
        }

        $ext      = [System.IO.Path]::GetExtension($fileName).TrimStart('.')
        if (-not $ext) { $ext = 'txt' }
        $fileBase = [System.IO.Path]::GetFileNameWithoutExtension($fileName)

        $snipName = if ($fileNames.Count -eq 1) {
            $baseName
        } else {
            "$baseName-$fileBase"
        }

        $params = @{
            Name     = $snipName
            Language = $ext
            Content  = if ($rawContent -is [string]) { $rawContent } else { $rawContent | Out-String }
            Force    = $Force
        }
        New-Snip @params

        # Stamp the bitbucketId into the index entry
        $idx = script:LoadIdx
        if ($idx.snippets.ContainsKey($snipName)) {
            Add-Member -InputObject $idx.snippets[$snipName] -NotePropertyName 'BitbucketId'  -NotePropertyValue $Id -Force
            Add-Member -InputObject $idx.snippets[$snipName] -NotePropertyName 'BitbucketUrl' -NotePropertyValue (if ($meta.links.html.href) { $meta.links.html.href } else { '' }) -Force
            script:SaveIdx -Idx $idx
        }
        script:Out-OK "Imported Bitbucket snippet '$snipName' ($ext)."
    }
}

function Export-BitbucketSnip {
    <#
    .SYNOPSIS
        Exports a local snippet to Bitbucket as a new snippet.
    .DESCRIPTION
        Reads the local snippet content and POSTs it to
        POST /2.0/snippets/{workspace} as multipart/form-data. On success the new
        snippet URL is displayed and the bitbucketId / bitbucketUrl are saved to the
        local index. A temporary staging file is created inside the PSSnips home
        directory and removed immediately after the upload.
    .PARAMETER Name
        Mandatory. The local snippet name to upload.
    .PARAMETER Title
        Optional. The title to use on Bitbucket. Defaults to the snippet name.
    .PARAMETER IsPrivate
        Optional switch. When specified the snippet is created as private (not public).
    .EXAMPLE
        Export-BitbucketSnip my-snippet
    .EXAMPLE
        Export-BitbucketSnip my-snippet -Title 'Deploy script' -IsPrivate
    .INPUTS
        None.
    .OUTPUTS
        None.
    .NOTES
        Requires BitbucketUsername + BitbucketAppPassword config or
        $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD.
        If the snippet already has a bitbucketId it is reported as already exported;
        use the Bitbucket UI to update existing snippets or delete and re-export.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position=0, HelpMessage='Local snippet name to export')]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
        [string]$Title = '',
        [switch]$IsPrivate
    )
    script:InitEnv
    $p = script:Get-RemoteProvider -Name 'Bitbucket'
    if (-not $p.IsConfigured()) { script:Out-Warn 'Bitbucket credentials not set. Run: Set-SnipConfig -BitbucketUsername <user> -BitbucketAppPassword <app-pwd> (or set $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD)'; return }
    $cred = script:GetBitbucketCreds
    if (-not $cred) { return }

    $idx = script:LoadIdx
    if (-not $idx.snippets.ContainsKey($Name)) {
        Write-Error "Snippet '$Name' not found." -ErrorAction Continue
        return
    }

    $meta    = $idx.snippets[$Name]
    $path    = script:FindFile -Name $Name
    if (-not $path -or -not (Test-Path $path)) {
        Write-Error "Snippet file for '$Name' not found." -ErrorAction Continue
        return
    }

    $content      = Get-Content $path -Raw -Encoding UTF8
    $fn           = "$Name.$($meta.Language)"
    $effectTitle  = if ($Title) { $Title } else { $Name }
    $base         = 'https://api.bitbucket.org/2.0'
    $workspace    = $cred.UserName

    # Write content to a staging file named after the snippet so Bitbucket
    # receives the correct filename in the multipart Content-Disposition header.
    $stagingFile  = Join-Path $script:Home "._export_$fn"
    try {
        Set-Content $stagingFile -Value $content -Encoding UTF8 -NoNewline

        $form = @{
            title      = $effectTitle
            is_private = if ($IsPrivate) { 'true' } else { 'false' }
            $fn        = Get-Item $stagingFile
        }

        $result = Invoke-RestMethod -Uri "$base/snippets/$workspace" `
                      -Method POST -Credential $cred `
                      -Headers @{ 'User-Agent' = 'PSSnips/1.0' } `
                      -Form $form -ErrorAction Stop

        $idx.snippets[$Name].BitbucketId  = $result.id
        $idx.snippets[$Name].BitbucketUrl = if ($result.links.html.href) { $result.links.html.href } else { '' }
        script:SaveIdx -Idx $idx
        script:Out-OK "Bitbucket snippet created: $($result.links.html.href)"
        script:Invoke-SnipEvent -EventName 'SnipPublished' -Data @{
            Name     = $Name
            Provider = 'bitbucket'
            Url      = ''
        }
    } catch {
        script:Out-Err "Failed to export '$Name' to Bitbucket: $_"
    } finally {
        if (Test-Path $stagingFile) { Remove-Item $stagingFile -Force }
    }
}

function Sync-BitbucketSnips {
    <#
    .SYNOPSIS
        Synchronises local snippets with Bitbucket in one or both directions.
    .DESCRIPTION
        Pull downloads all Bitbucket snippets found via Get-BitbucketSnipList and
        imports each with Import-BitbucketSnip. Push uploads every local snippet
        that does not yet have a bitbucketId via Export-BitbucketSnip. Both runs
        Pull then Push in sequence.
    .PARAMETER Workspace
        Optional. The Bitbucket workspace slug. Defaults to the authenticated user's
        username.
    .PARAMETER Direction
        Optional. 'Pull' (default), 'Push', or 'Both'.
    .PARAMETER Force
        Optional switch. Passed through to Import-BitbucketSnip to allow overwriting
        existing local snippets during a Pull.
    .EXAMPLE
        Sync-BitbucketSnips
    .EXAMPLE
        Sync-BitbucketSnips -Direction Both -Force
    .EXAMPLE
        Sync-BitbucketSnips -Direction Push -Workspace myteam
    .INPUTS
        None.
    .OUTPUTS
        None.
    .NOTES
        Requires BitbucketUsername + BitbucketAppPassword config or
        $env:BITBUCKET_USERNAME / $env:BITBUCKET_APP_PASSWORD.
    #>

    [CmdletBinding()]
    param(
        [string]$Workspace = '',
        [ValidateSet('Pull','Push','Both')]
        [string]$Direction = 'Pull',
        [switch]$Force
    )
    script:InitEnv
    $cred = script:GetBitbucketCreds
    if (-not $cred) { return }

    $ws = if ($Workspace) { $Workspace } else { $cred.UserName }

    if ($Direction -in 'Pull','Both') {
        script:Out-Info 'Pulling snippets from Bitbucket…'
        $remote = Get-BitbucketSnipList -Workspace $ws
        if ($remote) {
            foreach ($r in $remote) {
                Import-BitbucketSnip -Id $r.Id -Workspace $ws -Force:$Force
            }
        }
    }

    if ($Direction -in 'Push','Both') {
        script:Out-Info 'Pushing local snippets to Bitbucket…'
        $idx = script:LoadIdx
        foreach ($snipName in $idx.snippets.Keys) {
            $entry = $idx.snippets[$snipName]
            $hasBbId = $null -ne $entry.PSObject.Properties['BitbucketId'] -and $entry.BitbucketId
            if (-not $hasBbId) {
                Export-BitbucketSnip -Name $snipName
            }
        }
    }

    script:Out-OK 'Bitbucket sync complete.'
}