public/Save-AzureDevOpsRepoItem.ps1




Function Save-AzureDevOpsRepoItem {
<#
    .SYNOPSIS
    Gets a file or folder from Azure DevOps.
     
    .DESCRIPTION
    Gets a file or folder incl subfolders from Azure DevOps and preserve changed data timestamp from Azure DevOps.
 
    .EXAMPLE
    Invoke-SqlCmdWithMessages -Query 'select 0' -ServerInstance 'localhost' -Database master -OutputFile C:\tmp\test2.txt
     
    .PARAMETER Organization
    The name of the Azure DevOps organization
     
    .PARAMETER Repository
    The name of the Azure DevOps repository containg the items to save locally. Must start with $/ and end with /
 
    .PARAMETER ProjectPath
    The path to a file or folder within the repotitory eg. /Folder/file.ext. Must not start with /.
    If the ProjectPath points to a folder, it will get all files and subfolders recursively.
    If the ProjectPath points to a folder and does not end with / and point it will get the entire folder.
    If it does not end with /, it will only save the content of that folder.
 
    .PARAMETER ChangeSet
    Optional parameter to retrieve a specific version of a file or folder.
 
    .PARAMETER AzureDevOpsAccessToken
 
    .PARAMETER OutputPath
    The local folder to save the Azure DevOps files in.
     
    .OUTPUTS
    The content of the output folder
     
    .LINK
    https://github.com/DennisWagner/SQLServerDevOpsTools
     
    .NOTES
    Written by (c) Dennis Wagner Kristensen, 2021 https://github.com/DennisWagner/SQLServerDevOpsTools
    This PowerShell script is released under the MIT license http://www.opensource.org/licenses/MIT
#>

    Param (
                [Parameter(Mandatory=$true)]
                [string]
                $Organization,
                [Parameter(Mandatory=$true)]
                [ValidateScript({$_.StartsWith('$/') -and $_.EndsWith('/')})]
                [string]
                $Repository,
                [Parameter(Mandatory=$true)]
                [ValidateScript({-not $_.StartsWith('/')})]
                [string]
                $ProjectPath,
                [Parameter(Mandatory=$false)]
                [string]
                $ChangeSet,
                [Parameter(Mandatory=$false)]
                [string]
                $AzureDevOpsAccessToken,
                [Parameter(Mandatory=$true)]
                [string]
                $OutputPath
    )
    BEGIN {
        $ScopePath = "$($Repository)$($ProjectPath)"
        $FolderPath = @{label="FullPath"; expression={Join-Path -Path $OutputFolder -ChildPath ($_.path.Replace($ScopePath, '')).Replace('/', '\')}}
        
        if (-not (Test-Path -Path $OutputPath)) {
            Write-Verbose "Creating output folder: $OutputPath."
            New-Item -Path $OutputPath -ItemType Directory | Out-Null
        }

        $IncludeTopfolder = -not $ProjectPath.EndsWith('/')
        $OutputFolder = $OutputPath
        
        $AzureDevOpsAuthenicationHeader = @{Authorization = 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$($AzureDevOpsPAT)")) }
        $uri = "https://dev.azure.com/$($Organization)/_apis/tfvc/items?api-version=6.0&recursionLevel=full&versionDescriptor.versionType=changeset"
        $uri += "&version=$($ChangeSet)"
        $uri += "&scopePath=$ScopePath"

        Write-Verbose "URL: $uri"

    }

    PROCESS {

        # get a list of the files and folders in the project based on the changeset
        #$result = Invoke-RestMethod -Uri $uri -Method get -Headers $AzureDevOpsAuthenicationHeader
        $response = Invoke-WebRequest -Uri $uri -Method get -Headers $AzureDevOpsAuthenicationHeader 

        If ($response.StatusCode -eq 203) {
            Throw "There was an error accessing Azure DevOps: Error: $($response.StatusCode) $($response.StatusDescription). Response from Azure DevOps: $($response.Content)"
        } elseif ($response.StatusCode -ne 200) {
            Throw "There was an unknown error accessing Azure DevOps at URL: $($uri): Error: $($response.StatusCode) $($response.StatusDescription)."
        }

        $result = $response.Content | ConvertFrom-Json

        If ($result.count -eq 0) {
            Throw "Unable to locate the specified project path: $ProjectPath in the repository: $Repository for the Azure DevOps organization: $Organization"
        }

        If ($IncludeTopfolder) {
            $firstitem = $result.value | Select-Object -First 1

            If ($firstitem.IsFolder -and $firstitem.path -eq $ScopePath) {

                # the ProjectPath points to a folder, and that must be included in the download
                $topfolder = ($firstitem.path -split '/') | Select-Object -Last 1
                $OutputFolder  = Join-Path -path $OutputFolder -ChildPath $topfolder
            }
        } else {
            Write-Verbose "Only get the content of the project path, in case it points to a folder."
        }

        $RepoItems = $result.value | Select-Object version, changeDate, size, hashValue, encoding, path, url, isFolder, $FolderPath

        # Download all files and folders in the project
        foreach ($item in ($RepoItems | Sort-Object Path)) {

            If (($item.path.length -le $ScopePath.length) -and $item.IsFolder) {
                # skip the top level level - only get the content of the folder
                continue
            }
            
            If ($item.isFolder) {
                if (-not (Test-Path -Path $item.FullPath)) {
                    New-Item -Path $item.FullPath -ItemType Directory | Out-Null
                }
            } else {
                $FileName = ($item.path -split '/') | Select-Object -Last 1
                $FolderPath = $item.FullPath -replace $FileName, ""

                If ($result.count -eq 1) {
                    # The projects path points to a single file, so replace the FullPath. FullPath only works for folders
                    $item.FullPath = Join-Path -Path $OutputFolder -ChildPath $FileName
                } else {
                    # make sure the target folder exists in case the report path only contains files
                    If (-not (Test-Path -Path $FolderPath)) {
                        New-Item -Path $FolderPath -ItemType Directory | Out-Null
                    }
                }

                Try {
                    Invoke-WebRequest -Uri "$($item.url)"  -OutFile $item.FullPath -Headers $AzureDevOpsAuthenicationHeader
                } Catch {
                    Throw "There was an unknown error accessing Azure DevOps at URL: $($item.url): Error: $($_)"
                }
            }
        }

        # Set changed date in reverse order to make sure lastwritetime on folders is not updated by windows, when setting
        # the timestamps on content in the folder
        foreach ($item in ($RepoItems | Sort-Object Path -Descending)) {

            If (($item.path.length -le $ScopePath.length) -and $item.IsFolder) {
                # skip the top level level - only get the content of the folder
                If (-not $IncludeTopfolder) {
                    continue
                }
            }

            $file = Get-Item $item.FullPath
            $file.LastWriteTime = $item.changeDate
        }
       
    }
    END {
        Get-ChildItem -Path $OutputPath -Recurse | Select-Object Name,Directory,LastWriteTime | Format-Table
    }
}