YouTrackAutomation.psm1

# Copyright WebMD Health Services
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License

#Requires -Version 5.1
Set-StrictMode -Version 'Latest'

# Functions should use $moduleRoot as the relative root from which to find
# things. A published module has its function appended to this file, while a
# module in development has its functions in the Functions directory.
$moduleRoot = $PSScriptRoot

# Store each of your module's functions in its own file in the Functions
# directory. On the build server, your module's functions will be appended to
# this file, so only dot-source files that exist on the file system. This allows
# developers to work on a module without having to build it first. Grab all the
# functions that are in their own files.
$functionsPath = Join-Path -Path $moduleRoot -ChildPath 'Functions\*.ps1'
if( (Test-Path -Path $functionsPath) )
{
    foreach( $functionPath in (Get-Item $functionsPath) )
    {
        . $functionPath.FullName
    }
}



function Get-YTIssue
{
    <#
    .SYNOPSIS
    Gets an issue from YouTrack.
 
    .DESCRIPTION
    The `Get-YTIssue` function gets an issue from YouTrack using the issue ID or issue key. Pass the issue ID to the
    `IssueId` parameter. The function returns the following fields by default:
 
    * `id`
    * `idReadable`
    * `summary`
    * `description`
    * `project(name)`
    * `reporter(name)`
    * `attachments(id,name,url,created,author(name))`
 
    In order to add additional fields to the response, use the `AdditionalField` parameter, with each field name being
    an entry in the string array.
 
    .EXAMPLE
    Get-YTIssue -Session $session -IssueId 'DEMO-1'
 
    Demonstrates fetching an issue based on the issue key. This will return the default fields for the `DEMO-1` issue.
 
    .EXAMPLE
    Get-YTIssue -Session $session -IssueId '3-4'
 
    Demonstrates fetching an issue based on the issue id. This will return the default fields for the `DEMO-5` issue.
 
    .EXAMPLE
    Get-YTIssue -Session $session -IssueId 'DEMO-1' -AdditionalField 'comments(id,author(name),text)'
 
    Demonstrates fetching an issue based on the issue key and including additional fields. This will return the default
    fields for the `DEMO-1` issue, as well as the comments for the issue along with the comment id, comment author,
    and comment text.
    #>

    [CmdletBinding()]
    param(
        # The Session object for a YouTrack session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The ID of the issue to get. This can be the issue key (`DEMO-3`) or the issue ID (`3-4`).
        [Parameter(Mandatory)]
        [String] $IssueId,

        # Additional fields to include in the response. This should be a comma-separated list of field names.
        [String[]] $AdditionalField
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $fields = 'id,idReadable,summary,description,project(name,shortName),reporter(name),attachments(id,name,url,created,author(name))'

    if ($AdditionalField)
    {
        $fields += ",$($AdditionalField -join ',')"
    }

    $fields = [Uri]::EscapeDataString($fields)

    Invoke-YTRestMethod -Session $Session -Name "issues/${IssueId}?fields=$fields"
}


function Get-YTIssueCustomField
{
    <#
    .SYNOPSIS
    Gets a custom field for an issue.
 
    .DESCRIPTION
    The `Get-YTIssueCustomField` function gets the specified field information from a YouTrack issue. Provide the
    issue's ID to the `IssueID` parameter. Provide the name of the custom field to the `CustomField` parameter. If the
    custom field is not available this function will throw an error.
 
    By default, this function will return an object containing the name of the custom field and its value. To just get
    the value of the field use the `Value` switch.
 
    .EXAMPLE
    Get-YTIssueCustomField -Session $session -IssueId 'DEMO-4' -CustomField 'Type'
 
    Demonstrates getting the issue type for issue 'DEMO-4'.
 
    .EXAMPLE
    Get-YTIssueCustomField -Session $session -IssueId 'DEMO-20' -CustomField 'State' -Value
 
    Demonstrates getting the state value for the issue 'DEMO-20'
    #>

    [CmdletBinding()]
    param(
        # The Session object for a YouTrack session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The ID of the issue.
        [Parameter(Mandatory)]
        [String] $IssueId,

        # The name of the custom field to fetch.
        [Parameter(Mandatory)]
        [String] $CustomField,

        # Returns only the value of the custom field.
        [switch] $Value
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $issueFields = Resolve-YTIssueCustomFields -Session $Session -IssueId $IssueId

    $endpoint = "issues/${IssueId}/customFields/$($issueFields[$customField])?fields=id,projectCustomField(id,field(id,name))," +
                'value(id,isResolved,localizedName,name)'
    $customFieldObject = Invoke-YTRestMethod -Session $session `
                                             -Name $endpoint
    if ($Value)
    {
        return $customFieldObject.value.name
    }

    return $customFieldObject
}


function Get-YTProject
{
    <#
    .SYNOPSIS
    Gets projects from YouTrack.
 
    .DESCRIPTION
    The `Get-YTProject` function gets projects from YouTrack. By default, all projects are returned. To return a
    specific project pass in its short name to the `ShortName` parameter. Wildcards are not supported.
 
    The function returns the following fields by default:
 
    * `id`
    * `name`
    * `shortName`
 
    In order to add additional fields to the response, use the `AdditionalField` parameter, with each field name being
    an entry in the string array.
 
    If the `ShortName` parameter is provided, the function will return only the project with the matching short
    name.
 
    .EXAMPLE
    Get-YTProject -Session $session
 
    Demonstrates fetching all projects from YouTrack. This will return the default fields for all projects.
 
    .EXAMPLE
    Get-YTProject -Session $session -ShortName 'DEMO'
 
    Demonstrates fetching a specific project from YouTrack. This will return the default fields for the `DEMO` project.
 
    .EXAMPLE
    Get-YTProject -Session $session -AdditionalField 'description'
 
    Demonstrates fetching all projects from YouTrack and including additional fields. This will return the default
    fields along with the description for all projects.
    #>

    [CmdletBinding()]
    param(
        # The session object for a YouTrack Session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The short name of the project to get.
        [String] $ShortName,

        # Additional fields to include in the response.
        [String[]] $AdditionalField
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $fields = 'id,name,shortName'

    if ($AdditionalField)
    {
        $fields += ",$($AdditionalField -join ',')"
    }

    $fields = [Uri]::EscapeDataString($fields)

    $projects = Invoke-YTRestMethod -Session $Session -Name "admin/projects?fields=$fields"

    if ($ShortName)
    {
        return $projects | Where-Object 'shortName' -EQ $ShortName
    }

    return $projects
}


function Invoke-YTRestMethod
{
    <#
    .SYNOPSIS
    Invokes a REST method in YouTrack.
 
    .DESCRIPTION
    The `Invoke-YTRestMethod` function invokes a REST method in YouTrack using the provided session object. Pass in the
    name of the API endpoint to make the request to. By default, this function makes the HTTP request using the HTTP
    `Get` method. Use the `Method` parameter to use a different HTTP method. Provide the body of the request as a
    hashtable to the `Body` parameter.
 
    .EXAMPLE
    Invoke-YTRestMethod -Session $session -Name 'admin/projects'
 
    Demonstrates invoking the `GET` method on the `admin/projects` endpoint in YouTrack.
 
    .EXAMPLE
    Invoke-YTRestMethod -Session $session -Name 'admin/projects' -Method Post -Body @{
        name = 'Demo Project'
        shortName = 'DEMO'
        leader = @{id = '2-1'}
    }
 
    Demonstrates invoking the `POST` method on the `admin/projects` endpoint in YouTrack with a body containing the
    name, short name, and leader of the project.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The Session object for a YouTrack session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The name of the API endpoint to make a request to. This should be everything after the `/api/` in the URL.
        [Parameter(Mandatory)]
        [String] $Name,

        # The type of request method, defaults to Get.
        [Microsoft.PowerShell.Commands.WebRequestMethod] $Method =
            [Microsoft.PowerShell.Commands.WebRequestMethod]::Get,

        # The body of the request in the form of a hashtable.
        [hashtable] $Body
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $url = "$($Session.Url)${name}"
    Write-Debug "URL: $url"
    $headers = @{
        'Authorization' = "Bearer $($Session.ApiToken)"
        'Accept' = 'application/json'
    }

    $requestParams = @{}

    if ($Body)
    {
        $requestParams['Body'] = $Body | ConvertTo-Json
        $requestParams['ContentType'] = 'application/json'
    }

    if ($Method -eq [Microsoft.PowerShell.Commands.WebRequestMethod]::Get -or $PSCmdlet.ShouldProcess($url, $method))
    {
        try
        {
            Invoke-RestMethod -Uri $url -Headers $headers -Method $Method @requestParams |
                ForEach-Object { $_ } |
                Where-Object { $_ } |
                Write-Output
        }
        catch
        {
            Write-Error -Message ($_.ToString() | ConvertFrom-Json | Select-Object -ExpandProperty 'error_description')
            return
        }
    }
}


function New-YTIssue
{
    <#
    .SYNOPSIS
    Creates a new issue in YouTrack.
 
    .DESCRIPTION
    The `New-YTIssue` function creates a new issue in YouTrack. Pass the project's short name, ID, or a project object
    where the issue should get created to the Project parameter, and the title of the issue to the `Summary` parameter.
    You can also provide an optional description to the `Description` parameter.
 
    .EXAMPLE
    New-YTIssue -Session $session -Project 'DEMO' -Summary 'New Issue'
 
    Demonstrates creating a new issue in the `DEMO` project with the summary `New Issue`.
 
    .EXAMPLE
    New-YTIssue -Session $session -Project 'DEMO' -Summary 'Write Docs' -Description 'Write YouTrackAutomation docs'
 
    Demonstrates creating a new issue in the `DEMO` project with the summary `Write Docs` and the description `Write
    YouTrackAutomation docs`.
    #>

    [CmdletBinding()]
    param(
        # The session object for a Youtrack Session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The project that the issue will be created in. This can be a project object, project short name, or project ID.
        [Parameter(Mandatory)]
        [Object] $Project,

        # The summary of the issue.
        [Alias('Title')]
        [Parameter(Mandatory)]
        [String] $Summary,

        # The description of the issue.
        [String] $Description
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $issueFields = @{
        summary = $Summary;
        project = @{
            id = Resolve-YTProjectId -Session $session -Project $Project;
        };
    }

    if ($Description)
    {
        $issueFields['description'] = $Description
    }

    $fields = 'id,idReadable,summary'

    Invoke-YTRestMethod -Session $Session -Name "issues?fields=$fields" -Body $issueFields -Method Post
}


function New-YTProject
{
    <#
    .SYNOPSIS
    Creates a new project in YouTrack.
 
    .DESCRIPTION
    The `New-YTProject` function creates a new project in YouTrack. The function requires the following parameters:
 
    * `Name`: The name of the project.
    * `ShortName`: The short name of the project.
    * `Leader`: The id or the name of the project owner.
 
    .EXAMPLE
    New-YTProject -Session $session -Name 'Demo Project' -ShortName 'DEMO' -Leader 'admin'
 
    Demonstrates creating a new project in YouTrack with the name `Demo Project`, the short name `DEMO`, and the project
    owner `admin`.
 
    .EXAMPLE
    New-YTProject -Session $session -Name 'Demo Project' -ShortName 'DEMO' -Leader '2-1'
 
    Demonstrates creating a new project in YouTrack with the name `Demo Project`, the short name `DEMO`, and the project
    owner `admin`, but using the project owner's id instead of their name.
    #>

    [CmdletBinding()]
    param(
        # The session object for a YouTrack Session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The name of the project.
        [Parameter(Mandatory)]
        [String] $Name,

        # The short name of the project.
        [Parameter(Mandatory)]
        [String] $ShortName,

        # The id or the name of the project owner.
        [Parameter(Mandatory)]
        [String] $Leader,

        # The description of the project.
        [String] $Description,

        # Template project to use for the new project.
        [ValidateSet('scrum', 'kanban')]
        [String] $Template,

        # Additional fields to include in the response.
        [String[]] $AdditionalField
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    if ($Leader -notmatch '\d+-\d+')
    {
        $Leader =
            Invoke-YTRestMethod -Session $Session -Name 'users?fields=name,id' |
            Where-Object { $_.name -eq $Leader } |
            Select-Object -ExpandProperty 'id'
    }

    $fields = 'id,name,shortName'
    $body = @{
        name = $Name;
        shortName = $ShortName;
        leader = @{
            id = $Leader;
        };
    }

    if ($Description)
    {
        $body['description'] = $Description
    }

    if ($AdditionalField)
    {
        $fields += ",$($AdditionalField -join ',')"
    }

    $fields = [Uri]::EscapeDataString($fields)

    if ($Template)
    {
        # Template portion needs to be encoded with EscapeUriString as EscapeDataString creates a query string with
        # invalid syntax
        $fields += "&template=$([Uri]::EscapeUriString($Template))"
    }

    Invoke-YTRestMethod -Session $Session -Name "admin/projects?fields=$fields" -Body $body -Method Post
}


function New-YTSession
{
    <#
    .SYNOPSIS
    Creates a new YouTrack session object.
 
    .DESCRIPTION
    The New-YTSession function creates a session object required by most YouTrackAutomation functions. Pass the URL to
    YouTrack to the Url parameter and your API token to the ApiToken parameter. The URL should just be the protocol and
    hostname. The function returns an object that can be passed to all YouTrackAutomation functions' Session parameter.
 
    .EXAMPLE
    $session = New-YTSession -Url 'https://my-youtrack-instance.com' -ApiToken 'my-api-key'
 
    Demonstrates creating a YouTrack session object with the url 'https://my-youtrack-instance.com' and the API token
    'my-api-key'.
    #>

    [CmdletBinding()]
    param(
        # The URL of the YouTrack instance.
        [Parameter(Mandatory)]
        [String] $Url,

        # The API token for the YouTrack instance.
        [Parameter(Mandatory)]
        [String] $ApiToken
    )

    return [pscustomobject]@{
        Url = "$Url/api/"
        ApiToken = $ApiToken
    }
}


function Remove-YTProject
{
    <#
    .SYNOPSIS
    Removes a YouTrack project.
 
    .DESCRIPTiON
    The `Remove-YTProject` function deletes an entire project in YouTrack. Pass the project object, the id of the
    object, or the short name of the project to the `Project` parameter.
 
    .EXAMPLE
    Remove-YTProject -Session $session -Project 'DEMO'
 
    Demonstrates removing the project with the 'DEMO' short name.
 
    .EXAMPLE
    Remove-YTProject -Session $session -Project '0-1'
 
    Demonstrates removing the project with the '0-1' id.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # The session object for a YouTrack session. Create a new Session using `New-YTSession`.
        [Parameter(Mandatory)]
        [Object] $Session,

        # The project to be deleted. This can be a project object, project short name, or project ID.
        [Parameter(Mandatory)]
        [Object] $Project
    )

    Set-StrictMode -Version 'Latest'
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState

    $projectId = Resolve-YTProjectId -Session $Session -Project $Project
    Invoke-YTRestMethod -Session $Session -Method Delete -Name "admin/projects/${ProjectId}"
}


function Resolve-YTIssueCustomFields
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Object] $Session,

        [Parameter(Mandatory)]
        [String] $IssueId
    )

    $issue = Get-YTIssue -Session $Session -IssueId $IssueId -AdditionalField 'customFields(id,projectCustomField(field(name)))'

    $fields = @{}

    foreach ($customField in $issue.customFields)
    {
        $fields[$customField.projectCustomField.field.name] = $customField.id
    }

    return $fields
}


function Resolve-YTProjectId
{
    <#
    .SYNOPSIS
    Gets ID for a project.
 
    .DESCRIPTION
    The `Resolve-YTProjectId` function takes in a project, project short name, or project id and returns the project id
    associated with the project provided.
 
    .EXAMPLE
    Resolve-YTProjectId -Session $session -Project 'DEMO'
 
    Demonstrates resolving the project id for the project with the `DEMO` short name.
 
    .EXAMPLE
    Resolve-YTProjectId -Session $session -Project '0-1'
 
    Demonstrates resolving the project id for the project with the `0-1` id.
 
    .EXAMPLE
    Resolve-YTProjectId -Session $session -Project $project
 
    Demonstrates resolving the project id using a project object.
    #>

    [CmdletBinding()]
    param(
        # The session object for a YouTrack session. Create a new Session using `New-YTSession`.
        [Object] $Session,

        # The project to resolve the id for. Can be a project object, a project id, or a project short name.
        [Object] $Project
    )

    if ($Project | Get-Member -Name 'id')
    {
        return $Project.id
    }

    if (-not $Project -is [String])
    {
        $msg = "Failed to resolve project ""${Project}"": expected an object with an `id` property, a string ID " +
            '(e.g. ''0-0''), or a project short name.'
        Write-Error $msg
        return
    }

    if ($Project -match '^\d+-\d+$')
    {
        return $Project
    }

    return Get-YTProject -Session $Session -ShortName $Project | Select-Object -ExpandProperty 'id'
}


function Use-CallerPreference
{
    <#
    .SYNOPSIS
    Sets the PowerShell preference variables in a module's function based on the callers preferences.
 
    .DESCRIPTION
    Script module functions do not automatically inherit their caller's variables, including preferences set by common
    parameters. This means if you call a script with switches like `-Verbose` or `-WhatIf`, those that parameter don't
    get passed into any function that belongs to a module.
 
    When used in a module function, `Use-CallerPreference` will grab the value of these common parameters used by the
    function's caller:
 
     * ErrorAction
     * Debug
     * Confirm
     * InformationAction
     * Verbose
     * WarningAction
     * WhatIf
     
    This function should be used in a module's function to grab the caller's preference variables so the caller doesn't
    have to explicitly pass common parameters to the module function.
 
    This function is adapted from the [`Get-CallerPreference` function written by David Wyatt](https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d).
 
    There is currently a [bug in PowerShell](https://connect.microsoft.com/PowerShell/Feedback/Details/763621) that
    causes an error when `ErrorAction` is implicitly set to `Ignore`. If you use this function, you'll need to add
    explicit `-ErrorAction $ErrorActionPreference` to every `Write-Error` call. Please vote up this issue so it can get
    fixed.
 
    .LINK
    about_Preference_Variables
 
    .LINK
    about_CommonParameters
 
    .LINK
    https://gallery.technet.microsoft.com/scriptcenter/Inherit-Preference-82343b9d
 
    .LINK
    http://powershell.org/wp/2014/01/13/getting-your-script-module-functions-to-inherit-preference-variables-from-the-caller/
 
    .EXAMPLE
    Use-CallerPreference -Cmdlet $PSCmdlet -SessionState $ExecutionContext.SessionState
 
    Demonstrates how to set the caller's common parameter preference variables in a module function.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        #[Management.Automation.PSScriptCmdlet]
        # The module function's `$PSCmdlet` object. Requires the function be decorated with the `[CmdletBinding()]`
        # attribute.
        $Cmdlet,

        [Parameter(Mandatory)]
        # The module function's `$ExecutionContext.SessionState` object. Requires the function be decorated with the
        # `[CmdletBinding()]` attribute.
        #
        # Used to set variables in its callers' scope, even if that caller is in a different script module.
        [Management.Automation.SessionState]$SessionState
    )

    Set-StrictMode -Version 'Latest'

    # List of preference variables taken from the about_Preference_Variables and their common parameter name (taken
    # from about_CommonParameters).
    $commonPreferences = @{
                              'ErrorActionPreference' = 'ErrorAction';
                              'DebugPreference' = 'Debug';
                              'ConfirmPreference' = 'Confirm';
                              'InformationPreference' = 'InformationAction';
                              'VerbosePreference' = 'Verbose';
                              'WarningPreference' = 'WarningAction';
                              'WhatIfPreference' = 'WhatIf';
                          }

    foreach( $prefName in $commonPreferences.Keys )
    {
        $parameterName = $commonPreferences[$prefName]

        # Don't do anything if the parameter was passed in.
        if( $Cmdlet.MyInvocation.BoundParameters.ContainsKey($parameterName) )
        {
            continue
        }

        $variable = $Cmdlet.SessionState.PSVariable.Get($prefName)
        # Don't do anything if caller didn't use a common parameter.
        if( -not $variable )
        {
            continue
        }

        if( $SessionState -eq $ExecutionContext.SessionState )
        {
            Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
        }
        else
        {
            $SessionState.PSVariable.Set($variable.Name, $variable.Value)
        }
    }
}